外壳执行很糟糕

2012年3月11日 | Stefan Karpinski

通过中间 shell 生成一系列连接程序的管道——也就是“外壳执行”——是一种非常方便和有效的方法来完成任务。它非常方便,以至于一些“粘合语言”,例如 PerlRuby,甚至为此提供了特殊的语法(反引号)。但是,外壳执行也是错误、安全漏洞、不必要的开销和静默故障的常见来源。以下列出了外壳执行存在问题的三个原因

  1. 元字符脆弱性。当命令以编程方式构建时,生成的代码几乎总是脆弱的:如果用于构建命令的变量包含任何 shell 元字符,包括空格,则命令可能会中断并执行与预期截然不同的操作——可能是一些非常危险的操作。

  2. 间接性和低效率。当执行外壳时,主程序会派生并执行一个 shell 进程,以便 shell 可以依次派生并执行一系列命令,并将其输入和输出适当地连接起来。启动 shell 不仅是不必要的步骤,而且由于主程序不是管道命令的父级,因此它无法在命令终止时收到通知——它只能等待管道完成并希望 shell 指示发生了什么。

  3. 默认情况下静默失败。在大多数语言中,外壳执行的命令中的错误不会自动变成异常。这种默认的宽松性会导致代码在执行外壳命令失败时静默失败。更糟糕的是,由于间接性问题,在许多情况下,派生管道中进程的失败无法被父进程检测到,即使对错误进行了细致的检查。

在本文的其余部分,我将介绍演示每个问题的示例。在最后,我将讨论外壳执行的更好替代方案,并在后续文章中。我将演示 Julia 如何使这些更好的替代方案变得非常易于使用。以下示例使用 Ruby 执行到 Bash,但无论使用哪种语言执行外壳,问题都一样:使用中间 shell 进程生成外部命令的技术是有问题的,而不是语言。

元字符脆弱性

让我们从一个简单的 Ruby 外壳执行示例开始。假设您想要计算给定目录下所有文件中包含字符串“foo”的行数。一种选择是编写 Ruby 代码来读取给定目录的内容,查找所有文件,打开它们并遍历它们以查找字符串“foo”。但是,这需要大量工作,并且比使用标准 UNIX 命令的管道慢得多,这些命令是用 C 编写的并且经过了大量优化。在 Ruby 中,最自然和最方便的做法是执行外壳,使用反引号捕获输出

`find #{dir} -type f -print0 | xargs -0 grep foo | wc -l`.to_i

此表达式将dir变量插入到命令中,生成一个 Bash shell 来执行生成的命令,将输出捕获到字符串中,然后将该字符串转换为整数。该命令使用-print0-0选项来正确处理从findxargs的管道中文件名的奇怪字符(这些选项导致文件名由NUL而不是空格分隔)。即使使用非常谨慎的选项,此外壳执行代码也很简单明了。以下是它的实际操作

irb(main):001:0> dir="src"
=> "src"
irb(main):002:0> `find #{dir} -type f -print0 | xargs -0 grep foo | wc -l`.to_i
=> 5

很好。但是,这仅在目录名称dir不包含 shell 认为是特殊的任何字符时才按预期工作。例如,shell 使用空格来确定构成命令单个参数的内容。因此,如果dir的值是一个包含空格的目录名,则会失败

irb(main):003:0> dir="source code"
=> "source code"
irb(main):004:0> `find #{dir} -type f -print0 | xargs -0 grep foo | wc -l`.to_i
find: `source': No such file or directory
find: `code': No such file or directory
=> 0

解决空格问题的简单方法是在插入的目录名周围加上引号,告诉 shell 将内部的空格视为普通字符

irb(main):005:0> `find '#{dir}' -type f -print0 | xargs -0 grep foo | wc -l`.to_i
=> 5

优秀。那么问题是什么?虽然此解决方案解决了文件名中包含空格的问题,但它仍然容易受到其他 shell 元字符的影响。如果文件名中包含引号字符怎么办?让我们试一试。首先,让我们创建一个非常奇怪命名的目录

bash-3.2$ mkdir "foo'bar"
bash-3.2$ echo foo > "foo'bar"/test.txt
bash-3.2$ ls -ld foo*bar
drwxr-xr-x 3 stefan staff 102 Feb  3 16:17 foo'bar/

这是一个公认的奇怪目录名,但在所有类型的 UNIX 中都是完全合法的。现在回到 Ruby

