为什么应该避免在Bash中使用eval,我该使用什么代替它?

138

一次又一次地,在 Stack Overflow 上看到使用 eval 的 Bash 答案,结果因使用这种“邪恶”的构造而受到批评。为什么 eval 如此“邪恶”?

如果不能安全地使用 eval,我应该使用什么代替?


不仅仅是 eval 可能有问题;请参见 https://www.vidarholen.net/contents/blog/?p=716 了解其他一些例子。有许多 shell 构造最终会被评估、扩展等,而仅仅关注 eval 是错误的,因为它会产生虚假的安全感。重要的是要理解使用任何不受信任的数据的风险以及如何利用它。话虽如此,一个好的 SO 回答应该假定存在不受信任的外部数据,或者至少警告可能的陷阱,所以我倾向于支持 bashing,除了可能过度指责 eval - Thomas Guyot-Sionnest
1
@ThomasGuyot-Sionnest,我认为我在我的回答中已经很好地解释了这一点。请注意,这是一个自问自答的问题;我故意提出了一个其他人经常问的问题,尽管我已经知道答案。 - Zenexer
1
@ThomasGuyot-Sionnest 那个博客页面非常有趣(但是吓人极了)。我的观点是,eval 往往很容易用另一种方法替代 - 在某些情况下,甚至只需直接运行命令而无需使用字符串 - 因此值得对其进行警告。绝对想要使用 eval 通常会导致过度的事情,比如该页面上的答案将整个数组转换为另一个数组(然后转换为字符串),而初始数组本可以直接用于以相同的安全性和不使用 eval 的方式运行命令,据我所知。 - Alice M.
1
@AliceM。正确。实际上,我认为应该记住的重点是eval并不比shell的其他部分更“不安全”,而是你使用它的方式很重要。通常我的问题不是直接与eval有关,而是处理数据的方式使得使用eval更容易。如果脚本可能处理不受信任的数据,则数据验证至关重要,无论在哪里使用(包括eval)。我见过的大多数与此相关的问题甚至都没有涉及到eval。在某些情况下,运行时环境也非常重要,例如当允许脚本通过sudo运行时。 - Thomas Guyot-Sionnest
3个回答

177
这个问题比表面看起来的要复杂。我们先从显而易见的方面入手:eval有可能执行“脏”数据。脏数据是指任何未被重写为安全使用于情境XYZ的数据,在我们的情况下,是任何未经格式化以便进行评估的字符串。
乍一看,清理数据似乎很容易。假设我们正在处理一个选项列表,那么bash已经提供了一个很好的方法来清理单个元素,并提供了另一种将整个数组作为单个字符串进行清理的方法:
function println
{
    # Send each element as a separate argument, starting with the second element.
    # Arguments to printf:
    #   1 -> "$1\n"
    #   2 -> "$2"
    #   3 -> "$3"
    #   4 -> "$4"
    #   etc.

    printf "$1\n" "${@:2}"
}

function error
{
    # Send the first element as one argument, and the rest of the elements as a combined argument.
    # Arguments to println:
    #   1 -> '\e[31mError (%d): %s\e[m'
    #   2 -> "$1"
    #   3 -> "${*:2}"

    println '\e[31mError (%d): %s\e[m' "$1" "${*:2}"
    exit "$1"
}

# This...
error 1234 Something went wrong.
# And this...
error 1234 'Something went wrong.'
# Result in the same output (as long as $IFS has not been modified).

现在假设我们想要添加一个选项,将输出重定向作为println的参数。当然,我们可以在每次调用时仅重定向println的输出,但是为了举例说明,我们不会这样做。我们需要使用eval,因为变量无法用于重定向输出。

function println
{
    eval printf "$2\n" "${@:3}" $1
}

function error
{
    println '>&2' '\e[31mError (%d): %s\e[m' "$1" "${*:2}"
    exit $1
}

error 1234 Something went wrong.

看起来不错,是吧?问题是,在任何shell中,eval会解析两次命令行。在第一次解析时,将移除一层引号。随着引号的移除,一些变量内容得到执行。
我们可以通过让变量扩展在eval内部进行来修复这个问题。我们只需要把所有东西都用单引号括起来,把双引号留在原地。有一个例外:我们必须在eval之前扩展重定向,因此它必须保持在引号外面:
function println
{
    eval 'printf "$2\n" "${@:3}"' $1
}

