程序语言正常情况下并不构成安全风险,风险是伴随着程序开发者而产生的。几乎每一种语言都有这样那样的瑕疵,而这些瑕疵往往导致不少安全性不足的软件出现。但是整个软件的安全性在很大程度上还是取决于作者的知识、对程序的理解以及安全意识。在安全方面,Perl有它自己的让人意料不到的特性,可大多数Perl程序员却没有意识到它的存在。
下面,我们将从Perl脚本的广泛特性及其误用问题着眼,看看这些不当的使用将会带来何种的系统安全隐患,包括那些缺陷程序的广大用户正在使用的系统。同时,我们将会介绍如何利用这些缺陷以及如何避免和修补这些漏洞。
基本的用户输入漏洞
Perl脚本安全问题之一便是对用户的输入过滤不严(未经验证的用户input)。很多时候,你的程序会直接或者间接地接受一些来自未被信任的用户的输入,在这个问题上,程序员更应该谨慎。例如,你正在用Perl脚本编写一个CGI程序,那你就应该要考虑到会有恶意的用户给你的CGI程序发送伪造的输入。
如果用户输入未经过验证便予以信任和使用,那么输入会导致该程序出现各种各样的错误。最明显的一点就是程序执行的时候才知道我们执行的是别的用户提供的程序,而这种程序没有经过任何检查和验证。
system()和exec()函数
Perl以复合语言著称,实际表现为能够很好地调用其他程序来为它协同工作,通过它们的输出来协调运行情况,通过一种特别的方式把它们作为另一种形式的输入,传递给别的程序,从而确保每个环节能够顺利运行。正如Perl所告诉我们的,实现的方法不止一个。
执行外部程序或者执行一个系统命令我们只需要调用exec()函数便可。当Perl遇到exec()表达式的时候,便去寻找函数调用的参数,然后创建一个新的过程来执行指定的命令。Perl不会把控制权交还给原先调用exec()函数的程序,而是直接进入函数的过程。
另外还有一个类似的函数是system()函数。system()和exe()过程十分相似,它们之间唯一的区别就是,perl首先从初处理过程中分离出一个子程序来。这个子程序就是被送入系统的参数,然后初处理等待直至子程序运行,再执行剩余的其他程序。下面我们来进一步讨论system() 函数的调用,但绝大部分的结论同样可以应用到exe()函数中。
被输入到系统的参数是一个列表形式,列表的第一项是被调用执行的程序名称,而剩下的几项将作为参数被传递给该程序。尽管如此,如果只有一个参数的话,system()会异常运行。在这种情况下,Perl会扫描这个参数是否包含Shell元字符,如果有的话,它会把这些字符通过Shell来解释。因此Perl便产生了大量的内部命令来完成工作,否则Perl将拆分这些字符并调用更有效的C程序库,但是这些库也不能很好地识别这些内部命令字符。
现在假设我们有一个CGI表单需要用户名来列出一些文件(包含用户的统计数据表),我们就可以通过system()来调用CAT来实现我们的目的,代码如下:
system ("cat /usr/stats/$username")
而用户名$username则来自下面这个表单项里:
$username = param ("username");
用户填写表单用户名,比如jdimov,然后提交它。Perl却找不到任何元字符在“cat /usr/stats/jdimov”的字符串中,所以Perl就调用execvp()来运行“cat”,再返回到我们的脚本程序里来。这段脚本看上去没什么,其实是可以被别有用心的攻击者利用的。问题在于攻击者可以在表单里面使用特殊字符来实现任意Shell命令的执行。我们假设攻击者在用户名的地方输入如下字符串:“jdimov; cat /etc/passwd”字符串,Perl把分号作为元字符发送到内部命令Shell中,像下面这样:
cat /usr/stats/jdimov; cat /etc/passwd
入侵者将同时得到虚假的状态文件和密码文件,如果是比较有破坏性的,就可能仅仅发送“; rm rf /*”。
开始我们提到system()函数包含一个参数的列表,并且以命令的形式来执行列表的第一个元素,其他的元素作为参数来传递。所以,我们稍微改动脚本,就可以得到我们想要执行的命令结果。
system ("cat", "/usr/stats/$username");
即使我们单独指定每个参数到程序中,Shell也不会被调用。发送“;rm -rf /*”将不起任何作用,因为这个攻击性的字符串将仅仅作为一个文件名而被阻止。
这种方法比单参数的方法好得多,虽然它避免使用Shell,却仍然有潜在的缺陷。特别地,我们需要关心的是用户名的字段值能否被利用来攻击现行程序的弱点(以CAT为例),比如入侵者仍然可以利用我们重新写的编码版本去显示系统密码文件,那就是通过设置$username成字符串:“../.. /etc/passwd”。运行这个程序,会有很多地方出错。比如一些应用程序采用特殊的字符顺序作为执行Shell命令的请求,一个常见的问题就是一些 UNIX MAIL工具的版本,当碰到“~”Esc控制序列时(在特别匹配的上下文中)会执行Shell命令,这样在特定的环境下,用户输入包含“~!rm -rf *”的字符将可能引起错误。
open()函数
open()函数的功能是打开文件夹,用得最多的也是普遍的格式为“open (FILEHANDLE, "filename");”。在这个应用中,filename是以只读的方式打开,如果filename为“>”前缀方式,那打开时就是输出。如果文件已经存在,则覆盖该文件,如果是字符“>>”,它将采取追加的形式打开。前缀“<”以输入的方式打开文件,如果没有前缀的时候,就是默认模式。未验证用户输入的问题应该已经显而易见,就像后面将要介绍的目录列举伎俩在这里同样有效。
隐患依然存在,我们用open()来代替CAT,比如“open (STATFILE, "/usr/stats/$username");”。我们要读取某个文件里面的代码并显示它。Perl文档告诉我们,如果文件名以“|”开头,则对该文件名的解释就变成了重定向输出的管道命令。如果文件名以“|”结尾 ,该文件名的解释就是作为管道输出。
用户在/usr/stats目录下将可以执行任何命令,仅仅是通过修改一个“|”。若向后遍历目录,则可以让用户执行任意程序。
要解决这个问题,一个方法是总是详细指出需要打开的文件并且加上“<”的标志作为前缀,比如“open (STATFILE, "
有些时候,我们确实需要调用一些程序。例如,我们要改变脚本让它可以读取纯文本文件/usr/stas/username,然后把它传递到一个html过滤,然后显示给用户。比如,我们有一个简便实用工具作此用途。实现的方法是这样的:
open(HTML,"/usr/bin/txt2html/usr/stats/$username|")
print while ;
不幸的是,这仍然是通过内部命令实现的。不过,我们可以使用复合形式的open()函数调用,这样就能避免产生内部命令。
open (HTML, "-|") 或者
exec("/usr/bin/txt2html", "/usr/stats/$username");
print while ;
当我们为了读取("-|")或者写入("-|")而打开一个以“-”开始的管道,Perl会先从当前进程里面分出一个子进程,并返回一个子程序的PID给父进程,而返回0给子进程。“or”表达式决定我们是在父程序还是子程序中。如果我们处于父进程中(open()的返回值非零),则我们继续 print()语句。如果我们处于子进程,则通过使用安全的exec()函数来执行txt2html程序,可以避免传参的时候通过Shell。这样子程序打印输出txt2html的结果指向stdout,然后终止(记住exec()没有返回值),同时父进程从stdin读取结果。这样相似的同一技术可用于管道输出到外部程序:
open (PROGRAM, "|-")
or exec ("/usr/bin/progname", "$userinput");
print PROGRAM, "This is piped to /usr/bin/progname";
在需要的时候,这些形式的open()更始终倾向于直接管道的open(),因为它们不经内部命令。
现在假定我们转换统计表文件的数据经过格式化的html网页,为方便起见,决定把它们存放在与用于显示它们的脚本文件同一目录中。那么我们的open()的表达式将是这样的:
open (STATFILE, "<$username.html");
当用户从表单输入username= jdimov,脚本就会显示jdimov.html,这里就有一个攻击可能性。不像C和C++语言,Perl不使用null终止字符串。因此字符串 “jdimov\0blah”在C程序库里解释为“jdimov”,但在Perl里面仍然是“jdimov\0blah”。Perl传递含有一个null 的字符串到一些C语言编写的程序时,问题就会出现。Unix内核和大多数Unix内部命令都是纯C语言,而perl本身主要就是C语言编写的。当用户这样调用脚本:“statscript.pl?username=jdimov\%00”时,会发生什么呢?我们的脚本传送字符串“jdimov \%00.html”到相应的系统调用过程中用以打开,但由于这些系统调用是由C编码的,并且等待null终结的字符串部分——“.html”。结果如何?脚本只会显示文件“jdimov”(如果存在的话)。它可能不存在,即使存在,也没有什么很大的作用。但是,如果我们这样来构造脚本:“statscript.pl?username=statscript.pl%00”,若脚本文件和html文件位于同一目录的话,我们就可以欺骗这个脚本程序来显示它的源代码。在这种情况下,虽然它对于程序本身意义不大,但是这个方法倘若应用到别的脚本,便可以帮助攻击者分析全部Perl代码,找到别的可能存在的利用漏洞。
反引号
在Perl中,还有另一种方式来读取一个外部程序的输出,那就是把命令封装入backticks。因此,如果我们想要存储统计文件数据的内容到标量$stats里面,我们可以这样:“$stats = `cat /usr/stats/$username`;”,这样确实要经过Shell,任何涉及到用户输入backtick的脚本都存在着我们上文已经介绍过的种种安全隐患。虽然目前有几种不同的方法可以令Shell不解释元字符,但是最安全的办法就是不使用backticks,而打开通往STDIN的管道,然后像我们在先前部分的末尾一样,用open()分离和执行外部程序。
eval()函数和/e regex正则表达式修饰符
eval()函数在运行时可以执行整块Perl的代码并返回最后一个表达式的值。这种功能常用于配置文件,这些文件用Perl代码来编写。除非你绝对信任传递给eval()参数的来源,否则不要写类似“eval $userinput”的程序。这也适用于“/e”正则表达式修饰符,来使Perl先诠释表达,然后再处理。
用户输入的过滤
我们现在要讨论的是过滤一切不必要的输入和可疑数据,这是个很简单的办法,却能解决最多的问题。举例来说,我们可以过滤掉所有时段,以避免向后遍历目录。同样,每当我们看到无效字符则可以舍弃之。
这就是“黑名单”策略。也就是说,一个东西如果不是明确禁止的,那它便是合理的,然而“白名单”更好些,就是说,东西如果没有明确指出是允许的,那么它就是禁止的。
黑名单策略的问题在于难以保持完整和更新。你可能忘记过滤出某种字符,或者你的程序可能要切换到不同的内部命令环境下,而这种环境用的是一套完全不同的元字符。
不同于过滤不必要的元字符和其他危险输入,白名单策略只需要确定哪些输入是合法的。下面片断中,例如如果用户输入包含任何除字母、数字、圆点或@以外的符号时,将停止执行一个关键安全操作。
unless ($useraddress =~ /^([-\@\w.]+)$/) {
print "Security error.\n";
exit (1);
}
基本思路不是尝试编辑一份列表式的特殊准则去防范,而是要拿出一个可以安全接受的规则列表。选择接受的输入准则自然是每一种程序会有所不同。可接受的准则应选择以尽量减少其损失潜在可能性这样的一种方式。
避免内部命令:shell
当然,你也应该尽可能避免内部命令。然而,这一技术有更广泛的适用性。如果调用其中有特殊序列编辑器,你必须确保这些序列是不容许的。
通常,我们可以利用现存的Perl模块来避免使用外部程序来执行一个函数。综合Perl存档网络(CPAN)是一个巨大的功能测试模块资源库,几乎任何一个标准的UNIX工具都可以应用到它。和调用一个外部程序相比,包含并调用一个模块可能会多花点功夫,但是大致上看,模块化的方法更具有安全性和灵活性。为了说明这一点,我们利用“NET::smtp”代替exec()函数就可以解决必须经过内部指令的问题,可以防止你的用户在endmail的代理设置里利用已知的漏洞。
其他来源的安全问题
1)不安全的环境变量
用户输入的确是Perl程序安全问题的主要来源,但在编写Perl的安全代码时也有其他需要考虑的影响因素。一个普遍的缺陷就是,脚本 Shell环境下或网络服务器环境下的不安全环境变量,最常见的就是路径变量。当你从指定的一个相对路径的代码进入一个外部程序时,你赌上了整个应用程序和系统的安全。假设你有一个system()调用是这样的:“system ("txt2html", "/usr/stats/jdimov");”,对于这个运行的调用,你假设txt2html文件是存放在这样的一个目录,这个目录包含路径变量的某个位置。攻击者会改变你的路径指向其他一些拥有同样文件名的恶意程序,这样你的系统的安全便没有了保障。为了防止这样的情况发生,每个需要加以远程安全重视的程序都应该按如下的方式进行:
#!/usr/bin/perl -wT
require 5.001;
use strict;
$ENV{PATH} = join ':' => split (" ", << '__EOPATH__');
/usr/bin
/bin
/maybe/something/else
__EOPATH__
如果程序依赖于其他环境变量,也应在使用之前重新明确定义。
另一个危险变量(这是一个更具体的Perl)是一个@INC数组变量。除了它特别指明Perl应该在哪里寻找并在程序里面包含模块以外,这个变量很像PATH变量。@INC问题和path路径问题基本上类似,它可能指向具有相同名称以及和你期望的一样的Perl的一个模块,但它也可能在后台做一些阴谋性的活动。因此,@INC和路径变量一样同样不应该信任,应该在包含任何外部模块之前完全重新定义。
2)setuid脚本
正常情况下,Perl程序继承了执行它的有效用户的权限。通过制作一个setuid脚本,它的有效用户ID 可设置成一个拥有“访问实际使用者所不能获取的资源”权限的ID(即以主人身份的文件包含程序)。例如密码程序通过使用setuid脚本来获取写入许可,对系统密码文件passwd进行操作,从而允许用户修改自己的密码。因为通过CGI接口执行的程序是按照Web服务器用户的特权来运行的(通常是用户 “nobody”,拥有非常有限的权限),CGI程序员常常忍不住用setuid技术让脚本实现其他程序做不到的功能。这的确很有用,但它也是很危险的。其一,如果一个攻击者在该脚本里面找到一个可以利用的弱点,他们不仅将获得系统访问权,同时也将获得有效的脚本uid权限(通常是ROOT的uid)。
为了避免这种情况,在任何文件操作前,Perl程序应给真正进程的uid和gid设置有效的uid和gid。
\begin{verbatim}
$> = $< # set effective user ID to real UID.
$) = $( # set effective group ID to real GID
而CGI脚本应该总是赋予最低的权限。
要注意的是,你的setuid脚本并不总是解决问题。部分操作系统有内核缺陷,使setuid脚本更不安全。为此这样那样的原因,当Perl运行setuid或setgid脚本的时候,它会自动切换到一个特殊的安全模式(外部指令模式)。
3)rand()函数
rand()函数随机生成的数在特定的机器上是一个不一般的问题。在关键安全的应用上,随机数字用于许多重要任务,如密码生成或者加密学。出于这个需要,尽可能生成接近随机数至关重要,这会让攻击者极度难以(但绝不是不可能的)预测我们将要生成的数字。Perl的rand()函数简单地从标准C 语言库调用相应rand(3)函数,这样的程序是很不可靠的。该C语言库的rand()函数生成一个伪随机序列,这些序列是基于一些称为种子的初始值而得来的。给定同一种子,同一程序的两个不同的实例程式将利用rand()函数产生相同的随机值。在许多C执行中,以及在所有5.004之前的Perl版本中,如果种子没有明确指明,它将根据当前计算机系统的当前时间值来进行估算,而系统时钟则是随机的。假如给定一个特定的点和足够的时间,通过获取随机函数生成值的相关信息,任何攻击者很任意地便可获取下一个将由随机函数产生的数字序列,从而获取威胁到系统安全的相关信息。解决这一随机函数问题的方法之一,就是使用一个基于Linux系统内置的随机数发生器——/dev/random和/dev/urandom。比起标准的随机函数库,这些都是相对比较好的随机来源。但像其他的事物一样,它们也有自己的缺陷。两者的区别是设备/dev/random在随机池耗尽时而不再生成随机数字,而/dev /random在随机池耗尽时利用加密的方法产生新的随机数字。另一种解决方法是使用像Yarrow这种较为复杂的加密随机数发生器来产生随机数字。
竞争条件(race conditions)
竞赛条件(以及缓冲区溢出)是经验丰富的入侵者的最爱。看一下如下代码:
unless (-e "/tmp/a_temporary_file") {
open (FH, ">/tmp/a_temporary_file");
}
乍一看,这是一个非常合理的代码,似乎没有造成任何危害。我们先检查是否有临时文件存在,如果不存在,我们就告诉Perl去创建和打开它并写入。这里的问题是,我们假定我们的检查在打开文件时是正确的。当然,Perl在一个文件的存在问题上不会对我们说谎,但在这看似不可能的情况下,文件完全有可能在这么一个时间区间内(从检查结束到文件被打开这段时间内)发生改变。又比方说,一个熟悉我们程序工作原理的攻击者,正好在我们刚刚检查完临时文件的存在性之后,执行了如下的命令:“ln -s /tmp/a_temporary_file /etc/an_important_config_file”。现在我们对临时文件所做的一切操作全部被转移到了我们重要的配置文件身上了(即移花接木 ——译者注)。因为我们认为临时文件不存在(这就是我们的检查结果告诉我们的),所以我们直接打开它并对其进行写操作。结果,config文件就这样被自己抹掉了。更不幸的是,如果攻击者知道他们所做的,这甚至可能导致毁灭性的后果。
在这种情况下,攻击者可以在程序的两个动作之间竞争和改变一些东西,从而给我们带来麻烦,我们称之为竞争条件。在这种特殊的情形下,我们会有一个TOCTOU (Time-Of-Check-Time-Of-Use)的竞争条件。此外,还有几种类似的竞争条件的类型。这种程序的缺陷往往容易被富有经验的程序员所忽略,却被攻击者频繁利用。目前还没有简单有效的办法来解决这类问题,当竞争条件可能存在的时候,最好的一个办法是使用原子操作。这种方法仅用一个系统调用来同时实现文件的检查和创建,这期间不给处理器任何机会去切换到另一个进程,可这并不总是可行的。在我们的例子里,还有另一件事我们可以做,就是使用 sysopen()函数并指定一个只写方式,而不设定任何截断标记。
unless (-e "/tmp/a_temporary_file") {
#open (FH, ">/tmp/a_temporary_file");
sysopen (FH, "/tmp/a_temporary_file", O_WRONLY);
}
这样,即使我们的文件被伪造了,在我们开始写入的时候也不会删除文件。
注:为了使sysopen()函数调用能工作,fcntl模块必须被包含,因为fcntl模块是o_rdonly、o_wronly、o_creat等常量被定义的地方。
缓冲区溢出与Perl
一般来说,Perl对缓冲区溢出并不敏感,因为在必要时Perl可以动态扩展它的数据结构。Perl保持跟踪字符大小和长度,然后分配给每个字符串。在每次要存储一个字符串之前,Perl都确保系统有足够的空间可供使用,必要的时候会为其分配更多的存储空间。但也有在一些老Perl版本中存在的少数已知的缓冲区溢出情况。特别是在5.003版本,缓冲区溢出是可以被利用的。所有的suidperl版本(一类针对一些内核而用setuid脚本编写的程序,围绕竞争条件而设计的)的建立都是早于5.004 版的(CERT Advisory CA--97.17)。
结论
如果时间允许的话,在后续的文章中,我们将花一些时间深入探讨Perl所提供的安全功能,尤其是Perl的外部命令模式;然后我们将弄清楚一些问题,如果我们不加以注意的话,这些问题可能成为安全问题的漏网之鱼。在研究Perl的方方面面以及看一些典型例子的过程中,我们的目标是建立一种能够帮助我们一眼认识到Perl脚本安全问题的直觉,并避免在我们的程序中犯下类似的错误。
阅读(587) | 评论(0) | 转发(0) |