DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-40901: DataEase Root RCE via Unfiltered Quartz Deserialization

1. Overview

DataEase is an open-source BI (business intelligence) and data visualization platform maintained by FIT2CLOUD, with over 23K stars on GitHub. CVE-2026-40901 lives in how DataEase's bundled Quartz scheduler deserializes job data: there's no filter whatsoever on the ObjectInputStream that reads it back, so an attacker-controlled serialized payload gets executed as-is — resulting in root-level remote command execution inside the container.

On its own this is already severe, but it's actually the final link in a 4-vulnerability chain disclosed by the OX Security research team.

Stage CVE Description
0 CVE-2026-23958 DataEase authentication bypass (disclosed at RSAC 2026)
1 CVE-2026-40899 Lombok @Data auto-generated setter bypasses the JDBC parameter blocklist → arbitrary file read
2 CVE-2026-40900 previewSql subquery escape + allowMultiQueries → stacked-query SQL injection
3 CVE-2026-40901 Quartz job data deserialization → root RCE

This post focuses on stage 3, CVE-2026-40901 itself — specifically, how a deserialization bug turns into full RCE, walked through at the source code level.

2. Why Quartz Becomes the Attack Surface

Quartz is a widely-used Java job scheduling library. It manages job definitions (what to run) and triggers (when to run it). DataEase configures it with a JDBC JobStore, meaning job state lives in a database table instead of memory.

That table is qrtz_job_details, and one column in particular matters here: JOB_DATA.

-- qrtz_job_details table (relevant columns only)
SCHED_NAME     VARCHAR(120)  -- scheduler instance name
JOB_NAME       VARCHAR(200)  -- job name (e.g. check_status)
JOB_GROUP      VARCHAR(200)  -- job group (e.g. Datasource)
JOB_CLASS_NAME VARCHAR(250)
JOB_DATA       BLOB          -- ★ a serialized Java object lives here
Enter fullscreen mode Exit fullscreen mode

JOB_DATA holds a JobDataMap object, serialized via ObjectOutputStream into raw bytes. Every time Quartz fires a job, it pulls this BLOB back out and deserializes it with ObjectInputStream. That's the crux of the vulnerability.

3. The Deserialization Call Path

DataEase bundles Quartz 2.3.2. The actual call chain looks like this:

JobStoreSupport.retrieveJob(SchedulingContext, JobKey)
    └── StdJDBCDelegate.selectJobDetail(Connection, JobKey, ClassLoadHelper)
            └── getJobDataFromBlob(ResultSet, colName)
                    └── getObjectFromBlob(ResultSet, colName)   // ← the actual deserialization point
Enter fullscreen mode Exit fullscreen mode

The last method, getObjectFromBlob(), is where the problem is fully exposed:

// org.quartz.impl.jdbcjobstore.StdJDBCDelegate
protected Object getObjectFromBlob(ResultSet rs, String colName)
        throws ClassNotFoundException, IOException, SQLException {
    Object obj = null;
    Blob blobLocator = rs.getBlob(colName);

    if (blobLocator != null) {
        InputStream binaryInput = blobLocator.getBinaryStream();
        if (binaryInput != null && binaryInput.available() != 0) {
            ObjectInputStream in = new ObjectInputStream(binaryInput);
            try {
                obj = in.readObject();   // ★ deserializes whatever class is on the wire
            } finally {
                in.close();
            }
        }
    }
    return obj;
}
Enter fullscreen mode Exit fullscreen mode

One line does all the damage: in.readObject(). A few things stand out about it:

  • No class allowlist. Whatever class the BLOB says it is, it gets instantiated — as long as that class exists somewhere on the JVM's classpath.
  • No ObjectInputFilter (JEP 290, standard since Java 9) attached. There's no hook intercepting readObject() to reject dangerous classes before they're constructed.
  • No trust boundary check on the source of the bytes. JOB_DATA is read straight from the database — whoever can write to that column controls what gets deserialized.

That resolves into a simple equation: write access to qrtz_job_details.JOB_DATA == arbitrary code execution. And DataEase already hands an attacker that write access via the SQL injection in the prior stage (CVE-2026-40900).

-- Conceptual example of the stacked-query SQL injection overwriting JOB_DATA
UPDATE qrtz_job_details
SET job_data = 0x<serialized malicious bytes>
WHERE job_name = 'check_status' AND job_group = 'Datasource';
Enter fullscreen mode Exit fullscreen mode

4. What Gets Planted — the InvokerTransformer Gadget Chain

