目录


第二十三章 安全

不管你是正在处理一个用户坐在键盘前面键入的命令还是处理别人通过网络发送过来的 信息,你都需要仔细注意到达你的程序里的数据,因为其他人可能有意无意地给你发送 一些有害无益的数据。Perl 提供一种特殊的安全检查机制,叫感染模式,它的目的就是 隔离感染了的数据,这样你就不会把这些数据误用于一些不该用的场合。比如,如果你 了一个感染了的文件名,结果就有可能向你的口令文件里增加记录,而你还认为是一个日志 文件。这个感染的机制在“操作不安全数据”节里讲述。

在一个多任务的环境里,一个进程的后台动作可能会影响你自己的程序的安全。如果你 认为自己对一个外部对象(尤其是文件)有绝对的所有权,就好象你是系统里的唯一的 的一个进程一样,那么你就完全暴露在那些比来自你操作的数据或者程序代码中的错误 更不确定的错误之中。Perl 在可以帮助你检测一些超出你的控制的情况,但是对于那些 你可以控制的事情,关键是了解那些方法是对那些入侵者免疫的。“处理计时缝隙”节 讨论这些问题。

如果你从一个陌生人处获取的数据正好是一段可执行的代码,那么你就需要比对待他们的 数据更仔细。Perl 提供一些检查以截获伪装成数据的隐蔽的代码,这样你就不会无意中 执行它们。不过,如果你想执行外部代码,Safe 模块让你隔离可疑的代码,在隔离区里, 它无法做任何有害的事情,只可能做一些有益的事情。这些是“处理不安全代码”一节的 内容。

23.1 处理不安全数据

即使你的程序要被那些比程序本身更不可靠的用户使用,Perl 也能让你写安全的程序变得 更容易。也就是说,程序需要赋予它们的用户一些有限的权限,而不能给出其他权限。 Unix 里的 setuid 和 setgid 程序就落在这个范畴里,以及在其他操作系统上的各种支持 这个概念的运行在特权模式的程序。即使在那些不支持这个概念的系统上,同样的概念也 适用于那些网络服务器以及任何网络服务器运行的程序(比如 CGI 脚本,邮件列表 处理器,以及在 /etc/inetd.conf 里列出的守护进程)。所有这样的程序都需要一个比 普通程序更高级别的安全性。

甚至那些在命令行上运行的程序有时候也是感染模式的好候选,尤其是如果它们要被一个 有特权的用户使用的时候。那些在不可靠的数据上操作的程序,比如那些从日志文件生成 统计信息的程序或者用 LWP::* 或者 Net::* 抓取远程数据的程序,都应该运行在明确地 把感染打开的模式下;不谨慎的程序容易冒进入“特洛伊木马”的圈套的风险。因为程序 没有任何减少风险的措施,所以它们就没有理由不小心在意。

与 Unix 的命令行 shell 相比(shell 实际上只是调用其他程序的框架),Perl 更容易 安全编程,因为它的直接了当和自包容性。和其他大多数 shell 编程语言不一样,那些 语言是以对每行脚本的多次神秘的替换阶段为基础的,而 Perl 使用的是更方便的计算 设计,并且隐藏的障碍也更少。另外,因为该语言有更多内建的功能,所以它很少依赖外部 (外部可能是不可靠的)程序实现其目的。

在 Perl 的家乡,Unix 里,破坏系统安全的最好的方法就是诱骗一个特权程序做一些它 不该做的事情。为了避免这种攻击,Perl 开发了一种独特的方法用于处理敌对环境。当 Perl 检测到它的程序运行时其真实用户或组 ID 和有效用户或组 ID 不同时,会自动打开 感染模式(注:在 Unix 里 setuid 的权限是 04000,而 setgid 位是 02000;其中一个 或者两个都可以赋予程序的使用者和程序的所有者相同的权限。(这些程序叫做 set-id 程序。)其他操作系统可能用其它方法给程序赋予特殊权限,但是概念是一样的)。即使 包含你的 Perl 脚本的文件本身并没有打开 setuid 或者 setgid 位,该脚本仍然可能会 以感染模式执行。这种情况发生在你的脚本被另外一个程序调用,而那个程序本身是运行在 不同的 ID 上的。没有设计成在感染模式下运行的 Perl 程序在碰到违反合理感染策略的 情况下会提前退出。这么处理是合理的,因为这种方法是历史上感染 shell 脚本并破坏 系统安全的恶作剧之一。Perl 可没那么容易上当。

你还可以用 -T 命令行开关明确打开感染模式。你应该对那些守护进程,服务器,以及任何 代表别人运行的程序(比如 CGI 脚本)上打开这个开关。那些可以远程运行的程序以及 可以由网络上的外人匿名运行的程序是在最恶劣的环境中运行的程序,你不应该害怕偶尔 说句“No!”。与全面信任相反,你可以采取许多谨慎的措施来保证安全而不用担心丧失 功能。

在更关心安全的站点上,把所有 CGI 脚本运行在 -T 标志下就不仅仅是个好主意了,而是 一条命令。我们并不是说在感染模式下运行就能让给你的脚本足够安全。不是这样的,光是 描写那些可能不安全的东西就足够写一整本书了。但是如果你不在感染模式下运行你的 CGI 脚本,那么你实际上就是毫无道理地放弃了 Perl 能够给你的最强的保护。

在感染模式里,Perl 采取特殊的预防措施以避免明显的和隐藏的陷阱,这个预防措施叫 感染检查(taint checks)。这些检查中的一部分是非常简单的,比如验证危险的环境变量 是否设置以及你路径上的目录能否被别人写入;细心的程序员总是使用这样的检查。不过, 其他检查最好由语言本身来支持,并且正是因为这种检查,才令用 Perl 写的特权程序比 相应的 C 程序安全,或者说是 Perl 写的 CGI 比任何一种没有感染检查的语言写的都安全。 (据我们所知,是除 Perl 以外的所有语言。)

这里的原则很简单:你不能用来自程序之外的数据影响程序之外的某些事务——至少,不能 无控制地施加影响。任何从程序之外来的的东西都标记为感染了的,包括所有命令行参数, 环境变量,以及文件输入。感染过的数据不能直接或者间接用于任何调用子 shell 的操 作,也不能用于任何修改文件,目录或者进程的操作。如果一个变量是在一个原先引用过 感染的数据值的表达式里设置的,那么它自身也被感染,即使从逻辑上来将,那个感染的值 不能影响变量也这样。因为感染是与每个标量相关的,所以在数组或者散列里的独立的数值 可能被感染而其他的则没有。(当然,散列里只有数值可能被感染,而不是键字。)

下面的代码演示了当你按顺序执行代码的时候,感染是如何运转的。标记着“Insecure” (不安全)的语句将触发一个例外,而那些是“OK”的则不会。

   $arg = shift(@ARGV);      # $arg 是感染了的(因为 @ARGV)。
   $hid = "$arg, 'bar'";      # $hid 也被感染(因为 $arg)。
   $line = <>;         # 感染(从外部文件读取)
   $path = $ENV{PATH};      # 感染(又见下文)
   $mine = 'abc';         # 未感染

   system "echo $mine";      # 直到设置 PATH 之前都不安全
   system "echo $arg";      # 不安全:用污染的 $arg 给 sh
   system "echo", $arg;      # 一旦 PATH 设置以后就 OK(不使用 sh )
   system "echo $hid";      # 两方面不安全:感染,PATH。

   $oldpath = $ENV{PATH};      # $oldpath 被感染(源于 $ENV)
   $ENV{PATH} = '/bin:/usr/bin';   # (让它执行其他程序的时候OK。)
   $newpath = $ENV{PATH};      # $newpath 没有被感染

   delete @ENV{qw{IFS
           CDPATH
           ENV
           BASH_ENV}};   # 令 %ENV 更安全

   system "echo $mine";      # OK,一旦设置了路径就安全了。
   system "echo $hid";      # 不安全,因为使用了感染的 $hid.

   open(OOF, "< $arg");      # OK(不检查只读打开)
   open(OOF, "> $arg");      # 不安全(试图写入一个感染了的 arg)。

   open(OOF, "echo $arg|")      # 因为感染了的 $arg 而不安全,不过...
      or die "can't pipe from echo: $!";

   open(OOF, "-|")         # 认为 OK:见下文关于
      or exec "echo", $arg   # 免除 exec 一个列表
      or die "can't exec echo: $!";

   open(OOF, "-|", "echo", $arg)   # 和前面一个一样,认为 OK
      or die "can't pipe from echo: $!";

   $shout = `echo $arg`;      # 不安全,使用了感染的 $arg
   $shout = `echo abc`;      # 反勾号让 $shout 受到感染
   $shout2 = `echo $shout`;   # 不安全,因为受到 $shout 感染

   unlink $mine, $arg;      # 不安全,因为用了污染的 $arg。
   unlink $arg;         # 不安全,因为用了污染的 $arg。

   exec "echo $arg";      # 不安全,因为用了污染的 $arg 传递给 shell。
   exec "echo", $arg;      # 认为 OK (不过请参考下文)
   exec "sh", '-c', $arg;      # 认为 OK ,但实际上不是!