function error
{
    println '&2' '\e[31mError (%d): %s\e[m' "$1" "${*:2}"
    exit $1
}

error 1234 Something went wrong.

这应该可以正常工作。只要在println中的$1从未被恶意篡改,就是安全的。
现在稍等一下: 我一直使用与我们最初在sudo中使用的相同的未引用语法!为什么它在那里起作用,而不是在这里?为什么我们必须将所有内容都用单引号括起来?sudo更加现代化:它知道对其接收到的每个参数都加上引号,尽管这是一个过度简化的说法。eval仅仅是将所有内容连接起来。
不幸的是,没有针对eval的替代品像sudo一样处理参数,因为eval是一个shell内置命令;这一点很重要,因为当它执行时,它会承担周围代码的环境和范围,而不是像函数一样创建一个新的堆栈和范围。

eval的替代方案

特定的用例通常有可行的替代方法来代替eval。以下是一个便捷的列表。在command中代替您想要发送给eval的任何内容。

不执行任何操作

在bash中,一个简单的冒号是一个无操作符:

:

创建子Shell

( command )   # Standard notation

执行命令的输出

永远不要依赖外部命令。您应该始终控制返回值。将它们放在自己的行中:

$(command)   # Preferred
`command`    # Old: should be avoided, and often considered deprecated

# Nesting:
$(command1 "$(command2)")
`command "\`command\`"`  # Careful: \ only escapes $ and \ with old style, and
                         # special case \` results in nesting.

基于变量的重定向

在调用代码中,将&3(或任何大于&2的值)映射到您的目标:

exec 3<&0         # Redirect from stdin
exec 3>&1         # Redirect to stdout
exec 3>&2         # Redirect to stderr
exec 3> /dev/null # Don't save output anywhere
exec 3> file.txt  # Redirect to file
exec 3> "$var"    # Redirect to file stored in $var--only works for files!
exec 3<&0 4>&1    # Input and output!

如果这只是一次性的调用,你就不需要重定向整个 shell:

func arg1 arg2 3>&2

在被调用的函数中,重定向到&3

command <&3       # Redirect stdin
command >&3       # Redirect stdout
command 2>&3      # Redirect stderr
command &>&3      # Redirect stdout and stderr
command 2>&1 >&3  # idem, but for older bash versions
command >&3 2>&1  # Redirect stdout to &3, and stderr to stdout: order matters
command <&3 >&4   # Input and output!

变量间接引用

场景:

VAR='1 2 3'
REF=VAR

不好:

eval "echo \"\$$REF\""

为什么?如果REF包含双引号,它将会被破坏并暴露给攻击者。虽然可以对REF进行清理,但有更好的方法:

echo "${!REF}"

没错,从版本2开始,bash内置了变量间接引用。如果您想执行更复杂的操作,它会比eval棘手一些。
# Add to scenario:
VAR_2='4 5 6'

# We could use:
local ref="${REF}_2"
echo "${!ref}"

# Versus the bash < 2 method, which might be simpler to those accustomed to eval:
eval "echo \"\$${REF}_2\""

尽管有经验的程序员习惯于使用eval,但新方法更加直观。

关联数组

关联数组在bash 4中被内置实现。一个注意点是:必须使用declare创建。

declare -A VAR   # Local
declare -gA VAR  # Global

# Use spaces between parentheses and contents; I've heard reports of subtle bugs
# on some versions when they are omitted having to do with spaces in keys.
declare -A VAR=( ['']='a' [0]='1' ['duck']='quack' )

VAR+=( ['alpha']='beta' [2]=3 )  # Combine arrays

VAR['cow']='moo'  # Set a single element
unset VAR['cow']  # Unset a single element

unset VAR     # Unset an entire array
unset VAR[@]  # Unset an entire array
unset VAR[*]  # Unset each element with a key corresponding to a file in the
              # current directory; if * doesn't expand, unset the entire array

local KEYS=( "${!VAR[@]}" )  # Get all of the keys in VAR

在旧版的bash中,您可以使用变量间接引用:
VAR=( )  # This will store our keys.

# Store a value with a simple key.
# You will need to declare it in a global scope to make it global prior to bash 4.
# In bash 4, use the -g option.
declare "VAR_$key"="$value"
VAR+="$key"
# Or, if your version is lacking +=
VAR=( "$VAR[@]" "$key" )

