Skip to content

Commit 3b9bd31

Browse files
committed
fix(mcp): SHA-256 the launch fingerprint — shortHash was forgeable in minutes
Review on #55, and the most serious finding of the series. It is a real break of the launch gate, not a theoretical weakness. launchFingerprint used shortHash — a 32-bit djb2 variant emitted as 6 base36 chars, so a ~2^31 output space. That helper exists to truncate tool names stably; it was never meant to carry an authorization decision, and I reached for it out of convenience. Why it is exploitable rather than merely weak: the attacker AUTHORED the benign command that earned trust, so they know the target value exactly, and they control the replacement. That makes this a second preimage, not a birthday collision. Measured on one core: 1,019,215,873 candidate hashes in 150s (~6.8M/sec) against a 2.15e9 space — so ~5 minutes of offline work to find a malicious command whose fingerprint matches. Ship it in a later commit and the gate says "already trusted" and spawns it silently. That is RCE with the consent prompt bypassed. Now SHA-256 over canonical material. Two further hardenings: * env pairs are STRUCTURAL — [[k, v]] sorted — not joined into "k=v". Joining is ambiguous: {'a': 'b=c'} and {'a=b': 'c'} both flatten to "a=b=c", handing over a collision for free in the one function where collisions are the threat. * a non-array args no longer reaches .map and throws, per the same review. No migration concern: S4b has not shipped, so no trust store exists in the wild. Had it shipped, changing the hash would invalidate stored fingerprints and re-prompt once — the safe direction. A known-answer test pins the digest so a future "simplification" back to a short hash fails loudly rather than quietly. 56 mcpConfig cases; 23 suites, 0 failures.
1 parent d939ab0 commit 3b9bd31

3 files changed

Lines changed: 79 additions & 4 deletions

File tree

docs/MCP.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ Two details the one-line rule above does not carry, both load-bearing:
121121
`NODE_OPTIONS=--require /tmp/evil.js` is RCE without touching command or args at all. It is shown on
122122
the card for the same reason.
123123

124+
- **The fingerprint is SHA-256**, not the `shortHash` used for tool-name truncation. That helper is a
125+
32-bit djb2 emitted as 6 base36 chars (~2^31), and here the attacker knows the trusted value — they
126+
authored the command that earned trust — and controls the replacement, so a second preimage *is* the
127+
attack. Measured at ~6.8M candidate hashes/sec on one core, that is roughly five minutes of offline
128+
work to forge a malicious command that inherits trust. Env pairs are encoded structurally
129+
(`[[k, v]]`, sorted) rather than joined into `k=v`, which would make `{'a': 'b=c'}` and `{'a=b': 'c'}`
130+
collide for free.
131+
124132
The gate **fails closed**: with no webview there is nobody to ask, so the server does not start. A
125133
headless or test context must never be the path that silently spawns a repo's process.
126134

extensions/levelcode-ai/mcpConfig.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020
* by approveMcpLaunch in agent.js. For the same reason the
2121
* user's setting WINS on a name collision: a repo can never shadow a server the user defined.
2222
*
23-
* Pure + dependency-free (path only) — file reading is injected as a readFile callback, so all of it
23+
* Pure + dependency-free (node builtins `path` and `crypto` only) — file reading is injected as a
24+
* readFile callback, so all of it
2425
* is unit-testable (test/mcpConfig.test.js) without a filesystem, a child process, or the editor.
2526
* Nothing here connects, spawns, or calls anything.
2627
*--------------------------------------------------------------------------------------------*/
2728
'use strict';
2829

2930
const path = require('path');
31+
const crypto = require('crypto');
3032

