前言
最近打算看看 weblogic 的漏洞,于是在网上搜集相关的资料,其中 T3 协议的反序列化漏洞占据了 weblogic 漏洞历史浓墨重彩的一部分。
搜着搜着发现了一个问题,漏洞本身是反序列化漏洞,反序列化漏洞的文章无非就是两点,一是入口点,二是反序列化 Gadget 链,其他的就是环境搭建或者其他利用技巧等。
这里我搜集到的网上 99% 的文章都是拿着一个 python 的利用脚本打,然后不知道从哪里得出的结论,就在一个不知道哪里得来的方法下断点就开始分析了。对于研究漏洞原理或者分析来说,是非常不好的习惯。
于是我花了一些时间对 Weblogic RMI 的设计模式与 T3 协议的流程浅浅的研究一下,形成了本文,本来想等着 Weblogic 系列的漏洞分析文章一起发 JavaSec 的,但是突然想起来我还有一个公众号好久没更新了,就随便发一下,算是水文一篇。
在网上还没看到有相关的完整分析文章,所以这可能算是第一篇,由于 T3 是闭源协议,在网上能找到的资料实在少之又少,本文的全部内容都是通过看反编译的代码步进 Debug 后进行观察和猜测得到的。个人能力和精力十分有限,可能有诸多的理解偏差乃至错误,希望各位大佬们不吝赐教,给予指正。
1
Weblogic RMI
在我之前的文章《Java RMI 攻击由浅入深》中介绍了 RMI 思想在原生 Java 中的实现,通过建立注册中心绑定服务,并在服务提供者和消费者之间传递 Java 原生序列化数据的方式进行对象状态的传递,借助了 Stubs 和 Skeletons 的概念来屏蔽网络通信的复杂性,通讯协议为 JRMP 。
支持 RMI 对象的故障转移和负载平衡;
WebLogic RMI 在运行时动态生成 Stubs 和 Skeletons;
无需 SecurityManager,由 WebLogic Server 实现身份验证、授权和 Java EE 安全服务;
支持事务。
这里通过简单的案例,梳理一下大概的流程,由于刚开始学习 Weblogic,因此环境搭建为 weblogic 10.3.6,后续的版本协议可能有所变动,后续将会完整的更新,以下的分析基于此环境。
案例
服务端和客户端均存在一个 org.su18.weblogic.IHello 接口:
public interface IHello extends java.rmi.Remote {
String sayHello() throws RemoteException;
}
服务端有一个这个接口的具体实现类 org.su18.weblogic.HelloImpl:
public class HelloImpl implements IHello {
private String name;
public HelloImpl(String s) throws RemoteException {
super();
name \= s;
}
public String sayHello() throws java.rmi.RemoteException {
return "Hello World!" + name;
}
}
服务端实例化提供服务的对象,并向注册中心绑定:
public class T3Server {
public static void main(String\[\] argv) throws Exception {
Hashtable<String, String\> env \= new Hashtable<String, String\>();
env.put("java.naming.factory.initial", "weblogic.jndi.WLInitialContextFactory");
env.put("java.naming.provider.url", "t3://10.10.11.172:7001");
try {
InitialContext ic \= new InitialContext(env);
ic.rebind("Server", new HelloImpl("ServerHelloImpl"));
Thread.sleep(1000000);
} catch (Exception ex) {
System.err.println("An exception occurred: " + ex.getMessage());
throw ex;
}
}
}
客户端查询方法,并将其强转为 IHello 类型,然后调用其 sayHello()
方法。
public class T3Client {
public static void main(String\[\] argv) throws Exception {
Hashtable<String, String\> env \= new Hashtable<String, String\>();
env.put("java.naming.factory.initial", "weblogic.jndi.WLInitialContextFactory");
env.put("java.naming.provider.url", "t3://10.10.11.172:7001");
try {
InitialContext ic \= new InitialContext(env);
IHello obj \= (IHello) ic.lookup("Server");
System.out.println(obj.sayHello());
} catch (Exception ex) {
System.err.println("An exception occurred: " + ex.getMessage());
throw ex;
}
}
}
运行结果:
可以发现成功调用远端服务,返回结果。
绑定
在上述案例中,是通过 JNDI 查询对象并调用,将 java.naming.factory.initial 设置为 weblogic.jndi.WLInitialContextFactory,并将 java.naming.provider.url 设置为 t3://10.10.11.172:7001 服务地址。
此时程序实际调用 weblogic.jndi.WLInitialContextFactoryDelegate#getInitialContext() 创建上下文,调用 weblogic.rjvm.ServerURL#findOrCreateRJVM() 来查找或创建相关对象。
ServerURL 使用 weblogic.rjvm.RJVMFinder 来查找,RJVMFinder 又调用 weblogic.rjvm.RJVMManager 来集中管理,如果本地不是 Server 端、没找到已经建立的连接的情况下,会创建新的 ConnectionManager 对象,并进行初始化。
初始化过程中会调用 weblogic.rjvm.ConnectionManager#findOrCreateConnection() 方法。
此方法中首先调用createConnection() 方法建立握手连接,创建 weblogic.rjvm.MsgAbbrevJVMConnection 对象,并创建一些基础信息进行数据通信。
中间产生的所有信息,都会存在一个 RJVM 类型的对象中,里面包含一个 weblogic.rjvm.JVMID 用来对服务器信息进行唯一标识。随后将使用这个信息创建一个上下文,用来进行后续的操作,由于是远程服务器,将会调用 newRemoteContext 方法。
这个方法中实例化了 BasicRemoteRef 对象用来作为远端服务器的引用,然后使用 newRootNamingNodeStub 方法来生成封装关键信息的 ServerNamingNode_1036_WLStub 对象,并用它来初始化 weblogic.jndi.internal.WLContextImpl 作为 JNDI 上下文的实例化对象。
这里发现,如果 lookup 有参数,将会调用 lookup 方法继续查询。
其 lookup 会委托其内部的 RemoteReference 对象进行查询。
而这个对象是之前创建的 ClusterableRemoteRef 实例。
在获取到上下文之后,下一步就是进行查询、绑定等操作了,这里我们是服务端,想在服务器上绑定一个提供服务的对象。WLContextImpl 的 rebind 同样调用 ClusterableRemoteRef 的 invoke 方法。
在 Debug 时可以清晰地看到,中间传递的数据有名称、提供服务的对象、以及存放属性的 Map。
在调用中,会调用 OutboundRequest#marshalArgs() 方法将对象序列化,发送并反序列化返回值,如下图。
在序列化对象时,可以看到调用了 weblogic.rmi.internal.ObjectIO#writeObject() 方法进行序列化。
序列化过程中,除了基础类型外,会调用 weblogic.common.WLObjectOutput#writeObjectWL() 方法序列化,实际类型是 weblogic.rjvm.MsgAbbrevOutputStream,其中会调用到 replaceObject 方法。
实际是委托调用 weblogic.rmi.utils.io.RemoteObjectReplacer#replaceObject() ,这里如果对象是继承了 Remote、InvokeHandler、CORBA Object、Servant 等接口的情况,将调用 replaceRemote 进行对象的替换。
调用 OIDManager#getReplacement() 方法。
通过 makeServerReference 来创建 ServerReference 对象。
这个方法会调用 weblogic.rmi.internal.DescriptorManager#getDescriptor() 方法。
这个方法就是用提供服务对象的具体信息创建一个 BasicRuntimeDescriptor 实例。
在实例化对象时,会准备相关的信息,用来生成 Skeleton 实例,并实例化一个 ClientRuntimeDescriptor 对象,里面是对客户端对象的描述。
比如其中调用的 initializeRuntimeDescriptor() 方法用来生成基础信息。
其中 initClassNames 方法将提供服务的对象名称后面加 “_WLSkel” 形成 Skeleton 类名。
回到 BasicRuntimeDescriptor 实例化过程,其中调用 createSkeletonClass 方法,调用了 weblogic.rmi.internal.SkelGenerator#generateClass() 方法。
调用 weblogic.utils.classfile.utils.CodeGenerator#getClassBytes 方法生成类字节码,并进行加载。
类字节码的生成调用 weblogic.utils.classfile.ClassFile#write() 方法。
这里将生成的类反编译看下,可以看到生成的继承了 Skeleton,调用了 传入参数的 sayHello() 方法,并将结果使用 OutboundResponse.getMsgOutput().writeObject() 进行写回。
生成之后,回到 OIDManager#getReplacement() 方法,此时在服务提供端,已经为提供服务的类生成完整的 ServerReference 对象了,接下来就是调用这个对象的 getStubReference() 方法,生成包含 Stub 信息的 StubInfo 对象。
这个 StubInfo 对象将会替代原始的提供服务的类传递给注册中心。
注册中心在接收到序列化数据并反序列化 StubInfo 对象时,其 readResolve() 方法将会根据其中的信息动态生成 Stub 类,如果本地没有,将会调用 weblogic.rmi.internal.StubGenerator#hotCodeGenClass() 动态生成类字节码。
下图为在注册中心生成的 Stub 类字节码。
绑定之后可以在管理后台的 View JNDI Tree 中看到:
可以看到绑定的 JNDI 名称和示例:
当然也可以在 WLEventContextImpl 的实例化对象中直接看到:
查询与调用
客户端的查询过程中获取 Context 与服务端的通信一致,不再重复,客户端查询时,注册中心返回 StubInfo 对象,客户端根据相关信息动态生成 Stub 类,客户端生成的类字节码如下:
可以看到这个类实现了 IHello 接口,并且存在一个 sayHello 方法,调用方法时委托其中的 RemoteReference 进行调用,与注册中心进行通信。这个调用也是将要传递的参数序列化,并将返回结果反序列化的过程。
虽然客户端不直接跟服务端通信,但是可以看到 StubInfo 中还是携带了一些服务提供者的信息。
异同
在分析了上述调用过程后,我们可以看到 Weblogic RMI 与 Java RMI 的差异,首先,虽然两者都是通过反序列化传递数据,但在细节上有所不同:
Java RMI 是通过 RemoteObjectInvocationHandler 对提供服务的对象进行动态代理,并将会在注册端、客户端传递并反序列化这个对象,在客户端调用这个对象的指定方法时,会委托 RemoteRef 与对象中记录的服务端暴露通信的端口直接通信,完成调用。
Weblogic RMI 在通信时,会在序列化数据的过程中对关键对象进行“替换”, 真正传递的是这些替换后的类对象,这些类对象是使用 CodeGenerator 根据相关信息动态生成的类字节码然后实例化加载的,服务提供者将提供服务的类替换后的 StubInfo 类传递给注册中心,注册中心进行记录,客户端查询时,注册中心会将这个 StubInfo 类返回给客户端,StubInfo 在反序列化时会根据相关信息动态生成 Stub 类,客户端实际调用这个 Stub 类实例,这个类实例通过 RemoteReference 对象与注册中心通信完成调用。
通过流程上的差异可以看出,Java RMI 主要使用动态代理,在实际调用时,由客户端和服务端直接进行通信,注册中心只起到“指路”的作用,Weblogic RMI 使用关键信息动态生成 Skeleton 与 Stub,实际调用时由客户端与注册中心通信,注册中心再调用服务提供者,客户端与服务端不直接通信。
而 weblogic 之所以设计如此,也是为了负载均衡的目的,这样在具体调用时,可以由注册中心进行同一分发。
第二个不同的点是, Java RMI 支持动态类加载,在进行调用时,如果遇到本地没有的类,会尝试从 codebase 中获取 class 并加载,但是 Weblogic RMI 在遇到本地不存在的类时,会直接生成类字节码,因此类加载这种攻击方式对 Weblogic 是不生效的。
值得一提的是,在 Weblogic RMI 调用中,与 Java RMI 一样,也有 DGC 的相关实现和交互。
2
T3 协议
上面描述了 WebLogic RMI 的调用部分,但在一些涉及通信的地方都略过了。WebLogic RMI 通讯协议主要为 T3 协议(另外是基于 CORBA 的 IIOP 协议)和安全的 T3S,其实就是对应 JRMP。这一小节简要分析下 T3 协议的结构和关键实现。
握手
之前提到,在绑定服务对象之前获取上下文时,会先建立握手连接。
因为 provider url 是以 “t3” 开头,因此这里实际调用 weblogic.rjvm.t3.ConnectionFactoryT3#createConnection() 方法建立连接。这个方法实例化了一个 weblogic.rjvm.t3.MuxableSocketT3,实例化的时候其实是跟注册中心进行 TCP 三次握手,但是此时还没有在协议上传输数据。
随后调用了 MuxableSocketT3#connect() 方法。
这个方法开始进行 T3 协议上的数据交互,其中先向注册中心发了 协议名 + 版本 ,以及 CONNECT_PARAMS 数据,然后读取返回的数据,并根据返回结果判断交互是否成功(Login.checkLoginSuccess())、服务器版本(Login.getVersionString())等。
发送的 CONNECT_PARAMS 数据是一串固定格式的字符。
验证
握手成功后,开始进行第一次的数据交互。由于方法名带有 “IdentifyMsg” 字样,这里暂且称这一步骤为验证,也就是之前提到的“创建一些基础信息进行数据通信”。处理方法为 weblogic.rjvm.ConnectionManager#createIdentifyMsg。
这个方法首先实例化了 weblogic.rjvm.MsgAbbrevOutputStream ,这个类在实例化时,会先 skip 19 个字节
这里为了体现数据的写入过程,以下截图均使用 debug 时候的截图,供大家了解这个过程。
首先写入了心跳包的间隔时间, 默认是 60000 毫秒,也就是 60 秒,转为 hex 就是 ea60。
接下来调用 LocalRJVM.getLocalRJVM().getPublicKey() 方法获取了一个随机生成的 byte 值。并以 Int 值写入长度。(图中红框错位了一位)
然后写入这个 PublicKey。
然后调用了 LocalRJVM.getLocalRJVM().getPeerInfo() 获取了一个包含本地信息的 PeerInfo 对象,这个对象用来告诉服务器自己的一些信息。然后调用 MsgAbbrevOutputStream#writeObject 对象对 PeerInfo 进行序列化写入。
可以看到写了一个 byte 值 02
然后调用 reset 写入一个 79
接下来就是调用原生的 ObjectOutputStream 写入流了,就是 73 72 ... 的正常序列化数据了,只是反序列化头部不是 ACED 0005 开头了。
序列化基本的 PeerInfo 数据后,接下来会调用 weblogic.rjvm.MsgAbbrevJVMConnection#sendMsg() 发送消息,这个消息发送并不是单纯的 flush 数据,而是进行了一些消息的封装和信息的写入,接下来重点看一下。sendMsg 调用了 writeMsgAbbrevs 方法。
writeMsgAbbrevs 方法调用了 addAbbrev 向 MsgAbbrevOutputStream 中加入了一些对象,然后调用 OutboundMsgAbbrev#write 方法写入对象。
OutboundMsgAbbrev 对象里包含了两个 Stack ,变量名分别为 abbrevs 和 headerAbbrevs,这两个 stack 中的内容都会写到输出的数据流中。abbrevs stack 是跟序列化数据相关的内容,headerAbbrevs 是跟协议交互双端相关的信息。
随后又加入了两个 JVMID 对象,分别包含了本机的一些信息,和服务器的一些信息。
之后调用 write 方法写入。这个方法先写了一个 abbrevs 和 headerAbbrevs 两个栈的大小总和,也就是 06。然后开始调用 writeAbbrevs 开始写入栈中的数据。
在 writeAbbrevs 方法中,可以看到是 for 循环,将栈中的数据依次写入,首先是调用 writeLength 写入了一个长度值,调用 getAbbrev() 方法获得的,这个值其实就是在第一步握手时 AS 的值 + 1, 也就是 weblogic.rjvm.MsgAbbrevJVMConnection#ABBREV_TABLE_SIZE 的值 + 1,默认情况下是 255。
writeLength 的方法是如果大于 254,则先写个 255,在写入后面的值,默认的 255 就是 FE 01。
如图:
这个值应该是一次传输中最大的数据数量。写入这个值后,后续就是调用 writeObject 进行对象的写入,先写入一个 0,然后进行序列化对象的写入。
这就是 FE01 0000 的由来,很多文章以它作为 t3 协议的特征,称其为“分隔符”,其实是有偏差的,这个值其实可以随便改。例如:
Class z \= Class.forName("weblogic.rjvm.MsgAbbrevJVMConnection");
Field field \= z.getField("ABBREV\_TABLE\_SIZE");
field.setAccessible(true);
Field modifiersField \= Field.class.getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
field.set(null, 222);
我这里用反射将其改为 222,特征就完全变了。
继续回到序列化数据的拼接上来,对象写入后,将会调用 sendOutMsg
方法进行最后数据的拼接和封装。
将会调用 weblogic.rjvm.t3.MuxableSocketT3$T3MsgAbbrevJVMConnection#sendMsg()方法 。
这个方法调用 weblogic.rjvm.MsgAbbrevOutputStream#getChunks() 方法。
这个方法首先调用 setPosition(0) 从头开始写入数据,也就是填充最开始空余的数据,然后用 Int 写入整个数据包的大小,随后调用 JVMMessage#writeHeader() 方法写入 header 信息。
writeHeader 方法写入相关的数据,这些数据也是可以酌情修改的。
例如 QOS、flags,其中值得注意的是,this.abbrevOffset 是指向 abbrev 对象的偏移,通过修改这个偏移,我们可以在序列化对象中间插入任意数据。比如我在下面填充 4 个 Int 数据 0 1 2 3。
看看这个流量,是不是跟你之前见过的不一样了?
本文到这里就结束了,更多的内容后续慢慢更新,本文希望抛砖引玉,引起大家的讨论。