# Recover a simple value.
local var_key="VAR_$key"       # The name of the variable that holds the value
local var_value="${!var_key}"  # The actual value--requires bash 2
# For < bash 2, eval is required for this method.  Safe as long as $key is not dirty.
local var_value="`eval echo -n \"\$$var_value\""

# If you don't need to enumerate the indices quickly, and you're on bash 2+, this
# can be cut down to one line per operation:
declare "VAR_$key"="$value"                         # Store
echo "`var_key="VAR_$key" echo -n "${!var_key}"`"   # Retrieve

# If you're using more complex values, you'll need to hash your keys:
function mkkey
{
    local key="`mkpasswd -5R0 "$1" 00000000`"
    echo -n "${key##*$}"
}

local var_key="VAR_`mkkey "$key"`"
# ...

5
我注意到缺少了对 eval "export $var='$val'" 的提及……(?) - Zrin
1
@Zrin 可能你的期望并不是这样的。export "$var"="$val" 才是你想要的。你可能只有在 var='$var2' 的情况下才会使用你的形式,而且你想要双重引用它——但你不应该在 bash 中尝试做任何类似的事情。如果你真的必须这样做,你可以使用 export "${!var}"="$val" - Zenexer
1
@anishsane:假设你有这样一个变量 x="echo hello world";,那么要执行 x 中包含的任何内容,我们可以使用 eval $x。然而,$($x) 是错误的,对吗?是的:$($x) 是错误的,因为它会运行 echo hello world,然后尝试运行捕获的输出(至少在我认为你使用它的上下文中是这样),这将失败,除非你有一个叫做 hello 的程序。 - Jonathan Leffler
1
@tmow 啊,所以你实际上想要 eval 功能。如果是这样的话,你可以使用 eval;只需记住它有很多安全注意事项。这也表明你的应用程序存在设计缺陷。 - Zenexer
2
ref="${REF}_2" echo "${!ref}" 这个示例是错误的,因为bash在执行命令之前会先替换变量。如果 ref 变量确实未定义,则替换结果将是 ref="VAR_2" echo "",这也就是实际执行的内容。 - Yoory N.
显示剩余13条评论

26

如何安全地使用eval

eval 可以被安全地使用,但是它的所有参数都需要先加上引号。以下是方法:

这个函数可以帮你实现:

function token_quote {
  local quoted=()
  for token; do
    quoted+=( "$(printf '%q' "$token")" )
  done
  printf '%s\n' "${quoted[*]}"
}

示例用法:

给定一些不受信任的用户输入:

% input="Trying to hack you; date"
构建一个用于执行的命令:
% cmd=(echo "User gave:" "$input")

使用表面上看起来正确的引用进行评估:

% eval "$(echo "${cmd[@]}")"
User gave: Trying to hack you
Thu Sep 27 20:41:31 +07 2018

注意,你的账户被黑了。执行了date而不是直接打印出来。

相反,使用token_quote()

相反,使用token_quote()

% eval "$(token_quote "${cmd[@]}")"
User gave: Trying to hack you; date
%

eval 不是邪恶的,它只是被误解了 :)


