前言
本章主要来谈一谈 URLDNS
这条利用链 .
在一般的Web测试环境中 , 确定目标系统是否存在反序列化漏洞一直是个难题 . 试想当我们对着一个业务系统打了各种反序列化 Payload , 结果目标系统代码中压根没有可控的 readObject()
方法 , 那真是一件让人非常郁闷的事 .
YSoSerial
中通过 URLDNS Payload
来解决上述问题 . 该 Payload
的核心目的只有一个 , 就是确定目标系统上是否存在可控的 readObject()
方法
不同于前面几章中提到的 CommonsCollections Payload
, URLDNS Payload
不依赖任何的第三方库 , 且其主要功能就是查询我们指定的域名 , 因此在测试环境中比较隐蔽 , 没有什么限制 , 使用起来非常方便 .
下面就让我们来看一看 URLDNS Payload
是怎么实现的 .
URLDNS POP Chain
URLDNS Payload 使用方法
在上一章中我们做了一个测试环境 , 本章我们依旧使用该环境 . 如果您还有疑问 , 可以参考 : Java 反序列化漏洞(5) – 解密 YSoSerial : Java动态代理机制 中环境搭建的部分 .
-
捕获 DNS 查询记录
URLDNS Payload
本质上就是查询攻击者指定的域名 , 因此我们必须要有一个工具来记录域名查询记录 . 这里推荐使用 CEYE 的 DNS Query 功能 . 注册完毕后即可获得一个可用的域名 .所有对该域名的查询请求将会被 CEYE 记录 .
-
利用 YSoSerial 生成 URLDNS Payload
测试环境是 :
YSoSerial
+JDK1.7
( 不同版本的 JDK 中函数调用不同 )配置序列化数据的存储位置( 修改
ysoserial.Serializer.serialize()
方法 )运行
ysoserial.GeneratePayload.main()
方法后即可生成/tmp/payload.ser
文件 , 其中存放了URLDNS Payload
序列化数据 . -
运行 Java Web 服务器
测试环境 :
Tomcat7
+JDK1.7
, 这里的DemoServlet
和上一章相同. -
发送序列化数据 , URLDNS Payload 被Web服务器解析执行 , 请求攻击者指定的域名
# 使用
curl --data-binary
参数来发送序列化数据
curl "http://localhost:8080/webdemotest_war_exploded/demotest" --data-binary @/tmp/payload.ser
在 CEYE 上看到对应的 DNS 查询记录 , 可以确定
URLDNS Payload
执行成功了 . 目标系统存在可控的readObject()
方法
YSoSerial URLDNS Payload 利用原理
注 : 此处使用的 JDK 版本为 JDK1.7
我们先来看一看这个 Payload 是如何被触发执行的 . 在 DemoServlet
的 readObject()
方法添加断点 , 开启调试 , 然后再次发送 URLDNS Payload
经过一些底层解析调用 , 程序最终会调用 HashMap.readObject()
方法.
而该方法正是 URLDNS
利用链的起点.
java.util.HashMap.readObject()
在看该方法前我们需要了解一些 HashMap 的知识 .
HashMap 基本原理
简单说一下什么是 HashMap . 引用 HashMap 的实现原理 里的一句话 :
在 JDK1.7 中 , HashMap 可以看作是一个链表散列的数据结构 , 也就是数组和链表的结合体.
HashMap 底层就是一个数组结构 , 而数组的每一项都是一个链表 . 当 HashMap 初始化时就会创建一个桶数组 Buckets
. 这个数组的每一项定义如下 :
注意 Buckets 桶数组是在 HashMap 初始化时被创建 , 即调用
put()
方法时被创建 , 而不是在实例化时被创建
// HashMap.class : 316
table = new Entry[capacity];
hash 值决定了键值对在这个 Buckets 桶数组中的寻址 . hash 值相同的键值对会以链表形式被存储 . 在 Java HashMap工作原理及实现 一文中有个例子很棒 :
通过上图应该很容易理解 HashMap 的数据存储结构 .
Capacity
和LoadFactory
是 HashMap 中两个重要参数
-
Capacity : 容量
Capacity 是 Buckets 中 " 桶 " 的个数 , 它的值必须为2的N次方 .
很多新手会把
Capacity
和Size
弄混 , 这里我还是通过上面那个图来说明 .Capacity 为数组的容量 , 如图其值为 8 Size 为数组中已有的键值对个数之和 , 如图其值为 4 上图只是举例说明 , 默认情况下 , Capacity 的默认值为 2^4 = 16 .
-
LoadFactory : 装载因子
LoadFactory 用于表示 HashMap 满的程度 , 其默认值为 0.75 .
HashMap 本身有扩容机制 . 默认情况下 , 当 Size 超过临界值( Threshold )时就会触发扩容( Resize ) .
Threshold = Capacity × LoadFactory = 16 × 0.75 = 12
默认情况下 , 临界值被设定为 12 , 当 Size( 数组中已有键值对的个数之和 ) 大于这个值时 , Capacity 就会触发扩容( 扩容至原来的 2 倍 )
put()
和get()
两个方法是 HashMap 的核心方法 .
-
HashMap.put()
HashMap 会先通过
inflateTable()
方法初始化 table 数组HashMap 支持存放
[ Null : Null ]
这样的键值对 . 当程序发现key
为 Null 时 , 会调用putForNullKey()
方法在table[0]
来存储其对应的键值对 .对于其他键值对 , HashMap 的存储流程如下:
- 调用
hash()
方法来计算 key 的 Hash 值. - 根据计算出的 Hash 值 , 调用
indexFor()
方法来计算该键值对在数组中的索引位置. - 查看该索引位置是否已有元素 , 如果该位置为空 , 就将 value 放在当前位置 . 如果该位置不为空( 发生 Hash 碰撞 ) , 则循环遍历链表节点 , 根据 if 语句条件( hash 和 key 都相同 )将 value 覆盖到后面的位置.
addEntry()
方法会判断 Size 是否到达 Threshold , 如果超过就进行 Resize 操作 , 将当前 Capacity 扩容为原来的 2 倍.
关于这部分内容 , 有疑问的师傅可以参考 HashMap JDK1.7put()方法流程图 .1
- 调用
-
HashMap.get()
该方法会先判断 key 是否为 Null , 如果为 Null 则调用
getForNullKey()
方法 , 判断 Buckets 中元素个数是否为 0 , 不为 0 则从table[0]
处获取对应的 value .对于其他的键值对 , HashMap 调用
getEntry()
方法的读取流程如下:- 判断当前 buckets 是否为空 .
- 如果链表中有值 , 则通过
hash()
方法计算 key 的 hash 值. - 在 hash 值对应的链表上查找与 key 相同的键名 , 如果 key 相同 , 则返回对应的 value . 如果链表中没有与 key 相同的键名 , 则返回 Null .
了解过 HashMap 的一些基本知识后 , 我们再来看 readObject()
方法就不难理解了 .
根据 HashMap.readObject()
方法可以得知 , 我们最终会通过 putForCreate()
方法循环进行赋值操作 . 当带入 URLDNS Payload
时 , 可以看到我们指定的域名作为参数被带入该方法中 .
下面我们跟进 putForCreate()
方法 , 来看一看该方法的实现原理 .
java.util.HashMap.putForCreate()
通过源码不难发现 , putForCreate()
方法与 put()
方法非常类似 , 只是缺少初始化 Buckets 这个步骤 . 该方法同样会通过 hash()
方法来计算 key 的 hash 值 .
我们跟进 hash()
方法 , 来看一看该方法的实现原理 .
java.util.HashMap.hash()
该方法会先获取一个 hashSeed( 默认为 0 , 用于降低 Hash碰撞 的几率 ) , 然后调用 Obj.hashCode()
方法来计算对象的 Hash 值
由于这里 Obj 为 URL实例对象 , 因此这里需要跟进 Url.hashCode()
方法 , 查看 hash 值的计算原理 .
java.net.Url.hashCode()
这里会先判断 hashCode 是否为 -1
, 如果为 -1
则直接返回 hashCode . 如果不为 -1
则调用 handler.hashCode()
计算 hash 值 .
注意的是 , 这里一般情况下 hashCode 是不会为 -1
的 . -1
是 hashCode() 的默认值 .
而当我们通过 readObject()
方法获取 key 的值后 ,key 的HashCode 就不会为 -1
了
因此 , 如果想要继续走完这条 POP Chain , 那么就需要手动调整 hashCode 的值 , 使其为 -1
. 这个问题我放到后面说 YSoSerial URLDNS Payload
构造时来讲 .
我们继续跟进 handler.hashCode()
方法 , 这里 URLDNS Payload
传入的 handler 为 URLStreamHandler
, 所以这里会调用 URLStreamHandler.hashCode()
方法来计算 hash 值.
java.net.URLStreamHandler.hashCode()
该方法会先解析 URL 使用的 Protocol 字段. 因此我们在构造 Payload 时不能只将域名作为参数 , 而要将一个完整的 URL( 包含 http://
等协议字段 )作为参数 .
当获取了 Protocol 字段后 , 会计算该字段的 Hash 值 . 然后调用 getHostAddress()
方法来解析URL Host 字段.
我们跟进 getHostAddress()
方法
java.net.URLStreamHandler.getHostAddress()
这里会先判断当前 Host 是否已有对应地址 , 如果没有则通过 getHost()
方法获取 Host 字段 , 然后调用 InetAddress.getByName()
方法来解析 Host 对应的 IP 地址 .
那么这里又有一个问题了 : 如果 Host 字段为一个域名 , 且我们之前解析过这个域名 , 那么程序会将解析后的 IP 地址缓存到 hostAddress 参数中 , 当我们再次请求时 , 由于 hostAddress 已有值 , 就不会走完剩下的 POP Chain 了.
YSoSerial 中也提到了这个问题 , 这个点我们后面再说 .
java.net.InetAddress.getAllByName()
这里会先经过一些函数调用 , 看定义就能看明白 , 最终添加了一个 reqAddr 参数用于存储解析后的 IP 地址 .
然后程序会判断 Host 字段是否是一个 IPv4 地址 , 如果是则不会去做进一步解析 . 如果不是则调用 getAllByName0()
方法来进行后续解析工作 .
java.net.InetAddress.getAllByName0()
该方法会先判断 SecurityManager 的值 , 然后通过 getCachedAddresses()
方法从缓存中寻找域名对应的 IP 地址 , 如果没有找到对应IP地址 , 则调用 getAddressesFromNameService
查询对应域名 .
从函数名也能看出 , 这个 getAddressesFromNameService()
就是调用查询 DNS 请求的方法 !
java.net.InetAddress.getAddressesFromNameService()
该函数就是真正发起 DNS 请求的方法 , 底层的东西我们这里就不多讨论了 .
至此 , YSoSerial URLDNS Payload POP Chain
就走完了 , 我们成功通过可控的 readObject()
方法查询了我们指定的域名 .
YSoSerial URLDNS Payload 构造原理
URLDNS Payload 的构造是比较简单的( 相比与其他 POP Chains ) , 我们直接来看一下.
-
URLStreamHandler hander = new SilentURLStreamHandler();
SilentURLStreamHandler 类的实现原理
为什么这里不直接实例化 URLStreamHandler 类呢 ? 原因很简单 , 因为 URLStreamHandler 是个抽象类 , 不能直接被实例化.
不过 SilentURLStreamHandler 类却没这么简单 , 我们来看一看其定义 .
SilentURLStreamHandler 类中还定义了两个方法 , 其中一个我们还特别熟悉 , 即
getHostAddress()
方法 . 在服务器端 , 我们正是通过该方法来延续 POP Chain那么为什么要定义这两个方法呢? 其实这里用到了下面这个知识点 :
Java 中子类会覆盖父类的同名方法 .
这么做的原因是 :
YSoSerial 在生成 URLDNS Payload 时会去主动请求域名 , 会对测试结果造成干扰.
把
SilentURLStreamHandler.getHostAddress()
方法注释后 , 生成 YSoSerial URLDNS Payload , 就能看到 Ceye 上出现 DNS 查询记录 .取消注释后开启调试 , 当 YSoSerial 调用
URLStreamHandler.getHostAddress()
方法时 , 会直接跳转到SilentURLStreamHandler.getHostAddress()
中 , 返回 Null , 不去解析 Host 对应的 IP 地址 .URLDNS Payload 中通过实现 SilentURLStreamHandler 类 , 不仅生成了 URLStreamHandler 实例对象 , 还避免了在生成 Payload 时发起 DNS 查询 , 使得测试结果更加精确 . 实在是太强了!
如何使得 key.hashCode == -1
-
URL u = new URL(null, url, handler);
将 URLStreamHandler 对象作为参数 handler 写入到 URL 实例对象中 , 使得反序列化时
handler.hashCode()
会调用URLStreamHandler.hashCode
, 进入 POP Chains . -
Reflections.setFieldValue(u, "hashCode", -1);
当调用
HashMap.put()
方法后 , key 的 HashMap 值肯定会发生变化 , 而要想执行 POP Chains , 就必须使得key.hashCode == -1
. 这里通过Reflections.setFieldValue()
方法将key.hashCode
强制转为-1
.
其他注意事项
在 YSoSerial URLDNS Payload 中也提到了域名解析缓存的问题 , 并将它作为一个可能的利用失败点 .
也就是说 , 如果目标服务器之前解析过攻击者指定的域名 , 那么当执行 URLDNS Payload 时 , 就不会再触发域名解析 .
关于 JDK1.8 +的 URLDNS POP Chian
本章一直以 JDK1.7 作为测试环境 , 其实 JDK1.8 的函数调用思路很类似 , 主要是HashMap 类的实现有所不同 , 但最终还是会调用 java.net.Url.hashCode()
方法 . 这里就不细说了 , 有兴趣的师傅可以自己调一调 .
后记
本章的主要内容就是 YSoSerial URLDNS Payload 的利用原理以及构造原理 , 在下一章中我将会分析其他的 POP Chain .
URLDNS Payload POP Chian 中的很多操作真的很炫酷 , 前辈们真是太强了 !