引言
对一种编程语言而言,在设计这种语言的时候,一般是不会产生安全隐患的,事实上,这种隐患是由程序员引入的。几乎每一种编程语言都有一定这样的漏洞,这种漏洞将会在某种程度上导致不安全软件的产生,但是一个如软件整体的安全性仍然大部分依赖于这个软件制造者的知识面、理解能力和他的安全意识。Perl也有它安全上令人担忧的部分,然而大多数程序员完全没有意识到这些方面。
在这篇文章里,我们将会看一下Perl中一些最普遍被误用和忽视的属性。我们将会看到它们的误用将会怎样对运行它们的系统的安全以及它们的用户造成威胁。我们将会演示怎样把这些弱点挖掘出来以及如何去修改、避免它们。
用户输入上的弱点
Perl脚本中产生安全问题的一个很大的来源是没有经过正确确认(或根本就没有确认)的用户的输入。每次当你的程序要从一个不信任用户那里获取输入信息的时候,即使采用的是非直接的方式,你都应该小心。举个例子来说吧,如果你在Perl中写CGI脚本,你要预期到恶意的用户将会发送给你假的输入。 不正确的用户输入,如果没有经过确认就被认可并使用了,将会导致许多方面出错。最常见和明显的错误是,没有经过确认就去执行有用户自定义参数的其他程序。
syetem()和exec()函数
Perl以能被用作一种“粘合”语言而著称——它能够通过如下方式完成一个出色的工作:在调用其他程序来为它工作的时候,通过采集一个程序的输出,将它重新格式成一种特定的方式后传递到其他程序的输入的方式仔细的协调它们的运行。这样各个程序就能很好的运行了。
正如Perl发布标语告许我们的,我们有不止一种方法可以做同样的事。
一种执行一个外部程序和一个系统命令的方法事通过调用exec()函数。当Perl遇到一个exec()语句的时候,它审视exec()被调用处的参数,然后启动一个新的进程来执行这条特定的命令。Perl从不会返回控制给调用exec()的原来的那个进程。
另一个相似的函数是system()。system()的运行方式非常象exec()。它们之间的唯一的大的区别是Perl会首先从父进程中分叉出一个子进程,子进程作为提供给system()的一个参数。父进程等到子进程结束运行后再接着运行程序的其余部分。我们将会在下面更详细的讨论system()调用,但这些讨论大部分也适用于exec()。
传递给system()的参数是一个列表——列表里的第一个元素是要被执行的这个程序的程序名,其他元素是传给这个程序的参数。然而,如果只有一个参数的的话,system()的执行方式会发生差异。在那种情形下,Perl将会扫描这个参数看它是不是包含任何shell转换字符。如果有的话,它就要把这些字符通过shell来解释。所以产生一个shell命令行来工作。不然,Perl会降字符串拆成单词然后调用效率更高的c库函数execvp(),这个函数不能理解特殊的shell字符。
现在假设我们有一张CGI表单,它要询问用户名,然后显示包含这个用户统计信息的一个文件。我名可以如下使用system()来调用’cat’实现那种要求:
system ("cat /usr/stats/$username");
用户名来自这样的一个表单:
$username = param ("username");
. 举个例子,当用户在表单里添上username = jdimov,然后提交后。Perl在字符串``cat /usr/stats/jdimov'中没有找到任何转换字符创,所以它就调用execvp()函数运行”cat”后返回到我们的脚本中。这个脚本也许看起来没有害处可言,但是它容易被一个恶意的攻击者所利用。
问题是这样的,通过在表单的”username”域内使用特殊的字符,一个攻击者可以通过 shell来执行任何命令。举个例子,我们可以这样说,如果攻击者传递这样的字符串"jdimov; cat /etc/passwd",Perl会把分号当作一个转换字符,然后把它传递到shell中:
cat /usr/stats/jdimov; cat /etc/passwd
攻击者既可以获得亚元文件,又可以获得密码文件。如果攻击者想要搞破坏的话,他只要发送"; rmrf /*"就可以了。
我们在前面提到system()有一个参数表,并且将第一个元素看作命令来执行,而将其余的元素作为参数来传递。所以我们可以稍微改变一下我们的脚本,使只有我们想让执行的程序能够被执行:
system ("cat", "/usr/stats/$username");
既然我们分开来指定程序的参数,那么shell就永远也不会被调用了。所以发送";rm -rf /*"也就不会起作用了,因为攻击字符串将只会被解释成一个文件名而已。
这种方法比单个参数的版本要好多了,因为它避免了使用shell命令,但是仍然有潜在的缺陷。特别的,我们要考虑到$username的值会不会被利用产生程序中能被执行的弱点。举例来说,一个攻击者仍然可以利用我们重写的代码版本,通过把$username设置成字符串"../../etc/passwd"来获得系统的密码文件。
使用那样的程序的时候很多地方会出错,举例来说,一些应用程序将特殊的字符序列解释成执行一条shell命令的请求。一个普遍的问题是有些版本的Unix邮件工具当它们在一定的上下文背景下看到有”~!…”等字符序列的时候将会执行一个shell命令。所以在一个消息体中的空白行中包含"~!rm -rf *"的用户输入将会在某种情形下产生问题。
只要是谈及安全的,上面论及system()函数的任何内容也适用于exec().
Open()函数
在Perl中open()函数被用来打开文件。在最为常见的形式中,它是这样使用的:
open (FILEHANDLE, "filename");
这样使用的时候,’filename”是以只读方式打开的。如果”filename”是含有”>”标志的前缀,那么它是为输出而打开的,并且在文件已经存在的时候覆盖原文件;如果含有”>>”前缀,那么是为追加打开的;前缀”<”打开文件来进行输入操作,但这也是不含前缀的时候的默认方式。用未经确认的用户输入作为文件名的一部分所产生的一些问题应该总是比较明显的。举例来说,向后回溯浏览目录的骗招在这里仍然能用。还有其他值得担忧的问题。现在我们使用open()替换”cat”来修改我们的脚本文件。我们象这样的命令:
open (STATFILE, "/usr/stats/$username");
然后我们从文件中读取代码并显示它。Perl文档告许我们:如果文件名是以”|”开始的,文件名将会被解释成一个输出管道命令;反之,如果文件名以”|”结束的话,文件名将会被解释成将让我们进行输出的管道。
于是,只要加上一个”|”前缀,用户就可以在/usr/stats目录下运行任何命令了。向后回溯目录的操作能够让用户在这个系统里执行任何程序。
一种解决这个问题打方法是:对于你想要打开并向其中输入的文件总是要求通过加”<”标识显式的指明.
有时我们确实要调用一个外部的程序,比如,我们想要改表我们的脚本文件以让他能够读取旧的纯文本文件/usr/stats/username,但是在显示给用户之前要先通过一个HTML过滤器。我们有一个马上就可以使用的便利的方法来实现这个意图。一种方法可以这样做:
open (HTML, "/usr/bin/txt2html /usr/stats/$username|");
print while <HTML>;
不幸的是,这依然要通过shell层。然而我们可以采用open()调用的另一个形式来避免牵涉到shell:
open (HTML, "-|")
or exec ("/usr/bin/txt2html", "/usr/stats/$username");
print while <HTML>;
当我们打开一个管道命令,或者是为了读(“-|”),或者是为了写(”|-“)的时候,Perl在当前进程中产生分支,并且返回子进程的PID给父进程,返回0给子进程。”or”语句用来决定我们是在父进程还是在子进程。如果我们在父进程(返回值为非零),我们继续执行print()语句。否则我们在子进程中,就执行txt2html程序,使用多于一个参数的exec()的安全版本来避免传递任何命令到shell层。所发生的是,子进程答应txt2html产生的STDOUT输出,然后就默默的消亡了(记住:exec()从不返回),同时父进程从STDIN中读取结果。象这样的技术可以被用来通过管道将输出输到一个外部程序的技术:
open (PROGRAM, "|-")
or exec ("/usr/bin/progname", "$userinput");
print PROGRAM, "This is piped to /usr/bin/progname";
在我们需要管道的时候,open()的以上这些形式应该总是比直接的管道open()命令优先采用,因为它们不通过shell层。现在让我们设想我们要将静态文本转化成格式化很好的HTML页面,并且,基于方便考虑,要存放在显示这些页面的Perl脚本相同的目录下。那么我们的open语句看起来可能是如下形式:
open (STATFILE, "<$username.html");
当用户通过表单中传递username=jdimo的时候,脚本显示jdimov.html。这里仍然有被攻击的可能。不同于c++和c ,perl不用空字节来结束字符串,这样的话字符串jdimov/”jdimov/lo/bah在绝大数c库调用中解释为”jdimo”,但是在Perl中却是”jdimov\0blah”。当perl传递一个含空字符的字符串给用c写的程序的时候,这个问题就突出了。UNIX内核以及绝大多数UNIX 和shell 都 是纯c 语言的。Perl自身也主要是且c编写,当用户如下调用我们的脚本:
statscrit.plusername=jdimov/%00
会发生什么呢?我们的程序传递字符串”jdimov/%。html”到对应的系统调用里以打开它,但是因为那些系统调用是用c编写,接受的是空字节的字符串方式。结果怎样呢?如果有文件”jdimov”的话就会显示这个文件,可能并没有这个文件,即使有也不是很有用。但是如果用"statscript./pusername=statscript。p/%"来调用脚本,会发生什么呢?如果脚本和我们的html文件在同一个目录下的话,这样我们可以用这个输入来期骗脚本,来显示给我们所有的内容。在这种情况下或许不是什么大的安全危险,但是它肯定能被其它的程序使用,因为它允许攻击者分析其他可利用的缺陷的来源。
单引号
在perl中,另一种读取外部程序的输出的方法是把命令放在单引号里。所以如果我们想在分等级的$stats的文件中保存我们stats文件的内容的话,我们可以这样做:
$stats=’cat/user/stats/$username’;
这确实要通过shell层来实现。任何把用户输入包含在一对单引号内的脚本都有发生前面讨论的所有的安全问题的危险。有很多方法可试图使shell不要误解一席可能的转换字符。但是最安全的事就是不要用但引号。取而代之的是,打开一个通到STDIN的管道,然后分叉执行外部程序,就像我们在前一节open()所做的一样。
Eval()和/e 修饰符
函数eval()可以在运行时间执行一个Perl代码块,并返回上一次经评估语句的值。这种函数功能经常用于诸如配置文件,它可以写成perl代码,除非你绝对相信输进eval()的源代码,否则不要做诸如eval/$userinput,之类的事,这也适用于一个常规表述中的/e 修饰符,用来使perl在执行之前解释该表述。
过滤用户输入
用于本节我们所讨论的所有问题的过滤用户输入的一个通常方法(FUIn OCA )就是过滤任何不需要的转换字符和有问题的数据。例如我们可以在任何时段过滤来避免向后的目录查看。类似的,我们一旦看见非法的字符,就让程序运行失败,这种策略被称为”黑名单”这种哲学就是如果某东西没有明确禁止,那它肯定是好的。一个更好的策略就”白名单”,它指如果某东西没有被明确认可,那么它必须禁止。黑明单的最重要的问题是它非常难保持完整性并得到更。你也许会忘掉过滤某一特定字符,或者你的程序或许不得不随不同的转换字符集合转到一个不同shell中。不过滤掉不需要的转换字符和其他危险输入,相反,只过滤进合法的输入。下面的片段就是一个例子,它会停止执行一个安全性有问题的操作,如果有户输入中包含了除字母,数字,点和@符号外任何东西(@经常用于用户的电子邮件地址)
unless($useradress=~/^[-@/w。]+)$/)
{print”secrity error。/n”exit(1);
}
基本的思想是不去编译一个特定值的列表来保护,而是产生一个安全列表来接受可接受的输入值的列表。可接受的输入输入值的选择当然会随着不同的应用程序而变化。可接受的值应该采用某种能将破坏的可能性降到最小的方式来选择。
避免shell
当然,你也必须尽可能的避免shell,然而这种技术可被广泛地应用。如果你调用一个有特定序列的编辑器。你必须确认这些特定序列是不被允许使用的。一般,通过使用现存perl模块,你能避免使用外部程序来执行一个外部函数,CPAN是一个能完成几乎所有标准UNIX工具集能做的任何事的经测试的函数的模块来源,然而它或许会费点劲来包含一个模块,并且调用它,而不是调用一个外部程序,模块方法一般来说更安全和灵活,为解释清楚这一点,使用Net::SMTP,而不用exec()’ing sendmail/--T会帮你少一些使用shell的麻烦,并能防止你的用户在sendmail代理中寻求已知的弱点。
其它安全问题的来源(不安全环镜变量)
用户输入实际上是perl程序的主要安全问题的来源,但是还有其它因素是在写安全的perl源代码时所必须考滤的,经常 在shell下运行的脚本的易受攻击的弱点或者通过网络服务器是不安全的环镜变量,最通常的是PATH /变量。当你从你代码内部中使用一个外部应用程序或功能,而仅仅指明了一条相对路径的时候,你就使 你这个程序和运行它的系统处于危险中。如果你有如下一个system()调用:
system(“txt2html”,”user/stats/jdiov”);
对于这种调用,你假设txt2htm文件是包含在PATH变量某处的目录下,但是假如发生这种情况,一个攻击者改变你的路径指向含有相同的名字的其他带恶意的程序中,你的系统的安全性就得不到保证。为了避免例似的事情发生,每个需要含有远程安全意识的程序都应该这样开始写:
#!/usr/bin/perl -wT
require 5.001;
use strict;
$ENV = join ':' => split (" ", << '__EOPATH__');
/usr/bin
/bin
/maybe/something/else
__EOPATH__
如果程序依赖于其他环境变量,它们也要在它们使用前明确的定义出来。
另一个危险变量(这个更是针对perl的)是@INC距阵变量,它非常像PATH,只不过它 指明Perl到哪里去找要包含在程序中的模块。有关@INCR 的问题和PATH是非常相似的。有人可能会把你的perl指向一个具有相同的名字模块,而且如你所料的做同样的事,但是它也背地里做一些坏事,因此@INC同PATH都不值的信任。在包括任何外部模块之前都 必须完全重新定义。
Setuid脚本
通常一个Perl程序是以执行它的用户的权限来运行的。通过产生一个setuid脚本,它的有效用户的ID可以设定成更高的权限,这个权限使这个用户可以访问他实际没有访问权限的资源,比如passwd程序使用setuid来获取对系统password文件的写的权限,这样允许用户来更改自已的密码,因为执行程序是通过CGI界而来执行的,该界面是在使用网络服务器的用户权限下运行的,CGI程序员经常 试图使用setuid技巧来让他们的脚本执行一些恶作剧。这有可能用,但也可能十分危险,对一个事情,如果一个攻击者发现一各方法可以利用脚本的弱点他们不仅是获得访误问系统的权限,但是他们会用有效的UID的特权来获得存在着另外几种类似的种族状况,在一个程序当中,这类缺陷是比较容易监控的,尤其是对有经验的程序员来说,目前相关方面的工作正在积极的探索着。关于这个问题,目前还没有一个既容易又完全有效的解决方法,常常在种族状况存在的可能情况下,用到的最好的方法是使用原子操作方式来进行,这就意味着仅仅使用一种系统来同时检查和生存档案。而不必使用处理器,在二者之间进行切换。当然,这不可能是常有的。
另外我们所作的一种标准模式是使用SYSOPEN来确定一种只读模式,不必再设定删减标志。
通过这种方法,即使我们的文件名确实已经形成,当我们打开文件进行写操作的时候 ,我们也不会破坏文件。注意:Fcnt1模快必须包含进来,以便让sysopen()函数起作用,因为这个模块是如下常数,O_RDONLY, O_WRONLY, O_CREAT,等被定义的地方。
缓冲区溢出和perl
一般来说,perl脚本是不容易发生缓冲区溢出的,因为perl能在需要的时候动态的扩展它的数据结构。Perl跟踪为每个字符串分配的大小。在一个字符串每次被赋值之前,perl保证有足够的空间可以利用,如果需要的话,也可以为那个字符串分配更多的空间。
然而在一些较老的perl实现方法中有几种熟为人知的缓冲区溢出情形。一个明显的例子是5。003版本会因为缓冲区溢出而崩溃。所有的suidperl版本(一种设计用来工作在为某些内核)都是在早于5。004版本的perl的不同分类的基础上建立起来的。
结论:
在我们以后的文章中,我们将会化一定的时间来熟悉perl提供给我们的安全特性,尤其是perl的”taint 模式“,并且,我们将会证明,如果我们不小心的话,即使在如此坚固的安全机制下,依然可能会出现的一些问题。在学习perl的这些方面以及一些典型的例子的时候。我们的目标是为了培养我们的一种直觉,这种直觉能帮助我们在看perl脚本的第一眼时就能够意识到其中的安全问题,以避免在我们的程序中犯类似的错误。