如果你想做一些不安全的事情,你就会收到一个例外(除非你捕获它们,否则就是致命错误 ),象什么 “Insecure dependency”或者“Insecure $ENV{PATH}”,参阅稍后“清理你 的环境”一节。

如你给 system,exec,或者管道的 open 传递一个 LIST,那么 Perl 不会检查该参数是否 受到感染,因为对于一个 LIST 参数而言,Perl 不需要调用有潜在危险的 shell 来运行 命令。不过你仍然可以很容易地写一个使用 LIST 形式的不安全的 system,exec,或者 管道的 open,就好象我们在上一个例子的最后演示的那样。这些形式是免检的,因为 Perl 假定你在这么做的时候知道自己在干什么。

不过,有时候你无法准确地说出传递了多少参数。如果你给这些函数提供了一个数组(注: 或者一个生成一个列表的函数),而且该数组只有一个元素,那么就好象在第一个位置传递 了一个字串一样,那么 shell 就可能会用到。解决方法是在间接的对象槽位上传递一个 明确的路径:

   system @args;         # 不会调用 shell,除非 @args == 1
   system { $args[0] } args;   # 即使对一个参数的列表也会忽略 shell。

23.1.1 侦测和消毒感染了的数据

要检测一个标量变量是否包含污染了的数据,你可以使用下面的 is_tainted 函数。它利用 了 eval STRING 的这样一个机制:如果你试图编译一个感染了的数据,那么 eval STRING 就会抛出一个例外。尽管在需要编译的表达式的 $nada 变量总是空的,如果 $arg 被感染 那么它也会被感染。外层的 eval BLOCK 不做任何编译。外层的 eval 只是用来捕获内层的 eval 在收到感染数据之后抛出的例外。因为 Perl 保证在每次 eval 之后如果抛出了例外 , $@ 变量都不是空的,如果没有抛出例外则是空的,我们则返回测试它的长度是否为零的 结果。

   sub is_tainted {
      my $arg = shift;
      my $nada = substr($arg, 0, 0);   # 零长
      local $@;         # 前面的调用者的变量
      eval { eval "# $nadata" };
      return length($@) != 0;
   }

不过测试纯净与否只能到此为止了。通常你非常清楚哪个变量包含感染了的数据——你只是 需要证实该数据是否纯净。绕开感染机制的唯一的一个官方的方法是引用程序前面的正则 正则表达式匹配的返回的子匹配。(注:一个非官方的方法是把感染了的数据当作散列的 键字存储,然后取回该值。因为键字并不完全是 SV(标量值的内部名称),所以它们并不 运载感染属性。这个行为可能在未来的某一天修改,因此不要依赖它。处理键字的时候要 小心,避免无意地给你的数据消了毒或者对它们做一些不安全的事情。)当你写一个包含 捕获圆括弧的模式的时候,你可以通过匹配变量(比如 $1,$2,和 $+ 等)访问捕获的 子字串,或者在列表环境里计算该模式。不管是哪种方法,Perl 都假定你在写该模式的 时候就清楚自己在干什么,并且用没有任何危险动作的方式写它。因此,你必须给它一些 真正的思想——决不要盲目地消毒,否则你就需要捍卫整个感染机制。

最好先确认该变量只包含“好”的字符,然后再检查它是否包含什么“坏”字符。这是因为 人们最容易忽视的就是从来没有考虑到的坏字符。比如,下面是一个确保 $string 里只 包含“单词”字符(字母,数字,和下划线),连字符,@ 符号,和点的测试:

   if ($string =~ /^([-\@\w.]+)$/) {
      $string = $1;          # 现在字串消过毒了
   }
   else {
      die "Bad data in $string";   # 在什么地方记录这个日志
   }

这样出来的 $string 就相当安全了,可以在后面的程序里的一个外部命令里使用,因为 /\w+/ 通常不匹配 shell 的元字符,而且也不匹配任何对 shell 有特殊含义的字符。 (注:除非你用着一个有意破损了的本地设置。Perl 假定你的系统的本地设置定义是可能 有损坏的。因此,如果在 use locale 用法下运行,而且模式里面有符号表,象 \w 或者 :alpha:?,则生成感染了的结果。)如果我们使用的是 /(.+)/s,那它就有可能不安全 ,因为这个模式让任何东西都通过。当时 Perl 不会检查这些事情。在做消毒工作时,请 非常注意你的模式。使用正则表达式清洗数据是唯一的一种 Perl 允许的清洗脏数据的内部 机制。而有时候这个方法是完全错误的。如果你使用感染模式的原因是因为你使用了 set-id 而不是因为你故意打开了-T,那么你可以通过派生一个运行在比较低的权限的 子进程的方法来减少风险;参阅“清理你的环境”一节。

use re 'taint' 用法关闭当前词法范围结束之前的任何模式匹配的隐含的消毒功能。如果 你只是想从一些可能感染了的数据中抽取几个子字串,那么你就可以使用这个用法,不过 既然你没有认真对付安全问题,那么你最好还是保留那些子字串的感染状态以避免日后 不幸的事故。

假设你正在匹配类似下面这样的东西,这里 $fullpath 是感染过的:

   ($dir, $file) = $fullpath =~ m!(.*/)(.*)!s;

缺省时,$dir 和 $file 现在就会被消毒。但是你可能不想做这么正式地处理,因为你从来 没有考虑够安全性问题。比如,如果 $file 包含 "; rm -rf *;" 的时候你可能会极为 难受,我们在这里只是举一个最过分的例子。如果 $fullpath 被感染,那么下面的代码也 让两个结果变量遭受感染:

   {
      use re 'taint';
      ($dir, $file) = $fullpath =~ m!(.*/)(.*)!s;
   }

