shell中实用eval命令和安全问题

eval命令非常强大,但也非常容易被滥用。

它会导致代码被解析两次而不是一次。这意味着,如果你的代码中包含变量引用,shell解析器将评估该变量的内容。如果变量包含一个shell命令,shell可能会运行该命令,无论你是否希望运行它。这可能会导致意外的结果,特别是当变量可以从不受信任的来源(如用户或用户创建的文件)读取时。

请注意,eval命令在编程中被广泛认为是危险的。它可以执行任意的Shell代码,包括恶意代码,因此应该谨慎使用。

Bash的名称引用问题

Bash 4.3引入了declare -n("名称引用")来模仿Korn shell的nameref​​特性,允许变量保存对其他变量的引用。然而,Bash中使用的实现存在一些问题。

首先,Bash的declare -n​​实际上并没有避免名称冲突问题:

$ foo() { declare -n v=$1; }
$ bar() { declare -n v=$1; foo v; }
$ bar v
bash: warning: v: circular name reference

换句话说,我们无法给名称引用指定一个安全的名称。如果调用者的变量恰好具有相同的名称,那就麻烦了。

其次,Bash的名称引用实现仍然允许任意代码执行:

$ foo() { declare -n var=$1; echo "$var"; }
$ foo 'x[i=$(date)]'
bash: i=Thu Mar 27 16:34:09 EDT 2034: syntax error in expression (error token is "Mar 27 16:34:09 EDT 2023")

这个例子并不优雅,但你可以清楚地看到date​​命令实际上被执行了。这绝不是我们想要的结果。

尽管存在这些缺点,declare -n​​特性是朝着正确方向迈出的一步。但你必须小心选择一个调用者不会使用的名称(这意味着你需要对调用者有某种控制,即使只是告诉他们“不要使用以_my_pkg​​开头的变量”),并且必须拒绝不安全的输入。

eval的良好使用示例

eval最常见的正确使用方式是从专门设计为以这种方式使用的程序输出中读取变量。例如,

# 在旧系统上,调整窗口大小后必须运行以下命令:
eval "`resize`"

# 更高级的用法:获取SSH私钥的密码短语。
# 这通常从.xsession或.profile类型的文件执行。
# ssh-agent生成的变量将被导出到用户会话中的所有进程,以便之后的ssh命令可以继承这些变量。
eval "`ssh-agent -s`"

eval还有其他用途,特别是在创建变量时(参考indirect variable references ↗)。以下是一种解析不带参数的命令行选项的示例:

# POSIX
#
# 动态创建选项变量。尝试调用:
#
#    sh -x example.sh --verbose --test --debug

