内容纲要

前言

本章主要来谈一谈 URLDNS 这条利用链 .

在一般的Web测试环境中 , 确定目标系统是否存在反序列化漏洞一直是个难题 . 试想当我们对着一个业务系统打了各种反序列化 Payload , 结果目标系统代码中压根没有可控的 readObject() 方法 , 那真是一件让人非常郁闷的事 .

YSoSerial 中通过 URLDNS Payload 来解决上述问题 . 该 Payload 的核心目的只有一个 , 就是确定目标系统上是否存在可控的 readObject() 方法

不同于前面几章中提到的 CommonsCollections Payload , URLDNS Payload 不依赖任何的第三方库 , 且其主要功能就是查询我们指定的域名 , 因此在测试环境中比较隐蔽 , 没有什么限制 , 使用起来非常方便 .

下面就让我们来看一看 URLDNS Payload 是怎么实现的 .


URLDNS POP Chain

URLDNS Payload 使用方法

在上一章中我们做了一个测试环境 , 本章我们依旧使用该环境 . 如果您还有疑问 , 可以参考 : Java 反序列化漏洞(5) – 解密 YSoSerial : Java动态代理机制 中环境搭建的部分 .

  1. 捕获 DNS 查询记录

    URLDNS Payload 本质上就是查询攻击者指定的域名 , 因此我们必须要有一个工具来记录域名查询记录 . 这里推荐使用 CEYEDNS Query 功能 . 注册完毕后即可获得一个可用的域名 .

    所有对该域名的查询请求将会被 CEYE 记录 .

  2. 利用 YSoSerial 生成 URLDNS Payload

    测试环境是 : YSoSerial + JDK1.7( 不同版本的 JDK 中函数调用不同 )

    配置序列化数据的存储位置( 修改 ysoserial.Serializer.serialize() 方法 )

    运行 ysoserial.GeneratePayload.main() 方法后即可生成 /tmp/payload.ser 文件 , 其中存放了 URLDNS Payload 序列化数据 .

  3. 运行 Java Web 服务器

    测试环境 : Tomcat7 + JDK1.7 , 这里的 DemoServlet 和上一章相同.

  4. 发送序列化数据 , 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 是如何被触发执行的 . 在 DemoServletreadObject() 方法添加断点 , 开启调试 , 然后再次发送 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 的数据存储结构 .


CapacityLoadFactory是 HashMap 中两个重要参数

  • Capacity : 容量

    Capacity 是 Buckets 中 " 桶 " 的个数 , 它的值必须为2的N次方 .

    很多新手会把 CapacitySize 弄混 , 这里我还是通过上面那个图来说明 .

     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 的存储流程如下:

    1. 调用 hash() 方法来计算 key 的 Hash 值.
    2. 根据计算出的 Hash 值 , 调用 indexFor() 方法来计算该键值对在数组中的索引位置.
    3. 查看该索引位置是否已有元素 , 如果该位置为空 , 就将 value 放在当前位置 . 如果该位置不为空( 发生 Hash 碰撞 ) , 则循环遍历链表节点 , 根据 if 语句条件( hash 和 key 都相同 )将 value 覆盖到后面的位置.
    4. addEntry() 方法会判断 Size 是否到达 Threshold , 如果超过就进行 Resize 操作 , 将当前 Capacity 扩容为原来的 2 倍.

    关于这部分内容 , 有疑问的师傅可以参考 HashMap JDK1.7put()方法流程图 .1

  • HashMap.get()

    该方法会先判断 key 是否为 Null , 如果为 Null 则调用 getForNullKey() 方法 , 判断 Buckets 中元素个数是否为 0 , 不为 0 则从 table[0] 处获取对应的 value .

    对于其他的键值对 , HashMap 调用 getEntry() 方法的读取流程如下:

    1. 判断当前 buckets 是否为空 .
    2. 如果链表中有值 , 则通过 hash() 方法计算 key 的 hash 值.
    3. 在 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 中的很多操作真的很炫酷 , 前辈们真是太强了 !

最后修改日期:2020年8月4日

作者

留言

撰写回覆或留言

发布留言必须填写的电子邮件地址不会公开。