irb(main):006:0> dir="foo'bar"
=> "foo'bar"
irb(main):007:0> `find '#{dir}' -type f -print0  | xargs -0 grep foo | wc -l`.to_i
sh: -c: line 0: unexpected EOF while looking for matching `''
sh: -c: line 1: syntax error: unexpected end of file
=> 0

哎呀。虽然这可能看起来像是人们不必真正担心的不太可能发生的极端情况,但它会带来严重的安全隐患。假设目录的名称来自不受信任的来源——例如 Web 提交或来自不受信任用户的 setuid 程序的参数。假设攻击者可以安排他们想要的任何dir

irb(main):008:0> dir="foo'; echo MALICIOUS ATTACK 1>&2; echo '"
=> "foo'; echo MALICIOUS ATTACK 1>&2; echo '"
irb(main):009:0> `find '#{dir}' -type f -print0  | xargs -0 grep foo | wc -l`.to_i
find: `foo': No such file or directory
MALICIOUS ATTACK
grep:  -type f -print0
: No such file or directory
=> 0

您的系统现在被攻破了。当然,您可以对dir变量的值进行清理,但在安全(尽可能受限)和灵活性(尽可能不受限)之间存在根本的拉锯战。理想的行为是允许任何目录名,无论多么奇怪,只要它确实存在,但“消除”所有 shell 元字符。

完全防止此类元字符攻击(无论是恶意的还是意外的)——同时仍然使用外部 shell 来构建管道,的唯一两种方法是执行完整的 shell 元字符转义

irb(main):010:0> require 'shellwords'
=> true
irb(main):011:0> `find #{Shellwords.shellescape(dir)} -type f -print0  | xargs -0 grep foo | wc -l`.to_i
find: `foo\'; echo MALICIOUS ATTACK 1>&2; echo \'': No such file or directory
=> 0

使用 shell 转义,这可以安全地尝试搜索一个非常奇怪命名的目录,而不是执行恶意攻击。虽然 shell 转义确实有效(假设 shell 转义实现中没有任何错误),但实际上,没有人真正这样做——太麻烦了。相反,使用以编程方式构建的命令执行外壳的代码通常在最好的情况下充满了潜在的错误,在最坏的情况下存在巨大的安全漏洞。

间接性和低效率

如果我们使用上述代码来计算目录中包含字符串“foo”的行数,我们希望检查一切是否正常,并在出现问题时做出相应的响应。在 Ruby 中,您可以使用奇怪命名的$?.success?指示器检查外壳执行的命令是否成功

irb(main):012:0> dir="src"
=> "src"
irb(main):013:0> `find #{Shellwords.shellescape(dir)} -type f -print0  | xargs -0 grep foo | wc -l`.to_i
=> 5
irb(main):014:0> $?.success?
=> true

好的,这正确地指示了成功。让我们确保它可以检测失败

irb(main):015:0> dir="nonexistent"
=> "nonexistent"
irb(main):016:0> `find #{Shellwords.shellescape(dir)} -type f -print0  | xargs -0 grep foo | wc -l`.to_i
find: `nonexistent': No such file or directory
=> 0
irb(main):017:0> $?.success?
=> true

等等。什么?!那不成功。发生了什么事?

问题的核心是,当您执行外壳时,管道中的命令不是主程序的直接子级,而是其孙级:程序生成一个 shell,该 shell 创建一堆 UNIX 管道,派生子进程,使用dup2系统调用将输入和输出连接到管道,然后执行相应的命令。因此,您的主程序不是管道中命令的父级,而是其祖父。因此,它不知道它们的进程 ID,也不能等待它们或在它们终止时获取它们的退出状态。shell 进程(它是它们的父级)必须执行所有这些操作。您的程序只能等待 shell 完成并查看是否成功。如果 shell 只执行单个命令,则可以

irb(main):018:0> `cat /dev/null`
=> ""
irb(main):019:0> $?.success?
=> true
irb(main):020:0> `cat /dev/nada`
cat: /dev/nada: No such file or directory
=> ""
irb(main):021:0> $?.success?
=> false

不幸的是,默认情况下,shell 对其认为成功的管道相当宽松

irb(main):022:0> `cat /dev/nada | sort`
cat: /dev/nada: No such file or directory
=> ""
irb(main):023:0> $?.success?
=> true

只要管道中的最后一个命令成功——在本例中为sort——整个管道就被认为是成功的。因此,即使管道中一个或多个较早的程序出现严重故障,最后一个命令也可能不会失败,导致 shell 将整个管道视为成功。这可能不是您对成功的含义。

Bash 对管道成功的概念可以通过pipefail选项变得更加严格。此选项会导致 shell 仅当其所有命令都成功时才将管道视为成功

irb(main):024:0> `set -o pipefail; cat /dev/nada | sort`
cat: /dev/nada: No such file or directory
=> ""
irb(main):025:0> $?.success?
=> false

由于每次执行外壳时都会生成一个新的 shell,因此必须为每个多命令管道设置此选项才能确定其真正的成功状态。当然,就像转义每个插入变量一样,在每个命令的开头设置pipefail只是没有人真正做的事情。此外,即使使用pipefail选项,您的程序也无法确定管道中哪些命令不成功——它只知道某处出了问题。虽然这比静默失败并继续执行就像没有问题一样要好,但它对事后调试并没有太大帮助:许多程序不像cat那样表现良好,并且在崩溃前打印错误消息时实际上并没有识别自己或具体问题。

鉴于外壳执行导致的其他问题,提及仅仅为了生成一堆其他进程而执行 shell 进程效率低下似乎是一个无关紧要的附带说明。但是,它确实是造成不必要开销的真正来源:主进程可以自己完成 shell 执行的工作。要求内核派生一个进程并执行一个新程序是一项非微不足道的任务。您唯一需要 shell 为您完成这项工作的理由是它很复杂并且很难做好。shell 使其变得简单。因此,编程语言传统上依赖于 shell 为它们设置管道,而不管由于间接性导致的额外开销和问题。

默认情况下静默失败

让我们回到我们执行外壳以计算“foo”行的示例。以下是我们为了避免元字符损坏而需要使用的完整表达式,这样我们才能实际判断整个管道是否成功

`set -o pipefail; find #{Shellwords.shellescape(dir)} -type f -print0  | xargs -0 grep foo | wc -l`.to_i

