Skip to content

Clipboard format spoofing from a web page's copy event bypasses paste sanitization and leads to remote code execution in the desktop app

High
88250 published GHSA-9rr9-pxr4-gcgc Aug 28, 2026

Package

siyuan-note/siyuan

Affected versions

<= 3.8.1

Patched versions

v3.8.2

Description

CVSS 3.1: 8.9 High — CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

Summary

In SiYuan v3.8.1 (latest release, Windows official installer, default settings), the
protyle paste handler trusts a custom clipboard MIME type (text/siyuan) as if only
SiYuan itself could have written it. Any web page can forge this type via a standard
copy event (event.clipboardData.setData('text/siyuan', ...)), so a victim who copies
text from an attacker-controlled page and pastes it into a SiYuan note gets attacker
HTML inserted without sanitization
into the editor's main window — a renderer with
nodeIntegration: true, contextIsolation: false, webSecurity: false and @electron/remote
enabled. This yields arbitrary command execution from a plain copy-paste.

Attack scenario (user journey)

  1. Victim visits an attacker page (e.g. a "deployment commands cheat sheet") and copies
    selected text with Ctrl+C, as they normally would when collecting material into notes.
  2. The page's copy listener overwrites the clipboard:
    e.clipboardData.setData('text/siyuan', payload) (custom MIME types are allowed in
    copy events and survive cross-application paste into Chromium-based apps).
    A second, equivalent injection edge exists: setting text/plain to
    payload + "\u200b" (trailing zero-width space) with no text/html, which
    paste.ts also treats as trusted internal SiYuan clipboard data.
  3. Victim switches to SiYuan and presses Ctrl+V in a document.
  4. The paste handler inserts the payload without sanitization and attacker JavaScript
    executes with full Node.js access (marker file write / calc spawn verified).

No warning, no confirmation — a routine copy-paste.

Root cause (v3.8.1, app/src/protyle/util/paste.ts)

  • paste.ts:537 / :545siyuanHTML = event.clipboardData.getData("text/siyuan")
    is taken verbatim from an externally controlled clipboard.
  • paste.ts:615-616text/plain ending with the zero-width space is likewise
    promoted to siyuanHTML.
  • paste.ts:625-635 — sanitization only runs in the if (!siyuanHTML) branch:
    textHTML = Lute.Sanitize(textHTML). The siyuanHTML path skips it entirely.
  • paste.ts:711-746 — sinks:
    • ≤ 512 KiB: tempElement.innerHTML = siyuanHTML (event-handler attributes execute
      in the main frame);
    • 512 KiB: streamInsert()document.write() into a same-origin hidden
      iframe
      , so <script> executes synchronously and reaches parent.require.

The Electron main window (electron/main.js:1051-1062) runs with
nodeIntegration: true, contextIsolation: false, webSecurity: false plus
remote.enable(), so the injected script has full Node.js / main-process access.

Proof of concept

Attacker page (served from any origin):

<script>
document.addEventListener('copy', (e) => {
  e.clipboardData.setData('text/siyuan',
    '<img src=x onerror="require(\'fs\').writeFileSync(\'siyuan_poc_marker.txt\',\'RCE host=\'+require(\'os\').hostname())">');
  e.clipboardData.setData('text/plain', 'deployment snippet');
  e.clipboardData.setData('text/html', '<pre>docker run ...</pre>');
  e.preventDefault();
});
</script>

Select text on the page → Ctrl+C → focus a SiYuan document → Ctrl+V.

For the document.write sink, use a text/siyuan payload larger than 512 KiB
(e.g. an HTML comment padded to ~600 KB followed by
<script>parent.require('child_process').exec('calc')</script>).

Verified impact (Windows 11, v3.8.1 official installer, default settings)

  • Sink A (innerHTML): siyuan_poc_marker_A.txt written by the SiYuan renderer —
    F01-SIYUAN-PASTE-RCE-A sink=innerHTML host=LAPTOP-*** t=2026-08-28T06:53:48Z
  • Sink B (document.write iframe): siyuan_poc_marker_B.txt written and
    CalculatorApp.exe spawned via parent.require('child_process').exec('calc')
    (F01-SIYUAN-PASTE-RCE-B sink=document.write-iframe ... t=2026-08-28T06:56:22Z)

Full desktop recordings of both runs (copy on the attacker page → paste in SiYuan →
marker / calc) are available; happy to attach or link on request.

Affected versions

v3.8.1 (latest) and presumably earlier versions carrying the same clipboard-trust logic
in the paste handler. Default configuration; the allowHTMLBLockScript setting is NOT
required to be enabled for this chain.

Note on intent: this is distinct from the documented HTML-block script feature —
that feature is opt-in via allowHTMLBLockScript (default off, "allow scripts in
HTML blocks"). Both PoC runs below were executed with that setting off, i.e. the
chain bypasses the product's own expressed boundary for script execution. The paste
handler also already sanitizes its other path (Lute.Sanitize(textHTML)), which
suggests untrusted-content sanitization on paste is the intended behavior and the
siyuanHTML branch was missed.

Suggested fix

  1. Sanitize the trusted-format paths the same way as textHTML:
    siyuanHTML = Lute.Sanitize(siyuanHTML) (or DOMPurify) before it reaches either sink
    — one line closes both injection edges (text/siyuan and the ZWSP text/plain route).
  2. Replace the same-origin hidden-iframe document.write in streamInsert() with
    DOMParser + sanitization; a same-origin iframe is itself an unsandboxed sink.
  3. Hardening (optional, larger): the main editor window does not need
    nodeIntegration/@electron/remote; isolating it would break this entire class of
    paste-XSS-to-RCE chains (as already done for iframe URL blocks, which correctly use
    sandbox="allow-scripts").

Happy to provide the full PoC page, the CDP-assisted reproduction script, and the
recordings. Thanks for the great product and the fast security response track record!

Severity

High

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits