这是一篇关于 shell 注入的介绍性文章,shell 注入是一个安全漏洞,允许攻击者在用户的计算机上执行任意代码。这是一个经过充分研究的问题,并且有简单有效的解决方案。以这样一种方式设计库 API 相对容易,以保护应用程序开发人员免受 shell 注入的风险。

我写这篇文章有两个原因。首先,今年我在三个不同的库中指出了这个问题。看来,尽管这个问题得到了很好的研究,但它并不为人所知,所以只是重复一些事情可能会有所帮助。其次,我最近报告了一个关于VS Code API的相关问题,我想将这篇文章用作GitHub的扩展评论:-)

Pwnd脚本的奇特案例

当一个程序需要执行另一个程序并且其中一个参数由用户/攻击者控制时,可能会发生 shell 注入。作为模型示例,让我们编写一个快速脚本来读取 stdin(标准)中的 URL 列表,并 curl 针对每个 URL 运行。

这不现实,但很小,很说明问题。这是脚本在 NodeJS 中的样子:

const readline = require('readline');

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

async function main() {
  const input = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
    terminal: false,
  });

  for await (const line of input) {
    if (line.trim().length > 0) {
      const { stdout, stderr } = await exec(`curl ${line}`);
      console.log({ stdout, stderr });
    }
  }
}

main()

我会在 Rust 中写这篇文章,但是,唉,它不容易受到这种特定攻击:)

有趣的台词是这样的:

const { stdout, stderr } = await exec(`curl ${line}`);

在这里,我们使用来自 Node 的 API 来 exec 生成一个子 curl 进程,将一行输入作为参数传递。

似乎适用于简单的案例?

$ cat urls.txt
<https://example.com>

$ node curl-all.js < urls.txt
{
  stdout: '<!doctype html>...</html>\n',
  stderr: '% Total    % Received ...'
}

但是,如果我们使用稍微富有想象力的输入呢?

$ node main.js < malice_in_the_wonderland.txt
{
  stdout: 'PWNED, reading your secrets from /etc/passwd\n' +
    'root:x:0:0:System administrator:/root:/bin/fish\n' +
    '...' +
    'matklad:x:1000:100::/home/matklad:/bin/fish\n',
  stderr: "curl: try 'curl --help' for more information\n"
}

这感觉很糟糕 - 似乎脚本以某种方式读取了我的 /etc/passwd .这是怎么发生的,我们只调用了 curl

生成进程

要了解刚刚发生的事情,我们需要了解一下生成过程的一般工作原理。本节有点特定于UNIX——在Windows上的实现方式略有不同。尽管如此,大部分结论也在那里成立。

使用命令行参数运行程序的主要 API 是函数 exec 系列。例如,这里是 execve

int execve(const char *pathname, char *const argv[],
           char *const envp[]);

它采用程序的名称 ( )、命令行参数列表 ( pathname argv ) 和新进程的环境变量列表 ( envp ),并使用这些参数来运行指定的二进制文件。这究竟是如何发生的是一个引人入胜的故事,情节中有许多分支,但它超出了本文的范围。

奇怪的是,虽然底层系统 API 需要一个参数数组,但来自 node 的 child_process.exec 函数只接受一个字符串: exec("curl http://example.com")

让我们来了解一下!为此,我们将使用 strace 工具。此工具检查(跟踪)程序调用的所有系统调用。我们将特别 strace 要求寻找 execve ,以了解节点如何 exec 映射到底层系统的API。我们需要参数来 --follow 跟踪所有进程,而不仅仅是顶级进程。为了减少输出量并仅打印 execve ,我们将使用 --trace 标志:

$ strace --follow --trace execve node main.js < urls.txt
execve("/bin/node", ["node", "curl-all.js"], 0x7fff97776be0)
...
execve("/bin/sh", ["/bin/sh", "-c", "curl https://example.com"], 0x3fcacc0)
...
execve("/bin/curl", ["curl", "https://example.com"], 0xec4008)

我们在这里看到的第一个 execve 是我们对 node 二进制文件本身的原始调用。最后一个是我们想要做的—— curl 用一个参数生成,一个网址。中间一个是节点 exec 的实际作用。

让我们仔细看看:

/bin/sh -c "curl https://example.com"

在这里,node 使用两个参数调用 sh 二进制文件(系统的shell): -c 以及我们最初传递给 child_process.exec 的字符串。 -c 代表命令,并指示 shell 将该值解释为 shell 命令,解析它,然后运行它。

换句话说,node 不是直接运行命令,而是要求 shell 完成繁重的工作。但是 shell 是 shell 语言的解释器,通过精心制作输入到 exec ,我们可以要求它运行任意代码。特别是,这就是我们在上面的坏示例中用作有效负载的内容:

malice_in_the_wonderland.txt

; echo 'PWNED, reading your secrets from /etc/passwd' && cat /etc/passwd

字符串内插后,生成的命令是

/bin/sh -c "curl; echo '...' && cat /etc/passwd"

也就是说,首先运行 curl ,然后 ,然后 echo 读取 /etc/passwd .

那些研究历史的人注定要重蹈覆辙

Node 中有一个等效的安全 API: spawn 。与exec 不同的是 ,它使用参数数组而不是单个字符串。

-  exec(`curl ${line}`)
+ spawn("curl", line)