An unfiltered readObject() alone doesn't automatically mean command execution. Java deserialization, by design, only reconstructs object state — turning that into Runtime.exec() requires chaining together classes that already happen to sit on the classpath. This is what's known as a "gadget chain."

DataEase's container happens to have both ingredients needed to complete one:

  • commons-collections-3.2.1.jar — ships InvokerTransformer, a class that invokes an arbitrary method via reflection
  • velocity-1.7.jar — DataEase already uses the modern velocity-engine-core-2.3.jar, but this old, unused velocity-1.7.jar was never cleaned out of the classpath, and it's the thing that drags the vulnerable commons-collections-3.2.1 in with it

Here's the core of InvokerTransformer:

// org.apache.commons.collections.functors.InvokerTransformer
public Object transform(Object input) {
    if (input == null) {
        return null;
    }
    try {
        Class cls = input.getClass();
        Method method = cls.getMethod(iMethodName, iParamTypes);
        return method.invoke(input, iArgs);   // ★ arbitrary method call via reflection
    } catch (...) { ... }
}
Enter fullscreen mode Exit fullscreen mode

iMethodName, iParamTypes, and iArgs are all fields inside the deserialized object — meaning the attacker fully controls them when crafting the serialized payload. Chaining several of these together with a ChainedTransformer produces a sequence like:

ChainedTransformer execution order:
1) Obtain the Runtime class      (via the static Runtime.getRuntime method)
2) Call getRuntime()             to get a Runtime instance
3) Call exec(String) on it       ← arbitrary command execution happens here
Enter fullscreen mode Exit fullscreen mode

What actually kicks this transformer chain off during deserialization is a side effect of classes like LazyMap or AnnotationInvocationHandler invoking equals(), hashCode(), or a dynamic proxy's invoke() while the object graph is being reconstructed — this is the well-known CommonsCollections6 (CC6) gadget chain. In short, the attacker hijacks "some method call that happens automatically right after deserialization" and turns it into a call to Runtime.exec().

The root cause, in one sentence:

A deserialization sink with no filter (Quartz's raw ObjectInputStream) coexisting on the same classpath as gadget-chain material (an outdated Commons Collections, dragged in by an unnecessary legacy Velocity dependency).

Removing any single one of the three — a serialization filter, a patched Commons Collections version, or cleaning up the dead Velocity jar — would have broken this chain.

5. Full Attack Flow

  1. Using the SQL injection obtained via the CVE-2026-40899 + CVE-2026-40900 chain, the attacker overwrites the JOB_DATA column of the Datasource/check_status job in qrtz_job_details with a serialized CC6 gadget chain.
  2. That job has a cron trigger firing every 6 minutes, so the attacker just waits — no additional trigger action needed.
  3. When the trigger fires, JobStoreSupport calls retrieveJob(), which eventually reaches StdJDBCDelegate.getObjectFromBlob() and calls ObjectInputStream.readObject().
  4. During deserialization, the InvokerTransformer chain fires, ultimately calling Runtime.exec(attacker_command).
  5. Since DataEase's JVM process runs as root inside the container, the attacker gets a root shell (typically a reverse shell) immediately.

Chained together with the authentication bypass (CVE-2026-23958), this becomes fully unauthenticated remote root compromise of any internet-exposed DataEase instance.

6. The Fix (v2.10.21)

DataEase shipped v2.10.21 on April 16, 2026, resolving the issue by removing the gadget-chain ingredients rather than hardening the deserialization sink itself:

  • Removed the unused legacy velocity-1.7.jar dependency
  • As a consequence, the commons-collections-3.2.1.jar it was dragging in was also removed from the classpath

In other words, the underlying architectural issue — Quartz's unfiltered deserialization sink — is still structurally there. The patch cuts off the specific gadget material that made it exploitable.

7. What to Check on Your Own Instance

If you're running DataEase and haven't upgraded yet, check whether these legacy jars are present:

# Check for the legacy velocity/commons-collections jars inside the container
find / -name "velocity-1.7.jar" 2>/dev/null
find / -name "commons-collections-3.2.1.jar" 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

If an immediate upgrade isn't possible, a JEP 290 deserialization filter is a viable stopgap:

# Add to DataEase's JAVA_OPTS at startup
JAVA_OPTS="-Djdk.serialFilter=!org.apache.commons.collections.functors.*;!org.apache.commons.collections.Transformer;maxdepth=20"
Enter fullscreen mode Exit fullscreen mode

This causes any class under commons-collections's functors package to be rejected with InvalidClassException the moment it appears during deserialization — before any gadget method gets a chance to run.

Top comments (0)