前几天在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师傅的猜测可以说是正确的