一个好的策略是让子匹配在整个源文件的范围内缺省时是感染的,并且只是根据需要在嵌套 的范围里允许消毒数据:

   use re 'taint';
   # 文件剩余部分令 $1 等处于感染状态
   {
      no re 'taint';
      # 这个块现在对 re 匹配消毒
      if ($num =~ /^(\d+)$/ {
         $num = $1;
      }
   }

从一个文件句柄或者一个目录句柄来的输入自动被感染,除非它是从一个特殊的文件句柄: DATA 中来的。如果有必要,你可以用 IO::Handle 模块的 untaint 函数把其他句柄标记 为可信任的数据源:

   use IO::Handle;

   IO::Handle::untaint(*SOME_FH);      # 任何过程
   SOME_FH->untaint();         # 或者使用 OO 风格的方法

在整个文件句柄上关闭感染模式是准备冒更大的风险。你如何才能知道它真的安全?如果你 准备这么干,你至少应该证实除了所有者以外没有人可以写该文件。(注:尽管你也可以 对一个目录句柄消毒,但这个函数只能操作文件句柄,这是因为如果给出一个目录句柄, 我们没有可移植的方法把它的文件描述符抽出来给 stat。)如果你在一个 Unix 系统上工 作(并且你谨慎地限制 chown(2) 给超级用户),那么下面的代码应该可以实现对整个 文件句柄的消毒工作:

   use File::stat;
   use Symbol 'qualify_to_ref';
   sub handle_looks_safe(*) {
      my $fh = qualify_to_ref(shift, caller);
      my $info = stat($fh);
      return unless $info;

      # 所有者既不是超级用户,也不是“我”,他的
      # 真正的 uid 在 $< 变量里
      if ($info->uid !=0 && $info->uid != $<) {
         return 0;
      }

      # 检查组或者其他人是否可以写文件。
      # 也要用 066 检查是否可以读
      if ($info->mode & 022) {
         return 0;
      }
      return 1;
   }

   use IO::Handle;
   SOME_FH->untaint() if handle_looks_safe(*SOME_FH);

我们对该文件句柄调用 stat,而不是对文件名调用,以避免危险的冲突条件。参阅本章 稍后的“处理冲突条件”。

请注意这个过程只是一个好的开始。一种更偏执的做法还会检查所有父目录,即便你无法 可靠地 stat 一个目录也如此。但如果你的父目录是公共可写的,那么你就知道不管是否 存在冲突条件你都有麻烦了。

Perl 对什么样的操作有危险有自己的看法,但是,如果使用其他那些不考虑是否是使用了 感染的数据的操作,你还是有可能碰上麻烦。对输入怎么仔细都不过分。Perl 的输出函数 并不测试它们的参数是否感染,但是有些场合下,是否感染是有区别的。如果你不关心 输出的是什么,那么最后可能是把那些输出字串分裂开,而这样的字串对处理这些输出的 程序而言是毫无意义的。如果你在一台终端上运行,特殊的逃逸和控制代码会把观察者的 终端变得乱七八糟。如果你是在一个 web 环境里,并且你盲目地分裂那些交给你的数据, 那么你就可能生成一些会剧烈改变 HTML 标签的效果的页面。更糟糕的是,有些标记甚至 可以反过来执行那些在浏览器上的代码。

设想一个访问登记簿的常见情况,用户输入它们自己的数据,这样其他人就可以检索他们的 信息。一个有恶意的用户可以提供不可见的 HTML 标记或者放在 序列里,回过头来在后面的用户的浏览器上执行代码(比如 JavaScript?)。

当检查访问你自己的系统的程序的感染数据的时候,你会很仔细地检查它们是否只包含 “好”字符,同样,如果你在一个 web 的环境里使用用户提供的数据的时候,也应该同样 仔细。比如,要删除任何不包含在指定“好”字符列表里的任何字符,可以用下面的句子:

   $new_guestbook_entry =~ tr[_a-zA-Z0-9 ,./~?(@+*-][]dc;

当然,你不会用这句话清理一个文件名,因为你可能不要那些有空格或者斜杠的文件名,这 句话只是给初学者的。不过它足够保证你的客人登记簿里没有暗藏的 HTML 标记和记录。 每个数据清洗环境都有一些区别,因此,一定要花一些时间判断什么是允许的而什么是不 允许的。感染机制目的是捕获愚蠢的错误,而不是为了摆脱思考。

23.1.2. 清理你的环境

当你在 Perl 脚本里执行另外一个程序的时候,不管是怎样执行的,Perl 都会检查你的 PATH 环境变量,确保它是安全的。因为它来自你的环境,所以你的 PATH 一开始就是 感染了的,如果你试图运行另外一个程序,Perl 就会抛出一个“Insecure $ENV{PATH}” 的例外。如果你把它设置为一个已知的,未感染的数值,Perl 就会确认该路径中的所有 目录都是只能为该目录的所有者和组写入;否则,它抛出一个“Insecure diredctory” 例外。

有时候你会觉得奇怪:就算你声明了想要执行的命令的全路径,Perl 也会关心你的 PATH。 的确,当你使用绝对路径的时候,不用 PATH 查找准备运行的可执行文件。但是,我们没有 理由信任你运行的程序不会转过来运行其他的程序并最终因为不安全的 PATH 碰上麻烦。 因此强迫你在调用任何程序之前都要设置一个安全的 PATH,不管你是如何调用那些程序 的。

PATH 也不是唯一的可能带来厄运的环境变量。因为一些 shell 使用 IFS,CDPATH,ENV, 和 BASH_ENV,Perl 在运行其他程序之前要确认那些环境变量要么是空的,要么是消过 毒的。你可以把这些环境变量设置为某些安全的东西,或者把它们从环境中全部删除:

   delete @ENV{qw(IFS CDPATH ENV BASH_ENV)};   # 令 %ENV 更安全

在一个普通环境里的便利的特性到了一个恶劣的环境里可能就会成为安全问题。即使你已经 注意到禁止文件名中包含回车符,你也应该明白 open 可以访问比单纯文件名更多的东西。 只要给文件名参数足够的修饰,对 open 的一个或者两个参数的调用也可以通过管道运行 任意的外部命令,派生出当前进程的额外的拷贝,复制文件描述符,以及把特殊的文件名 “-” 解释为标准输入或者输出的别名。这样的 open 还可以忽略前导和后跟的空白,于是 把一些奇特的参数隐藏起来,躲过你的检查模式的检查。尽管 Perl 的感染检查会捕获用于 管道打开的感染的参数(除非你使用一个独立的参数列表)和任何非只读打开的文件,但是 抛出的例外仍然会让你的程序行为错乱。

如果你企图使用任何源于外部的数据作为文件名的一部分打开,那你至少应该包括一个用 空格隔开的明确的模式。最安全的方式可能是使用低层的 sysopen 函数或者三个参数的 open 形式:

   # 神奇的 oepn --- 可以是任何东西
   open(FH, $file)      or die "can't magic open $file:$!";

   # 保证是一个只读文件打开而且不是一个管道或者 fork,
   # 不过仍然允许文件描述符和“-”,
   # 并且忽略名字两边的空白。
   open(FH, "< $file")      or die "can't open $file: $!";

   # WYSIWYG open:关闭所有便利特性。
   open(FH, "<", $file)      or die "can't open  $file: $!";

   # 和 WYSIWYG 3 参数版本一样的东西。
   require Fcntl;
   sysopen(FH, $file, O_RDONLY)   or die "can't sysopen $file: $!";

即使是这样的步骤也不够好。Perl 并不阻止你打开感染过的文件名用于读取,因此你应该 仔细检查你给人们显示的信息。一个打开用户提交的任意文件进行读取然后显示文件内容的 程序仍然有安全问题。如果它是一封私信怎么办?如果它是你的系统的口令文件怎么办? 如果它是薪水信息或者你的股票券书怎么办?

仔细检查那些由可能有敌意的用户(注:在网上,只有那些已经明显有敌意的人才是你可以 相信的。)提交的文件名,然后再打开它们。比如,你可能想证实在该路径中没有隐藏的 目录。比如象“../../../../../../../etc/passwd”是这种类型的最臭名昭著的例子。 你可以通过证实路径名中没有斜杠来保护你自己(假设斜杠是你的系统的目录分隔符)。 另外一个常见的骗局是在文件名里放上回车或者分号,一些差劲的弱智命令行解释器可能 会被哄得在文件名中间运行一条新的命令。这就是为什么感染模式不会运行没有检疫过的 外部命令。

23.1.3 在受限制的权限下访问命令和文件

下面的讨论适用于一些类 Unix 系统里的非常好的安全工具。其他系统的用户可以很安全地 (或者说,不安全地)忽略这节。

如果你运行 set-id 程序,在可能的情况下,力图做这样的安排:你做危险操作的时候是 以用户的权限进行,而不是以程序的权限进行。也就是说,如果你准备调用 open, sysopen,system,反勾号,以及任何其他文件或者进程操作,你可以通过把有效 UID 或 设置成真实的 UID 或者 GID 来保护自己。在 Perl 里,你可以在脚本里说 $> = $< (或者如果你 use English,就是 $EDUI = $UID ),以及对于 setgid 脚本可以说 $( = $)($EGID = $ GID)。如果两个 ID 都设置了,那么你应该两个都重置。不过, 有时候这样并不可行,因为你在程序的后面可能仍然需要那些提升了的权限。

对于这样的情况,Perl 提供了一个合理的方法让你可以在一个 set-id 的程序里打开文件 或者管道。首先,使用特殊的 open 语法派生一个子进程,父子进程之间用管道联结。 在子进程里,重置用户和组 ID 为它们的初始值或者已知的安全值。你还要修改任何子进程 自己的属性,但不要影响父进程,比如修改工作目录,设置文件创建掩码,或者修改环境 变量等。一旦不再处于任何额外的权限之下,子进程最后就可以代表平凡但疯狂的用户, 靠着它那有权有势但又偏执般公正的老爸调用 open 并传递它可以访问的任何数据。

虽然 system 和 exec 在收到超过一个参数以后不会使用 shell,但反勾号操作符可不会 使用这样的调用传统。通过派生技巧,我们可以轻松地模拟反勾号用法而又不用担心逃逸到 shell 里,同时用的权限还比较低(因此也更安全):

   use English;      # 使用 $UID 等
   die "Can't fork open: $!"   unless defined($pid = open(FROMKID, "-|"));
   if ( $pid ) {      # 父
      while () {
         # 做一些处理
      }
      close FROMKID;
   }
   else {
      $EUID = $UID;   # setuid(getuid(())
      $EUID = $GID;   # setgid(getgid()), 以及在 getgroups(2) 上 initgroups(2) 
      chdir("/")   or die "can't chdir to /: $!";
      umask(077);
      $ENV{PATH} = "/bin:/usr/bin";
      exec 'myprog', 'arg1', 'arg2';
      die "can't exec myprog: $!";
   }

这个方法可能是从一个 set-id 的脚本里调用其他程序的最好的方法。你应该确保决不使用 shell 执行任何东西,并且你在 exec 该程序之前降低了你的权限。(不过,因为列表 形式的 system,exec,和管道 open 是特定可以免除对它们的参数进行感染检查的,所以 你仍然必须仔细对待你传入的参数。)

如果你不需要降低权限,并且只是想实现实现反勾号或者一个管道 open,而又不想冒 shell 截获你的参数的风险,你可以使用下面这样的东西:

   open(FROMKID, "-|") or exec("myprog", "arg1", "arg2")
      or die "can't run myprog: $!";

然后只需要从父进程里的 FROMKID 读取数据即可。到了 Perl 5.6.1 版本,你可以写下面 这样的东西:

   open(FROMKID, "-|", "myprog", "arg1", "arg2");

派生(fork)技巧不仅仅可以用于在一个 set-id 的程序里运行命令。它还便于以运行程序 的 ID 下打开文件。假设你有一个 set-id 的程序需要打开一个文件用于写入。你不想在 你额外的权限下运行 open,但是你也不能永久地减低这些权限。这样就可以安排一个派生 出的进程拷贝,降低它的权限然后为你做 open 动作。当你想写入该文件的时候,写到这个 子进程,然后它会帮你写到该文件中。

   use English;
   
   define ($pid = open(SAFE_WRITER, "|-"))
      or die "Can't fork: $!";

   if ($pid) {
      # 你是父进程。向 SAFE_WRITER 子进程写数据
      print SAFE_WRITER "@output_data\n";
      close SAFE_WRITER
         or die $! ? "Syserr closing SAFE_WRITER writer: $!"
            : "Wait status $? from SAFE_WRITER writer";
   }
   else {
      # 现在是子进程,因此丢掉额外的权限
      ($EUID, $EGID) = ($UID, $GID);

      # 在原来用户的权限下打开该文件
      open(FH, "> /some/file/path")
         or die "can't open /some/file/path for writing: $!";

      # 从父进程(现在的 stdin)拷贝数据到该文件中
      while() {
         print FH $_;
      }
      close(FH)   or die "close failed: $!";
      exit;         # 不要忘记让 ASFE_WRITER 消失
   }

如果打开文件失败,子进程打印一条错误信息然后退出。如果父进程写入到这个现在是僵死 进程的子进程的文件句柄,它就会触发一个破损的管道信号(SIGPIPE),如果不捕获或者 忽略这个信号,那么它就是致命错误。参阅第十六章,进程间通讯,里的“信号”一节。

23.2. 处理计时缝隙

有时候你的程序的行为对超出你的控制范围之外的外部事件非常敏感。如果其他程序,特别 是那些有敌意的程序,在和你的程序竞争同样的资源(比如文件或者设备)的时候,我们 就特别关心这些问题了。在一个多任务的环境里,你无法预计哪个等待运行的进程会得到 处理器时间。所有合格的进程的指令流是相互交错的,因此第一个进程获取一些 CPU 时间,然后是第二个进程,如此下去。轮到谁运行以及它可以运行多长时间都是随机的。 如果只有一个程序,那不成问题,但是如果有好几个程序共享资源,那就有问题了。

线程程序员对这些问题特别敏感。他们很快就会知道不能说:

   $var++ if $var == 0;

而是要说:

   {
      lock($var);
      $var++ if $var == 0;
   }
前面一种方法在多个执行线程试图在同一时间运行这段代码的时候就会产生不可预料的 结果。(参阅第十七章,线程。)如果你把文件看作共享的对象,而把进程看作为访问那些 共享对象而竞争的线程,那么你就能明白问题是如何产生的了。毕竟,一个进程也只不过是 一个有自己看法的线程而已。反之亦然。

计时的不可预见性对特权场合和非特权场合都有影响。我们将首先描述如何对付一个在老的 Unix 内核里存在了很长时间的臭虫,它对所有 set-id 的程序都有影响。然后我们将继续 讨论一般性的冲突条件,以及它们如何能够变成安全漏洞,当然还有你可以采取什么样的 措施防止自己掉到这些漏洞里面去。

23.2.1. Unix 内核安全臭虫

在你给予象 shell 这样灵活而不安全的解释器特殊的权限引发那些显而易见的问题之前, 在老一些的 Unix 版本里有一个内核臭虫,这个臭虫甚至令所有 set-id 的脚本在到达 解释器之前就已经不安全了。这个毛病不是脚本自身的毛病,而是内核在找到一个 set-id 的可执行脚本以后所做处理中的一个冲突条件。(那些在内核里不能识别 #! 的机器不存在 这个臭虫。)当一个内核打开这么一个文件,检查它应该运行哪个解释器的时候,在解释器 (现在是 set-id 的)开始运行和重新打开该文件之间有一段延迟。这段延迟给那些有 恶意的家伙一个修改该文件的机会,特别是在你的系统支持符号链接的情况下。

幸运的是,有时候,这个内核“特性”可以关闭。不幸的是,关闭它的方法有好几种。系统 可以把设置了 set-id 的脚本判为非法,但这也帮不了什么忙。另外,它也可以忽略脚本上 的 set-id 位。对于后者,如果 Perl 注意到 Perl 脚本上的 set-id 位,那么它可以模拟 setuid 和 setgid 机制。它是通过一个特殊的可执行文件,叫 suidperl,来实现这个 目的的,如果需要,这个程序会被自动调用。(注:是有需要而且允许的时候——如果 Perl 注意到该脚本所在的文件系统是带 nosuid 选项装配的,那么 Perl 仍然尊重该 选项,也即不会执行 set-id 程序。你无法利用这个方法用 Perl 来绕开你的系统管理员 设置的安全策略。

不过,如果内核 set-id 脚本特性没有关闭,那么 Perl 就会大声抗议说你的 setuid 脚本 是不安全的。这样你就必须要么关闭这个内核 set-id 脚本“特性”,或者是在脚本上写 一个 C 封装。一个 C 封装只不过是一个编译了的程序,它什么也不干,只是调用你的 Perl 程序。编译了的程序不会遭受传染给 set-id 脚本的内核臭虫的骚扰。

下面是一个用 C 写的简单的封装的例子:

   #define REAL_FILE "/path/to/script"
   main(ac, av)
      char **av;
   {
      execv(READ_FILE,av);
   }

把这个封装编译成一个可执行文件然后把它 set-id,而不是给你的脚本 set-id。要记住 一定要用绝对路径,因为 C 可不会在你的 PATH 上做感染检查。

(另外一个可能的方法是使用 Perl 编译器的实验性的 C 代码生成器。如果脚本编译成 可执行影象以后是不会有冲突条件的,参阅第十八章,编译。)

最近几年,各个系统提供商最终开始供应没有 set-id 臭虫的系统了。在这些系统上,如果 内核把 set-id 脚本的名字交给解释器的时候,它们给的不再是容易修改的文件名,而是 传递一个特殊的,代表文件描述符的文件,比如 /dev/fd/3。这个特殊文件是已经在脚本 上打开了的,因此就没有那些有恶意的脚本可以利用的冲突条件。(注:在这些系统上, Perl 应该带着 -DSETUID_SCRIPTS_ARE_SECURE_NOW 选项编译。制作 Perl 的 Configure 脚本会试图自己找到这个问题的答案,所以你应该用不着明确声明这个选项。)大多数 现代版本的 Unix 都使用这个方法来避免打开同一个文件名两次带来的冲突条件。

23.2.2处理冲突条件

现在我们直接进入冲突条件这个论题了。它们是什么?冲突条件在安全讨论中出现的次数 非常多。(不过,糟糕的是它们通常出现在不安全的程序里。)那是因为它们是细微的 编程错误的肥沃的土壤,而这样的错误通常都可以转而成为安全漏洞(摧毁某人的安全系统 的委婉的说法)。如果有几个相互关联的事件之间依赖于各自发生的顺序,而由于无法预料 的计时因素,程序无法这个顺序,那么就会存在冲突条件。每个事件都争抢成为第一个 结束的事件,因而系统最终的状态就可想而知了。

假设你有一个进程覆盖一个现有的文件,而另外一个进程读取该文件。你无法预计你读取的 是老数据,新数据,还是一个偶然的新老混合的数据。你甚至都无法知道你是否已经读取了 数据。读进程可能会胜出竞争,先到达文件结束位置然后退出。同时,如果写进程在读进程 到达文件末尾以后继续写,那么该文件就会增长过读进程停止读取的位置,但读进程却根本 不知道这些。

这里的解决方法很简单:只需要两个当事人都 flock 该文件。读进程通常需要一个共享锁 ,而写进程通常需要一个排它锁。只要所有当事人都请求并尊重这样的劝告性的锁,那么 读和写进程就绝对不会相互交织在一起,并且也没有可能破坏数据。参阅第十六章“文件 锁定”一节。

每当你用对文件名的操作控制随后的对该文件的操作的时候,你实际上都在冒一种很不起眼 的冲突条件的风险。如果你使用文件名而不是文件句柄的时候,文件测试操作符就代表着 一条通向一个冲突条件的花园小径。看看下面这段代码:

   if (-e $file) {
      open(FH, "< $file")
         or die "can't open $file for reading: $!";
   }
   else {
      open(FH, "> $file")
         or die "can't open $file for writing: $!";
   }

这段代码和它的作用一样直接,但它仍然容易导致冲突。我们无法保证 -e 测试返回的结果 直到调用任何一个 open 的时候都仍然是有效的。在 if 块里,在它打开文件之前另外一个 进程可能先删除了该文件,并且你将找不到原来以为在该处的那个文件。在 else 块里, 在它的 open 可以创建该文件之间,另外一个进程可能已经创建了该文件,所以原来你以为 不存在的文件现在就会已经存在了。这个简单的 open 函数创建一个新文件,但覆盖了已经 存在的那个。你可能会说你就是想覆盖任何现存文件,不过,想一下,如果现有的这个文件 是新建的一个指向系统中其他地方的文件的别名或者符号链接,而这个文件是你非常不愿意 覆盖的文件怎么办?你可能会说你在任何时刻都知道一个文件名的含义,但你永远无法保证 这一点——只要可以访问该文件的目录的其他进程在同一系统里运行。

要修补这种覆盖的问题,你需要使用 sysopen,它提供关于是创建一个新文件还是覆盖现有 的一个文件的独立的控制。并且我们将把那个 -e 文件存在测试丢弃掉,因为它在这里没有 什么用,而且只是增加了我们暴露于冲突条件的机会。

   use Fcntl qw/O_WRONLY O_CREAT O_EXCL/;
   open(FH, "<", $file)
      or sysopen(FH, $file, O_WRONLY | O_CREAT | O_EXCL)
      or die "can't create new file $file: $!";

现在,即使在 open 失败后,sysopen 试图打开一个新文件写入的时候,该文件突然出现也 不会有问题,因为在我们提供的标记下,sysopen 将拒绝打开一个已经存在的文件。

如果有个家伙想设一个套,让你的程序表现不正常,那么这里就有一个机会,他们可以在你 没有准备的情况下让文件出现和消失。减少中计的风险的一种办法就是保证自己对同一个 文件名访问的次数从来不超过一次。只要你打开了一个文件,那么就忘掉文件名(除了用于 错误信息的以外),并且只对代表该文件的文件句柄进行操作。这样做要安全得多,因为就 算有人可以处理你的文件名,那他也没办法处理你的文件句柄。(或者就算他们可以,那 也是因为你允许他们这么做的——参阅第十六章的“传递文件句柄”。)

在本章的早些时候,我们演示了一个 handle_looks_safe 函数,它在一个文件句柄上调用 Perl 的 stat 函数(而不是文件名)以检查所有权和权限。这里,使用文件句柄是正确 操作的关键——如果我们使用了文件名,那么我们就无法保证我们正在检测属性的文件是 我们刚刚打开的那个(或者我们准备打开的那个)。最坏的家伙可以在 stat 和 open 之间 的某个点,先把我们的文件删除,然后很快地用一个设计地极为险恶的文件代替。不管先 调用的是哪个函数,都有机会在两者之间发生错误。你可能会说这个风险是非常小的,因为 窗口打开的时间很短,但是现在已经有许多破坏性的脚本存在了,它们会很乐于运行几千遍 你的程序,并且捕获其中不仔细的一次。一个聪明的破解脚本甚至会降低你的程序的级别, 这样你的程序就会比正常时中断的次数更多,这样就可以加速破解的进度。很多人非常努力 地做这些破解工作——这也是它们为什么叫“利用”(exploit)的原因。

通过对一个已经打开了的文件句柄调用 stat,我们就只访问该文件句柄一次,因此就可以 避免冲突条件了。避免两个事件之间的冲突条件的一个好策略就是在某种程度上把两种方法 组合成一种,这样就将操作变成原子化的。(注:没错,你仍然可以在一个无核区进行 原子操作:-)。当德谟克利特用“原子”这个词代表不可见的小块物质是,他的意思是某种 不可分割的东西;a(不可)+ tomos(分割)。一次原子操作就是一个不能中断的动作。 (就好象你试图中断一次核爆炸一样。))因为我们只有一次通过文件名访问文件,因此 在多个进程之间不可能有冲突条件,因此不管名字是否更改也没有关系。即使骇客们删除了 我们打开的文件(的确,这是可能发生的)并且放上了另外一个不同的文件想哄骗我们, 我们仍然拿着指向真实的最初的文件的句柄。

23.2.3. 临时文件

除了允许缓冲区溢出(实际上 Perl 脚本对此几乎免疫)以及信任了不可相信的输入数据 (我们可以用感染模式来做防护),不合时宜地创建临时文件也是一个最常被利用的安全 漏洞。走运的是,临时文件攻击方法通常要求骇客们在它们试图破解的系统上有一个有效的 用户帐号,这样就极大地减少了可能的坏蛋的数量。

粗心或者无意的程序会以各种不安全的方式使用临时文件,比如把它们放到一个全局可写的 目录,使用一个可以猜测的文件名,以及并不先确认文件尚未存在。如果你看见一个程序有 象下面这样的代码:

   open(TMP, ">/tmp/foo.$$")
      or die "can't open /tmp/foo.$$: $!";

那么你就找着一个同时犯了上面三戒的程序。那个程序本身就是一次等待发生的事故。

骇客利用这个漏洞的方法就是先养一个和你用的文件同名的文件。附加 PID 并不能保证唯 一;这听起来可能很让人惊讶,但猜测 PID 实在不算什么难事。(注:除非你用的系统是 象 OpenBSD? 这样的系统,它的 PID 是随机赋值的。)然后紧跟着的程序就是粗心的 open 调用,这回可不是新创建一个程序自己用的临时文件,而是覆盖骇客的文件。

那又有什么危险呢?太多了。要知道,骇客的文件实际上不是一个平面文件。它是一个符号 链接(有时候也可以是一个硬链接),可能指向某个骇客自己通常不能写入的关键性文件, 比如 /etc/passwd。该程序以为自己在 /tmp 里打开了一个崭新的文件,而实际上它删除了 位于其它地方的某个文件。

如果正确使用的话,Perl 里有两个函数可以处理这种情况。第一个是 POSIX::TMPNAM, 它返回一个你准备为自己打开的文件名:

   # 不停试验新文件名,直到我们拿到一个完全新的。
   use POSIX;
   do {
      $name = tmpnam();
   } until sysopen(TMP, $name, O_RDWR| O_CREAT | O_EXCL, 0600);
   # 现在用 TMP 句柄做 I/O。

第二个是 IO::File::new_tmpfile,它返回给你一个已经打开的句柄:

   # 也可以让该模块为我们做这些事情。
   use IO::File;
   my $fh = IO::File::new_tmpfile();   # 这是 POSIX 的 tmpfile(3)
   # 现在用 $fh 句柄做 I/O。

两种方法都不是完美的,不过在这两种方法中,第一种是更好的方法。第二种方法的主要 问题是 Perl 容易遭受你的系统的 C 库的 tmpfile(3) 的弱点的袭击,并且你无法保证 该函数不会做那些和我们试图修补的 open 一样危险的事情。(糟糕的是有些系统上的 这个函数的确是这样。)还有一个次要的问题是它根本没有办法给你文件的名字。尽管如果 你能够操作一个没有名字的临时文件会更好些,因为这样一来你就再也不能因为试图再次 打开它而进入一种冲突条件了,但是通常你做不到这一点。

第一个方法的主要问题是无法控制路径名,就好象你用 C 库的 mkstemp(3) 函数一样。 一方面,你绝对不想把文件放在一个装配上来的 NFS 文件系统上。Perl 无法保证 O_EXCL 在 NFS 里正确运行,所以在几乎同一时间里请求一个排它创建的多个进程可能全部会 成功。另一方面,因为返回的路径可能是其他人也可以写入的目录,那么有人就可以在那里 放一个指向不存在的文件的符号链接,这样就强迫你在一个他们喜欢的目录创建你的文件。 (注:有一个解决这个问题的方法,不过它只能在某些系统里使用,就是调用 sysopen 并且 OR 上 O_NOFOLLOW 标记。这样,如果该路径的最后一个元素是符号链接,那么函数 调用会失败。)如果你在临时文件里要放什么东西的话,不要把它们放在一个任何其他人 都可以写的目录里。如果你必须这么干,要记得给 sysopen 用 O_EXCL 标记,然后把它们 用在设置了只有所有者可以删除位(粘黏位)的目录里。

对于 Perl 5.6.1,还有第三种方法。标准的 File::Temp 模块考虑到了我们提到的所有 困难。你可以用下面这样的缺省选项:

   use File::Temp "tempfile";
   $handle = tempfile();

或者你可以声明一些类似下面这样的选项:

   use File::Temp "tempfile";
   ($handle, $filename) = tempfile("plughXXXXXX",
               DIR => "/var/apool/adventure",
               SUFFIX = '.dat');

File::Temp 模块还提供我们提到过的其他函数的经过安全考虑的仿真(不过内部接口仍然 是比较好的选择,因为它给你一个打开了的文件句柄,而不只是一个文件名,因为文件名 容易产生冲突条件)。参阅第三十二章,标准模块,获取该选项的更长一些的描述以及这个 模块的语意。

一旦你拿到了文件句柄,那你就可以做你想做的任何事情。它是打开用于读和写的,所以你 可以写入该文件句柄,seek (搜索)回文件开头,如果你愿意,然后你就可以覆盖任何你 刚刚写入的东西或者把它们重新读回来。你真正应该避免做的事情就是再次打开那个文件 名,因为你无法确认那是否就是你第一次打开的那个文件。(注:除非打开之后你用 stat 对文件名和文件句柄做一次调用,然后比较头两个返回值(设备/inode对)。不过这个时候 已经太晚了,因为你已经造成破坏了。你所能做的就是检测损害并退出(以及可能还要偷偷 地给系统管理员发一封 email)。)

如果你在你的脚本里启动另外一个程序,Perl 通常为你关闭所有文件句柄以避免另外一次 攻击。如果你用 fcntl 清掉了你的执行时关闭标记(就象在第二十九章,函数,的 open 函数的末尾演示的那样),那么你调用的其他程序将继承这个新的,打开了的文件描述符。 在支持 /dev/fd/ 目录的系统上,你可以给其他程序提供实际上指的是文件描述符的文件名 ,构造方法是:

   $virtname = "/dev/fd/" . fileno(TMP);

如果你只需要调用一个 Perl 子过程或程序,这个子过程或程序需要一个文件名做为一个 参数,并且你知道该子过程或者程序使用普通的 open 打开该文件,那么你可以使用 Perl 的文件句柄表示法给它传递一个句柄:

   $virtname = "=&" . fileno(TMP);

如果这个文件“名”传递给一个有一到两个参数(不能是三个,否则就会破坏这个作用)的 普通的 Perl open 时,你就获得了对该复制的描述符的访问。从某种角度来说,这个方法 比从 /dev/fd/ 传递一个文件更具有移植性,因为只要 Perl 能用的地方,就可以用这个 方法;而并不是所有系统都有 /dev/fd/ 目录。另一方面,通过文件描述符数字访问文件的 特殊的 Perl open 语法只能用于 Perl 程序,而不能用于其他语言的程序。

23.3 处理不安全的代码

感染检查只不过是一种安全毯,只有在你想捕获本来应该由你捕获的虚假数据,但是你在 传递给系统之前又不想捕获的数据的时候才需要它。它有点象 Perl 可以给你的可选的警告 ——它们可能不会标识一个真正的问题,但总体而言,对付这些误报错误的痛苦要比漏掉 那些真正的错误导致的痛苦要轻得多。对于感染而言,后者带来的痛苦更持久,因为使用 虚假数据不仅仅会给你错误的回答,而且它还会彻底摧毁你的系统,以及你几年来的工作 成果。(甚至还有往后几年的工作——如果你没有很好地备份。)当你相信自己写的代码 都是可靠的,并且没有必要认为那些给你传递数据的人不会给你传递一些会引诱你做一些 让自己后悔的事的数据的时候,感染模式就极为有用。

数据是一回事。如果甚至连自己运行的代码都不能相信的时候就是另外一回事了。如果你 从网络抓来一个小应用,而它包含病毒,或者时间炸弹,或者一匹特洛伊木马怎么办?在 在这里感染检查没有什么用,因为你给该程序传递的数据可能是好的——实际上是代码不 可靠。你把自己放在了这样一个环境下:某人从一个陌生人那里收到了一个奇怪的设备, 设备上写着:“只需要把这个东西对准你的脑袋然后扣动扳机。”可能你会认为它会给你 干头发,不过我相信你不会保留这个观点太长时间。

在这个领域里,谨慎就是偏执的同义词。你需要的是一个让你可以对可疑代码进行强制隔离 的系统。那段代码可以继续存在,并且甚至可以实现一定功能,但是你不会让它到处乱逛 并且自己喜欢干什么就干什么。在 Perl 里,你可以用 Safe 模块强加某种隔离措施。

23.3.1.安全隔仓

Safe 模块令你设置一个沙箱,沙箱是一个隔仓,在这里所有系统操作都会被捕获,并且 名字空间访问受到仔细的控制。这个模块的底层的技术细节处于一种流动的状态,因此 我们在这里将更多地介绍理论方面的东西。

23.3.1.1. 限制名字空间访问

在最基础的层次上,一个 Safe 对象就好象一个保险柜,只不过其概念是把坏东西约束在 里面,而不是外面。在 Unix 世界里,有一个叫 chroot(2) 的系统调用,如果你愿意, 可以永久地把一个进程装运在目录结构里的某一个子目录里——在它自己的小地狱里。一旦 该进程被放到了那里,那么就没有办法访问外部的文件了,因为它没有办法命名外部的 文件。(注:有些站点为执行 CGI 脚本做这件事情,并且使用自环只读的装配。有时候, 这在设置的时候非常痛苦,但就算哪个家伙跑了出来,它也找不到去的地方。)Safe 对象 Safe 对象有些类似这样的东西,只不过它不是把程序限制在文件系统目录结构的一个子集 里,它是把程序限制在 Perl 的包结构的一个子集里,而 Perl 包结构也和文件系统一样 是层次结构。

理解 Safe 对象的另外一个方法就是把它当作一个观察室,观察室的墙上有一面单向镜子, 警察可以把嫌疑犯放在观察室里观察。外面的人可以看到屋子里面,而里面的人看不到 外面。

如果你创建一个 Safe 对象,你可以给它一个你选择的包名。如果你不给它包名,Perl 会 为你指定一个新的包名:

   use Safe;
   my $sandbox = Safe->new("Dungeon");
   $Dungeon::Foo = 1;   # 直接访问是有危险的。

如果你使用变量或函数的时候,用的是全名,也就是带上你传递给 new 方法的包的名字, 那么你就可以从外边访问该包,至少在目前的实现是这样的。不过这个方法以后可能会 改变,因为现在的计划是把符号表克隆到一个新的解释器里。稍微更能向上兼容一些的方法 可能是在创建 Safe 之前先把东西设置好,就象我们下面显示的那样。这个方法可能会保持 有效,并且,如果你想设置一个有许多初始“状态”的 Safe 对象,这也是一个好方法。 (当然,$Dungeon::foo 没有太多状态。)

   use Safe;
   $Dungeon::foo = 1;   # 仍然是直接访问,仍然有危险
   my $sandbox = Safe->new("Dungeon");

不过,Safe 还提供了一个访问隔仓的全局变量的方法——即使你不知道隔仓的包的名字。 所以,向上兼容性最好的方法(但可能没有最高的速度)就是我们建议你使用 reval 方法:

   use Safe;
   my $sandbox = Safe->new();
   $sandbox->reval('$foo = 1');

(实际上,这个方法是你用来运行可疑代码的同样的方法。)如果你给隔仓传递代码并且 编译和运行,那么那段代码会认为它实际上是位于 main 包里的。当外部的代码调用 $Dungeon::foo 的时候,内部的代码认为是调用 $main::foo,或者 $::foo,或者就是 $foo——如果你不是运行在 use strict 下。在隔仓内部说 $Dungeon::foo 是没用的, 因为这样实际上会访问 $Dungeon::Dungeon::foo。通过给予 Safe 对象它自己的 main 的 概念,你的程序里的其他变量和子过程就能得到保护。

要想在隔仓里编译和运行代码,使用 reval("限制的 eval") 方法,把代码字串当作它的 参数传递。和其他 eval STRING 构造一样,reval 的编译错误和运行时例外不会停止你的 程序。它们只是退出 reval 并且把例外保留在 $@,所以在调用完 reval 之后要记得检查 它。

使用前面给出的初始化,下面的代码打印出“foo is now 2”:

   $sandbox->reval('$foo++; print "foo is now $main::foo\n"');
   if ($@) {
      die "Couldn't compile code in box: $@";
   }

如果你只想编译代码而不是运行它,把你的字串封装在一个子过程定义中:

   $sandbox->reval (q{
      our $foo;
      sub say_foo {
         print "foo is now $main::foo\n";
      }
   }, 1);
   die if $@;      # 检查编译

这次我们传递给 reval 第二个参数,因为它是真,所以告诉 reval 在 strict 用法下编译 这段代码。在这段代码字串里,你也不能关闭严格用法,因为输入和排出是两个你通常不能 在 Safe 隔仓里做的事情。在 Safe 隔仓里,你还是有很多事情不能做——见下一节。

一旦你在隔仓里创建了 say_foo 函数,下面的就非常相似了:

   $sandbox->reval('say_foo()');   # 最好的方法
   die if $@;

   $sandbox->varglob('say_foo')->();   # 通过匿名团调用

   Dungeon::say_foo();      # 直接调用,我们强烈反对这么干

23.3.1.2. 限制对操作符的访问

与 Safe 对象有关的另外一件重要的事情是 Perl 限制可以在沙箱里使用的操作。(你可能 愿意让你的孩子把一只小桶和一把小铲子带到沙箱里面去,不过你肯定不会让他们带 火箭筒。)这可不仅仅是为了保护其他部分的程序,而且还是为了保护你的计算机。

当你在一个 Safe 对象里编译 Perl 代码的时候,不管是用 reval 还是 rdo (一个 do FILE 操作符的限制版本),编译器都会参考一个特殊的,每个隔仓都有一个的访问 控制列表,以判断每个独立的操作是否可以认为是安全的,通过了的才编译。这样,你就 不用太担心那些不可预见的 shell 逃逸、不注意的情况下对文件的操作、在正则表达式里 怪异的代码断言、以及大多数人们通常非常烦恼的外部访问的问题。

声明那些操作符允许使用,而那些被限制使用的接口目前正在重新设计,所以我们在这里 只是演示如何使用它们的缺省设置。更详细的内容请参考 Safe 模块的在线文档。

Safe 模块并没有提供对拒绝服务攻击的完全的防护,尤其是把它用于更广泛的许可范围的 时候。拒绝服务攻击消耗掉系统中某种可用资源的全部,而拒绝其他程序访问基本的系统 设施。这种攻击的例子有填满内核进程表,通过永久运行一个高负荷的循环强占 CPU, 消耗掉所有可用内存,以及填满文件系统等。这种问题非常难解决,尤其是很难可移植地 解决。参阅“伪装成数据的代码”一节的末尾获取有关拒绝服务攻击的更多讨论。

23.3.1.3 安全例子

假设你有一个 CGI 程序,这个程序管理一个表单,用户可能在这个表单里填入任意 Perl 表达式并且从中获取计算结果。(注:别笑,我们真的看到过这么干的网页。而且还没有 使用 Safe!)和所有外部输入一样,该字串到来之后是感染了的,所以 Perl 不会让你 eval 它——你首先得用一个模式匹配对它消毒。问题是你可能永远也想不出一个可以侦测 所有可能的威胁的模式。而且你也不敢只是把你收到的东西消一下毒,然后就把它们发给 内建的 eval。(如果你敢,那么我们就会试着突入你的系统然后删除该脚本。)

这个时候就该用到 reval 了。下面是一个 CGI 脚本,它处理一个只有一个表单域的表单, (在标量环境)里计算它发现的字串,然后打印出格式化的结果:

   #! /usr/bin/perl -lTw
   use strict;
   use CGI::Carp 'fatalsToBrowser';
   use CGI qw/:standard escapeHTML/;
   use Safe;

   print header(-type => "text/html; charset=UTF-8"),   
      start_html("Perl Expression Results");
   my $expr = param("EXPR") =~ /^([^;]+)/
         ? $1 # 返回现在消过毒的部分
         : croak("no valid EXPR field in form");
   my $answer = Safe->new->reval($expr);
   die if $@;
   
   print p("Result of", tt(escapeHTML($expr)),
      "is", tt(escapeHTML($answer)));

假设有个不怀好意的用户给你一个 "print `cat /etc/passwd`" (或者更糟糕的东西)做 输入字串。由于限制的环境里不允许反勾号,Perl 在编译过程中就捕获到了问题,然后 立即返回。在 $@ 里的字串是 "quoted execution (` `, qx) trapped by operation mask", 另外还有一些客户化的信息告诉你问题发生在哪里。

因为我们没有声明,所以我们创建的所有隔仓现在用的都是缺省的允许操作集。在这里, 你如何声明特定的操作为许可的还是禁止的并不重要。重要的是这些都完全在你的程序的 控制之下。并且因为你可以在你的程序里创建多个 Safe 对象,所以你可以根据获取的代码 的不同来源,给不同的代码块赋予不同的信任度。

如果你想在 Safe 上做些开发,那么 Perl 里有一个小小的交互式计算器。它是一个你可以 输入数字表达式并且立即看到结果的计算器。但它并不仅限于数字。它很象第二十九章里 eval 下面的循环例子,你可以接受别人给你的任何东西,计算之,然后把结果返回给 它们。不同的是 Safe 版本并不执行任何你想要的东西。你可以在你的终端上交互地运行 这个计算器,键入一些 Perl 代码并且检查结果,感受一下 Safe 提供了什么样的保护。

   #! /usr/bin/perl -w
   # safecalc -- 研究 Safe 的演示程序
   use strict;
   use Safe;
   my $sandbox = Safe->new();
   while (1) {
      print "Input: ";
      my $expr = ;
      exit unless defined $expr;
      chomp($expr);
      print "$expr produces ";
      local $SIG{__WARN__} = sub { die @_ };
      my $result = $sandbox->reval($expr, 1);
      if ($@ =~ s/at \(eval \d+\).*//) {
         printf "[%s]: %s", $@ =~ /trapped by operation mask/
            ? "Security Violatin" : "Exception", $@;
      }
      else {
         print "[Normal Result] $result\n";
      }
   }

警告:Safe 模块目前正在设计成在同一个进程里,每个隔仓各自运行一个完全独立的 Perl 解释器的方式。(这个方法是 Apache 的 mod_perl 在运行预编译的 Perl 脚本时采用的 方式。)目前许多细节仍然有些朦胧,不过我们的“水晶球”告诉我们:如果你用一个命名 的包访问隔仓里的事务的话,那么在我们完成重写之后,你也不会离题太远。如果你运行的 Perl 版本比 5.6 还要新,那么检查一下 perldelta(1) 里的版本信息,看看有什么 改变,或者参阅 Safe 模块本身的文档。(当然,你就应该一直都这么做,对吗?)

23.3.2 伪装成数据的代码

Safe 隔仓可以用于那些有真正让人害怕的东西的事情,但这并不意味着如果你在屋子边上 做一些日常的活动的时候就可以把警惕性完全放松了。你需要培养对周围事物的警惕性, 以及养成以希望突破你的系统的人的眼光来看待问题的习惯。你应该采取一些预防措施, 比如保证光照以及修剪可能隐藏问题的灌木等。

Perl 也试图在这个领域帮助你。Perl 的分析和执行方式避免了 shell 编程语言常常落入 的捕食圈。在这门语言里有许多非常强大的特性,但从设计上开始,这些特性不论从语法上 还是语意上都是约束在程序员可以保持控制的范围和方法之内的。这里只有几个小例外, Perl 对每个记号只计算一次。这样,一些看起来象一些简单数据变量的东西才不能突然 到你的文件系统的根上。

糟糕的是,如果你调用 shell 为你运行其他的什么东西,那么这样的事情就可能发生, 因为那时侯你是运行在 shell 的规则下,而不是 Perl 的规则下。我们可以很容易避免 避免使用 shell——只需要使用 system,exec,或者管道 open 函数的列表参数形式就 可以了。尽管反勾号没有可以避免使用 shell 的列表参数形式,但你还是可以象“在 受限制的权限下访问命令和文件”节里描述的那样模拟它们。(尽管我们没有语法上的 方法令反勾号能够接收一个参数列表,但是我们正在开发一个下层的多参数形式的 readpipe 操作符;不过在我们写这些的时候,它还不能正式使用。)

当你使用在表达式里的变量的时候(包括你把它们代换为双引号字串的时候),该变量没有 机会包含一些做出你不想看到的事情的 Perl 代码。(注:不过,如果你是在生成网页, 那么这个变量还是有可能发出 HTML 标记,包括一些 JavaScript? 代码,而这些东西可能 会做一些远端的浏览器没有料到的事情。)和 shell 不同,Perl 从来不需要在变量周围 用引号保护——不管变量里面是什么。

   $new = $old;      # 不需要引号
   print "$new items\n";   # $new 不会伤害你

   $phrase = "$new items\n";   #这也不会
   print $phrase;         #仍然完全 OK。

Perl 的原则是“所见即所得”。如果你没有看到额外的代换层,那么它就不会发生。你 可以将任意 Perl 表达式代换到字串里,但只有在你让 Perl 这么干的时候它才会这么干。 (即便这样,如果你在感染模式的话,那么这些内容也要受到感染检查。)

   $phrase = "You lost @{[ 1 + int rand(6) ]} hit points\n";

不过,代换不是递归的。你没有办法在一个字串里隐藏任意表达式:

   $count = '1 + int rand(6)';      # 一些随机代码
   $saying = "$count hit points";      # 只是一段文本
   $saying = "@{[$count]} hist points";   # 还是一段文本

两个给 $saying 的赋值都会生成“1 + int rand(6) hit points”,而不会把 $count 代换的内容解释为代码。要让 Perl 把它当代码,你需要明确调用 eval STRING:

   $code = '1 + int rand(6)';
   $die_roll = eval $code;
   die if $@;

如果 $code 被感染,那么那个 eval STRING 就会抛出它自己的例外。当然,你几乎是绝对 不会计算任意用户代码的——但如果你这么干了,那你应该考虑使用 Safe 模块。你应该 已经听说过这个模块了。

有一个地方,Perl 可能有时候会把数据当代码看待;那就是当 qr//,m//,或者 s/// 操作符包含新的正则表达式断言,(?{ CODE }) 或者 (??{ CODE }) 的时候。如果把它们 当作模式匹配里的文本时不会有安全性问题:

   $cnt = $n = 0;
   while ($data =~ /( \d+ (?{ $n++ }) | \w+ )/gx) {
      $cnt++;
   }
   print "Got $cnt words, $n of which were digits.\n";

但是现有的那些把变量代换成匹配的代码是以“数据就是数据,而不是代码”的假设写的。 所以这种新构造可能会向以前认为安全的程序中引入安全漏洞。因此,如果一个代换过的 字串包含一个代码断言,那么 Perl 将拒绝计算模式,而是抛出一个例外。如果你真的需要 这个特性,那么你还是可以用词法范围的 use re 'eval' 用法打开它。(不过,你还是 不能把感染过的数据用做代换过的代码断言。)

有关正则表达式的另外一个完全不同的安全考虑是拒绝服务问题。它可能会让你的程序太快 退出,或者运行的时间太长,或者耗尽所有可用内存——而且有时候甚至倾倒核心,具体是 哪种情况取决于实际环境。

在处理用户提供的模式的时候,你不用担心会执行任意 Perl 代码。但是,正则表达式引擎 有自己的编译器和解释器,而这个用户提交的模式可能会让正则表达式编译器难受。如果 一个替换过的模式不是合法模式,那么会抛出一个例外,这个例外如果不加捕获就是致命 错误。如果你真的试图捕获它,请记住只用 eval BLOCK,而不用 eval STRING,因为后者 额外的计算层次实际上会允许任意 Perl 代码的执行。注意,你要想下面这样做处理:

   if (not eval { " " =~ /$match/; 1 }) {
      #(现在做你想对坏模式做的任何事情)
   }
   else {
      # 我们知道该模式至少可以安全地编译。
      if ($data =~ /$match/) { ... }
   }

更麻烦的拒绝服务的问题是就算给你正确的数据和正确的搜索模式,你的程序也可能被永远 挂起。这是因为有些模式匹配需要几何级的时间来计算,而这段时间很容易超过太阳系的 MTBF(平均无故障时间)。如果你再撞上狗屎运,那么这些高强度计算模式可能还需要 几何级的存储。如果这样,你的程序将耗尽所有可用虚拟内存,把系统其他部分也拖下 泥潭,骚扰你的用户,最终要么是带着通常的“Out of memory!”完蛋,要么是倾倒出一个 巨大无比的核心文件,当然这个文件可能比我们的太阳系要小一些。

和大多数拒绝服务攻击一样,这种问题并不好解决。如果你的平台支持 alarm 函数,那么 你可以给这样的模式匹配限时。糟糕的是,Perl (目前)不能保证操纵信号的小动作不会 触发一次核心倾倒。(我们已经安排在将来的版本中解决这个毛病。)但你还是可以试试, 这样就算这个信号不能得到体面的操纵,至少你的程序不会永远运行下去。

如果你的系统支持以进程为单位的资源限制,那么你可以在调用 Perl 程序之前在你的 shell 里设置这些东西,或者使用 CPAN 的 BSD::Resource 模块直接在 Perl 里干这件 事情。Apache 网页服务器允许你设置它所运行的 CGI 脚本的时间,内存,和文件尺寸限制。

最后,我们希望我们还是给你留下了一些没有完全解决安全问题的感觉。要知道,不要以为 只要你偏执就可以高枕无忧。也只有这样你才可能喜欢研究安全问题。


to top