坦白说研究Java反序列化安全的人几乎都绕不开一条链URLDNS。它不依赖任何第三方库不能直接弹计算器甚至跑完连一条命令都没有执行——但几乎所有讲Java安全的人都会把这条链当作第一课。原因很简单它是理解“反序列化入口如何被发现”的最佳切入点也是验证目标是否存在反序列化风险最稳的探针。这篇文章我从两个角度来拆一是这条链的底层机制到底是怎么串起来的二是你拿到一个可疑入口后怎么用一条URLDNS链快速验证。适合刚开始接触Java安全的同学也适合那些已经会打RCE链、但对“为什么URLDNS要先设置hashCode-1”还说不清楚的人。1. 为什么先分析URLDNS而不是直接上RCE链1.1 这条链到底长什么样一句话描述URLDNS是一条利用Java原生反序列化机制、通过HashMap触发URL对象计算hashCode、最终引发一次DNS解析请求的探测链。整条链只涉及两个核心类一个是HashMap一个是URL两者都是JDK自带的基础类任何一个标准的Java运行环境里都存在。传统认知里反序列化漏洞的终点是RCE远程代码执行。但URLDNS链没有RCE效果它干的活只有一个——让目标机器对外发起一次DNS查询。别小看这一次查询在安全验证场景中这相当于目标机器主动“招手”证明它确实把一个不可信输入流交给了ObjectInputStream去反序列化。有了这个证明后续才会投入精力去构造更复杂的利用链。1.2 它是“探针”而不是“武器”我习惯把URLDNS理解成一个探针不是武器。探针的意义在于低成本、低风险、高可判定性。先说低成本。整条链的搭建不需要引入任何第三方依赖JDK自带类全覆盖你甚至不需要理解Java反射的高级用法只需要会写几行put和readObject就能复现。相比那些要调试半天的CC链、CB链URLDNS的调试成本几乎可以忽略。再说低风险。它不会在目标机器上执行任意命令、不会写入文件、不会造成拒绝服务最多就是发起一次DNS查询。这在实际排查自有系统时非常重要因为你可以放心地在测试环境跑不用担心把业务打挂了。最后是高可判定性。反序列化漏洞最大的问题是“入口可能很多但无法直观看到结果”。URLDNS通过DNS解析这个外带通道把“目标确实执行了反序列化”这件事变得肉眼可见。只要你在可控域名下配好DNS记录目标回调一次你就能在DNS日志里看到确凿证据。1.3 分析一条链要回答的三个问题很多人第一次看链最容易犯的错是照着ysoserial源码抄了一遍然后跑通了但让他换个场景就抓瞎。我建议任何链的分析都要带着三个问题去拆入口在哪个readObject方法里也就是反序列化时哪个方法先被触发。中间经过哪些getter、hashCode、equals之类的魔法方法也就是链的“传导层”。最终动作落在哪个方法上也就是真正产生外部效果的执行点。URLDNS完美对应了这三个问题。入口是HashMap的readObject传导层是URL的hashCode执行点是URLStreamHandler里的DNS解析逻辑。把这三层弄明白后面理解真正能RCE的链就是同样的分析套路只是传导层更长、执行点换成了命令执行。2. 核心机制拆解从URL到DNS的一次击鼓传花2.1 入口HashMap.readObject 做了什么Java原生反序列化就是ObjectInputStream.readObject把字节流还原成对象图的过程。HashMap之所以能成为通用入口是因为它被重写了readObjectprivate void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { s.defaultReadObject(); // 省略重建table的逻辑 for (int i 0; i mappings; i) { K key (K) s.readObject(); V value (V) s.readObject(); putVal(hash(key), key, value, false, false); } }关键就在最后一行每次从流里读出一个key都会调用hash(key)。而HashMap的hash方法长这样static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }它不关心key具体是什么类反正就是一个hashCode调用。所以只要反序列化流里的key是一个hashCode方法有“故事”的对象故事就会在这一刻被触发。URL对象恰好就是这么一个有故事的对象。2.2 触发器URL.hashCode 的冷热状态URL类的源码里有一个非常关键的状态字段private int hashCode -1;注意这个字段初始值是-1而且它不是transient这意味着它会被序列化进流里。URL的hashCode方法长这样public synchronized int hashCode() { if (hashCode ! -1) return hashCode; hashCode handler.hashCode(this); return hashCode; }这就是冷热两种状态当hashCode字段等于-1说明还没计算过需要调handler去算当它不等于-1说明已经算过了直接返回缓存值不会再调handler。这个设计本身是为了缓存URL的哈希值但反序列化链要利用的正是“-1”这个特殊状态。所以你会看到利用工具里都有一行反射操作把URL对象的hashCode字段强行设置回-1。这样才能保证反序列化读到这个URL对象时hashCode方法不会直接返回缓存值而是老老实实去执行handler.hashCode(this)。2.3 执行点URLStreamHandler 的两个身份URL.hashCode会委托给它的handler字段而URL的handler是transient的不参与序列化。这一点非常关键。正常序列化URL对象时handler字段不会写入流反序列化后handler是空的URL对象需要自己做一次补救private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException { s.defaultReadObject(); if (handler null) { handler Handlers.getHandler(protocol); } }也就是说反序列化之后URL会根据它的protocol字段自动补齐一个默认的协议处理器。http协议对应的是默认的HTTP协议处理类这个类里继承的URLStreamHandler.hashCode逻辑最终会解析域名。我画一个抽象的链路图给你看HashMap.readObject - hash(key) - key.hashCode() - URL.hashCode() - handler.hashCode(this) - getHostAddress(this) - InetAddress.getByName(host) - DNS查询整个链路从头到尾没有一行是攻击者写的代码全是JDK自带类在互相配合。这也是为什么这条链在几乎所有Java版本都稳定触发。2.4 完整的触发时序我把完整的时序拆成六个阶段你对照着IDE断点走一遍会更清晰构造阶段new一个URL对象host字段设为你的DNS记录域名。put阶段把URL对象作为key放进HashMap。此时会调用一次URL.hashCode如果handler已经具备解析条件这里就会先触发一次DNS查询。重置阶段利用反射把URL对象的hashCode字段改回-1目的是让反序列化阶段重新走一遍hashCode逻辑。序列化阶段ObjectOutputStream.writeObject(map)。HashMap会把key原样写入URL的hashCode字段以-1的状态保存在流里。反序列化阶段ObjectInputStream.readObject(map)。HashMap重新计算key的hash调用URL.hashCode此时hashCode是-1于是执行handler.hashCode。DNS回调handler解析host目标机器发出DNS查询你在DNS日志里看到记录。多提一句put阶段那次DNS查询是“噪音”很多初学同学第一次跑代码看到DNS记录来了以为反序列化验证成功了其实那基本是put阶段发的。真正证明反序列化执行的应该是反序列化那一次回调实操时要用手段把put阶段那次查询屏蔽掉后面我会给出具体做法。3. 动手复现手写一个URLDNS验证Demo3.1 准备一个能观测DNS查询的环境复现URLDNS不需要复杂的靶场你只需要三样东西一个JDK环境8或11都可以我建议先用8后面我再讲高版本JDK的反射坑。一个可以观察DNS查询记录的域名或者自己起的DNS服务。一个Java工程不需要导入任何第三方依赖。关于DNS观测我讲两种方案。最简单的是用记录型DNS查询服务你拿到一个专属子域名然后到它后台看查询日志。另一种是自己起DNS服务在可控域名下加一条A记录然后盯着服务器日志或者用抓包工具过滤DNS报文。我建议学习阶段用第二种因为你能直观看到源IP、查询类型、查询域名也有助于你理解DNS外带原理。3.2 最低配的演示代码先上一个最低配版本这个版本能跑通但会混入一次put阶段的DNS请求import java.io.*; import java.lang.reflect.Field; import java.net.URL; import java.util.HashMap; public class URLDNSDemo { public static void main(String[] args) throws Exception { // 替换成你能看到DNS日志的域名 String dnslogDomain demo.your-domain.com; HashMapURL, Integer hashMap new HashMap(); URL url new URL(http:// dnslogDomain); // 这里会触发一次DNS查询属于put阶段的“噪音” hashMap.put(url, 1); // 反射把hashCode重置为-1 Field hashCodeField URL.class.getDeclaredField(hashCode); hashCodeField.setAccessible(true); hashCodeField.setInt(url, -1); // 序列化 ByteArrayOutputStream baos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(baos); oos.writeObject(hashMap); oos.close(); byte[] payload baos.toByteArray(); System.out.println(payload size: payload.length); // 反序列化这一步会再次触发DNS查询 ObjectInputStream ois new ObjectInputStream(new ByteArrayInputStream(payload)); ois.readObject(); ois.close(); } }运行以后你会看到两次DNS查询。第一次是put的时候触发的第二次才是反序列化阶段触发的。如果只盯着DNS日志数量你会误以为反序列化阶段连续发了两条记录实际上不是这样。3.3 用反射重置hashCode的细节上面的代码里最核心的一步是反射重置hashCodeField hashCodeField URL.class.getDeclaredField(hashCode); hashCodeField.setAccessible(true); hashCodeField.setInt(url, -1);简单解释一下为什么要做这一步。URL构造出来以后hashCode字段默认是-1。你把它put进HashMapHashMap会立刻调用它的hashCode方法于是handler就会解析一次域名同时URL会把计算结果缓存到hashCode字段里。此时hashCode已经不是-1了。如果不重置序列化时URL会把一个非-1的hashCode写进流反序列化后URL认为“我已经算过哈希了”直接返回缓存handler根本不会执行DNS也不会触发。所以重置hashCode是让整条链在反序列化阶段“重新燃烧”的关键操作。注意JDK9以上直接setAccessible(true)反射访问java.net.URL的私有字段可能会抛InaccessibleObjectException。解决办法是启动参数加--add-opens java.base/java.netALL-UNNAMED3.4 同一段代码产生的两条DNS记录我这里把两次查询的差异讲清楚很多人就是没搞懂这个导致验证结论出错。put阶段那次查询发生在当前Java进程里发送方是运行demo的这台机器。反序列化阶段那次查询发生在readObject执行的那一刻。如果你把payload写到文件里拿到另一台机器上去反序列化就会看到生成payload的机器在put阶段发了一条反序列化payload的机器再发一条。这正是URLDNS作为探针的价值所在——你完全可以在自己的机器上生成payload然后丢给目标环境去触发通过DNS记录判断目标环境有没有执行反序列化。这里有一个容易踩的坑如果你用的域名解析到的是一个公网IP而目标环境访问不了外网那么反序列化那条记录就收不到。这未必说明目标不存在反序列化入口可能只是DNS出不了网我在后面常见问题里再展开。3.5 屏蔽put阶段噪音的完整写法如果你想让整条链只留下反序列化这一次DNS查询就需要自定义一个URLStreamHandler把其实例传给URL构造函数import java.io.*; import java.lang.reflect.Field; import java.net.*; import java.util.HashMap; public class URLDNSDemo { static class SilentURLStreamHandler extends URLStreamHandler { Override protected URLConnection openConnection(URL u) throws IOException { return null; } Override protected synchronized InetAddress getHostAddress(URL u) { return null; } } public static void main(String[] args) throws Exception { String dnslogDomain demo.your-domain.com; URLStreamHandler handler new SilentURLStreamHandler(); URL url new URL(null, http:// dnslogDomain, handler); HashMapURL, Integer hashMap new HashMap(); hashMap.put(url, 1); Field hashCodeField URL.class.getDeclaredField(hashCode); hashCodeField.setAccessible(true); hashCodeField.setInt(url, -1); ByteArrayOutputStream baos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(baos); oos.writeObject(hashMap); oos.close(); ObjectInputStream ois new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray())); ois.readObject(); ois.close(); } }SilentURLStreamHandler重写了getHostAddress返回nullput阶段URL计算hashCode时不会真正解析域名。而handler是transient字段反序列化时不会保留URL对象会自动恢复成默认的http协议处理器所以反序列化阶段的DNS解析不受影响。这个写法也是开源社区那个经典反序列化工具里采用的思路。4. 从开源工具实现里学到的细节4.1 为什么入口选HashMap而不是别的集合选择HashMap作为入口理由有两个。第一JDK自带的很多类在反序列化时都会间接持有HashMap比如HashSet内部就是依赖HashMap实现的这等于天然扩大了触发面。第二HashMap的readObject会在还原每个key时立刻调用hash(key)不需要像某些类那样等整个对象完整还原、再经过特定getter才能命中触发路径非常短。HashSet其实也能做同样的效果因为HashSet.writeObject/readObject会把元素塞进内部的HashMap。但HashMap是更底层的存在也是反序列化对象图里最常见的集合类型所以大家都默认用HashMap来搭这条链。4.2 hashCode为什么必须被重置成-1我把这个点单独拎出来强调。URL.hashCode的缓存机制对Java的正常使用是友好的能够避免重复解析域名浪费性能。但在反序列化利用场景下这个缓存机制恰恰是我们需要绕开的。序列化那一刻URL.hashCode字段的值会被原样写入字节流。如果我们不重置写入流的是一个已经算好的非-1整数反序列化后URL对象读到的就是这个整数它觉得自己之前算过了于是直接return链在这里就断掉。只有把hashCode重置为-1字节流里保存的才是-1反序列化后URL才会认为“还没算过”从而进入重新计算的逻辑分支。这就是为什么整条链里反射设置字段是“点火”动作前面构造再多的URL对象少了这一步都白搭。4.3 handler为什么不参与序列化URL类的handler字段是transient的这一点很多初学者容易忽略。如果handler参与序列化那么我在3.5里写的SilentURLStreamHandler也会被一起写入payload。反序列化拿到payload’s handler由于目标环境没有这个自定义类直接抛ClassNotFoundException链没开始就结束了。正是因为handler是transient序列化流里根本没有这个字段反序列化后URL对象的handler为nullURL会调用readObject里的恢复逻辑自动找到默认的协议处理器。对于http协议默认处理器一定会去解析host。这是整个设计里最巧妙的一环构造payload时的自定义handler只影响本机行为不影响目标环境的反序列化行为。4.4 协议为什么默认用httpURL支持的协议有很多理论上传一个file、ftp、jar之类的协议也能在反序列化时进入handler.hashCode最终走域名解析。但实际构造payload时99%的情况都用http。原因在于协议处理器的可达性。反序列化环境在恢复handler时会依据protocol字段去查找对应的处理器类。http协议是Java环境默认支持的几乎不可能被裁剪。反过来如果你传一个极其冷门的协议目标环境的JDK可能根本没有对应的handler或者需要额外依赖很可能在恢复handler阶段就报错还没到DNS解析就断了。顺带说一句host字段里可以带端口比如http://demo.your-domain.com:8080同样能触发DNS查询。因为域名解析发生在建立连接之前端口不影响解析。payload构造时用不用端口都行不影响验证效果。5. 常见问题与排查技巧实录5.1 本机put阶段就收到了记录这是复现时最典型的困惑。运行代码后发现DNS日志里出现了记录但其实是put阶段发出去的并不是反序列化阶段验证成功。排查方法很简单去掉readObject那步只跑put和序列化看DNS日志还有没有记录。如果有说明是put阶段触发的不奇怪。想消除它就按3.5里的方式传入一个SilentURLStreamHandler重写getHostAddress返回null这样本机的put阶段就不会产生DNS查询。实际验证时我建议生成payload的机器和执行payload的机器分开因为目标环境里做的反序列化操作才是真正需要确认的。5.2 反序列化后一条记录都没有记录一条都没收到优先查四件事域名配没配好、目标环境出不出网、序列化流里hashCode到底是不是-1、反序列化真的执行到了readObject没有。域名配没配好最好在自己本机先用dig或nslookup解析一次确认这条DNS记录能被正常解析。目标环境出不出网可以看目标机器上能不能解析公网域名如果目标处在内网隔离环境DNS请求发不出去URLDNS就会失效这时需要换内网可解析的域名。序列化流里hashCode是不是-1可以把payload反序列化回来再反射看一眼URL的hashCode字段确认是-1。最后一个问题往往是最容易被忽略的有些调用链虽然表面上调用了readObject但目标数据流根本不是ObjectInputStream的标准格式或者中间被异常截断了链路根本没有执行到位。5.3 收到了记录但不确定是不是反序列化触发如果目标环境也复用了你的域名而你自己在本地测试时put阶段也触发过一次DNS日志里就会混入多条记录很难分辨哪条是目标环境反序列化发出来的。我的做法是给每个验证目标分配一个不同的子域名。比如验证A系统用a1.dnslog域名验证B系统用b2.dnslog域名。这样DNS日志里出现哪个子域名的记录就对应哪个验证目标。另一个技巧是看DNS日志里的来源IP如果来源IP是目标机器出口IP基本可以确认是反序列化阶段发起的查询。5.4 JDK版本和模块化反射的坑JDK9引入了模块化之后默认情况下不允许反射访问java.base模块里类的私有字段URL类正好就在java.base里。所以在JDK9以上环境直接跑3.2的代码可能报InaccessibleObjectException。解决方法有两个第一运行参数加JVM开关--add-opens java.base/java.netALL-UNNAMED第二代码尽量放在一个非模块化的普通工程里跑同时用Unsafe或者修改模块打开设置的方式绕开限制。实操上我建议学习阶段直接用JDK8少踩坑。真正做验证时如果目标环境是JDK11或17也不需要你在目标环境跑代码只需要目标环境能反序列化出来payload即可payload本身是利用Java序列化机制打的不需要目标环境反射URL。不过这里要特别注意如果你的序列化工具主动做了反射设置那生成payload的机器才需要考虑模块化问题目标机器反序列化只按流里的数据走不受反射限制。6. 它到底能验证什么不能验证什么6.1 URLDNS的探测价值URLDNS的核心价值是探测反序列化入口。现实中一个Java应用可能存在大量反序列化数据的接收点比如HTTP请求体、RPC调用参数、消息队列消息体、缓存客户端数据回读等。你不可能每个点都直接打一条完整的RCE链去试那样太粗暴而且容易出事。正确的做法是先用URLDNS探针把这些入口挨个测一遍。哪个入口会产生DNS回调就证明哪个位置存在反序列化触发点再针对这个点做后续深入分析。这一套打法在自查Java应用攻击面时非常实用。我经常跟团队里的新人说学会URLDNS之后你等于有了一把能照出反序列化入口的手电筒在黑暗的Java对象流转流程里一步步找门。6.2 影响的边界与限制URLDNS不是万能的它有很明确的边界。第一它无法判断反序列化后是否能继续执行到可利用的Gadget因为URLDNS链本身只是一条探测链终点是DNS解析而不是命令执行。反序列化入口存在不代表一定有可以RCE的Gadget依赖满足这是两码事。第二依赖DNS网络环境。目标机器如果处于一个完全隔离的网络连DNS解析都出不去URLDNS就没有任何回显你可能会得到假的阴性结果。第三它只能证明“存在反序列化入口”不能证明攻击者是否真的能控制整个对象图。有些应用会自定义ObjectInputStream子类或者配置了反序列化过滤器这种情况下URLDNS可能在到达HashMap之前就被拦截。6.3 后续该往哪走如果URLDNS探针确认了反序列化入口下一步要做的是梳理目标应用的依赖清单。重点关注那些依赖库中是否存在已知的高危反序列化Gadget比如常见的Commons-Collections、Commons-Beanutils、C3P0、Groovy等组件的对应版本。这里我刻意不提具体利用工具名称想表达的是一条RCE利用链的构造思路跟URLDNS是同一个套路——入口类通过魔法方法触发中间经过一连串类的方法调用最后落到危险执行点。从学习方法上来说我强烈建议你把URLDNS当成模板照着它的三层分析法去拆其他链。入口层找readObject、触发层找hashCode和equals、执行层找RCE方法。当你把其他链也拆到和URLDNS一样熟悉的时候Java反序列化安全的地基才算真正打牢。在我自己分析过的那么多链里面URLDNS依旧是返场率最高的一条。它简单、干净、不惹麻烦却把Java反序列化链的精髓全讲透了。如果你刚开始学Java安全不用急着去碰那些复杂的Gadget链先把URLDNS手写十遍把每一步为什么这么走都讲清楚再去学别的链会顺很多。真到了实战验证环节你会发现兜兜转转最后帮上大忙的还是这条不起眼的URLDNS链。