;Echo Shell 注入
这是一篇关于 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 来说不是一个大问题(我们的安全模型假定受信任的代码,因为每个 rustup
、 cargo
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 看起来对于不受信任的用户输入是安全的,而事实并非如此。这就是抗误用性。
就是这样,感谢您的阅读!