防止Node.js应用中的命令行注入攻击

  攻击者可以使用Node.js应用侵入你的系统。本文介绍如何阻止这种行为的发生。

  当Node.js首次发布时,它引起了一场革命。它允许开发人员在服务器端运行JavaScript,这是浏览器的主要编程语言。随着时间的推移,Node.js变得越来越流行,并成为构建Web应用程序和API的首选工具。

  Node.js由一个小而稳定的运行时核心和一组内置模块组成,这些内置模块提供了一些基本功能,如文件系统访问、TCP/IP网络、HTTP协议、加密算法、解析命令行参数等。这些内置模块功能强大,经过了充分的测试并且性能良好。

  不过,它并不能涵盖Web应用程序开发人员的都有需求。有时,我们需要使用Node.js之外的程序来实现我们正在开发的功能。

使用CLI扩展Node.js模块

  在Node.js的内置模块中,有一个叫做child_process的模块,它允许JavaScript代码运行其它程序并通过底层操作系统提供的标准输入/输出(I/O)机制进行通信。这样的程序通常可以由用户通过命令行界面(简称CLI)来启动。这也是我们为什么把这样的程序称之为CLI的原因。

  像Linux这样的现代操作系统包含许多实用工具,这些工具并没有包含在Node.js的内置模块中。我们可以使用child_process模块启动一个外部程序来实现特定的功能,通过这种方法来扩展Node.js标准库既简单又有效。

检索git历史记录

  让我们看一下如何实现一个简单的Node.js程序,从git中获取文件的修改记录。我们使用Express框架来处理HTTP请求和响应。由于Node.js的内置模块中没有可以直接运行git命令的方法,所以我们必须自己运行git命令来完成数据的检索。要做到这一点,我们需要使用child_process模块中的exec函数:

const exec = require('child_process').exec;

app.get('/history', (req, res) => {
  // Read file name from the URL query string.
  const file = req.query.file;

  // Prepare command to run
  const command = `git log --oneline ${file}`;

  // Execute the external program and send the response back
  exec(command, (err, output) => {
    // Respond with HTTP 500 if there was an error
    if (err) {
      res.status(500).send(err);
      return;
    }

    // If the program ran successfully, send the output in
    // the HTTP response
    res.send(output);
  });
});

  现在我们给程序发送一个HTTP请求来获取文件的历史修改记录。我们使用curl命令来完成这一操作,curl是一个很流行的CLI程序:

$ curl http://localhost:3000/history?file=app.js

  程序输出以下结果:

f38482b Call git log command
5444afb Initial commit

  看起来我们的程序运行良好。我们只需要几行代码就实现了一个相当复杂的操作。

调用命令

  我们的程序简单而灵活。我们可以将服务器上任何文件的路径传递给我们的程序,只要该文件在git的版本控制下,我们就可以获得它的修改记录。这种输入参数可靠吗?

参数中的不可信数据

  我们的应用程序,特别是那些面向互联网的应用程序,会暴露给各种不同的用户。有些用户可能会为了自己的目的而滥用我们应用程序的功能。而有些用户则是为了乐趣和学习,还有些用户是为了出名,甚至为了钱。

  强烈建议在编程实践中始终将用户的输入都视为不可信的,除非我们有其它的证明。在Web应用程序中,攻击者可能会通过HTTP请求来尝试所有的功能,从而迫使我们的应用程序做一些违背意愿的事情。可能的攻击向量包括HTTP请求体、请求头(包括cookies),以及字符串查询参数。

  通过滥用这些修改后的输入参数,攻击者可以从服务器泄露敏感信息、导致服务拒绝访问、甚至完全接管整个服务器。对我们编写的这个应用程序而言,拥有恶意意图的用户可以滥用file参数,这一点是显而易见的。

调用恶意的命令

  我们的程序通过使用JavaScript模板字面量来构造一个完整的命令:javascript const command = `git log --oneline ${file}`;

  如果提供的file参数的值是app.js,则执行的命令为:bash $ git log --oneline app.js

  攻击者可能会提供另外一个值,它可以改变整个命令行的结构。让我们看看如果将参数file的值改成app.js; ls会发生什么:

$ git log --oneline app.js;ls

  我们的程序执行git log命令后,又执行了ls命令。在HTTP响应中发送给用户的结果是两个命令的输出组合

f38482b Call git log command
5444afb Initial commit
app.js
bin
node_modules
package-lock.json
package.json
public
routes
views

  这种类型的安全漏洞被称之为命令行注入。使用这种攻击技术会造成更大的影响吗?让我们尝试通过下面的方式来获取应用程序的源代码:

$ curl http://localhost:3000/history\?file\=app.js\;cat%20app.js

  输出结果不仅包含了app.js文件的修改记录,还包含了通过cat app.js命令获取到的整个文件的内容。

  我们可以使用相同的方法来泄露应用程序可以访问的服务器上的任何文件的内容,包括配置文件。攻击者甚至还可以通过env命令读取系统环境变量的值。

  对于大多数恶意用户而言,这已经非常有用了,但他们还可以做更多的事情。

现实的攻击

  技术娴熟的攻击者不仅试图控制应用程序,还试图控制整个服务器。这可以让他们获得对受感染机器的永久访问权。

极简的shell

  许多服务器操作系统都有一个名为nc(或者netcat)的工具。如果攻击者可以通过命令行注入漏洞的方式运行这个程序,那么他就可以在受感染的服务器上执行任意命令了。最简单的方式就是对易受攻击的应用程序强制运行以下命令:

