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
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
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;
}
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 interceptingreadObject()to reject dangerous classes before they're constructed. -
No trust boundary check on the source of the bytes.
JOB_DATAis 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';
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— shipsInvokerTransformer, a class that invokes an arbitrary method via reflection -
velocity-1.7.jar— DataEase already uses the modernvelocity-engine-core-2.3.jar, but this old, unusedvelocity-1.7.jarwas never cleaned out of the classpath, and it's the thing that drags the vulnerablecommons-collections-3.2.1in 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 (...) { ... }
}
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
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
- Using the SQL injection obtained via the CVE-2026-40899 + CVE-2026-40900 chain, the attacker overwrites the
JOB_DATAcolumn of theDatasource/check_statusjob inqrtz_job_detailswith a serialized CC6 gadget chain. - That job has a cron trigger firing every 6 minutes, so the attacker just waits — no additional trigger action needed.
- When the trigger fires,
JobStoreSupportcallsretrieveJob(), which eventually reachesStdJDBCDelegate.getObjectFromBlob()and callsObjectInputStream.readObject(). - During deserialization, the
InvokerTransformerchain fires, ultimately callingRuntime.exec(attacker_command). - 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.jardependency - As a consequence, the
commons-collections-3.2.1.jarit 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
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"
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.