在内部,API 绕过 shell 并直接使用 execve 。因此,这个 API 不容易受到 shell 注入的影响——攻击者可以使用错误的参数运行 curl ,但它不能运行除 curl .

请注意,它在以下方面 spawn 很容易实现 exec

function myExec(cmd) {
  return spawn("/bin/sh", "-c", cmd)
}

这是许多语言中的常见模式:

  • 有一个 exec -style 函数,它接受一个字符串并在引擎盖 /bin/sh -c 下生成,
  • 这个函数的文档包括一个巨大的免责声明,说在用户输入中使用它是一个坏主意,
  • 有一个安全的替代方案,它将参数作为数组并直接生成进程。

为什么要提供可利用的API,而安全版本是可能的并且更直接?我不知道,但我的猜测是,这主要只是历史。C有 system ,Perl的反引号直接对应于那个,Ruby从Perl得到反引号,Python只是有 system ,node可能受到所有这些脚本语言的影响。

请注意,安全性并不是 /bin/sh -c 基于 API 的唯一问题。阅读另一篇文章以了解其余问题。

如果您是应用程序开发人员,请注意存在此问题。仔细阅读语言文档 — 最有可能的是,有两种类型的进程生成函数。请注意 shell 注入与 SQL 注入和 XSS 的相似之处。

如果开发用于方便地使用外部进程的库,请仅使用和公开底层平台中的无外壳 API。

如果您构建一个新平台,请不要首先提供 bin/sh -c API。像deno(还有Go,Rust,Julia)一样,不要像node(还有Python,Ruby,Perl,C)。如果出于遗留原因必须维护此类 API,请清楚地记录有关 shell 注入的问题。记录如何手动操作 /bin/sh -c 也可能是一个好主意。

如果要设计编程语言,请小心使用字符串内插语法。字符串内插可用于以安全的方式生成命令非常重要。这主要意味着库作者应该能够将 "cmd -j $arg1 -f $arg2" 文本解构为两个(编译时)数组: ["cmd -j ", " -f "][arg1, arg2] 。如果您没有在语言中提供此功能,库作者将拆分内插字符串,这将是不安全的(不仅对于掏出 - 对于SQL或HTML也是如此)。可以学习的好例子是JavaScript的标记模板和Julia的反引号。

VS Code 是怎么回事?

哦,对了,我写这个东西的真正原因。本节的TL;DR是我想稍微抱怨一下特定的API设计。

这个故事始于#9058。

我很高兴地破解了一些 Rust 库。在某些时候,我按下了 rust-analyzer 中的“运行测试”按钮。而且,惊讶的是,不小心扑到了自己!

Executing task: cargo test --doc --- Plotter<D>::line_fill --nocapture

warning: An error occurred while redirecting file 'D'
open: No such file or directory

The terminal process
/bin/fish '-c', 'cargo test --doc --- Plotter<D>::line_fill --nocapture'
failed to launch (exit code: 1).

Terminal will be reused by tasks, press any key to close it.

这令人失望。来吧,为什么我帮助维护的代码中有 shell 注入?虽然这对 rust-analyzer 来说不是一个大问题(我们的安全模型假定受信任的代码,因为每个 rustupcargo rustc 都可以通过设计执行任意代码),但这绝对是对我的审美敏感性的极大打击!

查看 git 历史记录,是我在审查期间错过了“将参数连接成单个字符串”。所以我绝对是这里问题的一部分。但另一部分是,接受单个字符串的 API 根本不存在。

让我们看一下 API:

export class ShellExecution {
  /**
    * Creates a shell execution with a full command line.
    *
    * @param commandLine The command line to execute.
    * @param options Optional options for the started the shell.
    */
  constructor(
    commandLine: string,
    options?: ShellExecutionOptions
  );

  /* ... */
}

所以,这正是我所描述的——一个采用单个字符串的进程生成 API。我想,在这种情况下,这甚至是合理的——API 在 GUI 中打开一个文字外壳,用户可以在命令完成后与之交互。

无论如何,在环顾四周之后,我很快就找到了另一个API,它看起来(背景中的不祥音乐)就像我正在寻找的一样:

export class ShellExecution {
  /**
    * Creates a shell execution with a command and arguments.
    * For the real execution the editor will construct a
    * command line from the command and the arguments. This
    * is subject to interpretation especially when it comes to
    * quoting. If full control over the command line is needed
    * please use the constructor that creates a `ShellExecution`
    * with the full command line.
    *
    * @param command The command to execute.
    * @param args The command arguments.
    * @param options Optional options for the started the shell.
    */
  constructor(
    command: string | ShellQuotedString,
    args: (string | ShellQuotedString)[],
    options?: ShellExecutionOptions
  );
}

API 采用字符串数组。它还试图说一些关于引用的事情,这是一个好兆头!措辞令人困惑,但似乎很难向我解释通过 ["ls", ">", "out.txt"] 实际上不会重定向,因为 > 会被引用。这正是我想要的!两个 API 上都没有任何类型的安全说明令人担忧,但哦,好吧。

因此,我重构了代码以使用第二个构造函数,并且,🥁 🥁 🥁它仍然具有完全相同的行为!事实证明,这个 API 接受一个参数数组,并且只是将它们连接起来,除非我明确说每个参数都需要转义。

这就是我要抱怨的 - API 看起来对于不受信任的用户输入是安全的,而事实并非如此。这就是抗误用性。

就是这样,感谢您的阅读!