尧图精选

PHP反序列化绕过与工程防御:从CTF题目到代码审计

🕒 发布时间:2026/10/1 19:21:25 📁 来源:尧图网络
1. 这题的“坑”其实埋在 PHP 的语言设计里“Make PHP Great Again” 这个标题我第一次看到的时候第一反应是这又是个用梗的比赛题。wmctf2020 里面这类命名不少主办方喜欢把一个看起来轻松的名字扣在一道并不轻松的代码审计题上。等真正打开环境才发现核心考点不是某个“神奇的绕过姿势”而是 PHP 这门语言在过去十几年里一直被人反复讨论的结构性特性类型转换、魔术方法、反序列化、动态调用。先说结论这道题所考察的核心场景基本可以收敛为“PHP 反序列化 正则过滤绕过”的组合。当然网上能找到的原题源码版本并不完全统一不同队伍赛后还原出来也有细节出入。我这里更想讲的是按主流写法还原出的等价最小用例以及从这个最小用例延伸开去的排查方法、修复思路和工程防御手段。换句话说就算你没有亲自参加过这几场比赛只要把下面这套分析走通以后再遇到任何带unserialize的代码你都能快速建立自己的判断框架。适合看这篇文章的人我觉得有三类第一类是刚开始打 CTF 的选手尤其是对 PHP 题型还没形成体系的新人第二类是很早就把 PHP 写成业务代码、但很少做安全审计的开发第三类是正准备梳理面试知识的人因为 PHP 反序列化、弱类型比较、文件上传校验这些点几乎是面试里绕不开的“基础知识”。我自己复盘的时候最大的感受是很多人把时间花在了记“payload”上却没有先花时间理解 PHP 为什么会出现这些“奇奇怪怪”的行为。一旦理解了你会发现所谓的绕过并不是“运气好”而是 PHP 在解析输入时的设计本就和很多朴素直觉不一致。这就是为什么我觉得“Make PHP Great Again”是个很妙的题目名它与其说是在喊口号不如说是在提醒你把 PHP 当年那些让你皱眉头的东西一个一个重新捡起来认真读一遍。1.1 为什么 PHP 的考题总是围绕语言特性写 Java、Go 或者 Rust 的 CTF 题出题人更多是把考点放在框架漏洞、加密协议、逻辑漏洞上因为语言本身帮你挡掉了很多误用。PHP 不是这样。PHP 的口号是“为 Web 而生”它的函数库极其庞大类型系统又非常宽松加上$_GET、$_POST、$_FILES这些超全局变量直接把用户输入铺在你眼前天然就容易写出“使用者只需要传入一个参数剩下的交给语言自动处理”的代码。这种宽松带来了开发效率也带来了很多边界情况。最典型的就是比较运算符和在 PHP 里的行为差异几乎可以撑起一门单独的安全课。字符串1e3在参与比较的时候会被当作数字处理某些以0e开头的字符串在哈希比较里又会被当成科学计数法结果数值恒为 0于是两个完全不同的哈希值就能被判定为相等。在 CTF 里这叫“魔法哈希”在真实业务里如果用它去做签名校验后果不亚于直接把验证逻辑删掉。再看反序列化。PHP 提供serialize()和unserialize()是为了方便把对象状态存到文件或传进 session这本是好意。可问题在于反序列化不只是简单恢复数据它会在对象被创建时自动调用__wakeup在对象被销毁时自动调用__destruct。如果这些魔术方法里存在危险操作比如执行命令、读写文件、调用方法攻击者只要精心构造一个序列化字符串就能在不让程序“主动执行攻击代码”的情况下完成利用。放在业务流程里这就是典型的“数据通道被当成了代码通道”。所以我复盘这道题时最大的收获不是学会了一条绕过正则的路径而是建立了一个习惯看到 PHP 代码时先看这个变量是从哪来的下一个动作会不会触发类型转换、方法回调或对象生命周期钩子。这个习惯比背一百条 payload 都值钱。2. 常见 PHP 题面里的五类攻击入口如果把近几年的 CTF PHP 题拉一个清单你会发现考来考去基本离不开下面几个入口。我把它们整理成一个对照表方便你快速定位一道题大概在考什么也方便你以后审计代码时按图索骥。类型常见成因CTF 里高频入口对应到真实项目弱类型比较和混用字符串自动转数字md5碰撞、0e开头哈希、数组与字符串直接比较签名/口令校验、支付金额判断反序列化魔术方法自动调用serialize/unserialize直接作用于用户输入可控参数进入unserialize()配合__destruct/__wakeup触发危险方法把外部传入的 JSON/缓存数据交给不安全的反序列化函数变量覆盖与动态回调extract()、parse_str()、变量函数、call_user_func()覆盖$config、$page、$whitelist等关键变量后包含文件或执行方法框架里通过用户参数决定调用哪个方法、加载哪个模板文件包含与文件上传include/require路径可控上传后缀和内容校验不严LFI本地文件包含、RFI远程文件包含、phar://触发反序列化、上传伪装文件头像上传功能只校验了 MIME或上传目录放在 Web 根目录下危险函数直接把用户输入传给eval、assert、system、exec、shell_exec一句话木马、代码执行、命令执行后台为了让业务人员“灵活配置”把用户规则交给了eval执行这张表看起来是在分析 CTF但每一项都能在真实项目里找到对应版本。我见过不少团队把反序列化当成“缓存技术”来用用unserialize处理 Redis 里的用户数据却忘了那些数据从哪来也见过上传组件只查了Content-Type导致攻击者把一段 PHP 代码包装成普通图片就通过了校验。CTF 题只是把这些问题浓缩在一个短期场景里核心逻辑和所有业务代码是同一个道理。2.1 为什么“危险函数”防不胜防eval、assert、system、exec、shell_exec这类函数本身没有错问题在于“谁给它们传参数”。在 PHP 里assert在历史版本中可以直接把字符串当成代码执行很多人会用assert来做简单的条件判断但一旦参数可控就变成了一个和eval等价的执行点。更麻烦的是有些函数乍一看不危险组合起来就危险call_user_func只是“调用一个函数”但当函数名和参数都可控时你就能调用system并传进任意命令。所以审计时我一般会先做一个“数据流追踪”找出所有能进入函数的变量路径重点看$_GET、$_POST、$_COOKIE、$_FILES、$_SERVER以及从数据库、缓存、消息队列里读出来的内容。很多队伍在打 CTF 时会忽略“二次输入”的位置比如反序列化数据不是直接从请求参数进入的而是先写进 session 文件下一次请求再被加载。这种间接路径往往比直接传参更难防御也更值得在实战中关注。3. 从反序列化到正则绕过一处处还原处理场景现在进入这道题最核心的部分反序列化过滤的绕过。当年这道题被讨论得很多其中一个关键点是不少选手手上有现成的 payload却匹配不上正则然后就开始怀疑是不是类的名字不对、属性名不对。实际上问题往往出在 PHP 的unserialize对序列化格式的“过度宽容”上。3.1 先看懂 PHP 序列化字符串的结构PHP 的序列化格式并不神秘一个对象可以表示成下面这段样子O:4:Flag:1:{s:3:cmd;s:2:id;}这段字符串拆开来看是这样的O表示对象类型4是类名的字节长度Flag是类名1是对象属性的数量s:3:cmd是属性名s表示字符串3是属性名字节长度s:2:id是属性值。很多人第一次手写序列化字符串时会栽在“属性名”上。比如 PHP 类里的属性名如果是cmd序列化生成的是s:3:cmd不能写多也不能写少。如果你处理的是中文还要注意字符串长度按字节算而不是按字符算一个中文在 UTF-8 编码下通常是 3 个字节所以序列化里面会出现s:6:你好这种写法。这道题之所以让不少人头疼就是因为他们对这个格式的构造不够熟一旦遇到正则拦截又漏掉了这个符号的存在。3.2 过滤O:的漏洞点加号绕过我再画一个等价的最小环境。假设目标代码长这样?php class Flag { public $cmd; public function __destruct() { system($this-cmd); } } $data $_GET[data]; if (preg_match(/^O:\d/, $data)) { die(blocked); } unserialize($data);这段代码的逻辑很简单如果传入参数以O:加数字开头就拦截否则直接反序列化。乍一看正常的序列化字符串确实都以O:4:之类开头正则/^O:\d/应该能挡住绝大多数对象输入。但 PHP 的序列化解析器在识别对象类型标记时非常宽容。你在O和数字之间插入一个解析器会照单全收。于是前面那个正常 payload 可以改写成O:4:Flag:1:{s:3:cmd;s:2:id;}正则里的意思是O后面必须直接跟冒号、冒号后面必须是数字。O:4中:的后面是不是数字所以preg_match(/^O:\d/, O:4:Flag:...)匹配失败。程序以为自己拦住了实际上unserialize依然把它当成一个合法的对象去解析。你可以本地跑一段最简单的验证代码?php var_dump(unserialize(O:4:Flag:1:{s:3:cmd;s:2:id;}));只要Flag类存在并能顺利包含进来你就会看到一个Flag对象被创建。接下来脚本结束__destruct被触发system(id)被执行命令结果直接输出到页面。我这里用一个完全可复现的小代码片段来说明原理一点也不依赖当时题目环境里的文件名、路由和额外配置。这就是反序列化类题目里最常用的“正则绕过”思路过滤条件只针对了“看起来正常”的序列化开头却没有去约束 PHP 解析器对加号的容忍度。这类问题在真实代码里也很常见比如做输入校验时只拦截了字符串O:却没有考虑大小写、空白、加号、URL 编码、Unicode 等价变形这些“同一语义不同写法”的情况。3.3__wakeup绕过数字不一致带来的对象生命周期差异如果你把过滤器改得更严格一些比如已经能拦住O:4那么攻击者还可以考虑另一条路绕过__wakeup方法。__wakeup和__destruct是对象生命周期里的一进一出。反序列化创建对象后会先执行__wakeup而__destruct会在对象被销毁时执行。如果一个类在__wakeup里强制把cmd属性改掉攻击者等于拿到了一个“没有攻击能力的对象”。不过在 PHP 7.4 之前的版本里存在一个反序列化特性当序列化字符串中声明的属性个数大于对象实际的属性个数时__wakeup不会被调用。我按这个逻辑设计一份代码?php class Flag { public $cmd; public function __wakeup() { $this-cmd echo test; } public function __destruct() { system($this-cmd); } }正常反序列化一个拥有cmdid的对象__wakeup会把cmd改掉。但如果你提交的是O:4:Flag:2:{s:3:cmd;s:2:id;}注意这里属性个数写的是2而对象实际上只有一个cmd属性。在 PHP 7.4 之前的版本里这个不一致会导致__wakeup被跳过对象直接进入后续生命周期。等到脚本结束__destruct触发system(id)照样执行。这个绕过并不是新东西它对应 CVE-2016-7124PHP 官方在后续版本里做了修复。但在比赛里主办方经常把容器版本固定在 PHP 7.3.x为的就是让选手有机会使用这个绕过。看到这里你应该能理解为什么我一直强调“看完代码还要看环境版本”一个在 PHP 8.0 里完全无效的 payload在 PHP 7.3 里可能就是唯一解。3.4 本地用 Docker 复现比纸上谈兵有效得多很多人打 CTF 时只在脑子里推逻辑一旦 payload 不生效就开始乱试。我的做法是本地快速拉起一个等价环境用 Docker 最省事。例如你手头有一个包含上述反序列化代码的目录叫php-lab可以这样跑docker run -d -p 8080:80 -v $PWD/php-lab:/var/www/html php:7.3-apache然后打开http://127.0.0.1:8080/index.php?data...直接观察输出。这种方式的好处是能快速验证“加号是否被接受”“__wakeup是否被绕过”“不同 PHP 版本的差异”这些关键结论。比赛中环境版本一般能通过响应头、报错信息或题目描述推测出来本地 Docker 镜像版本和容器版本保持一致能少走很多弯路。我当时也是这么复验的先跑一个 php:7.3 容器把上面两段代码分别放进去再用不同的 payload 去测试。结果很直观O:4能过正则O:4:Flag:2能跳过__wakeup。这些结论如果没有实际环境验证你很难确定它在边界情况下是否成立。4. 反过来想这套题放到真实项目中该怎么修CTF 题目的价值不只在于“打进去”还在于“修回去”。把一个反序列化绕过考到极致最终要回答的问题仍然是真实项目里我应该怎么避免自己挂在同一个点上4.1 能不用unserialize就尽量不用对绝大多数 Web 项目来说存储结构化数据首选json_encodejson_decode。JSON 格式里没有“类”的概念危险魔术方法就不会被自动触发。如果数据并不需要某一个具体类的实例完全没必要把整个对象序列化进去。如果确实需要反序列化PHP 7.0 以后提供allowed_classes参数可以白名单化允许创建的类$obj unserialize($data, [allowed_classes [UserProfile, CartItem]]);这样即使攻击者把序列化数据改成O:4:Flag:...反序列化器也会拒绝创建不在白名单里的类。这是我在审计时最推荐的最低成本加固方案。4.2 给魔术方法里的危险动作“上锁”即使你用了allowed_classes也没法保证被允许的类自身没有问题。审计时重点检查这些类的__wakeup、__destruct、__toString、__call、__get、__set等方法看看它们内部是否操作了命令执行、文件读写、方法回调、数据库访问等敏感动作。如果有尽量拆出去不要在反序列化自动触发的逻辑里做这些事。以__destruct为例对象销毁时机不可控你很难判断它发生在请求哪个阶段更不能假设“用户输入过了过滤所以这里一定安全”。比较好的设计是让这些魔法方法只做“数据清理”不要做“数据执行”。执行操作放在显式调用的业务方法里并单独做权限校验。4.3 把错误信息关进日志里很多 PHP 项目在出问题时会直接把错误打到页面上这对调试很方便但在生产环境等于把内部信息免费送给攻击者。一个小小Warning就能暴露文件路径、数据库表名、框架版本。你可以这样设置error_reporting(E_ALL); ini_set(display_errors, 0); ini_set(log_errors, 1); ini_set(error_log, /var/log/php_errors.log);开发环境可以开display_errors生产环境就必须关掉。能看到的错误越少攻击者做指纹探测和信息收集的难度就越高。这个点虽然是基础但在真实项目里仍然有大量遗漏。每次审计 PHP 代码时我都习惯先看错误处理配置因为一个过度“友善”的报错页面往往已经帮攻击者省掉一半的工作量。4.4 文件上传的几道闸门都要关紧搜索的时候我注意到很多人关心的另一个点是“php 上传漏洞”。这类问题和反序列化一样本质是“把用户输入当成可信内容”。文件上传至少要过四道闸门扩展名白名单不要只拦危险后缀文件内容头部校验比如判断图片的真实签名存储目录放在 Web 根目录外部不要给上传目录配置 PHP 执行权限文件名随机化不要使用用户提供的文件名。如果是在 nginx 环境里可以在上传目录的 location 里写死拒绝执行 PHPlocation ^~ /uploads/ { location ~ \.php$ { deny all; } }这道配置的意思是上传目录下所有以.php结尾的请求直接拒绝不管文件是原本就叫这个名字还是通过双扩展名、大小写变体绕过来的。在 Windows 下还要额外注意x.php.、x.php%20、x.php::$DATA这类文件系统解析差异字符串校验只是第一层真正可靠的还是“目录里没有脚本执行能力”。4.5 输出侧的防御也不能忘我看了搜索热度里还有“php跨域jsonp”“php div弹窗”这类词。其实它们都指向输出侧的同一个问题数据进入 HTML、JavaScript、JSONP 回调时如果没有正确编码就会带来 XSS。JSONP 在过去的年代很常见但它的回调函数名通常由前端或用户传入如果服务端没做白名单就会变成一个反射型 XSS 出口。现在更推荐的做法是能不用 JSONP 就不用用 CORS 白名单域名代替如果必须支持 JSONP回调名只允许[A-Za-z_][A-Za-z0-9_]*其他一律拒绝。HTML 输出时不要偷懒该转义的字段用htmlspecialchars($value, ENT_QUOTES, UTF-8)转义。听起来像是在讲基础课但真实问题往往就是从一个未转义的字段开始一路变成账号被盗。5. 工程自查清单别等被打了才回头审计复盘一道 CTF 题不是终点我更愿意把里面出现的每一个考点翻译成工程自查问题。下面这个清单是我每次帮团队做 PHP 代码头审计时都会过一遍的你可以直接拿来当模板。风险点自查问题修复建议输入来源哪些函数直接使用了$_GET、$_POST、$_COOKIE、$_FILES或请求头入口处统一做参数白名单/类型校验反序列化项目里有没有对外部数据调用unserialize()用了什么过滤改用 JSON必须用时加allowed_classes白名单魔术方法所有类里__wakeup、__destruct、__toString等方法是否涉及敏感操作把执行逻辑从魔术方法中拆出危险函数代码里有多少处eval、assert、system、exec、shell_exec、call_user_func能用白名单方法列表替代就替代参数严格约束比较逻辑登录、验签、金额判断用的是还是涉及哈希和数字判断一律用SQL 查询用的是 PDO 预处理还是字符串拼接全量切到 prepared statement文件上传上传目录是否可执行脚本文件名是否可控白名单 随机文件名 目录禁止执行 PHP文件包含include/require的文件名是否由用户控制用映射表或内置路由禁止直接拼接路径错误输出生产环境是否关闭了display_errors关掉页面报错只写日志输出编码HTML、JavaScript、JSONP 回调处是否有转义统一htmlspecialchars 回调名白名单依赖安全composer 依赖里有没有已知反序列化漏洞用composer audit定期扫描及时升级版本信息PHP 版本是多少是否还处于社区支持期内升级到受支持版本老版本的坑很难靠代码补齐这个清单看着很多实际执行起来并不复杂。先全局搜索unserialize、eval、system、include、require、extract、assert这些关键字把命中点逐个确认一遍再把所有入口参数整理成一张表最后用自动化工具跑一轮依赖漏洞扫描。重点是形成习惯而不是真的等到出了事故再返工。“Make PHP Great Again”这个题目本身我后来反复复盘过好多次。第一次做的时候满脑子都在想怎么把正则绕过去第二次做我开始琢磨为什么 PHP 解析器会对这么宽容第三次做我真正关心的已经变成了“如果这是我自己写的代码我应该在哪里拦一手”。这个转变恰好对应代码审计的三个层次先看到能打再理解为什么能打最后想明白怎么防。如果你正卡在第一步不用着急回去搭一个本地环境把序列化字符串一行一行拆开看就会突然发现这些题并没有想象中那么玄幻。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →