内容纲要

前言

最近搭建了很多测试环境 , 使用了像 Ysoserial , Marshalsec 等利用工具 . 也搭建了像 FastJson 1.2.47 反序列化漏洞 这样的测试环境 . 在复现过程中多次遇到了像 RMI , JNDI注入 等专有名词 . 一直不明白它们的含义 . 现在稍微有一些空闲时间了, 专门拿出来学习并记录下来 .


JAVA RMI

这部分内容比较多 , 各种知识点 , 难点也很多 .

因此将笔记分为两部分 , 一部分记录 Java RMI 的原理/流程 , 一部分记录 Java RMI 的利用手段 / 攻击技巧 . 尽量把每个点都说明白 .

全文参考了大量前辈们的文章 , 这里就不一一列举了 , 感谢前辈们的付出 !


什么是 JAVA RMI ?

引用一下 XingHun 前辈 java RMI原理详解 一文中的概念 :

RMI ( Remote Method Invocation , 远程方法调用 ) 能够让在某个 Java虚拟机 上的对象像调用本地对象一样调用另一个 Java虚拟机 中的对象上的方法 , 这两个 Java虚拟机 可以是运行在同一台计算机上的不同进程, 也可以是运行在网络中不同的计算机上 .

这个解释非常通俗易懂 .


为什么要有 JAVA RMI ?

先思考下面这几个问题 :

  • 假设服务端上存在很多对象及方法

    客户端上有程序想要调用服务端上某个对象的方法 . 于是客户端会发出请求 .

    但是服务端不可能将自己所有的对象与方法都发送给客户端 , 也不可能让客户端任意调用自己每一个对象与方法 . 一旦这样做 , 服务端的安全性将会受到极大的挑战 .


  • 假设服务端确定了远程调用的资源

    客户端开始访问需要的对象与方法 , 我们都知道 Java 调用本地对象方法的过程是下面这样的 .

    此时我们知道类名是 ObjectClassA , 实例对象是 objectA , objectA 存在方法 MethodA() .

    但是在远程调用的过程中 , 如果 ObjectAJVMA 上 , 我们执行的程序在 JVMB 上 . 那对于我们的程序来说 , 是不知道 JVMA 上创建的实例对象叫什么的 . 即使知道了实例对象叫什么 , 那么下次程序一修改 , 实例对象的名称很有可能会变化 , 这样我们的程序又无法远程访问了 .

    此时问题就产生了 , 实例对象的名称是可以随意改变的 , 但是在远程调用时 , 我们更希望目标的名称是固定的 , 这样我们就能在不修改任何代码的情况下调用远程对象了 .


  • 假设客户端找到了想要的资源

    此时客户端准备调用目标对象, 并将结果返回 .**

    现在存在一个问题 : 连接两端必须要确保数据在发送和返回的途中不会遭到破坏 .

    我们要知道 : 客户端访问的资源不是预定义的基本数据类型 , 而是完整的对象 . 如果先将对象分解成基本数据类型 , 再传输这些基本数据类型的数据 , 最后再将这些基本数据类型拼接成对象 . 即使转换过程中没有数据丢失 , 代码的复杂度也是一个很大的挑战 .


  • 假设客户端找到了一个合适的传输方法

    程序终于要开始进行远程调用了, 然而问题还没完 . 在大型项目中 , 程序可能需要频繁的进行远程调用 , 开发人员不可能为每次调用设计一套专门的调用方法 , 这样不仅耗时耗力 , 还很容易出错 .

    开发人员更希望有一个统一且规范的接口 , 能在添加尽可能少的代码的前提下 , 完成所有的远程调用 .

您会发现在远程调用时会出现种种问题

此时 JAVA RMI 出现了 , 通过它可以解决上述的所有问题 .


JAVA RMI 的流程 .

注 : 这部分内容还存在一些问题 , 详见文末补充内容~

  1. 先来解决第一个问题 : 如何确保服务端数据的安全性 .

    如果让客户端直接访问服务端的资源 , 那么有可能出现越权访问的风险 . 在 JAVA RMI 中 , 通过一个 中间人 来解决这类问题 .

    服务端( RMIServer ) 会将自己提供的服务的实现类交给这个中间人 , 并公开一个名称 . 任何客户端( RMIClient )都可以通过公开的名称找到这个实现类 , 并调用它 .

    这样以来 , 不仅避免了客户端和服务端资源的直接交互 . 也使得客户端能更好的查找要使用的对象( 直接去询问这个中间人 , 若中间人拥有对应实现类 , 那么客户端可以在本地直接调用该类的方法 . 若中间人没有对应的实现类 , 则说明服务端没有提供相应服务 )

    这个中间人也被称为 RMIService / RMIRegister .

    因此整个 RMI 的流程实际上分为三个部分 , RMIServer , RMIClient , RMIRegister . 其交互的流程如下所示 :

    上图中已经通过 序号 + 文字 的方式把整个流程解释的比较清楚了 , 但是不看代码总感觉缺点啥 . 下面将通过 Java 代码来实现整个过程 .


  2. 首先定义一个远程接口

    既然 RMIServer 要提供服务 , 那么它一定会准备一个接口 , 让客户端通过这个接口来访问服务 .

    需要说明的是 :

    在 Java 中 , 如果一个类继承了 java.rmi.Remote 接口 , 那么该类将成为一个服务端的远程对象 , 供客户端访问并提供一定的服务 .

    Remote 接口是一个标识接口 , 本身不包含任何方法 , 该接口用于标识其子类的方法可以被非本地的Java虚拟机调用

    由于远程调用的本质依旧是 " 网络通信 " . 而网络通信是经常出现异常的 . 因此 , 继承 Remote 接口的接口的所有方法必须要抛出 RemoteException 异常 . 事实上 , RemoteException 也是继承于 IOException 的 .


  3. 要想调用远程接口 , 还需要一个实现类

    实现类必须要继承 UnicastRemoteObject 类 ,

    只有当接口的实现类继承了 UnicastRemoteObject 类 , 客户端访问获得远程对象时 , 远程对象才将会把自身的一个拷贝以 Socket 的形式传输给客户端,这个拷贝也就是 Stub , 或者叫做 " 存根 " .

    准确的说 , java.rmi.server.UnicastRemoteObject 类的构造函数将生成 StubSkeleton . 而继承该类的子类将会在实例化时自动执行父类的构造函数 , 从而也生成 StubSkeleton .

    这个 Stub 可以看作是远程对象在本地的一个代理 , 其中包含了远程对象的具体信息 . 客户端可以通过这个代理与服务端进行交互 .

    Skeleton 也叫做 " 骨架 " , 可以看作是服务端的一个代理 , 用来处理 Stub 发来的请求 , 然后去调用客户端真正需求的方法 , 然后再将方法执行结果返回给 Stub .

    其实 , 与其说是客户端和服务端进行交互 , 不如说是 客户端代理( Stub ) 和 服务端代理( Skeleton ) 在进行交互 .

    但是为什么一直没有提到这个 Skeleton 呢 ? 因为在 JDK1.2 以后的 RMI 中 , 可以通过反射API 直接将请求发送给真实类 , 不再需要 Skeleton 来做中转了

    此外 , java.rmi.server.UnicastRemoteObject 的构造函数抛出了 RemoteException 异常 . 这种写法是非常不好的 , 详细内容可以参考 改善Java代码 : 不要在构造函数中抛出异常 这这篇文章 . 因此 , 实现类中的构造函数应该主动抛出异常 . 所以这里通过 super() 关键词来调用父类的构造函数 .

    最后 , 该实现类实现了远程接口中的 sayHello() 方法 .


  4. 现在要提供的服务已经准备好了 , 应当开启 RMIRegister 并在上面注册远程对象 , 并向客户端提供对应的服务了 .

    Hello h = new HelloImpl(); : 上文提到过, HelloImpl 对象在实例化时会自动调用其父类 UnicastRemoteObject 的构造方法 , 生成对应的 StubSkeleton . 由于 Skeleton 已经不再需要 , 所以这里仅会返回 Stub 的引用 .

    上文也曾提到 : JAVA RMI 由三个部分组成 , 分别是 RMIClient , RMIServer , RMIService . 其中 RMIService 可以放在单独的 Java 虚拟机中启动 , 也可以通过 RMIServer 来启动 . 在正常的开发环境中 , 基本不会给 RMIService 一个单独的运行环境的 , 往往是通过 RMIServer 来启动的 .

    LocateRegistry.createRegistry(1099); : 即在本地创建并启动 RMIService , 被创建的 RMIService 服务将会在指定的端口上监听请求 .

    RMIService ( RMIRegister ) 服务的默认端口为 : 1099

    java.rmi.Naming 类提供在对象注册表中存储和获得远程对远程对象引用的方法 . 这里将远程对象 " h " 绑定到 rmi://localhost:1099/hello 这个 URL 上 . 客户端可以通过这个 URL 直接访问远程对象 .


    这里涉及到了另一个问题 : 即 " 开发人员不知道远程实例对象的名称是什么 ." 而通过这种绑定机制 , 开发人员仅需要知道一个公开的路径(URL) , 就可以直接访问到对应的远程对象了 .

    至此 , 服务端的配置已经结束了 , RMIServer 将提供的服务注册在了 RMIService 上 , 并且公开了一个固定的路径 , 供客户端访问 .

  5. 客户端配置

    客户端只需要调用 java.rmi.Naming.lookup 函数 , 通过公开的路径从 RMIService 上拿到对应接口的实现类 , 拿到实现类后 , 在通过本地接口即可调用远程对象的方法 .

    因此 , 只需要一个接口 , 一个客户端连接程序 , 即可实现 JAVA 远程调用 .


JAVA RMI 执行过程 .

目前我们的测试环境应该是如下目录结构 .

下面开始处理上文给出的源代码 .


处理服务端源码

javac rmi/server/*.java -classpath rmi/server/

注意 : 如果不指定 -classpath 参数 , 则需要配置环境变量 .

然后利用 java 自带 rmic 工具生成 Stub 存根 .

rmic rmi.server.HelloImpl

注意 : rmic的路径必须要与源码中一致 . 比如源码中为 : package rmi.server , 那么此处 rmic 必须以 rmi.server 开头


处理客户端源码

javac rmi/client/*.java -classpath rmi/client

然后把服务端生成的 Stub 存根复制到客户端目录下 .

cp rmi/server/HelloImpl_Stub.class rmi/client/

现在代码都已经处理完毕 , 下面先运行 RMIServer , RMIServer 会自动开启 RMIService 并在上面注册对应的服务 , 然后再运行 RMIClient , 即可完成 JAVA 远程方法调用 .


完成 JAVA 远程方法调用

先运行 RMIServer

再运行 RMIClient

成功远程调用 RMIServer 的 sayHello() 方法 .


Wireshark 抓包分析

通过 Wireshark 抓取运行 RMIClient 的流量 , 然后进行分析 .

这里根据 TCP 数据流( tcp.stream eq 2 , tcp.stream eq 3 )可以划分出两次完整的交互过程 .


1. tcp.stream eq 2
  1. 首先是典型的 TCP 三次握手.

    注意 : 此时访问的目标端口是 1099 , 即我们指定的 RMISerivce 端口 . 然而最终我们调用远程方法时 , 需要去访问 RMIServer . 但 Stub 中保存的是 RMIService 的端口和地址 , RMIServer 的端口和地址我们是不知道的 .

    因此 , RMIService 肯定会将 RMIServer 的端口和地址告诉我们 , 供我们进行远程方法调用 .

  2. 接下来应该是一个 RMIService 的确认工作.

    这里 RMIService 返回了客户端的IP地址和端口 , 应该是用于确认要进行 RMI 服务的是否是 RMIClient .

    如果 RMIClient 作出了响应, 则代表 RMIClient 的确需要 RMI 服务 .

  3. RMIService 收到了 RMIClient 的响应 , 于是将 RMIServer 的地址发给了 RMIClient

    这里 RMIServer 的地址为 127.0.1.1 . 网上找了一圈没找到具体原因 , 我也没有调试源码 .

    但是考虑到上面确认了 hostname , 所以我猜测是 RMIServer 的地址是根据 hostname/etc/hosts 来决定的 .

    当然这仅是个猜测 , 如果您知道具体原因 , 欢迎评论 .

  4. 仅有IP还不够 , 只有同时拥有 IP地址和端口号才能访问 RMIServer . 因此 RMIClient 告诉 RMIClient 需要访问的类名( Hello.class ) , 然后 RMISerivceRMIServer 的端口号告诉 RMIClient

    可以看到 RMIClient 请求了 Hello.class 类 , 并且将数据参数通过序列化字符串的方式传输 . 如果您想要了解具体的序列化内容 , 可以通过 SerializationDumper 这个工具 .

    首先将在 Wireshark 里追踪 TCP 数据流 , 然后选择以 RAW 格式显示数据 .

    然后通过 SerializationDumper 工具就可以解析序列化字符串了 , 含义是一样的 .

    然后就是 ReturnData 的数据包了 , 同样将其转换成 Raw 格式来显示

    51 代表 RMI ReturnData 数据包 . 需要注意绿线标注的字符串 : 3132372e302e312e310000b2fd , 该字符串可以分为两个部分 .

    • 3132372e302e312e31 代表IP地址 127.0.1.1 , 这是 RMIServer 的地址
    • b2fd 代表十进制 45821 , 这是 RMIServer 的端口号

  5. 现在 RMIClient 已经知道 RMIServer 的IP地址和端口号 , 可以直接去访问 RMIServer 上对应类的方法了 . 下面进行一些收尾工作 , 就将结束了该 TCP 数据流 .


2. tcp.stream eq 3
  1. 首先依旧是 TCP 三次握手 .

    注意 : 此时访问的 IP 地址已经是刚才获取到的 127.0.1.1 了 , 访问的端口也是 RMIServer 的端口 45821 .

  2. 然后又是一个验证过程

    RMIServer 询问 RMIClient 是否是 127.0.0.1 , 如果 RMIClient 返回响应 , 则代表 RMIClient 需要 RMI 服务 . 这里和之前与 RMIService 的交互完全相同 , RMIClient 立刻做出了响应 .

  3. 紧接上一步 , RMIClient 在作出回应的同时 , 立刻请求 127.0.1.1 , 并且调用了 Java.rmi 包中的一些类

    接下来的内容我没看明白 , 通过 Raw 数据的 16 进制特征码来看 , 应该是完成了一次 RMI Call - RMI ReturnData 的流程 .

    50aced0005 : RMI Call 序列化字符串
    51aced0005 : RMI ReturnData 序列化字符串

    通过 SerializationDumper 工具查看后可以发现 , 其中调用了很多 Java.rmi 包中的类 , 猜测与解析反序列化字符串相关 . 具体含义我没有深究 .

  4. 在完成这一步后 , RMIClient将会把参数传输给 RMIServer , RMIServer 在本地执行后返回结果

    将参数 epicccal 传输给 RMIServer

    RMIServer 将参数 epicccal 带入 sayHello() 函数执行后 , 将结果返回给 RMIClient

    成功调用远程调用 sayHello() 方法 .

  5. 然后进行一些收尾工作 , 并结束整个 JAVA RMI 过程 .

至此 , 一个完整的 JAVA 远程方法调用流程就结束了 . 本文的内容也就结束了 .


总结

本文主要的内容主要是 Java RMI 的原理与流程 , 通过 画图分析 / 代码分析 / 流量包分析 三个角度 , 详细说明了整个 Java 远程方法调用的过程与原理 .

Java RMI 的利用方法与攻击技巧我会放在另外一章来说 , 尽量把知识点 , 难点都搞明白 , 学这个一定不能急躁 .

上文参考了大量前辈们的文章 , 再次感谢各位前辈 !

如果您对我的文章有异议 , 或者认为存在改进的地方 , 欢迎及时留言或交流 .


补充内容 2020-03-25 星期三

如何在不同的主机上实现 Java RMI 远程方法调用 ?

事情的起因是这样的 .

然后我就尝试在不同的VMware虚拟机里实现 Java 远程方法调用 . 结果发现坑真的蛮多的 , 而且我之前理解的内容也存在一些基础性的问题 . 在这里做一下统一的更正 .


环境搭建

首先找来两台 Kali 虚拟机作为测试使用 . 并却确保两台主机可以互通 .

  • kali 192.168.0.101 RMIClient
  • kali2 192.168.0.102 RMIServer

然后分别完成服务器端的配置和客户端的配置

  1. 首先还是编写上述三个基础文件 , 即 Hello.java , HelloImpl.java , HelloServer.java

  2. 上文提到过 , 在远程调用准备阶段 , RMIService 会把 RMIServer 的IP地址传递给 RMIClient

    当时 RMIServer 的 IP 地址为 127.0.1.1 , 但现在不一样了. 因为 RMIClientRMIServer 不在同一台主机上 . 因此必须要指定 RMIServer 的 IP 地址 .

    那么 RMIServer 是如何获取 RMIServer 的 IP 地址呢 ? 事实上 , 通过多次实验和抓包不难发现 , RMIService 会先找到 RMIServer 的域名 , 然后去 /etc/hosts 文件中查找该该域名对应的 IP 地址 . 最后把这个 IP 地址传递给 RMIClient

    有了这个流程 , 解决方法也就有了 . 网上主流的两种方法如下

    1. 修改 /etc/hosts 使得域名指向的 IP 地址为 RMIServer 的 IP 地址

    2. 通过设置 java.rmi.server.hostname 参数来指定当前 JVM 使用的域名或IP地址 .


修改 /etc/hosts
  • 这种方法其实没有什么坑点 , 只需要重新指定 RMIServer.java 文件中的 IP 地址

  • 然后修改/etc/hosts 文件就行了

    网上很多帖子都说 hostname -i 不能为 127.0.1.1 , 说的就是这里 .

  • 现在只需要将生成的 stub 拷贝到客户端就行了 . 这里需要注意一下客户端的目录结构 .

    在上文中 , 由于 RMIClientRMIServer 都在一个主机中 , 因此即使我没有注意目录结构的问题 , 也执行成功了 . 但是现在它们在不同的主机上了 , 若不注意目录结构 , 则会执行失败 .

    我们拷贝过去的 HelloImpl_Stub.classHello.java 都是属于 rmi.server 这个 package 的 .

    所以它们即使被传输到了 RMIClient , 也必须要保持这个目录结构 . 因此 RMIClient 最终的目录是如下这样的 .

  • 现在环境搭建完毕了 , 只需要先启动 RMIServer , 再启动 RMIClien 就可以了 .

    RMIServer : java rmi.server.HelloServer

    RMIClient : java rmi.client.HelloClient

    成功实现 Java RMI 远程方法调用 !


设置 java.rmi.server.hostname 属性

这第二种真是一个大坑 , 搞了我一个晚上 , 吐血了 ==

  • 网上的普遍说法是添加如下这一行代码 .

    然后不需要修改 /etc/hosts 文件 , 直接重新编译生成 stub 文件传给客户端 , 就可以实现 Java RMI 远程方法调用了 . 我们来尝试做一下 .

    然后直接运行 RMIClient , 发现会报错 .

    提示咱们连接不到 127.0.1.1 , 但是很明显不对啊 , 我们明明将 java.rmi.server.hostname 设置为了 192.168.0.102 .我们来抓包看一下 .

    这里给出 RMIServer 的 IP 地址还真是 127.0.1.1 . 这说明什么 ? 这说明我们设置的 java.rmi.server.hostname 属性并没有生效 !

    代码写法肯定是没问题的 , 网上给出的代码都是这样 . 那么问题会在哪呢 ? 我找遍了主流的帖子 , 也没有一个人提到过设置 java.rmi.server.hostname 属性不生效的问题 !

    最后一狠心 , 干脆去翻 Oracle 文档了 . 当时我也是急了 , 凭什么别人代码能行我不行 . 这一翻 , 还真找到了点思路 . 来看下边这段 .

    来源 : Frequently Asked Questions Java™ RMI and Object Serialization

    这里提到了需要在 RMIServer 启动时设置 java.rmi.server.hostname 属性 , 那么什么叫 " RMIServer 启动时 " 呢 ? 我也不清楚, 所以我干脆把这行代码放到主函数开头 .

    然后重新编译生成 Stub 文件 , 并拷贝到客户端 server 目录 . 最后运行服务 .

    最后运行 RMIClient 服务 .

    成功实现 Java RMI 远程方法调用 ! 坑死我了!


    重点事项

    下面列举一下在不同的主机上搭建 Java RMI 远程方法调用的注意点 .

    1. 需要注意 RMIClient 的目录结构 .

    2. 需要注意 /etc/hosts 的文件内容 .

    3. 如果使用 java.rmi.server.hostname 属性来设置 RMIServer 的地址 , 必须要将这行代码放在启动RMIServer的文件的主函数开头 !

    这个坑就填完了 , 最终成功在不同主机上实现了 Java RMI 远程方法调用 .

最后修改日期:2020年6月28日

作者

留言

撰写回覆或留言

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