但是,当执行外壳的命令失败时,默认情况下不会引发错误。为了避免静默错误,我们需要在每次执行外壳后显式检查$?.success?,如果它指示失败则引发异常。当然,手动执行此操作很繁琐,因此,它基本上没有被执行。默认行为——因此最简单和最常见的行为——是假设执行外壳的命令已成功并完全忽略失败。为了使我们的“foo”计数示例表现良好,我们必须将其包装在一个函数中,如下所示

def foo_count(dir)
n = `set -o pipefail;
   find #{Shellwords.shellescape(dir)} -type f -print0  | xargs -0 grep foo | wc -l`.to_i
raise("pipeline failed") unless $?.success?
return n
end

此函数的行为符合我们的预期

irb(main):026:0> foo_count("src")
=> 5
irb(main):027:0> foo_count("source code")
=> 5
irb(main):028:0> foo_count("nonexistent")
find: `nonexistent': No such file or directory
RuntimeError: pipeline failed
from (irb):5:in `foo_count'
from (irb):13
from :0
irb(main):029:0> foo_count("foo'; echo MALICIOUS ATTACK; echo '")
find: `foo\'; echo MALICIOUS ATTACK; echo \'': No such file or directory
RuntimeError: pipeline failed
from (irb):5:in `foo_count'
from (irb):14
from :0

但是,这个 6 行 200 个字符的函数与我们最初的简洁性和简洁性相去甚远

`find #{dir} -type f -print0 | xargs -0 grep foo | wc -l`.to_i

如果大多数程序员在程序中看到这个更长、更安全的版本,他们可能会想知道为什么有人会编写如此冗长、晦涩的代码来完成如此简单直接的事情。

总结与解决方法

总之,使用 shell 执行外部命令非常方便,但要编写出无 bug、安全且不易出现静默故障的代码,需要做到三件事,而这些事通常不会被执行。

  1. 对用于构建命令的所有值进行 shell 转义。

  2. 在每个多命令管道前加上 "set -o pipefail;"。

  3. 在每个 shell 执行的命令之后显式检查错误。

问题在于,在做了所有这些事情之后,使用 shell 执行命令就不再那么方便了,代码也变得冗长乏味。简而言之,负责任地使用 shell 执行命令有点令人头疼。

正如很多情况下一样,所有这些问题的根源在于依赖中间人,而不是自己动手。如果程序自己构建和执行管道,它就能控制所有子进程,确定它们的单独退出条件,自动适当地处理错误,并在出现问题时给出准确、全面的诊断消息。此外,如果没有 shell 来解释命令,也就没有 shell 会特殊处理元字符,因此也就没有元字符脆弱性的风险。 Python 做到了这一点:使用 os.popen 执行 shell 命令已被官方弃用,推荐的方式是使用 subprocess 模块,它可以在不使用 shell 的情况下生成外部程序。使用 subprocess 构建管道 可能会有点冗长,但它很安全,并且避免了 shell 执行命令容易出现的所有问题。在我的 后续文章 中,我将介绍 Julia 如何使构建和执行外部命令管道像 Python 的 subprocess 一样安全,像 shell 执行命令一样方便。