;回声“外壳注射”

2021-08-08 17:50:09

这是一篇关于shell注入的介绍性文章,一个允许攻击者在用户机器上执行任意代码的安全漏洞。这是一个经过充分研究的问题,并且有简单有效的解决方案。这样可以保护应用程序开发人员免受 shell 注入的风险。我写这篇文章的原因有两个。第一,今年我在三个不同的库中指出了这个问题。看来这个问题虽然研究得很好,但并不为人所知,所以只重复一些事情可能会help. 其次,我最近报告了一个关于 VS Code API 的相关问题,我想将这篇文章用作 GitHub 的扩展评论:-) Shell 注入可能发生在一个程序需要执行另一个程序时,一个参数由用户/攻击者控制。作为模型示例,让我们编写一个快速脚本来从标准输入读取 URL 列表,并为每个 URL 运行 curl。这不现实,但小而说明性。这就是脚本在 NodeJS 中的样子: const readline = require ( ' readline ' ); const util = require('util'); const exec = util 。承诺(要求('child_process')。exec);异步函数 main () { const input = readline . createInterface({输入:过程。标准输入,输出:过程。标准输出,终端:假,}); for await (const line of input) { if ( line .trim ().length > 0 ) { const { stdout , stderr } = await exec ( `curl ${ line } ` );安慰 。日志({标准输出,标准错误}); main () 我会用 Rust 写这个,但是,唉,它不容易受到这种特殊的攻击:) 在这里,我们使用来自节点的 exec API 来产生一个子 curl 进程,将一行输入作为一个论点。

$ cat urls.txthttps://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, 从 /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' 了解更多信息\n "} 感觉很糟糕——脚本似乎以某种方式读取了我的 /etc/passwd 的内容。这是怎么发生的,我们只调用了 curl?为了理解刚刚发生的事情,我们需要了解一些关于进程生成的一般工作原理。这一节有点特定于 UNIX — 在 Windows 上实现的东西有点不同。尽管如此,大局结论也成立。运行带有命令行参数的程序的主要 API 是 exec 系列函数。例如,这里是 execve:它接受程序名称(路径名)、命令行参数列表(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.txtexecve("/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 是我们对节点二进制文件本身的原始调用。最后一个是我们想要做的 - 用一个参数生成 curl,一个 url。中间的是节点的 exec 实际上做了什么。在这里,node 使用两个参数调用 sh 二进制文件(系统的 shell):-c 和我们最初传递给 child_process.exec 的字符串。 -c 代表 command,并指示 shell 将值解释为 shell 命令,解析它,然后运行它。换句话说,node 不是直接运行命令,而是让 shell 做繁重的工作。但是 shell 是 shell 语言的解释器,通过精心制作 exec 的输入,我们可以要求它运行任意代码.特别是,这就是我们在上面的坏例子中用作有效负载的内容:节点中有一个等效的安全 API:spawn.unlike exec,它使用参数数组而不是单个字符串。在内部,该 API 绕过 shell 并直接使用 execve。因此,该 API 不易受到 shell 注入的影响——被攻击可以运行带有错误参数的 curl,但除了 curl 之外,它不能运行其他东西。

有一个 exec 风格的函数,它接受一个字符串并在后台生成 /bin/sh -c,这个函数的文档包括一个巨大的免责声明,说将它与用户输入一起使用是一个坏主意,有一个安全的替代方案参数作为数组并直接生成进程。为什么要提供一个可利用的API,而一个安全的版本是可能的并且更直接?我不知道,但我的猜测是它主要只是历史。C有系统,Perl的反引号直接对应,Ruby从Perl得到反引号, Python 只是有系统,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]。如果您在语言中不提供此功能,库作者将拆分内插的字符串,这将是不安全的(不仅对于炮击 - 对于 SQLing 或 HTMLing 也是如此)。可以学习的好例子是 JavaScript 的标记模板和 Julia 的反引号。哦,对了,我写这个东西的真正原因。本节的 TL;DR 是我想抱怨一下特定的 API 设计。我很高兴地在一些 Rust 库上进行黑客攻击。在某个时候,我按下了 rust-analyzer 中的“运行测试”按钮。而且,很惊讶,不小心弄巧了自己!执行任务:cargo test --doc -- Plotter<D>::line_fill --nocapturewarning: 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'启动失败(退出代码:1)。终端将被任务重用,按任意键关闭它。太令人失望了。来吧,我帮助维护的代码中怎么会有一个 shell 注入?虽然这对于 rust-analyzer 来说不是一个大问题(我们的安全模型假设代码是可信的,因为 rustup、cargo 和 rustc可以按设计执行任意代码),这绝对是对我的审美感受的巨大打击!查看 git 历史,是我在审查期间错过了“将参数连接成单个字符串”。所以我肯定是这里问题的一部分。但另一部分是接受单个字符串的 API 根本存在.

export class ShellExecution { /** * 创建一个带有完整命令行的 shell 执行。 * * @param commandLine 要执行的命令行。 * @param options 启动外壳的可选选项。 */ 构造函数 ( commandLine : string , options ?: ShellExecutionOptions ); /* ... */ } 所以,这正是我所描述的——一个接受单个字符串的进程生成 API。我想,在这种情况下,这甚至可能是合理的——API 在 GUI 中打开一个文字 shell ,用户可以在命令完成后与其进行交互。无论如何,环顾四周后,我很快找到了另一个 API,它看起来(背景中的不祥音乐)就像我要找的: export class ShellExecution { /** * 创建一个带有命令和参数的 shell 执行。 * 对于真正的执行,编辑器将根据命令和参数构造一个 * 命令行。这个 * 有待解释,尤其是在涉及 * 引用时。如果需要完全控制命令行 * 请使用创建带有完整命令行的 `ShellExecution` 的构造函数。 * * @param command 要执行的命令。 * @param args 命令参数。 * @param options 启动外壳的可选选项。 */ 构造函数 ( command : string | ShellQuotedString , args : ( string | ShellQuotedString )[], options ?: ShellExecutionOptions ); API 接受一个字符串数组。它还试图说明一些关于引用的内容,这是一个好兆头!措辞令人困惑,但似乎很难向我解释传递 ["ls", ">", " out.txt"] 实际上不会重定向,因为 > 会被引用。这正是我想要的!两个 API 上都没有任何类型的安全说明是令人担忧的,但是哦。因此,我重构了代码以使用第二个构造函数,并且,🥁 🥁 🥁,它仍然具有完全相同的行为!原来这个 API 接受一个参数数组,并且只是将它们连接起来,除非我明确说每个参数都需要被逃脱。这就是我所抱怨的——API 看起来对于不受信任的用户输入是安全的,而事实并非如此。这就是抗滥用。