3133
// The agent's built-in tools (agent.js TOOLS). An MCP tool may never shadow one of these.
3234
const BUILTIN_TOOL_NAMES = [
@@ -488,12 +490,29 @@ function previewArgs(args) {
488490
* `env` is included, and that is not padding: `NODE_OPTIONS=--require /tmp/evil.js` turns an innocent
489491
* `node` command into arbitrary code execution without touching command or args. Keys are sorted so an
490492
* unrelated reordering of the JSON does not spuriously revoke trust.
493+
*
494+
* SHA-256, NOT the shortHash used for tool-name truncation. shortHash is a 32-bit djb2 variant emitted
495+
* as 6 base36 chars — a ~2^31 space, and it is not collision-resistant by design or intent. Here the
496+
* attacker both KNOWS the trusted value (they authored the command that earned trust) and controls the
497+
* replacement, so they need a second preimage — measured at ~6.8M candidate hashes/sec on one core,
498+
* i.e. roughly five minutes of offline work to forge a malicious command that inherits trust. A
499+
* truncation helper is the wrong tool for an authorization decision; the cost of a real hash here is
500+
* one call per server per run.
491501
*/
492502
function launchFingerprint(server) {
493503
const s = server || {};
494-
const env = s.env || {};
495-
const envPairs = Object.keys(env).sort().map((k) => k + '=' + String(env[k]));
496-
return shortHash(JSON.stringify([String(s.command || ''), (s.args || []).map(String), envPairs]));
504+
// Non-array args / non-object env become null rather than [] or {}: a malformed entry must not
505+
// fingerprint the same as an absent one, and `.map` on a string would throw in a function whose
506+
// whole job is to be safe to call on anything.
507+
const args = Array.isArray(s.args) ? s.args.map(String) : null;
508+
const rawEnv = (s.env && typeof s.env === 'object' && !Array.isArray(s.env)) ? s.env : null;
509+
// Pairs stay STRUCTURAL — [[k, v]] — instead of being joined into "k=v". Joining is ambiguous:
510+
// { 'a': 'b=c' } and { 'a=b': 'c' } both flatten to "a=b=c", which is a collision handed over for
511+
// free in the one function where collisions are the threat.
512+
const env = rawEnv ? Object.keys(rawEnv).sort().map((k) => [k, String(rawEnv[k])]) : null;
513+
514+
const material = JSON.stringify({ command: String(s.command || ''), args: args, env: env });
515+
return crypto.createHash('sha256').update(material, 'utf8').digest('hex');
497516
}
498517

499518
/**

extensions/levelcode-ai/test/mcpConfig.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,54 @@ test('G1: trust is keyed on what would RUN, so a repo cannot swap the command af
611611
'changed env must re-prompt');
612612
});
613613

614+
test('G1: the fingerprint is a real hash, not the tool-name truncation helper', () => {
615+
// This value decides whether a repo-authored process launches WITHOUT asking, and the attacker both
616+
// knows the trusted value (they authored the command that earned trust) and controls the
617+
// replacement — so a second preimage IS the attack. shortHash is a 32-bit djb2 emitted as 6 base36
618+
// chars: a ~2^31 space, searchable at ~6.8M/sec on one core, i.e. minutes of offline work.
619+
const fp = M.launchFingerprint(srv());
620+
assert.match(fp, /^[0-9a-f]{64}$/, 'must be a SHA-256 hex digest');
621+
assert.ok(fp.length > 32, 'a 6-char truncation helper must never be what gates a process launch');
622+
623+
// Known-answer, so a future "simplification" back to a short hash fails loudly rather than quietly.
624+
const expected = require('crypto').createHash('sha256')
625+
.update(JSON.stringify({ command: 'npx', args: srv().args, env: [] }), 'utf8').digest('hex');
626+
assert.strictEqual(fp, expected, 'material is {command, args, env} with env as sorted [k,v] pairs');
627+
});
628+
629+
test('G1: env pairs cannot be confused by an "=" inside a key or value', () => {
630+
// Joining pairs into "k=v" would make these two identical strings — a collision handed over free in
631+
// the one function where collisions are the whole threat.
632+
const a = M.launchFingerprint(srv({ env: { a: 'b=c' } }));
633+
const b = M.launchFingerprint(srv({ env: { 'a=b': 'c' } }));
634+
assert.notStrictEqual(a, b, 'the encoding must be structural, not string-joined');
635+
});
636+
637+
test('G1: a malformed entry fingerprints without throwing', () => {
638+
// launchFingerprint is exported and documented safe to call on anything. Before this, a non-array
639+
// `args` reached `.map` and threw.
640+
assert.doesNotThrow(() => M.launchFingerprint({ command: 'x', args: 'not-an-array' }));
641+
assert.doesNotThrow(() => M.launchFingerprint({ command: 'x', env: 'not-an-object' }));
642+
assert.doesNotThrow(() => M.launchFingerprint({ command: 'x', args: 42, env: [] }));
643+
assert.doesNotThrow(() => M.launchFingerprint(null));
644+
assert.doesNotThrow(() => M.launchFingerprint({}));
645+
646+
// A malformed args collapses to the same fingerprint as an absent one, and that is fine rather than
647+
// a gap: normalizeServer REJECTS a non-array args before a server can reach the gate, so neither
648+
// shape is reachable here, and "no usable args" is the conservative reading of both. What must hold
649+
// is that WELL-FORMED inputs stay distinguishable — asserted throughout the rest of these G1 tests.
650+
assert.strictEqual(
651+
M.launchFingerprint({ command: 'x', args: 'evil' }),
652+
M.launchFingerprint({ command: 'x' }),
653+
'documented: unreachable malformed shapes collapse; the command itself still differentiates'
654+
);
655+
assert.notStrictEqual(
656+
M.launchFingerprint({ command: 'x', args: 'evil' }),
657+
M.launchFingerprint({ command: 'y' }),
658+
'the command is always part of the material'
659+
);
660+
});
661+
614662
test('G1: nothing is trusted by default, and unrelated servers stay untrusted', () => {
615663
assert.ok(!M.isLaunchTrusted(srv(), {}), 'an empty store trusts nothing');
616664
assert.ok(!M.isLaunchTrusted(srv(), null), 'a missing store trusts nothing');

0 commit comments

Comments
 (0)