1
“token_quote”函数如何使用其参数?我找不到任何关于此功能的文档... - Akito
2
@Akito 【for循环中的 in words 部分是可选的。】(https://www.gnu.org/software/bash/manual/bash.html#Looping-Constructs) - Tom Hale
2
我认为“被误解”这个词并不足以描述它的问题,因为它经常被滥用,而且实际上并不需要。Zenexer的回答涵盖了许多这样的情况,但是任何使用eval的情况都应该引起警惕,并仔细检查是否已经有语言提供的更好的选项。 - dimo414
1
这个 eval "$(token_quote "${cmd[@]}")" 相对于直接写 "${cmd[@]}" 有什么优势呢?Bash 能够自己扩展数组并运行生成的命令;我在这里得到了正确的输出。 - Alice M.
2
@Akito,“for foo”等同于“for foo in”$@”,因此等同于“for foo in”$1“… ”$n”。 - Alice M.
显示剩余3条评论

10
我将把这个答案分成两个部分,我认为这两个部分涵盖了人们往往会被eval所吸引的大部分情况:
  1. 运行奇怪构建的命令
  2. 操纵动态命名的变量

运行奇怪构建的命令

很多时候,简单的索引数组就足够了,前提是你在定义数组时采取良好的习惯,使用双引号来保护扩展。

# One nasty argument which must remain a single argument and not be split:
f='foo bar'
# The command in an indexed array (use `declare -a` if you really want to be explicit):
cmd=(
    touch
    "$f"
    # Yet another nasty argument, this time hardcoded:
    'plop yo'
)
# Let Bash expand the array and run it as a command:
"${cmd[@]}"

这将创建foo barplop yo(两个文件,而不是四个)。
请注意,有时将参数(或一堆选项)仅放在数组中可以生成更易读的脚本(至少您一眼就知道正在运行什么):
touch "${args[@]}"
touch "${opts[@]}" file1 file2

作为额外的好处,数组让你可以轻松地:
1.添加关于特定参数的注释:
cmd=(
    # Important because blah blah:
    -v
)
  1. 在数组定义中留空行,以提高阅读性。
  2. 为了调试目的,注释掉特定的参数。
  3. 根据特定条件或循环,有时会动态地将参数附加到命令末尾:
cmd=(myprog)
for f in foo bar
do
    cmd+=(-i "$f")
done
if [[ $1 = yo ]]
then
    cmd+=(plop)
fi
to_be_added=(one two 't h r e e')
cmd+=("${to_be_added[@]}")
  1. 在配置文件中定义命令,同时允许包含空格的参数取决于配置:
readonly ENCODER=(ffmpeg -blah --blah 'yo plop')
# Deprecated:
#readonly ENCODER=(avconv -bloh --bloh 'ya plap')
# […]
"${ENCODER[@]}" foo bar

记录一个稳健可靠的命令,使用printf的%q格式完美地表示正在运行的命令。
function please_log_that {
    printf 'Running:'
    # From `help printf`:
    # “The format is re-used as necessary to consume all of the arguments.”
    # From `man printf` for %q:
    # “printed in a format that can be reused as shell input,
    # escaping  non-printable  characters with the proposed POSIX $'' syntax.”
    printf ' %q' "$@"
    echo
}

arg='foo bar'
cmd=(prog "$arg" 'plop yo' $'arg\nnewline\tand tab')
please_log_that "${cmd[@]}"
# ⇒ “Running: prog foo\ bar plop\ yo $'arg\nnewline\tand tab'”
# You can literally copy and paste that ↑ to a terminal and get the same execution.
  1. 使用这种方法比使用eval字符串更好的语法高亮,因为您不需要嵌套引号或使用“将在某个时刻评估但不会立即评估”的$

对我来说,这种方法的主要优点(相反,eval的缺点)是您可以遵循通常的引用、扩展等逻辑。无需费尽心思地试图“提前”在引号中放置引号中的引号,同时试图弄清楚哪个命令将在哪个时刻解释哪个引号对。当然,上面提到的许多事情使用eval更难甚至根本无法实现。

使用这些方法,我过去六年从未依赖于eval,可读性和鲁棒性(特别是涉及包含空格的参数)得到了提高。您甚至不需要知道IFS是否被篡改!当然,仍然存在一些边缘情况,可能确实需要使用eval(例如,如果用户必须能够通过交互提示或其他方式提供一个完整的脚本片段),但希望这不是您每天都会遇到的事情。

玩弄动态命名的变量

declare -n(或其函数内部的local -n替代方案),以及${!foo}大多数时候都可以解决问题。

$ help declare | grep -- -n
      -n    make NAME a reference to the variable named by its value

嗯,没有例子的话就不是特别清楚:

declare -A global_associative_array=(
    [foo]=bar
    [plop]=yo
)

# $1    Name of global array to fiddle with.
fiddle_with_array() {
    # Check this if you want to make sure you’ll avoid
    # circular references, but it’s only if you really
    # want this to be robust.
    # You can also give an ugly name like “__ref” to your
    # local variable as a cheaper way to make collisions less likely.
    if [[ $1 != ref ]]
    then
        local -n ref=$1
    fi
    
    printf 'foo → %s\nplop → %s\n' "${ref[foo]}" "${ref[plop]}"
}

# Call the function with the array NAME as argument,
# not trying to get its content right away here or anything.
fiddle_with_array global_associative_array

# This will print:
# foo → bar
# plop → yo

我喜欢这个技巧 ↑,因为它让我感觉像在面向对象的语言中将对象传递给函数一样。它的可能性令人惊叹。

至于 ${!…}(用于获取由另一个变量命名的变量的值):

foo=bar
plop=yo

for var_name in foo plop
do
    printf '%s = %q\n' "$var_name" "${!var_name}"
done

# This will print:
# foo = bar
# plop = yo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接