Skip to content

Commit 9f1444a

Browse files
VickyXAI1bcMax
andauthored
fix(security): close brace-expansion wallet read + 6to4 SSRF (3.29.14) (#96)
Round-9 convergence verify (re-attacking the round-8 normalization/rtk fixes) found 2 NEW classes — count converging 7->6->5->2. - CRITICAL brace-expansion wallet read: cat {~,}/.bloc{k,}run/.{session,} read the EVM key. Brace expansion is a 4th shell primitive the round-8 dequote didn't model; {~,} tilde-expands while .bloc{k,}run/.{session,} brace-split the literal path, defeating directory+basename+glob guards at once. Now any comma/.. brace group is opaque (prompts, like $VAR), and deny patterns also run on a brace-collapsed copy. Reader-agnostic. - MEDIUM 6to4 IPv6 SSRF: [2002:a9fe:a9fe::] embeds 169.254.169.254; decoded and rechecked like NAT64/::ffff: handlers. +2 regression test groups. Suite 506/506. Co-authored-by: 1bcMax <195689928+1bcMax@users.noreply.github.com>
1 parent 8d1bc18 commit 9f1444a

6 files changed

Lines changed: 68 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## Franklin Agent 3.29.14 — close brace-expansion wallet read + 6to4 SSRF (round-9)
4+
5+
A third convergence verify (re-attacking the round-8 quote-normalization and rtk-recursion fixes) confirmed **2 NEW classes** — the find count is converging (7→6→5→2) and the survivors are increasingly exotic, but one is still a critical wallet read.
6+
7+
- **Brace-expansion wallet-key read (CRITICAL).** `cat {~,}/.bloc{k,}run/.{session,}` read the EVM private key: brace expansion is a 4th shell primitive the round-8 quote/escape normalizer didn't model. The leading `{~,}` tilde-expands while `.bloc{k,}run` / `.{session,}` brace-split the literal `.blockrun`/`.session` — defeating the directory rule, the basename rule, AND the rooted-glob anchor (the token starts with `{`, not `~`/`.`/`/`) simultaneously. Now any comma-list / `..`-range brace group is treated as opaque (forces a prompt, like `$VAR`/`$(...)`), and the wallet/secret deny patterns also run against a brace-collapsed copy of the command. Reader-agnostic (`grep`/`cut`/`cat`) — the brace mechanism was the defect.
8+
- **6to4 IPv6 SSRF (MEDIUM).** `http://[2002:a9fe:a9fe::]/` survived WHATWG-URL normalization and reached the fetch path; the 6to4 range `2002::/16` embeds an IPv4 (`2002:a9fe:a9fe::``169.254.169.254`, `2002:7f00:1::``127.0.0.1`). Now decoded and re-checked like the existing NAT64 / IPv4-mapped handlers. (Exploiting the embedded target needs a 6to4 relay route, but the guard gap was unconditional.)
9+
10+
New regression tests pin both classes plus benign controls (public IPv6, brace commands). Verified: local suite 506/506.
11+
312
## Franklin Agent 3.29.13 — close 5 more single-command bypasses from the 3.29.12 convergence verify (round-8)
413

514
A second 13-agent convergence verify confirmed **5 NEW single-command bypass classes** the round-7 fixes didn't anticipate — one critical (a fresh evasion of the wallet directory rule itself). All fixed; benign controls preserved.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@blockrun/franklin",
3-
"version": "3.29.13",
3+
"version": "3.29.14",
44
"description": "Franklin Agent — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
55
"type": "module",
66
"exports": {

src/agent/bash-guard.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,17 +179,23 @@ function isSegmentSafe(segment: string): boolean {
179179
// quoting arrangement of a wallet/secret path can reach 'safe'. (Over-matching
180180
// here only ever prompts — it never blocks.)
181181
const norm = segment.replace(/\\(.)/g, '$1').replace(/['"]/g, '');
182+
// Brace expansion (`.bloc{k,}run`, `{~,}/...`, `.bloc{j..l}run`) is a 4th
183+
// obfuscation primitive — the shell rewrites it to other words before opening
184+
// the path, so `.blockrun`/basename literals read as non-contiguous text. Drop
185+
// the brace metachars so the deny regexes below see the collapsed first word.
186+
const debraced = norm.replace(/[{},]/g, '');
182187

183188
// Never auto-approve a command that touches the wallet key store. Matching the
184189
// FILENAME is hopeless — it's trivially obfuscated. So match the DIRECTORY: any
185190
// reference to ~/.blockrun forces a prompt. (The file Read/Write/Edit tools
186191
// have a separate canonicalized guard; this is the best-effort net for the shell.)
187-
if (/\.blockrun/i.test(norm)) {
192+
if (/\.blockrun/i.test(norm) || /\.blockrun/i.test(debraced)) {
188193
return false;
189194
}
190195
// Relative reads with no `.blockrun` in the text (e.g. the cwd is the wallet
191196
// dir): match the known key/secret basenames broadly (any *wallet*.json/.key).
192-
if (/(?<![\w-])(?:\.solana-session(?:-key2)?|\.session|[\w-]*wallet[\w-]*\.(?:json|key))(?![\w-])/i.test(norm)) {
197+
const keyBasename = /(?<![\w-])(?:\.solana-session(?:-key2)?|\.session|[\w-]*wallet[\w-]*\.(?:json|key))(?![\w-])/i;
198+
if (keyBasename.test(norm) || keyBasename.test(debraced)) {
193199
return false;
194200
}
195201
// Command/process substitution runs an arbitrary INNER command the classifier
@@ -214,6 +220,14 @@ function isSegmentSafe(segment: string): boolean {
214220
if (/\$\{?[A-Za-z_]/.test(segment)) {
215221
return false;
216222
}
223+
// Brace expansion with a comma list (`{a,b}`) or `..` range (`{0..9}`) fabricates
224+
// words/paths the classifier can't statically follow — `cat {~,}/.bloc{k,}run/...`
225+
// reaches the wallet store while every char-literal guard misses. Treat any such
226+
// group as opaque, like `$VAR`/`$(...)`. (A brace with NO comma/`..` — `{x}`, or
227+
// an fd-dup `2>&1` — does not expand, so it stays safe.)
228+
if (/\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(segment)) {
229+
return false;
230+
}
217231
// A glob/brace in an explicit PATH (a token rooted at ~, ., or /) expands AFTER
218232
// this guard and can reach the wallet store (`cat ~/.b*/.s*`) or a sensitive
219233
// file. Bare cwd globs (`*.md`, `src/*.ts`) have no such prefix and stay safe.

src/tools/ssrf.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ export function isBlockedSsrfHost(hostname: string): boolean {
3131
if (h.includes(':')) {
3232
if (h === '::1' || h === '::') return true; // loopback / unspecified
3333
if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true; // link-local / ULA
34+
// 6to4 (`2002:WWXX:YYZZ::/16`) embeds an IPv4 (W.X.Y.Z) in the first two
35+
// hextets; a 6to4 relay routes to it (e.g. 2002:7f00:1:: → 127.0.0.1,
36+
// 2002:a9fe:a9fe:: → 169.254.169.254). Decode and recheck the embedded v4.
37+
const sixToFour = h.match(/^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/);
38+
if (sixToFour) {
39+
const hi = parseInt(sixToFour[1], 16), lo = parseInt(sixToFour[2], 16);
40+
return isBlockedSsrfHost(`${(hi >>> 8) & 255}.${hi & 255}.${(lo >>> 8) & 255}.${lo & 255}`);
41+
}
3442
// NAT64 (`64:ff9b::a.b.c.d` / `64:ff9b::hhhh:hhhh`) embeds an IPv4 that a NAT64
3543
// gateway routes to — decode and recheck it like the IPv4-mapped form below.
3644
const nat64 = h.match(/^64:ff9b::(.+)$/);

test/local.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10545,3 +10545,35 @@ test('bash-guard: git branch/tag ref destruction prompts; read + create forms st
1054510545
assert.equal(safe('git tag -l'), true);
1054610546
assert.equal(safe('git tag v1.2.3'), true, 'creating a tag is safe');
1054710547
});
10548+
10549+
// ─── Round-9: the 2 NEW classes from the 3.29.13 convergence verify ──────────
10550+
test('bash-guard: brace expansion cannot smuggle a wallet-key read (4th obfuscation primitive)', async () => {
10551+
const { classifyBashRisk } = await import('../dist/agent/bash-guard.js');
10552+
const safe = (c) => classifyBashRisk(c).level === 'safe';
10553+
// `{~,}` tilde-expands while brace-splitting `.bloc{k,}run` / `.{session,}` so
10554+
// no char-literal `.blockrun`/`.session` survives — defeated all 3 guards at once.
10555+
for (const c of [
10556+
'cat {~,}/.bloc{k,}run/.{session,}', // EVM key (critical)
10557+
'cat {~,}/.bloc{k,}run/.{solana-session,}', // Solana key
10558+
'cat {~,}/.bloc{k,}run/.{solana-session-key2,}',
10559+
'grep -a . {~,}/.bloc{k,}run/.{session,}', // reader-agnostic
10560+
'cut -c1-99 {~,}/.bloc{k,}run/.{session,}',
10561+
'cat ~/.bloc{j..l}run/.session', // `..` range variant
10562+
]) assert.equal(safe(c), false, `${c} must prompt`);
10563+
// A brace with no comma/range does not expand, and benign reads stay safe.
10564+
assert.equal(safe('cat README.md'), true);
10565+
assert.equal(safe('cat package.json'), true);
10566+
});
10567+
10568+
test('isBlockedSsrfHost blocks 6to4 IPv6 (2002::/16) embedding loopback/metadata', async () => {
10569+
const { isBlockedSsrfHost } = await import('../dist/tools/ssrf.js');
10570+
for (const h of [
10571+
'2002:7f00:1::', // → 127.0.0.1
10572+
'2002:a9fe:a9fe::', // → 169.254.169.254 (metadata)
10573+
'[2002:a9fe:a9fe::]',
10574+
'2002:c0a8:101::', // → 192.168.1.1
10575+
]) assert.equal(isBlockedSsrfHost(h), true, `${h} must be blocked`);
10576+
// A genuinely public IPv6 (Cloudflare) and a 2002.* hostname stay allowed.
10577+
assert.equal(isBlockedSsrfHost('2606:4700::1111'), false);
10578+
assert.equal(isBlockedSsrfHost('2002:808:808::'), false, '2002:0808:0808 → 8.8.8.8 (public) allowed');
10579+
});

0 commit comments

Comments
 (0)