ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

反序列化WAF绕过底层逻辑:解析差异与认知博弈

反序列化WAF绕过底层逻辑:解析差异与认知博弈

做安全的这些年,我有个很深的感触:很多团队把 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,它就按key=value&key=value的格式去拆;你声明是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 个字段,那后面的内容自然被跳过。后端如果没做同样的深度限制,就能正常读取到深层数据。

超大请求体也有类似效果。我曾经测试过,把请求体撑到几十 MB,payload 夹在正中间。部分 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\";}"}

响应是 403,WAF 拦截了。说明基线规则对直接特征有效。

4.2 第一次突破:Content-Type 欺骗

接下来,我把同样的请求体保留,只把Content-Type从application/json改成application/x-www-form-urlencoded:

POST /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,只需要让自己看到的“事实”和后端执行时看到的“事实”保持一致。这是我个人这些年最深的体会:写安全规则的人,最好先学会像业务代码一样思考数据。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进