预防NodeJS命令注入的方法详解
作者:奇舞精选
前言
Node.js和npm为前端生态中提供了统一的开发语言、强大的包管理和模块生态系统、灵活的构建工具和任务自动化、以及丰富的前端框架和库等等。
可以说,正是因为nodejs带来的这些工具和资源使前端开发更加高效、便捷,并推动了前端技术的快速发展。
但是近年来,Node.js 生态系统中的 npm 软件包中出现了许多 CVE("Common Vulnerabilities and Exposures" 常见漏洞和公开漏洞),譬如lodash库的CVE漏洞——CVE-2018-16487[2]、express库的CVE漏洞——CVE-2018-17346[3]以及jsonwebtoken库的CVE漏洞——CVE-2018-12424[4]等等,在这其中有一个特别危险且屡禁不止的漏洞就是命令注入(Command Injection)。
作为前端工程师而言,在我们日常工作中,不仅需要快速交付、优化性能相关,还要时刻对项目中所采用的nodejs技术栈及其安全相关的因素考虑在内。
简而言之,关于安全这根弦儿得时刻紧绷着!
命令注入[5]是一种攻击,其目的是通过有漏洞的应用程序在主机操作系统上执行任意命令。当应用程序将用户提供的不安全数据(表单、cookie、HTTP 标头等)传递给系统shell时,就有可能发生命令注入攻击。在这种攻击中,攻击者提供的操作系统命令通常是以受攻击应用程序的权限执行的。命令注入攻击之所以可能,主要是因为输入校验不足。
这种攻击与代码注入不同,代码注入允许攻击者添加自己的代码,然后由应用程序执行。在命令注入攻击中,攻击者扩展应用程序的默认功能,执行系统命令,而无需注入代码。
场景分析
假设有某程序员a同学在某个nodejs项目中写出了类似的代码:
const { exec } = require('child_process'); function runCommand(userInput) { const command = `ls ${userInput}`; // 将用户所输入的内容拼接到命令中 exec(command, (error, stdout, stderr) => { if (error) { console.error(`执行命令时出错:${error}`); return; } console.log(`命令执行结果:${stdout}`); }); } const userInput = '; rm -rf /'; // 恶意用户所输入的内容 runCommand(userInput);
我们简单分析下以上代码,这段程序可以将用户所输入的内容直接拼接到命令行字符串中。如果因为项目工期紧张,没经过code review匆忙上线,恰好碰到个别用户所输入的恶意的命令,例如'; rm -rf /',那么最终执行的命令将变为ls ; rm -rf /,导致“删库跑路”的危险操作。
当然这只是为了举例的简单例子,实际项目中,发生命令注入的原因大多是没有对用户所输入的内容进行严谨的校验。
命令注入 - 常见威胁
命令注入是 Node.js 生态系统中真实而普遍的威胁。
看似显而易见的安全风险,如以下代码所示:
var exec = require('child_process').execSync var platform = require('os').platform() module.exports = function(){ var commands = Array.isArray(arguments[0]) ? arguments[0] : Array.prototype.slice.apply(arguments) var command = null commands.some(function(c){ if (isExec(findCommand(c))){ command = c return true } }) return command } function isExec(command){ try{ exec(command, { stdio: 'ignore' }) return true } catch (_e){ return false } } function findCommand(command){ if (/^win/.test(platform)){ return "where " + command } else { return "command -v " + command } }
上述命令注入漏洞是在 find-exec[6] npm 软件包中发现的,该软件包每周的下载量多达 2000 多次。虽然数量不多,但足以让一些用户面临风险。命令注入漏洞的后果可能是毁灭性的,从数据泄露到系统完全崩溃不等。
现在我们再回过头来看,到底什么是命令注入[7]?简而言之,命令注入的核心是应用程序允许未经审核的用户所输入的内容作为系统命令执行。这些命令可操纵底层系统,可能导致未经授权的访问、数据泄露,甚至完全破坏系统。
fs-git
在另外一个案例中,我们可以看下fs-git npm 软件包(版本 1.0.1)这个看似无害的模块是如何成为一个严重的安全隐患的:
fs-git 是 Node.js 的一个 npm 包,能够为 Git 仓库提供类似于文件系统的 API,进而可以让开发人员更直观、更容易地与 Git 仓库交互。它拥有相当数量的用户群体,所以该安全隐患所造成的影响可见一斑。
在1.0.1 版本的 fs-git 模块中,被发现了编号为 CVE-2017-1000451[8]漏洞。该模块依赖 child_process.exec 函数来执行系统命令。然而,用于构建执行字符串的 buildCommand函数缺少严谨的校验逻辑,使其容易受到命令注入的攻击。
以下是fs-git 中存在漏洞的代码片段:
showRef(): Promise<RefInfo[]> { let command = this._buildCommand("show-ref"); return new Promise((resolve: (value: RefInfo[]) => void, reject: (error: any) => void) => { child_process.exec(command, { maxBuffer: maxBuffer }, (error, stdout, stderr) => { if (error) { reject(error); } else { let list = stdout.toString("utf8").split("\n").filter(line => !!line); let resultList:RefInfo[] = list.map(str=> { let columns = str.split(" ", 2); 返回 { gitDir: this.path、 ref: columns[0]、 name: columns[1] }; }); resolve(resultList);
最终,代码还将调用 _buildCommand 函数,其中包含字符串连接和用户提供的数据:
_buildCommand(...args: string[]): string { return `git --git-dir=${this.path} ${args.join(" ") }`;
当攻击者篡改传递给 fs-git 模块的数据以制作利用命令注入漏洞的恶意代码时,攻击就展开了。通过提供精心制作的输入,攻击者能够向系统注入任意命令。这样,攻击者就可以利用运行进程的权限执行未经授权的命令,从而可能危及主机系统。
该漏洞影响深远。攻击者可以执行任意命令,其中可能包括外泄敏感数据、修改文件甚至破坏系统正常运行等操作。对于依赖fs-git的项目和应用程序来说,这个漏洞构成了重大的安全风险。
这个案例充分说明了校验用户所输入的内容和必要的数据清除在防止命令注入漏洞方面的重要性。
所以即使是看似无害的模块,如果不遵循安全编码实践,也会带来严重的安全风险。开发者在处理用户所输入的内容的数据时必须十分谨慎。
安全建议
对于NodeJs项目,我们可以大致从以下几点入手,从而减少命令注入的风险:
- 使用ORM(对象关系映射)库:使用ORM库可以帮助处理数据库查询,避免手动拼接SQL语句,从而减少SQL注入的风险。
譬如,笔者就在曾经的Egg.js项目中使用过的Sequelize[9] ORM库来执行安全的数据库操作。
- 校验严谨
对用户所输入的内容进行校验和过滤,以防止恶意输入
- 遵循安全编码规范
避免直接拼接用户所输入的内容到命令字符串、使用安全的文件路径拼接方法等。确保在代码中进行输入校验和输出转义,并注意处理用户所输入的内容时的边界情况。
- NPM Audit & NSP
使用经过安全审计和更新频繁的第三方库,以减少潜在的安全漏洞。另外还可以使用工具如npm Audit[10]或NSP(Node Security Platform)[11]来检查项目依赖的安全性。
回过头看
假设项目中需要使用到exec[12]和spawn[13]方法时,如果没有适当的数据清理和校验,用户所输入的内容可能被恶意利用,导致命令注入攻击。
以下是一个简单Demo说明这些类似的场景:
const { exec, spawn } = require('child_process'); // 示例:使用exec执行命令 function executeCommandWithExec(userInput) { const command = `ls ${userInput}`; // 拼接用户所输入的内容的命令 exec(command, (error, stdout, stderr) => { if (error) { console.error(`执行命令出错:${error}`); return; } console.log(`命令执行结果:${stdout}`); }); } // 示例:使用spawn执行命令 function executeCommandWithSpawn(userInput) { const command = 'ls'; const args = [userInput]; // 将用户所输入的内容作为命令行参数 const child = spawn(command, args); child.stdout.on('data', (data) => { console.log(`命令执行结果:${data}`); }); child.stderr.on('data', (data) => { console.error(`执行命令出错:${data}`); }); } // 测试示例 const userInput = '; rm -rf /'; // 恶意的用户所输入的内容,尝试删除整个系统 executeCommandWithExec(userInput); executeCommandWithSpawn(userInput);
在上面的示例中,executeCommandWithExec和executeCommandWithSpawn函数接受用户所输入的内容,并将其用于执行ls命令。
然而,如果恶意用户所输入的内容像; rm -rf /这样的内容,它会将rm -rf /命令添加到ls命令后面,进而导致"删库跑路"的悲剧发生。
为了防止这种攻击,应该对用户所输入的内容进行适当的数据清理和校验。
所以对于以上代码,可以使用安全的执行方法execFile和spawn,并将用户所输入的内容作为命令行参数而不是直接拼接到命令中:
const { execFile, spawn } = require('child_process'); // 示例:使用execFile执行命令 function executeCommandWithExecFile(userInput) { const command = 'ls'; const args = [userInput]; // 将用户所输入的内容作为命令行参数 execFile(command, args, (error, stdout, stderr) => { if (error) { console.error(`执行命令出错:${error}`); return; } console.log(`命令执行结果:${stdout}`); }); } // 示例:使用spawn执行命令 function executeCommandWithSpawn(userInput) { const command = 'ls'; const args = [userInput]; // 将用户所输入的内容作为命令行参数 const child = spawn(command, args); child.stdout.on('data', (data) => { console.log(`命令执行结果:${data}`); }); child.stderr.on('data', (data) => { console.error(`执行命令出错:${data}`); }); } // 测试示例 const userInput = '; rm -rf /'; // 恶意的用户所输入的内容,尝试删除整个系统 executeCommandWithExecFile(userInput); executeCommandWithSpawn(userInput);
在上面的代码实现中,executeCommandWithExecFile函数使用了execFile[14]方法来执行命令,而executeCommandWithSpawn函数保持不变,仍然使用spawn方法执行命令。
使用execFile方法可以避免将用户所输入的内容直接拼接到命令中,这样可以在一定程度上减少命令注入攻击的风险。
总结
记住,无论采用哪种方案,主体思想都应该是谨慎处理用户所输入的内容,并进行严谨的校验,以确保代码的安全性。
以上就是预防NodeJS命令注入的方法详解的详细内容,更多关于预防NodeJS命令注入的资料请关注脚本之家其它相关文章!