for i; do
    case $i in
       --test|--verbose|--debug)
            shift                   # 从命令行中移除选项
            name=${i#--}            # 删除选项前缀
            eval "$name=\$name"    # 创建*新*变量
            ;;
    esac
done

echo "verbose: $verbose"
echo "test: $test"
echo "debug: $debug"

那么,为什么这个版本是可接受的呢?这是因为我们限制了eval命令的使用,只有在输入是一组有限的已知值之一时才会执行。因此,用户无法滥用它以导致任意命令执行——任何包含奇怪内容的输入都不会匹配三个预定的可能输入之一。

请注意,这仍然是不推荐的:这是一条很陡峭的道路,稍后的维护很容易将这段代码变成危险的内容。例如,你想要添加一个功能,允许传递一堆不同的--test-xyz选项。你将--test更改为--test-*,而不费力地检查脚本的其他部分的实现。你测试你的用例,一切正常。不幸的是,你刚刚引入了任意命令执行:

$ ./foo --test-'; ls -l /etc/passwd;x='
-rw-r--r-- 1 root root 943 2007-03-28 12:03 /etc/passwd

再次强调:允许eval命令在未经过滤的用户输入上使用会导致任意命令执行。

尽一切可能避免将数据传递给eval,即使你的代码似乎处理了所有边界情况。

如果你经过深思熟虑并向#bash寻求了替代方法,但没有找到任何方法,请跳到"Robust eval usage"部分。

使用declare的问题

使用declare能更好地完成这个任务吗?

for i in "$@"; do
    case "$i" in
        --test|--verbose|--debug)
            shift                   # 从命令行中移除选项
            name=${i#--}            # 删除选项前缀
            declare $name=Yes       # 设置默认值
            ;;
        --test=*|--verbose=*|--debug=*)
            shift
            name=${i#--}
            value=${name#*=}        # value是第一个单词后面的内容和=
            name=${name%%=*}        # 仅限于第一个单词(即使值中有另一个=)
            declare $name="$value"  # 创建*新*变量
            ;;
    esac
done

请注意,--name用于默认值,--name=value是必需的格式。

以下是eval的一个良好使用示例,用于从专门设计为以这种方式使用的程序输出中读取变量:

# 在旧系统上,调整窗口大小后必须运行以下命令:

eval "`resize`"

# 更高级的用法:获取SSH私钥的密码短语。

# 这通常从.xsession或.profile类型的文件执行。

# ssh-agent生成的变量将被导出到用户会话中的所有进程,以便之后的ssh命令可以继承这些变量。

eval "`ssh-agent -s`"

eval还可以用于创建变量时,尤其是在创建间接变量引用时。下面是一个解析不带参数的命令行选项的示例:

# POSIX
#
# 动态创建选项变量。尝试调用:
#
#    sh -x example.sh --verbose --test --debug

for i; do
    case $i in
       --test|--verbose|--debug)
            shift                   # 从命令行中移除选项
            name=${i#--}            # 删除选项前缀
            eval "$name=\$name"    # 创建*新*变量
            ;;
    esac
done

echo "verbose: $verbose"
echo "test: $test"
echo "debug: $debug"

尽管这个示例中的eval使用看起来安全,但仍然不推荐广泛使用eval命令,因为它需要非常小心的输入过滤和验证,以避免任意命令执行漏洞。尽量避免将数据传递给eval,并寻找替代方案,以增加脚本的安全性。

使用declare存在的问题

难道使用declare不能更好地解决这个问题吗?

for i in "$@"; do
    case "$i" in
        --test|--verbose|--debug)
            shift                   # 从命令行中移除选项
            name=${i#--}            # 删除选项前缀
            declare $name=Yes       # 设置默认值
            ;;
        --test=*|--verbose=*|--debug=*)
            shift
            name=${i#--}
            value=${name#*=}        # 值是等号后面的内容
            name=${name%%=*}        # 仅限于第一个单词的名称(即使值中还有另一个等号)
            declare $name="$value"  # 创建*新的*变量
            ;;
    esac
done

请注意,默认情况下,--name和--name=value是必需的格式。

对于某些输入,declare确实可以更好地工作:

griffon:~$ name='foo=x;date;x'
griffon:~$ declare $name=Yes
griffon:~$ echo $foo
x;date;x=Yes

但它仍然会导致数组变量中的任意代码执行:

attoparsec:~$ echo $BASH_VERSION
4.2.24(1)-release
attoparsec:~$ danger='( $(printf "%s!\n" DANGER >&2) )'
attoparsec:~$ declare safe=${danger}
attoparsec:~$ declare -a unsafe
attoparsec:~$ declare unsafe=${danger}
DANGER!

这段代码展示了使用declare可能引发的安全问题。在某些情况下,使用declare可能会导致任意代码执行,从而产生潜在的安全漏洞。在这个例子中,变量的值包含了一个命令,当使用declare声明变量时,该命令将被执行。这可能导致不受信任的代码执行,从而引发安全问题。

为了确保脚本的安全性,应该避免将不受信任的数据传递给declare命令。如果需要动态创建变量,可以考虑使用其他安全的方法或寻找替代方案,以避免潜在的安全风险。

强大的eval​​用法

几乎总是(至少在Bash中99%或更多的时间内,但也适用于更简洁的shell),正确地使用eval​​的方式是在库代码中生成隐藏在函数背后的抽象层。这允许函数具有以下功能:

  • 向函数的调用者呈现一个明确定义的接口,指定哪些输入必须由程序员严格控制,哪些可能是不可预测的,例如受用户输入影响的副作用。重要的是要记录哪些选项和参数在没有控制的情况下是不安全的。
  • 对某些类型的输入进行输入验证,如果可行,例如整数。在这种情况下,可以轻松地退出并返回一个错误状态,该错误状态可以由函数的调用者处理。
  • 创建隐藏使用eval​​的丑陋实现细节的抽象。

通常,当满足以下至少全部条件时,eval​​是正确的:

  • 可能的所有eval​​参数都保证不会在任何情况下产生有害的副作用或导致任意代码的执行。这些输入是静态编码的,不与不受控制的动态代码交互,并且/或经过彻底验证。这就是为什么函数很重要,因为你不一定需要自己保证这个保证。只要您的函数记录了哪些输入可能是危险的,您就可以将这个任务委托给函数的调用者。
  • ​eval​​用法向用户或程序员呈现了一个清晰的接口。
  • ​eval​​使得原本不可能的事情成为可能,而无需编写更大、更慢、更复杂、更危险、更丑陋、更不实用的代码。

如果出于某种原因仍然需要动态构建Bash代码并评估它,请确保采取以下预防措施:

  1. 始终引用eval​​表达式:eval 'a=b'​​
  2. 始终使用单引号引用代码,并使用printf​​的%q​​将数据扩展到其中:eval "$(printf 'myvar=%q' "$value")"​​
  3. 不要使用动态变量名。即使使用了小心的%q​​用法,这也可能会被利用。

为什么要注意?如果未能遵循上述建议,以下是脚本可能会受到利用的示例:

  • 如果不对代码进行单引号引用,则存在将数据扩展到其中而没有进行%q​​处理的风险。这意味着该数据可以自由执行:
name='Bob; echo I am arbitrary code'; eval "user=$name"
  • 即使在对输入数据进行%q​​处理之后再将其视为变量名进行处理,如果赋值中存在非法变量名,Bash将会在PATH​​中搜索命令:
echo 'echo I am arbitrary code' > /usr/local/bin/a[1]=b; chmod +x /usr/local/bin/a[1]=b; var='a[1]' value=b; eval "$(printf '%q=%q' "$var" "$value")"

你可能感兴趣的:(安全)