前几天在p神的文章经典写配置漏洞与几种变形中看到了一个preg_replace的trick

看了下这个解法的提出者l3m0n师傅也没有给出具体的原理,只是猜测preg_replace做了转义的处理

但是preg_replace为什么要转义呢?翻看preg_replace的文档可以看到preg_replace的第二个参数replacement有两个特性:

replacement中可以包含后向引用\\n$n,语法上首选后者。 每个 这样的引用将被匹配到的第n个捕获子组捕获到的文本替换。 n 可以是0-99,\\0$0代表完整的模式匹配文本。 捕获子组的序号计数方式为:代表捕获子组的左括号从左到右, 从1开始数

如果要在replacement 中使用反斜线,必须使用4个(“\\\\”,译注:因为这首先是php的字符串,经过转义后,是两个,再经过 正则表达式引擎后才被认为是一个原文反斜线)

猜测这个trick与preg_replace的这两个特性有关,具体的原因还需要看看源码

环境搭建

windows下调试php需要用到php-sdk-binary-tools,具体操作可以参见官方文档

编译的时候需要将文档中的configure --disable-all --enable-cli --enable-$remains换为configure --enable-debug --enable-phpdbg

使用vscode调试编译好的php可以参见PHP源码调试分析

另外一开始使用vs2017编译时会报错,换成vs2019就行了…

测试的php代码

1
2
3
4
5
6
<?php
$replacement = <<<EOT
\'
EOT;
$replacement = addslashes($replacement);
echo preg_replace('/aaa/',$replacement,"bbbaaab"); //bbb\\'b

preg_replace实现

preg_replace代码位于ext/pcre/php_pcre.c中的php_pcre_replace_impl

先不贴代码,说下preg_replace的工作流程:

  • bbbaaab拆成bbbaaab
  • bbbaaa变成bbb\\'(我们关心的部分)
  • bbb\\'后面加上b

第二部分分为两步,主要是两个及其相似的while循环

  • 计算替换后原有字符串的长度,比如这里bbbaaa变成bbb\\'new_len就会是6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
while (walk < replace_end)
{
if ('\\' == *walk || '$' == *walk)
{
simple_string = 0;
if (walk_last == '\\')
{
walk++;
walk_last = 0;
continue;
}
if (preg_get_backref(&walk, &backref))
{
if (backref < count)
new_len += offsets[(backref << 1) + 1] - offsets[backref << 1];
continue;
}
}
new_len++;
walk++;
walk_last = walk[-1];
}
  • 真正的替换
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
while (walk < replace_end)
{
if ('\\' == *walk || '$' == *walk)
{
if (walk_last == '\\')
{
*(walkbuf - 1) = *walk++;
walk_last = 0;
continue;
}
if (preg_get_backref(&walk, &backref))
{
if (backref < count)
{
match_len = offsets[(backref << 1) + 1] - offsets[backref << 1];
memcpy(walkbuf, subject + offsets[backref << 1], match_len);
walkbuf += match_len;
}
continue;
}
}
*walkbuf++ = *walk++;
walk_last = walk[-1];
}
*walkbuf = '\0';
/* increment the result length by how much we've added to the string */
result_len += (walkbuf - (ZSTR_VAL(result) + result_len));
}

答案也很明显了,以第二个while循环为例:

  • walk用于遍历replacement
  • walk_last记录当前字符的上一个字符
  • walk'\\''$'时会触发反向引用
1
2
3
4
5
6
7
8
9
10
11
//反向引用
if (preg_get_backref(&walk, &backref))
{
if (backref < count)
{
match_len = offsets[(backref << 1) + 1] - offsets[backref << 1];
memcpy(walkbuf, subject + offsets[backref << 1], match_len);
walkbuf += match_len;
}
continue;
}
  • 连续两个\\就会吞掉一个\\,并且把walk_last置为'\0'
1
2
3
4
5
6
7
8
if ('\\' == *walk || '$' == *walk)
{
if (walk_last == '\\')
{
*(walkbuf - 1) = *walk++;
walk_last = 0;
continue;
}

测试代码中的$replacement经过addslashes后用c字符串表示是"\\\\\\'",第二个\\会被continue掉,因为这时walk_last变为了'\0'所以第三个\\就被保留了下来。直接输出或者写入到配置文件中就变成了\\',逃逸出了一个单引号

之所以有这样的特性就是为了区分反斜线和反向引用,也可以算是一种转义,所以l3m0n师傅的猜测可以说是正确的