$ nc -l 6667 | /bin/bash

  该命令侦听端口6667(由攻击者选择)上的传入连接,并将所有传入的数据直接传递给bash shell执行。假设端口是可用的,让我们看看这种方式是否可以通过我们的应用程序来实现:

$ curl http://localhost:3000/history?file=app.js;nc%20-l%206667%20|/bin/bash

  现在攻击者可以向受感染的服务器发送任意命令了:

$ echo 'killall node' | nc localhost 6667

  在这个例子中,攻击者使用nc程序向已经设置好的bash shell发送killall node命令。其结果就是导致拒绝服务攻击,终止服务器上的所有Node.js进程。

权限提升和内网漫游

  对攻击者来说,能够在服务器上运行任意命令非常有吸引力。在典型的攻击场景中,以这种方式破坏服务器只是攻击者采取的第一步。接下来是在被攻击的机器上安装恶意软件,从而允许攻击者可以长时间地与服务器通信。理想情况下,在被攻击的Node.js应用程序重启之后仍然可以有效地控制服务器。

  理想情况下,我们的Node.js应用程序以最小的权限集运行。命令行注入攻击允许攻击者对基础设施进行侦察并窃取管理权限,或者查找其它漏洞并进行错误设置,使攻击者获得权限提升,从而进一步通过网络进行传播。

  通过访问一台被攻击的服务器,攻击者可以转移到网络上的其它主机,这一过程被称之为内网漫游。这使得攻击者可以攻击网络上更多的主机,从而获得更多的权限,并进一步从我们的基础设施中寻找其它有趣的目标和数据。

  如果不被发现,这种攻击可以持续数周甚至数月,从而导致严重的数据泄露。那么我们如何加强我们的Node.js应用程序,以防止恶意用户利用命令行注入漏洞进行攻击呢?

防止命令行注入

  有几种技术可以防止或者至少可以极大地减少这种攻击的可能性。

不执行任意命令

  我们使用child_process模块中的exec函数,将传递给它的第一个参数的值作为命令直接传递给shell执行。这种操作非常灵活,但同时也带来了安全隐患,正如我们刚才所看到的。

  更好的方式是使用child_process模块中的execFile函数,它可以将参数作为数组传递给特定的命令:

**const execFile = require('child_process').execFile;**

app.get('/history', (req, res) => {
  // Read file name from the URL query string.
  const file = req.query.file;

  // Prepare command to run
  const command = `/usr/local/bin/git`;
  const args = ["log", "--oneline", file];

  // Execute the external program and send the response back
  execFile(command, args, (err, output) => {
    // Respond with HTTP 500 if there was an error
    if (err) {
      res.status(500).send(err);
      return;
    }

    // If the program ran successfully, send the output in
    // the HTTP response
    res.send(output);
  });
});

  通过这种方式,我们的应用程序可以抵御命令行注入攻击:

$ curl http://localhost:3000/history\?file\=app.js\;ls

  在服务器端,这将引发预期的错误:

{"killed":false,"code":128,"signal":null,"cmd":"/usr/local/bin/git log --oneline app.js;ls"}

  那如果我们需要exec函数的灵活性怎么办呢?有更好的解决办法吗?

输入验证还是输出清理?

  理想情况下,最好两者都有!

  命令行注入攻击的根本原因在于,当我们在代码中构造要执行的命令时,一些特殊字符(元字符)可能会改变命令的结构。最常用的元字符有:

& ; ` ' \ " | * ? ~ < > ^ ( ) [ ] { } $ \n \r

  确保这些字符不被攻击者滥用的最好方法是对不可信的输入数据执行严格的验证。输入验证可以验证数据的来源、大小或词法结构等内容。确保尽可能缩小可接受值的范围。

  如果传入的数据含有元字符,我们需要在传递给shell之前将这些字符进行适当的转义,以防止命令行注入攻击。自己实现这个功能比较困难,最好是使用可信且经过良好测试的库。shell-quote是个不错的选择,我们可以使用npm包管理器进行安装,然后使用它来格式化整个命令:

const quote = require('shell-quote').quote;

// ...

// Prepare command to run
const command = quote(["git", "log", "--oneline", file]);

// Execute the external program and send the response back
exec(command, (err, output) => {

  看起来这个方案不错,但是shell元字符编码是一个困难的问题,甚至在已建立的库中也发现了潜在的bug。

总结

  如果你的Node.js应用程序是通过调用外部程序来扩展内置模块的功能,那么可能就有命令行注入漏洞的风险。这些漏洞允许攻击者滥用我们的应用程序,并在我们的基础设施中进行内网漫游。

  听起来很可怕是吧?没错,这是一个非常严重的风险。

  好消息是我们可以采取几种防御性的编码方式来帮助我们构建不受命令行注入攻击的库和应用程序。使用execFile函数可以防止任意shell命令被执行,这是推荐的防御方法。另外,对输入值进行shell元字符验证也是一种有效的保护措施。

关于Auth0

  Okta的Auth0采用了一种现代化的用户身份识别方式,使组织能够为任何用户提供对任何应用程序的安全访问。Auth0是一个高度可定制的平台,开发团队可以根据需要对其进行简单而灵活的设置。Auth0每月保护数十亿次的用户登录,它给用户提供了便利、隐私和安全性,使客户能够专注于自己的创新领域。如想了解更多信息,可以访问https://auth0.com

 

原文地址:Preventing Command Injection Attacks in Node.js Apps

参考:OS Command Injection in Node.js

posted @ 2023-12-01 16:35  Jaxu  阅读(323)  评论(0编辑  收藏  举报