做安全的这些年我有个很深的感触很多团队把 WAF 当成一堵墙以为请求过了 WAF 就等于安全。但 WAF 本质上不是一个盾牌而是一个戴着滤镜的哨兵——它看见的世界和后面应用真正执行的世界经常不是同一个。反序列化漏洞的 WAF 绕过恰恰就是这种“认知差”的经典战场。这篇文章不打算只贴几个绕过 payload而是想把这些绕过手法背后的底层逻辑拆开为什么同样是解析请求WAF 和后端会得出不同的结论攻击者是怎么利用这种差异把反序列化攻击从“会被拦截”变成“能穿透”的无论你是做安全研发、写 WAF 规则还是做攻防对抗搞清楚“解析”和“执行”之间的认知博弈比记住十个绕过姿势重要得多。1. 绕不过去的前提先搞清楚反序列化漏洞在打什么1.1 反序列化的本质是“数据变逻辑”要理解绕过先得理解被绕过的对象长什么样。序列化这个概念本身不复杂程序运行时的对象没法直接存文件、走网络于是把它转换成一段字节流或者字符串这叫序列化反过来从这段字节流恢复出对象就叫反序列化。危险在于序列化后的内容里除了数据往往还带着“类型信息”。PHP 的序列化串长这样O:8:stdClass:1:{s:4:name;s:5:value;}这段字符串描述了“一个对象类名是 stdClass有一个属性 name值为 value”。Java 的序列化字节流转成 Base64 之后常见开头是rO0AB。还有一些 JSON、XML 系的绑定机制同样会根据传入的类名去动态创建对象。问题就出在这里如果反序列化的入口接受的是不可信输入攻击者就可以通过控制类名和属性值触发目标类里的魔法方法、构造方法、setter一步步把“单纯的数据还原”变成“执行了危险逻辑”。网上常说“一条反序列化链打穿一台机器”本质不是反序列化函数本身在执行命令而是它像一台会自动拆信、按信内容调动管家的机器信里的指令和收件人名字都是攻击者伪造的。这也是反序列化漏洞和普通注入最大的区别普通注入是改写一条已有命令反序列化是让应用亲手帮你把恶意对象搭出来。检测难度更大因为攻击行为伪装成了“合法格式的数据”。1.2 WAF 凭什么能拦住反序列化攻击WAF 拦截反序列化攻击靠的其实是一套很朴素的流程拿到请求以后先看数据在哪URL、请求头、请求体然后按照声明的格式去解析再做几层解码最后拿正则或语义规则去匹配特征。匹配哪些特征说白了就是在等攻击者“露马脚”PHP 反序列化的类名长度模式比如O:\d:、C:\d:Java 原生态序列化的魔数特征rO0AB常见漏洞链里的类名关键词比如某些带特定包名的类命令执行、文件操作等危险函数以及配合使用的特殊字符。这套机制在“攻击者老老实实把恶意字符串放在请求体里”的时候是有效的。因为很多反序列化 payload 确实长得很有辨识度类名、长度、结构都写在明面上。WAF 不需要理解这条链能不能打通它只要看见O:8:...这种形态宁可错杀也不会放走。但 WAF 有个天然的死穴它是在“看请求”不是“执行请求”。它不知道后端用的是哪个解析库、取的是哪个字段、解析时对格式有多宽容。它只能按自己对协议的理解去猜。猜就会有偏差。1.3 认知博弈解析差才是绕过空间我在分析绕过案例时习惯把问题归结成一句话WAF 认为的请求内容不等于后端实际处理的内容。这句话初看像废话其实里面藏着三个可以操作的方向看不见payload 明明在但 WAF 因为编码、请求头、解析范围的限制没看到那一层看错了payload 在 WAF 视野里但经过解析后它看到的“值”和后端看到的不是同一个值特征被扭曲了看不完payload 确实存在但超出了 WAF 检查深度、字段数量或者性能上限WAF 选择了放弃检查。攻击者要做的不是发明一种新的漏洞利用链而是找到一种方式让 WAF 对请求的“认知”和后端对请求的“执行”产生分歧。这个分歧就是绕过。理解了这条主线下面所有手法就都能串起来了它们无非是在三个方向上做文章。2. 绕过手法的底层逻辑与实战变形2.1 维度一让 WAF“看不见”关键特征最常见也最容易成功的方向是让 WAF 压根没有检查到 payload 所在的位置。第一招Content-Type 欺骗。很多 WAF 会严格按照请求头里的 Content-Type 来决定如何解析请求体。你声明是application/x-www-form-urlencoded它就按keyvaluekeyvalue的格式去拆你声明是text/plain它可能干脆不做内容检查。但后端的实际行为未必这么老实。相当多的 Web 框架支持“自动识别请求体格式”哪怕 Content-Type 写的是表单只要 body 长得像 JSON它照样按 JSON 解析。于是出现一种很经典的错位body 就是一段合法的 JSON 反序列化 payload但 WAF 当作表单数据拆完以后看到的全是些{、:、O:8之类的“无意义 token”特征对不上。第二招藏在 multipart 的某个分片里。WAF 对 multipart/form-data 的检查往往集中在文件名、第一个分片这类位置有些实现甚至只解析首个 boundary。攻击者可以把完整 payload 放进后续某个分片后端按正常逻辑读取了那个字段WAF 却根本没 scan 到那个区域。第三招编码套娃。请求体里的关键字符被编码了一层或多层WAF 只做了解一层后端的解析逻辑却解了两层。最典型的就是 JSON 里的 Unicode 转义。JSON 规范本身允许\u004f这种写法它表示字符O。如果 WAF 没有做 Unicode 归一化那它看到的\u004f只是一个反斜杠加字母 u 加一堆数字完全匹配不上O:8:这种模式而后端 JSON 解析器读到\u004f还原出的真实值就是O照样拼出完整的反序列化结构。这招的原理可以类比成安检员只检查了明面上的行李箱不知道夹层里藏了东西可收件人那边拆箱时是会打开夹层的。只要两端对同一份数据的解码次数不一致特征就能被隐藏。2.2 维度二让 WAF“看错”关键特征如果 payload 已经被 WAF 看见了那就在“看见的值”上做文章让同样一段正文WAF 解析出来的结果和后端解析出来的结果不是一个东西。重复键是这里面最典型的例子。JSON 语法本身对重复键的态度是“没态度”规范没说禁止不同实现处理方式完全不同。有的解析器取第一个有的取最后一个有的直接把重复键都保留。WAF 做安全检查时可能默认取第一个值发现是安全内容就放行后端业务代码用的解析库却取最后一个值结果执行的就是恶意内容。攻击者只需要构造一个这样的对象{config: {data: safe}, config: {data: O:8:\stdClass\:1:{s:4:\name\;s:5:\value\;}}}WAF 盯住第一个config看起来人畜无害后端盯着最后一个config拿到的是完整攻击串。同一个请求两个解析器却得出两份不同的“事实”。宽松解析和严格解析的差异也常被利用。有些 JSON 解析器允许注释、尾部逗号、单引号字符串甚至允许裸词WAF 如果用的是严格解析碰到这些“不合规”的写法解析失败后可能直接放弃检查而后端用的是宽松解析照样能拿到值。这就是“用垃圾代码掩盖真实结构”的思路。类型混淆是另一个容易忽略的点。JSON 里同样一段内容写成字符串、数组或者嵌套对象解析后的结构完全不同。WAF 的正则在扁平字符串上匹配得很准但攻击者把关键字符拆散到数组的多个元素里或者用嵌套对象包几层有些基于正则的规则就直接失效了。这不是后端的功劳纯粹是规则写得不够语义化。2.3 维度三让 WAF“看不完”关键特征第三种思路是把 payload 放在 WAF 的检查范围之外让它“没耐心看”或者“没能力看”。深层嵌套是绕过正则和递归解析的常用手段。很多 WAF 为了避免被超长请求拖垮会设定最大解析深度、最大字段数、单个字段最大长度。攻击者把真正包含反序列化特征的内容放在第 100 层的嵌套对象里或者放在第 1000 个字段后面如果 WAF 只解析前 128 层、前 100 个字段那后面的内容自然被跳过。后端如果没做同样的深度限制就能正常读取到深层数据。超大请求体也有类似效果。我曾经测试过把请求体撑到几十 MBpayload 夹在正中间。部分 WAF 在性能压力下会进入“放行模式”——宁可漏检也不愿意拖垮业务。这种场景下WAF 不是被骗了是被“累到放弃”。用序列化和反序列化格式之间的自嵌套。比如外层是 JSON内层是 Base64 编码的 Java 序列化流再里面还有一层 URL 编码。WAF 如果只解了外层的 Base64看到了里面还有一层编码没有继续递归解码那最内层的特征就露不出来后端也许在业务代码里会依次解码最终还原出完整的恶意序列化对象。这三个维度不是互斥的实际攻击中经常叠加使用用 Content-Type 欺骗让 WAF 不解析用重复键让解析结果失真再把 payload 放到深层字段里让 WAF 检查不到。防御方如果不理解这三层逻辑很容易陷入“加了规则又被绕过、绕过后再加规则”的被动循环。3. 从绕过手法反推检测策略规则该如何重建3.1 正则拦不住“视角问题”语义化检测才是解药很多 WAF 规则集本质上就是一堆正则。正则这东西用来匹配固定形态的字符串很好用但用来对抗“解析视角差异”就很吃力。攻击者变一下编码、插一个重复键、换一种嵌套结构正则特征就失配了。而且正则还存在两个天然缺陷一是回溯问题一个写得不严谨的正则在超长输入上可能直接超时二是“只认样子不认语义”它根本不知道解析器最后会取出哪个值。更靠谱的思路是把检测从“匹配字符串模样”升级成“理解解析后的结构”。具体来说WAF 不应该在原始请求体上直接跑正则而是应该先把请求体按声明格式完整解析成结构化的对象对解析后的每个键值做归一化处理包括 Unicode 解码、转义还原再对最终值去匹配反序列化特征。只有站在“处理完之后的视角”才能和后端站在同一条起跑线上。这里的关键不是规则写得多长而是检测要发生在哪一层。在原始字节上做匹配永远追不上解析差异。3.2 归一化与解析一致性站在攻击者的角度做预处理我在自己的防守实践里最推荐的一个动作是让 WAF 和后端共用同一套解析语义。具体要做的有四步统一解码链路明确 URL 解码、Base64 解码、Unicode 解码、HTML 实体解码的顺序和次数并且做成可配置。攻击者每套一层编码WAF 的解码器就要能解一层。统一重复键策略在 WAF 规则前先做“显式键去重”固定取哪个键建议和后端解析库一致消除“同一个键两个值”的视野偏差。统一嵌套限制WAF 的解析深度、字段数量、单个字段长度上限不能低于后端的实际可读范围。否则浅层检查等于形同虚设。统一字符编码处理对\uXXXX、八进制、十六进制等转义形式在匹配前统一转成真实字符。这些动作看似和“拦截”无关其实比单纯加规则重要得多。你只有先把自己变得“和后端一样瞎”才谈得上看见攻击者。用人话说就是不要问“我该怎么拦截”先问“我的解析器和业务代码的解析器是不是在做同一道题”。3.3 纵深防御别把宝全押在 WAF 上WAF 再怎么加强也只是外部检查。反序列化漏洞的最终执行点不在 WAF 控制的范围内所以更可靠的做法是分几层布防。第一层入口收敛。能不做反序列化就不要做尽量用白名单格式、强类型约束这类手段从根上缩小攻击面。第二层类名白名单。在业务代码的反序列化入口处强制校验允许反序列化的类名集合。反序列化攻击能“打穿机器”很大程度上依赖了类名可被攻击者指定。一旦类名被锁死后面链再长也很难走通。第三层运行时监控。在反序列化执行的现场检查对象创建行为和敏感方法调用。这种检测在应用内部等于在“让 WAF 猜”之外加了“让应用自己看着办”是在攻击者真正碰触到危险函数之前做拦截。WAF 是眼睛运行时检测是手只有眼睛没有手看到也拦不住。第四层日志与响应比对。定期分析“WAF 拦截记录”和“应用报错日志”之间的差额。如果应用经常出现莫名其妙的格式错误、类加载异常但 WAF 里没有任何拦截记录那大概率不是没有攻击而是有攻击穿透了。4. 实操复盘一次反序列化 WAF 绕过的完整验证过程文字讲多了不如来一次模拟验证。下面这个实验基于我实际做过的授权测试提炼细节做了脱敏处理思路保持不变。4.1 实验场景与初始基线假设目标是一个模拟项目 X 的资产导入功能POST /api/import正常请求体是 JSON里面有个config字段。由于业务历史原因这个config字段的值会交给后端的 PHPunserialize去还原成一个配置对象——这本身就是一个典型的不安全反序列化入口。WAF 的规则很简单主要匹配O:\d:、C:\d:、rO0AB这几种序列化特征。我先把一个无害的 PHP 序列化串直接放在请求体里做基线测试{config: O:8:\stdClass\:1:{s:4:\name\;s:5:\value\;}}响应是 403WAF 拦截了。说明基线规则对直接特征有效。4.2 第一次突破Content-Type 欺骗接下来我把同样的请求体保留只把Content-Type从application/json改成application/x-www-form-urlencodedPOST /api/import HTTP/1.1 Content-Type: application/x-www-form-urlencoded {config: O:8:\stdClass\:1:{s:4:\name\;s:5:\value\;}}WAF 按表单格式去解析 body拿和拆分结果发现整段 body 根本拆不出多少有效的键值对序列化特征没有被触发直接放行。而后端框架对 Content-Type 并不严格发现 body 是合法 JSON 后仍然走了 JSON 解析config字段被完整取到随后进入了unserialize。我用了一个无害对象做验证确认真实走到了反序列化代码路径应用返回了正常响应日志里也能看到配置对象被创建。这次突破的关键不是 payload 变了而是 WAF 对请求的“分诊”被误导了。4.3 第二次突破Unicode 混淆与重复键在第一次成功的基础上我又测了针对“已经正确解析 JSON”的防线。模拟将 WAF 升级为“即使 Content-Type 是表单也尝试解析 JSON”的版本。这时候我改用 Unicode 转义来隐藏类名字符{config: O:8:\stdClass\:1:{s:4:\name\;s:5:\value\;}}但把开头的O写成\u004f。WAF 如果没做 Unicode 归一化它在 JSON 解析后看到的值依然是\u004f:8:...特征匹配不上后端 JSON 解析库却正确还原成了O:8:...照常进入反序列化。紧接着我测试了重复键场景。把请求体改成{config: safe, config: O:8:\stdClass\:1:{s:4:\name\;s:5:\value\;}}WAF 的解析器在重复键时取了第一个值safe拦截逻辑空手而归后端业务使用的解析库取的是最后一个值恶意内容直接到达unserialize。整个过程中WAF 看到的“事实”是安全内容后端执行的“事实”是攻击内容双方都在自己的逻辑里自洽。4.4 验证结论与防御方行动项测试结束后我整理了一张结果对照表用来向防守团队说明问题不是出在某一条规则漏了而是出在整个检测视角上测试项WAF 看到的内容后端执行的内容是否绕过直接发送特征 payload匹配特征匹配特征否Content-Type 改为表单拆不出有效键值按 JSON 完整解析是类名 Unicode 编码特征被转义打散还原出真实类名是重复键安全值在前取到安全值取到恶意值是防守不是靠把这几条 payload 加进黑名单就完事而是要反向补上三件事统一 Content-Type 和后端解析行为、在匹配前做 Unicode 归一化、显式定义重复键的处理策略。这样即便将来攻击者换了一个新姿势只要他还利用这些维度差防线就能兜住。5. 常见问题与排查技巧实录5.1 本地绕过成功上线却失败这是我在实际测试中经常遇到的情况本地搭建的测试环境里payload 明明能穿透模拟 WAF一上真实环境就被拦。原因通常有三个一是真实 WAF 的解码链路比本地模拟多比如多做了一层 Base64 或 Unicode 解码二是真实环境前面还串联了别的安全设备比如流量审计、主机防护多道关卡叠加后单一绕过手法就失效了三是运行时规则版本不同线上规则集更新更全。排查方法也比较机械先把 payload 逐层编码看每一层编码在线上能不能通过再用同样的 payload 去打线上一个无害的测试接口通过返回码差异来判断到底被哪一层拦截。不要默认线上环境和本地环境等价WAF 的版本差异往往是绕过失败的元凶。5.2 加了“垃圾字段”后 WAF 突然不拦了有段时间我一直想不通为什么一个本来必拦的 payload在前面加了一个无用字段后就放行了。后来看 WAF 的日志才发现它陷入了对超大对象的深度解析超时超时后走了“放行”分支。这提醒我“加垃圾字段绕过”不一定是因为规则漏洞很可能是因为性能上限。遇到这种情况最直接的方法是做对照实验逐步减少垃圾字段数量、降低嵌套深度找到让 WAF 回落到正常检查模式的临界点。如果确认是性能导致那就算本地能绕过也不代表线上稳定——线上 WAF 的机器配置、并发量都会影响这个临界点。不要把一个运气成分很高的姿势当成稳定绕过。5.3 分不清是 WAF 拦截还是应用报错判断请求到底有没有穿透 WAF我习惯用一个非常朴素的方法观察返回内容的差异。WAF 拦截的响应通常有固定特征比如统一返回 403、带安全提示文案、响应头里有特殊标识而应用自身的报错则千奇百怪可能是 500、可能是格式错误的 JSON、可能是某个异常堆栈。更可靠的做法是构造一个“放之四海皆正常”的对照请求单独打同一个接口。如果这个接口在相同位置返回的总是应用侧的响应那说明请求已经到达了后端如果应用日志里出现了反序列化相关的报错更可以确认到达了执行点。还有一种技巧用一个故意不符合反序列化格式的字符串比如O:9999999:...如果后端真的执行了反序列化必然报错如果 WAF 拦了就是统一的拦截响应。这样就能把“被墙挡了”和“被门夹了”区分开。5.4 验证绕过时如何保证不造成实际危害做这类验证有一个底线原则永远不要用真实的破坏性利用链去验证穿透。我在测试中统一使用最无害的序列化对象比如stdClass配合一个静态属性既能证明反序列化执行点被触达又不会调用任何危险方法。一旦确认穿透立刻停止测试把结果交给防守方修复。还有两个细节值得注意一是不要在正式环境里拿线上数据做实验风险不可控二是所有测试要保留完整的请求日志方便复盘。安全研究的价值在于发现和修复不是在于“打穿”这一点不管是红队还是蓝队都应该刻在心里。反序列化漏洞的 WAF 绕过说白了就是一场关于“谁能看到事实”的博弈。攻击者赢不是因为技术更花哨而是因为他利用了防御者在解析视角上的盲区防御者要赢也不需要记住所有 payload只需要让自己看到的“事实”和后端执行时看到的“事实”保持一致。这是我个人这些年最深的体会写安全规则的人最好先学会像业务代码一样思考数据。
阅读完成 · 觉得有帮助?