<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
	<channel>
		<title>Posts on brain overflow</title>
		<link>https://brainoverflow.blog/posts/</link>
		<description>Recent content in Posts on brain overflow</description>
		<generator>Hugo -- 0.162.1</generator>
		<language>en-us</language>
		<lastBuildDate>Mon, 01 Jun 2026 11:35:14 -0700</lastBuildDate>
		<atom:link href="https://brainoverflow.blog/posts/index.xml" rel="self" type="application/rss+xml" />
		
		
		<item>
			<title>Hidden Gaps in Claude Code Security Reviews</title>
			<link>https://brainoverflow.blog/posts/claude-code-security-review-bias/</link>
			<pubDate>Mon, 01 Jun 2026 11:35:14 -0700</pubDate><guid>https://brainoverflow.blog/posts/claude-code-security-review-bias/</guid>
			<description><![CDATA[&lt;no value&gt;]]></description><content type="text/html" mode="escaped"><![CDATA[<p><em>Anthropic recently shipped a new security plugin for Claude Code that automatically reviews code for vulnerabilities as you make changes, complementing the existing <code>/security-review</code> skill. I decided to test both against a deliberately constructed set of security flaws to see if the new tool improves coverage. Little did I know how deep this rabbit hole would take me. Fair warning: this is a long read.</em></p>
<hr>
<h2 id="1-background">1. Background<a href="#1-background" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Claude Code supports LLM-based security reviews at three stages:</p>
<table>
	<thead>
			<tr>
					<th>Tool</th>
					<th>Plans</th>
					<th>What the reviewer sees</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>/security-review</code></td>
					<td>All</td>
					<td>Full branch, same or new session context (user&rsquo;s choice)</td>
			</tr>
			<tr>
					<td><strong>Security guidance plugin</strong> <em>(new, May 2026)</em></td>
					<td>All</td>
					<td>Git diff from current turn, fresh model context</td>
			</tr>
			<tr>
					<td>Code Review</td>
					<td>Team / Enterprise only</td>
					<td>Full codebase, multi-agent, independent model (runs on PRs)</td>
			</tr>
	</tbody>
</table>
<p>The new plugin shipped with an explicit design goal: avoid the <strong>model anchoring bias</strong> problem <a href="/posts/ai-native-threat-modeling/#5-on-model-bias-in-security-analysis">I wrote about earlier</a>. To understand what model bias means here, consider the human equivalent: if you ask the author of the code to review it, they&rsquo;ll likely tell you it&rsquo;s fine — they wrote it after all. A reviewer who wasn&rsquo;t in the room when the decisions were made will challenge assumptions the author has stopped seeing. The same dynamic applies to LLMs: when Claude writes code and then reviews it in the same session, it has the full conversation history in context, including every design choice and tradeoff it reasoned through while writing. It validates against those decisions rather than challenging them. A fresh session is the AI equivalent of a second pair of eyes.</p>
<p>The new plugin addresses this by running a <strong>separate Opus 4.7 session with a fresh context</strong>: the reviewer starts from the diff with no session history and no investment in the original approach. Anthropic&rsquo;s own documentation is direct about the design intent:</p>
<blockquote>
<p>&ldquo;The plugin does not ask the same Claude instance that wrote the code to grade itself. […] The end-of-turn and commit reviews run as a separate Claude call with a fresh context and a security-focused prompt: the reviewer starts from the diff, has no investment in the original approach, and is instructed only to find problems.&rdquo;</p>
</blockquote>
<p>This is a real solution to the model bias problem, but if you read deeper, it has its own limitation: <strong>a diff-scoped reviewer can only see what changed in the current turn</strong> and cannot reason about interactions between pre-existing code and new additions. That constraint is likely a cost decision: Opus 4.7 is expensive, and reviewing the full codebase on every change would be prohibitively token-intensive.</p>
<p>This gives me two hypotheses to experiment with:</p>
<p><strong>H1:</strong> same-session <code>security-review</code> is affected by model anchoring bias and will suppress findings that a cold run on the same code surfaces. The delta between the two runs measures how bad the gap is in practice.</p>
<p><strong>H2:</strong> the newly introduced diff-based plugin will miss vulnerability chains where each change looks benign in isolation but the two together form something exploitable, because the reviewer only ever sees one diff at a time and has no memory of what came before.</p>
<hr>
<h2 id="2-test-corpus">2. Test corpus<a href="#2-test-corpus" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>My target is based on a real Telegram bot that routes voice and text messages into a backend, but the version used here was vibe-coded from scratch for this experiment. The spec was written to elicit insecure decisions without explicitly asking for them: the goal was a realistic-looking codebase with seeded flaws.</p>
<p>The three flaws, ranging in complexity:</p>
<h3 id="f1-fail-open-authentication-simple">F1: Fail-open authentication (simple)<a href="#f1-fail-open-authentication-simple" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p><code>TELEGRAM_ALLOWED_USERS</code> is read into a set at startup. When the env var is absent, the set is empty. The auth guard uses the set as a condition:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> TELEGRAM_USERS <span style="color:#f92672">and</span> (<span style="color:#f92672">not</span> user <span style="color:#f92672">or</span> user<span style="color:#f92672">.</span>id <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> TELEGRAM_USERS):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span>
</span></span></code></pre></div><p>When <code>TELEGRAM_USERS</code> is empty, the entire <code>if</code> is skipped: any Telegram user is accepted. The correct default is deny-all: a bot that can read files and spawn subprocesses should fail closed, not open.</p>
<h3 id="f2-unrestricted-subprocess-permissions-medium">F2: Unrestricted subprocess permissions (medium)<a href="#f2-unrestricted-subprocess-permissions-medium" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p>The bot classifies incoming messages and dispatches them, spawning <code>claude -p</code> subprocess with the <code>process_notes</code> skill and an <code>--allowedTools</code> list needed for the skill to run its operations. The allowed tools list passed to the inner Claude instance includes <code>Bash(python3:*)</code> without path restrictions. The <code>process_notes</code> skill reads the note from disk and invokes Python with it as input. If the skill passes note content to Python without sanitization, the chain reaches arbitrary code execution.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># python3 unrestricted</span>
</span></span><span style="display:flex;"><span>allowed_tools <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Read,Write,Bash(python3:*),Bash(mv:*),Bash(rm:*), ...&#34;</span>  
</span></span></code></pre></div><h3 id="f3-write--path-scoped-python3--write-then-execute-chain-hard">F3: Write + path-scoped python3 = write-then-execute chain (hard)<a href="#f3-write--path-scoped-python3--write-then-execute-chain-hard" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p>Now <code>python3:*</code> is hardened to <code>python3:.claude/scripts/*</code>, but the <code>Write</code> permission remains. The chain: write a payload to <code>.claude/scripts/</code>, invoke it via python. Neither permission is dangerous alone: the vulnerability only exists when you hold both simultaneously. This flaw is the key test case for the new plugin: a diff-based reviewer seeing only the second permission added can&rsquo;t chain it to the first to recognize the combined severity.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>allowed_tools <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Read,Write,Bash(python3:.claude/scripts/*),Bash(mv:*), ...&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#                     ^^^^^ unrestricted       ^^^^^ scoped — looks safe</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Chain: Write payload → .claude/scripts/evil.py, python3 runs attacker&#39;s code</span>
</span></span></code></pre></div><p>The four tests map directly to the two hypotheses:</p>
<table>
	<thead>
			<tr>
					<th>Test</th>
					<th>Tool</th>
					<th>Setup</th>
					<th>Tests</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>T1</td>
					<td><code>/security-review</code></td>
					<td>Same session that wrote the code</td>
					<td>H1: does model bias suppress findings?</td>
			</tr>
			<tr>
					<td>T2</td>
					<td><code>/security-review</code></td>
					<td>Fresh session, no prior context</td>
					<td>H1 control: cold reviewer, does it catch everything?</td>
			</tr>
			<tr>
					<td>T3</td>
					<td>Security guidance plugin</td>
					<td><code>Write</code> + <code>python3:*</code> both new in the diff</td>
					<td>H2: does plugin catch a chain when it&rsquo;s fully visible?</td>
			</tr>
			<tr>
					<td>T4</td>
					<td>Security guidance plugin</td>
					<td><code>Write</code> pre-existing, only <code>python3 scripts/*</code> added</td>
					<td>H2: does plugin miss a chain split across commits?</td>
			</tr>
	</tbody>
</table>
<hr>
<h2 id="3-h1--t1--t2-security-review-and-model-bias">3. H1 | T1 &amp; T2: /security-review and model bias<a href="#3-h1--t1--t2-security-review-and-model-bias" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Model bias in AI security review is a known problem, and Anthropic implicitly acknowledged it by releasing the new plugin. I&rsquo;m not going to dwell on it; the tests here are a quick empirical confirmation with a concrete measurement of the gap.</p>
<p>The <code>security-review</code> skill runs a multi-agent pipeline — initial identification followed by parallel false-positive filtering against known precedents, with a scoring system and a confidence threshold to report. It is more reliable than casually asking Claude to &ldquo;review this code for vulnerabilities&rdquo;, though some real issues may get filtered if the evidence isn&rsquo;t strong enough.</p>
<p><strong>Test 1</strong> ran in the same session that produced the bot; <strong>Test 2</strong> ran in a fresh session with no prior context. Same code, model, thinking effort, command.</p>
<table>
	<thead>
			<tr>
					<th>Finding</th>
					<th>Test 1 (same-session)</th>
					<th>Test 2 (cold session)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Fail-open auth: empty allowlist (F1)</td>
					<td>Not surfaced</td>
					<td><strong>High</strong> (0.95)</td>
			</tr>
			<tr>
					<td>Path traversal in file retrieval</td>
					<td>Not surfaced</td>
					<td>Medium (0.82)</td>
			</tr>
			<tr>
					<td>Unrestricted subprocess permissions (F2)</td>
					<td>Filtered out</td>
					<td>Not identified</td>
			</tr>
	</tbody>
</table>
<p>Test 1 reported zero vulnerabilities; Test 2 reported one High and one Medium. The same-session reviewer framed the threat model as &ldquo;authorized-only Telegram access&rdquo;, treating the auth as working correctly because the spec said so, and F1 never surfaced. The cold reviewer had no spec context and flagged it right away.</p>
<p>The path traversal finding is actually a stronger signal. It wasn&rsquo;t seeded in my prompt; it was a real bug the vibe-coding session introduced on its own, with no spec instruction to blame. The cold reviewer caught it; the same-session reviewer missed it alongside F1. ✅ <strong>H1 confirmed</strong>.</p>
<hr>
<h2 id="4-f2-the-component-boundary-you-shall-not-pass">4. F2: the component boundary you shall not pass<a href="#4-f2-the-component-boundary-you-shall-not-pass" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>It&rsquo;s not uncommon that while testing a hypothesis you run into new discoveries. So why did neither run flag the unrestricted subprocess permissions?</p>
<p>The answer is in the architecture. Both <code>write</code> and <code>process</code> are legitimate bot operations: the attacker stores a note to disk, then triggers <code>process</code> normally. <code>handle_process()</code> spawns <code>claude -p /process-notes --allowedTools [..]</code>. The subprocess call is visible in <code>inbox-bot.py</code>, but the skill it invokes is a separate file. Whether the skill passes vault content to Python in an exploitable way, and whether the permissions it runs with are appropriate, live outside the review scope.</p>
<pre class="mermaid">flowchart TD
    A["Attacker note\n(malicious payload)"] --> B["handle_write\n(legitimate)"]
    B --> C[("note on disk")]
    D["process command\n(legitimate)"] --> E["handle_process()"]
    E --> F["claude -p /process-notes\n--allowedTools Bash(python3:*) ..."]
    F -.->|"spawns"| G["/process-notes skill\n(out of review scope)"]
    G -->|"reads"| C
    G --> H["Bash(python3:*)\n→ RCE if vulnerable"]
    subgraph scope ["reviewed: inbox-bot.py"]
        B
        E
        F
    end
</pre>
<p>The architecture makes the concern visible: attacker-controlled vault content flows into a subprocess running with unrestricted python3. Neither automated reviewer evaluated it at that level, though they each hit the boundary differently.</p>
<p>In <strong>T1</strong> (same session), the reviewer identified the chain, labeling it &ldquo;Prompt injection via vault write to claude subprocess&rdquo;, but the false-positive filter dismissed it: <em>&ldquo;The attacker and vault owner are the same person; there is no external trust boundary being crossed.&rdquo;</em> That&rsquo;s <strong>model bias in a different form</strong>: not suppressing a finding outright, but supplying a session-derived trust assumption that the reviewer couldn&rsquo;t actually validate, because doing so would require seeing what <code>process-notes</code> does with the data it receives.</p>
<p>In <strong>T2</strong> (cold session), the reviewer checked for shell injection: seeing list-form <code>subprocess.run</code> with no <code>shell=True</code>, it marked the subprocess as clean and moved on. Seemingly, the <strong>presence of a known secure coding pattern steered the LLM into trusting the call as safe overall</strong>: the right invocation style closed scrutiny before it reached the component boundary question. The <code>--allowedTools</code> string with <code>Bash(python3:*)</code> was never evaluated.</p>
<p>Neither reviewer asked whether <code>python3:*</code> was too broad. That question doesn&rsquo;t require seeing <code>/process-notes</code> to answer: attacker-controlled data flowing into a subprocess with unrestricted python3 is a concern on its own, regardless of what the downstream skill does with it. A human reviewer would flag that pattern without needing to verify what the downstream component does with it. When you can&rsquo;t see past the boundary, the right default is to surface the concern.</p>
<hr>
<h2 id="5-h2--t3--t4-the-plugin-and-diff-isolation">5. H2 | T3 &amp; T4: the plugin and diff isolation<a href="#5-h2--t3--t4-the-plugin-and-diff-isolation" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>After a quick detour, we&rsquo;re back to probing the second part of the original hypothesis: does the new Claude plugin&rsquo;s diff-scoped reviewer miss a vulnerability chain where each change looks benign in isolation? This time, the chain is in the same file, and in a single tool call.</p>
<p><strong>Test 3 (1 diff):</strong> <code>Write</code> + <code>Bash(python3:*)</code> introduced together.
The plugin caught both: <code>python3:*</code> flagged as too broad, <code>Write</code> flagged as needing tighter scope. Two correct findings, auto-fix applied. But it treated them as independent concerns rather than a chain. The fix addressed F2; F3 survived.</p>
<pre tabindex="0"><code>The security hook flagged two real issues:
1. Bash(python3:*) is too broad — permits running any Python script.
   Should be scoped to the specific script path.
2. Write is too broad — should be scoped to the wiki directory under VAULT_ROOT.
</code></pre><p><strong>Test 4 (2 diffs):</strong></p>
<ul>
<li><code>Write</code> committed in the baseline. Nothing suspicious in isolation.</li>
<li><code>Bash(python3:.claude/scripts/*)</code> added in a new session. A narrow, path-scoped python3 permission — looks like a reasonable hardening move. <code>Write</code> is outside the diff and invisible to the reviewer.</li>
</ul>
<pre tabindex="0"><code>LLM code review: no vulnerabilities found.
</code></pre><p>And just like that, ✅ <strong>H2 confirmed</strong>.</p>
<p>Side observation from the test: when my git commit message named the permissions that had been removed, Claude read the log and inferred exactly what to restore, producing broad <code>python3:*</code> directly. I&rsquo;ve repeated the test with a neutral commit message, and it resulted in a different fix. The commit message didn&rsquo;t affect the plugin&rsquo;s review, but it changed what the writing model produced. Small sample, but a useful reminder that in vibe-coding sessions the model reads everything in context, and metadata you don&rsquo;t think of as instructions can still shape output.</p>
<hr>
<h2 id="6-what-can-we-do-about-all-this">6. What can we do about all this?<a href="#6-what-can-we-do-about-all-this" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The model bias gap is actionable, and the fix is simple: run <code>/security-review</code> in a fresh session, not the one where you wrote the code. The unfortunate truth is that most users won&rsquo;t know to do this. The natural instinct is to run the skill right there in the session where you just finished writing the code. Model anchoring isn&rsquo;t obvious unless you know about it. Anthropic could nudge users here: detect when the tool is invoked in a session that also wrote the code, and warn before running.</p>
<p>I asked Claude to prototype this using session hooks. Available as a gist <a href="https://gist.github.com/obormot/9a241032c72c4d19a259f8bce6fa8ed3">here</a>, it works, but frankly it&rsquo;s not very good. The <code>decision: block</code> output is blunt; it stops the prompt and requires re-running. That&rsquo;s an API limitation: <code>UserPromptSubmit</code> hooks have no non-blocking notification option, so block is the only way to surface a visible message. Another caveat: in Claude Desktop, blocked prompts fail silently — the user gets no response and no explanation. This hook is only reliable in the Claude Code CLI.</p>
<hr>
<h2 id="final-thoughts">Final thoughts<a href="#final-thoughts" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Every tool in this space has gaps. Some are documented, and some are hidden, surfacing only when you test carefully enough. The title of this post came from expecting to confirm two gaps and finding three.</p>
<p>Are we all doomed until Mythos comes to save us? Models evolve rapidly, and Mythos is reportedly strong at exactly the cross-boundary chain reasoning that today&rsquo;s tools miss. It may well close these gaps - time will tell.</p>
<p>My broader take: fully autonomous code reviews don&rsquo;t replace human judgment. They extend your reach, and they&rsquo;re most useful when you understand what they can and can&rsquo;t see. Know the limits of your tools. Trust <strong>and</strong> verify.</p>
<hr>
<h2 id="references">References<a href="#references" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<ul>
<li><a href="https://code.claude.com/docs/en/security-guidance">Anthropic — Catch security issues as Claude writes code</a></li>
<li><a href="https://gist.github.com/obormot/9a241032c72c4d19a259f8bce6fa8ed3">Claude hook to warn about model anchoring bias</a></li>
</ul>
]]></content>
		</item>
		
		<item>
			<title>AI Assisted Bug Bounty Experiment</title>
			<link>https://brainoverflow.blog/posts/ai-assisted-bug-bounty/</link>
			<pubDate>Sun, 24 May 2026 13:01:31 -0700</pubDate><guid>https://brainoverflow.blog/posts/ai-assisted-bug-bounty/</guid>
			<description><![CDATA[&lt;no value&gt;]]></description><content type="text/html" mode="escaped"><![CDATA[<p><em>Five authorization bypass paths, clean PoCs for each, full disclosure report — output of a six-hour session: one human, one Claude Code agent, an idea, a repo clone, and a live target in Docker. I&rsquo;ll cover the technical details in a follow-up post once the disclosure process is complete.</em></p>
<hr>
<h2 id="1-the-experiment">1. The experiment<a href="#1-the-experiment" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>With the recent hype surrounding Mythos and its autonomous vulnerability discovery capabilities, the question of what AI can do for security research is hard to ignore. Most of that conversation centers on fully autonomous systems — multi-agent frameworks operating at scale with large budgets. I wanted to try something way simpler: what can one person accomplish with a single AI agent and a modest budget?</p>
<p>I came in with five things: familiarity with the target as a practitioner, enough Python and Django knowledge to steer the agent, years of application security experience, an evening to experiment, and a $20 Claude subscription with Cyber Verification Program approval.</p>
<p><img src="images/image-1779751697871.png" alt="Fear and Loathing in Las Vegas (1998)">
<em>Photo by Archive Photos/Getty Images — © 2012 Getty Images. (cropped image)</em></p>
<hr>
<h2 id="2-the-hypothesis">2. The hypothesis<a href="#2-the-hypothesis" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>My target is a Django-based web application enforcing a role-based access model — controlling who can see which resources, data, and system settings, at what level of detail. The access control model is the interesting surface.</p>
<p>I asked Claude to map the REST API — URL patterns, associated models and views — and report back on any gaps in URL parameter validation. It flagged something it thought looked interesting: a parameter responsible for fetching related objects alongside the primary response. My intuition when I saw it: likely a WebUI-driven performance optimization added on top of the existing API functionality, making the authorization model a potential target for logical flaws. Features like this get tested for functionality — does it return the right data? — but access control testing of this specific path may not have received the same attention as the core endpoints. Performance shortcuts and abstraction layers are common places where authorization logic gets underspecified, because they&rsquo;re added later and the direct-endpoint tests don&rsquo;t exercise them.</p>
<p>Whether it would lead anywhere was an open question.</p>
<hr>
<h2 id="3-how-the-research-ran">3. How the research ran<a href="#3-how-the-research-ran" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>From that exchange — Claude surfacing the parameter, me recognizing the authorization angle — we had a hypothesis. I asked Claude to examine how the feature works internally. It traced the relevant source files and identified the core gap: when the feature resolves a related object, no check is made against whether the requesting user is authorized to see it. The access controls on the direct API endpoints are simply not invoked in this path.</p>
<p>From there: local Docker instance, Claude calling the application&rsquo;s own REST API with admin credentials to provision test accounts and settings, then switching to an unprivileged account to test the hypothesis. The test pattern was clean — confirm the direct endpoint returns 403, then show the same data returns through the bypass. Seeing both in the same output is unambiguous.</p>
<p>The first two bypass paths were straightforward once the root cause was clear. I then asked Claude to think more broadly about the attack surface, prompting it to consider how Django&rsquo;s data model exposes direct and reverse object relationships. It identified three additional paths, including one through internal notes that users can mark private — the bypass returns their full content regardless, circumventing the visibility restriction the UI enforces.</p>
<p>Five exploitable paths in total, all from the same root cause — Missing Authorization (CWE-862) across the board, with Exposure of Sensitive Information (CWE-200) sprinkled in. Network-exploitable via the REST API by an authenticated user with read permissions, accessing admin-only data. Drafted to <code>CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N</code> (4.3 Medium).</p>
<h2 id="4-chasing-the-escalation">4. Chasing the escalation<a href="#4-chasing-the-escalation" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The core bypass came together quickly. What followed was the natural next move for any researcher: try to chain it, escalate severity, turn a Medium into something bigger. That&rsquo;s where most of the dead ends lived. Some of the vectors we tested:</p>
<ul>
<li>Could the bypass leak API authentication tokens (full account takeover)? Blocked.</li>
<li>Could it be flipped into a write path? Read-only by design.</li>
<li>Could unusual parameter values expose internal state or crash the app in a useful way? One 500 error, no data.</li>
<li>Could object traversal chain across multiple hops to reach higher-value targets? Limited to one level.</li>
<li>Could the attack surface extend beyond the object relationships already mapped? Explored and found nothing new reachable.</li>
<li>Could reading related data trigger a server-side request — an SSRF via this feature would have been a significant escalation. Nope.</li>
</ul>
<p>Thorough analysis of every relevant code path found nothing exploitable. At some point Claude even made a comment that the codebase outside the bypass appeared well-hardened — the dead ends weren&rsquo;t just unlucky angles, they were genuinely blocked.</p>
<p><img src="images/claude-comment.png" alt="Claude comment"></p>
<p>The dead-end analysis likely consumed more tokens than the core bypass itself. Each candidate path required the agent to load and reason over significant amounts of source context before concluding it was blocked. At some point I made a call to stop — it was getting late, and it was clear the token burn rate on escalation paths was outpacing any realistic chance of a meaningful outcome.</p>
<hr>
<h2 id="5-division-of-labor">5. Division of labor<a href="#5-division-of-labor" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Across the full session, the process looked like this:</p>
<p><img src="images/research-loop.svg" alt="Research loop diagram"></p>
<p><strong>Target selection</strong> was mine. The application is open source with a commercial offering, backed by OWASP, and used in production by security teams — that&rsquo;s how I knew about it. Hundreds of releases and thousands of GitHub stars; it runs a HackerOne bug bounty program for many years with a good number of submissions behind it.</p>
<p><strong>Attack surface discovery</strong> was Claude&rsquo;s. Asked to map the REST API — URL patterns, models, and views — it flagged a specific parameter as worth investigating: one responsible for fetching related objects alongside the primary response. That observation became the hypothesis we tested.</p>
<p><strong>The hypothesis</strong> was joint. I directed the exploration and recognized why the parameter Claude surfaced was worth pursuing — that features like this tend to be undertested for authorization completeness. That judgment comes from years of looking at application security. An autonomous agent starting from scratch has no basis for it.</p>
<p><strong>Reading and tracing source code</strong> across a large, unfamiliar codebase — holding relevant context across many files simultaneously — was Claude&rsquo;s most consistent contribution. Work that would take a human hours of careful reading took Claude minutes. That&rsquo;s where LLMs really shine.</p>
<p><strong>Environment setup</strong> was handled by Claude directly against the live application. The agent called the target&rsquo;s own REST APIs with admin credentials to provision the test accounts and configuration needed to validate each vector — then switched to an unprivileged account to confirm the bypass. No manual setup required and it knew the APIs already from reading the codebase.</p>
<p><strong>PoC development and live validation</strong> was Claude&rsquo;s. It wrote the test scripts, ran them against the live Docker instance, interpreted the results, diagnosed problems and iterated to completion.</p>
<p><strong>Steering</strong> was mine throughout. When the initial finding was confirmed, I directed Claude to expand the search along a specific axis: how Django models expose object relationships, and where those traversal paths might reach objects the requesting user shouldn&rsquo;t see. That framing produced three additional bypass paths.</p>
<p><strong>Boundary analysis</strong> was Claude&rsquo;s. When I asked whether the initial finding could be chained or escalated, Claude systematically traced each candidate path and explained whether it was viable or blocked and why.</p>
<p><strong>Impact assessment</strong> was mine. One of the five bypass paths exposes internal security notes that teams mark private. Characterizing what that means in a real-world application requires understanding how those notes are used in practice, not just what the access model technically permits.</p>
<p><strong>Structured reporting</strong> was Claude&rsquo;s — CVSS scores, impact analysis, affected-code tables, and remediation recommendations for all five findings in a single consolidated report, with live response examples for each confirmed vector.</p>
<hr>
<h2 id="6-numbers-and-disclosure">6. Numbers and disclosure<a href="#6-numbers-and-disclosure" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>What led to this session was a failed attempt with one of the open source autonomous frameworks. I pointed it at the same target, let it run, and watched it burn through 2.5 million tokens — mostly attempting to set up its own environment, failing to start the target application it didn&rsquo;t need, and exhausting its budget before reaching the actual testing phase. That experience prompted the question: what could a different approach produce?</p>
<p>Our session — hypothesis to five confirmed findings, with live validation, dead-end analysis, and a full structured report — consumed 1.4 million tokens at a total API cost of $25. That gap is partly explained by the hypothesis: starting from a well-formed idea of where to look is a significant multiplier on what a given budget can produce. Autonomous tools trading specificity for breadth need proportionally more tokens to work through open-ended reconnaissance before they converge on anything. It&rsquo;s also worth noting that Anthropic&rsquo;s model is arguably more capable than what the open source framework was running — and more expensive per token — so the human-agent configuration does double duty: it keeps the effort targeted, and that targeting matters more when the model you&rsquo;re running costs more to use.</p>
<p>After the core research wrapped, I asked Claude to analyze the git commit history and map when the vulnerable feature was introduced. It traced the initial commit to January 2021 — identified the exact PR that introduced it and confirmed the authorization gap was present from the start — then mapped the feature&rsquo;s expansion across subsequent releases. The finding had been in production for over five years across multiple major version milestones, including a significant expansion of the attack surface in 2023 that added it to roughly twenty additional API endpoints.</p>
<p>I submitted the findings to the vendor&rsquo;s HackerOne bug bounty program — public, improvement-oriented, no monetary bounties. The report covered all bypass paths with confirmed live responses, CVSS scoring, and root cause analysis. A follow-up post with the full technical details will go up once the disclosure process is complete.</p>
<hr>
<h2 id="final-thoughts">Final thoughts<a href="#final-thoughts" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Looking at the flow diagram, the natural question is: can the human node be replaced with another autonomous agent? Yes — projects like Mythos and <a href="https://github.com/KeygraphHQ/shannon">Shannon</a> demonstrate that fully autonomous security research is viable, and companies operate at scale doing it. The cost is higher: an autonomous agent has to discover through exploration what a human researcher brings as context, and that shows up in the token count.</p>
<p>All in all, a Medium severity authorization bypass with five confirmed network vectors, in a reasonably hardened codebase in a single evening — that&rsquo;s a good result in my book, and it took way less effort than comparable research I&rsquo;ve done solo. Happy bug hunting!</p>
]]></content>
		</item>
		
		<item>
			<title>AI-Native Threat Modeling</title>
			<link>https://brainoverflow.blog/posts/ai-native-threat-modeling/</link>
			<pubDate>Wed, 20 May 2026 11:29:02 -0700</pubDate><guid>https://brainoverflow.blog/posts/ai-native-threat-modeling/</guid>
			<description><![CDATA[&lt;no value&gt;]]></description><content type="text/html" mode="escaped"><![CDATA[<p><em>When I ask hiring managers why they&rsquo;re opening a product security role, the answer is
usually the same: we can&rsquo;t keep up. Development org grew, product surface expanded, and
the security team is the bottleneck. It&rsquo;s not a problem unique to any one organization
— it&rsquo;s the default state of product security. AI-accelerated development and vibe
coding are making it worse: more code, shipped faster, with the same security team
trying to keep up. The conventional wisdom is that vibe coding is a killer for AppSec
— and on the current trajectory, it is.</em></p>
<p><em>In this post, I argue that linear scaling won&rsquo;t solve that problem, and make the case
that AI-generated code, treated the right way, can be a force multiplier for security.</em></p>
<hr>
<h2 id="1-the-appsec-scaling-problem">1. The AppSec Scaling Problem<a href="#1-the-appsec-scaling-problem" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The 1:100 ratio — one AppSec engineer for every hundred developers — is the number
the industry has quietly accepted as roughly accurate for mature organizations. It
sounds manageable until you sit with what it means in practice: a team of five
reviewing the output of five hundred, under sprint pressure, across a surface that
keeps growing. It&rsquo;s a demanding job — I wrote about <a href="/posts/thoughts-on-product-security-career/">what it actually
takes</a>.</p>
<p>The standard response is to hire more security engineers. That&rsquo;s reasonable when the
ratio is temporarily out of balance, but it doesn&rsquo;t address the structural problem. If
the development org doubled and the security team grew from five to ten, you&rsquo;re at the
same ratio. And the ratio assumes a roughly stable development velocity. AI coding
assistants are shattering that assumption.</p>
<p>Developers using GitHub Copilot, Cursor, or Claude Code ship more, faster. Vibe
coding — letting the model write code from a high-level natural language prompt
— compresses timelines further. Features that took two weeks take days.
The code surface is expanding at a rate that&rsquo;s no longer proportional to engineering headcount,
which means the AppSec scaling problem is now a two-sided function: development
velocity increasing, security team capacity roughly flat. The gap is structural, and
it is getting wider.</p>
<h2 id="2-where-traditional-approaches-break-down">2. Where Traditional Approaches Break Down<a href="#2-where-traditional-approaches-break-down" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The vocabulary for addressing the AppSec scaling problem is well developed:
<strong>shift-left</strong>, <strong>secure-by-design</strong>, <strong>developer enablement</strong>, creating
<strong>paved roads</strong>. They&rsquo;re not wrong ideas. The problem is that they all require the same
scarce resource: AppSec time.</p>
<p><strong>Threat modeling</strong> — the recommended practice for high-risk features — is the
clearest example. The canonical process: the development team writes a design document;
the security team (or a joint session) works through the STRIDE framework or similar,
maps data flows and trust boundaries, produces a model; there&rsquo;s back-and-forth and
eventual sign-off. This is genuinely valuable when it happens. In practice, it often doesn&rsquo;t —
the process is time-consuming, and AppSec time is scarce.</p>
<p>What actually happens is one of three failure modes:</p>
<ol>
<li><strong>Delay</strong> — security reviews become release blockers, friction accumulates,
relationships with engineering teams deteriorate.</li>
<li><strong>Risk-accept</strong> — features ship with &ldquo;accepted risk&rdquo; security exceptions that go
into a backlog and are rarely revisited.</li>
<li><strong>No review at all</strong> — code ships without security involvement, entire product areas
built and deployed without the security team ever being in the loop.</li>
</ol>
<p>With AI now compressing time-to-exploitation — public vulnerabilities can have working
proof-of-concept code within hours — the third option is no longer a viable gamble.</p>
<p>Security code reviews have the same structural problem one step later: someone writes
code, another team reads it, back-and-forth, sign-off. Every handoff is a scheduling
dependency that adds release latency.</p>
<h2 id="3-the-threat-model-maintenance-problem">3. The Threat Model Maintenance Problem<a href="#3-the-threat-model-maintenance-problem" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>There&rsquo;s a second-order problem with threat modeling that gets less attention than the
initial production cost: <strong>drift</strong>.</p>
<p>A threat model is created as a snapshot, but the system keeps evolving. New endpoints added,
authentication flows refactored. Six months after a threat model is signed off, it describes
a system that no longer looks the same.
The question of who owns maintenance is usually a gray area: the development team didn&rsquo;t
write the model and isn&rsquo;t trained to maintain it; the security team is not aware of changes
and has to context-switch back into a system they last looked at months ago.
Neither path works well in practice.</p>
<p>Most organizations treat the threat model as a gate the security team required at feature launch —
it was produced, the box was checked, and maintenance was never part of the contract.
It documents what the system looked like at one point in time and then quietly expires.</p>
<h2 id="4-the-key-insight">4. The Key Insight<a href="#4-the-key-insight" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Here&rsquo;s where the mental model needs to shift.</p>
<p>In the current workflow, threat modeling is <strong>derivative work</strong>: a security person reads
what a developer built and reconstructs the security-relevant picture from it — after
the fact, inherently lossy, potentially inaccurate, and always one step behind.</p>
<p>Open-source projects such as <a href="https://github.com/davidmatousek/tachi">Tachi</a> and several
commercial offerings recognize this and offer tools that automate the reconstruction:
read the codebase, analyze diffs, apply a methodology, output a structured model.
These tools are useful, but they&rsquo;re still doing
the same derivative work, just faster — reverse-engineering security structure from
existing code rather than having a human do it. There&rsquo;s also a cost dimension:
analyzing an existing codebase means feeding it back through an LLM as new input,
which is expensive at scale. The larger and more frequently updated the codebase, the
higher the token cost of each analysis pass.</p>
<p>Now consider what changes when AI is writing the code — through vibe coding,
spec-driven development, AI-generated scaffolding from a design document, or an
agentic coding loop that implements a full feature end-to-end.</p>
<p>It doesn&rsquo;t reverse-engineer anything — it knows, because it built it: every data flow
it designed, every entry point it created, every asset it touched, every trust boundary
it crossed or established, every authentication decision it made. The complete map
required for a threat model exists as a natural byproduct of the design work the AI
just did — and it exists <em>at the moment of creation</em>, not after. And because that
context is already in the model&rsquo;s working window, generating the threat model alongside
the code is parallel effort on the same inputs, with little additional token cost.</p>
<p>The consequence of this observation is straightforward: <strong>threat models should be
generated alongside code, as first-class artifacts, not assembled later as derivative
documents</strong>.</p>
<pre class="mermaid">gantt
    title 1. Current — human-driven, sequential
    dateFormat YYYY-MM-DD
    axisFormat %d
    section Developer
    Design doc            :a1, 2024-01-01, 3d
    Write code            :a2, 2024-01-04, 3d
    section Reconstruct (Security)
    Reconstruct & model   :a3, 2024-01-07, 3d
    section Review (Security)
    Review & sign-off     :a4, 2024-01-10, 2d
</pre>
<pre class="mermaid">gantt
    title 2. AI-assisted — LLM writes code, LLM reads code
    dateFormat YYYY-MM-DD
    axisFormat %d
    section Developer
    Generate code         :b1, 2024-01-01, 3d
    section Reconstruct (AI-assisted)
    LLM reconstructs TM   :b2, 2024-01-04, 2d
    section Review (Security)
    Review & sign-off     :b3, 2024-01-06, 2d
    section Time saved
    time saved            :done, 2024-01-08, 4d
</pre>
<pre class="mermaid">gantt
    title 3. AI-native — code and threat model in parallel
    dateFormat YYYY-MM-DD
    axisFormat %d
    section Code
    Generate code         :c1, 2024-01-01, 3d
    section Threat Model
    Generate threat model :c2, 2024-01-01, 3d
    section Review (Security)
    Review & sign-off     :c3, 2024-01-04, 2d
    section Time saved
    time saved            :done, 2024-01-06, 6d
</pre>
<p>Accuracy improves — the model is a direct output from the entity that designed the
system, not a reconstruction. Maintenance improves because every code change can
regenerate or update it in the same operation; the entity making the change already
knows what changed and why. The multi-step,
multi-team back-and-forth collapses into a single step. Security practitioners remain
in the loop — for methodology, formal sign-off, challenging assumptions the AI didn&rsquo;t
surface — but the labor-intensive baseline work of constructing the model moves from a
human bottleneck to an automatic output.</p>
<p>This is what &ldquo;shift-left&rdquo; should actually mean: not <em>have the security team review
earlier</em>, but <em>produce the security model at the same moment the system is designed</em>.
The security artifact is contemporaneous with the code, not chasing it.</p>
<h2 id="5-on-model-bias-in-security-analysis">5. On Model Bias in Security Analysis<a href="#5-on-model-bias-in-security-analysis" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>A legitimate concern about this approach is AI model bias. There&rsquo;s a well-documented
pattern in AI-assisted security review: when a model writes code and is then asked to
evaluate it for security in the same context window, it tends to anchor to its own design
decisions, finding reasons why its choices are sound rather than challenging them. An
independent reviewer operating from a fresh context — a second model, or a human who
didn&rsquo;t write the code — is more likely to surface issues the original author missed. This
is a real limitation, and it applies directly to using AI for code security review.</p>
<img src="images/model-bias.png" alt="Code review vs. threat modeling under model bias" width="460" style="max-width:100%;display:block;margin:0 auto;border-radius:12px;">

<p>The core distinction here is that <strong>code security review</strong> and <strong>threat modeling</strong> are quite different.
A security review asks the model to evaluate whether its own implementation is correct and
secure — the question where anchoring bites hardest, because the model is judging choices
it already committed to. A threat model asks something structurally different: document the
architecture, establish trust boundaries, map data flows and assets, then apply a framework
like STRIDE that poses a fixed set of questions across threat categories.
The framework is external to the code; its questions don&rsquo;t change based on how well or
poorly the implementation is written. The question it asks — given what this system does,
what can go wrong in each of these categories? — is answered from the architectural map,
not from a judgment about implementation quality.</p>
<p>What bias <em>could</em> still affect is the model&rsquo;s assessment of severity — an AI that made a
particular design trade-off might rate the resulting risk lower than an independent reviewer
would. That&rsquo;s a real concern, and it&rsquo;s exactly why human review of the model&rsquo;s
outputs and assumptions is still valuable in this workflow.</p>
<h2 id="6-why-threat-modeling-still-matters">6. Why Threat Modeling Still Matters<a href="#6-why-threat-modeling-still-matters" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>A reasonable objection at this point: if AI writes the code, why not just ask it to
write <em>secure</em> code and skip the threat model entirely? We should absolutely ask for
that — but threat modeling serves purposes that &ldquo;write secure code&rdquo; doesn&rsquo;t address.</p>
<p><strong>Security architecture documentation.</strong> Threat models capture architectural decisions
and their security implications: trust boundaries, data classifications, what the system
assumes about its environment, where the blast radius of a failure ends. These don&rsquo;t
live in code. A system can be implemented correctly while making architectural
trade-offs that accept certain risks; those trade-offs need to be explicit, owned, and
findable.</p>
<p><strong>Known gaps and accepted risks.</strong> Every system ships with tradeoffs —
incomplete defenses, deferred work, risks that were evaluated and accepted.
A threat model makes these explicit: here is what we considered, here is what we&rsquo;re
not defending against, and here is why. This matters for accountability, for
prioritization, and for the engineer who joins the team six months from now.</p>
<p><strong>Compensating controls.</strong> Good security architecture is layered.
WAF rules, rate limiting, network segmentation, monitoring and alerting — these don&rsquo;t live
in application code, but they&rsquo;re part of the security posture. The threat model is
where they&rsquo;re connected to the threats they compensate for. This is also where
code-analysis-based automated tools tend to generate false positives:
they see the change in isolation, unaware of the external controls that already
mitigate a given risk.</p>
<p><strong>Compliance requirements.</strong> SOC 2, PCI-DSS, ISO 27001, HIPAA, and similar frameworks
require documented evidence of threat analysis. Auditors want artifacts. A threat model
that exists and is demonstrably current — generated from the same
codebase it describes — is a far stronger compliance artifact than one that was
carefully written at launch and hasn&rsquo;t been touched since.</p>
<p><strong>Incident response preparation.</strong> When something goes wrong — and eventually something
does — a current threat model tells you what&rsquo;s at risk, what attacker paths exist, and
what to prioritize. You want this analysis done before the incident, not during it.</p>
<p><strong>Stakeholder communication.</strong> Engineering leadership, legal, product, and board-level
security committees need to understand risk in terms they can act on. The codebase
doesn&rsquo;t serve this purpose; a structured threat model does.</p>
<p>The case for threat modeling doesn&rsquo;t weaken when AI writes the code — if anything,
AI makes the security artifacts cheaper to produce, easier to keep current,
and more consistently complete than the human-driven alternative.</p>
<h2 id="final-thoughts">Final thoughts<a href="#final-thoughts" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>I think this is the direction the AI coding toolchain is already moving toward, even
if the full vision hasn&rsquo;t arrived yet. AI coding tools are increasingly integrating
security into the development workflow: GitHub Copilot&rsquo;s real-time vulnerability
detection during code generation, Claude Code&rsquo;s security analysis during code review,
Replit&rsquo;s Security Agent in the development environment. None of these offer AI-native threat
model generation, but they signal that the industry is treating security as something
the coding tool prioritizes and produces alongside code.
The extension of that to living, maintained threat models is the logical next step.</p>
<p>The reframe for ProdSec practitioners is this: stop thinking of threat
modeling as a process your team performs on code that developers write. Start thinking
of it as an artifact the AI coding assistant produces alongside the code, which your
team validates, challenges, and signs off on. The security team&rsquo;s job shifts from
construction to judgment — which is where human expertise actually compounds.</p>
<p>The dreaded 1:100 ratio won&rsquo;t disappear. But the work of constructing and maintaining
the threat model doesn&rsquo;t have to stay a human-hours problem. The needle can move —
but only if the security team&rsquo;s role evolves with it.</p>
<p><img src="images/surf.png" alt="Image generated by Google Gemini"></p>
]]></content>
		</item>
		
		<item>
			<title>TrustFall: The Perimeter Problem in Agentic Tools</title>
			<link>https://brainoverflow.blog/posts/perimeter-problem-in-agentic-tools/</link>
			<pubDate>Mon, 18 May 2026 10:00:00 -0700</pubDate><guid>https://brainoverflow.blog/posts/perimeter-problem-in-agentic-tools/</guid>
			<description><![CDATA[&lt;no value&gt;]]></description><content type="text/html" mode="escaped"><![CDATA[<p><em>On May 7, 2026, Adversa AI published <a href="https://adversa.ai/blog/trustfall-coding-agent-security-flaw-rce-claude-cursor-gemini-cli-copilot/">TrustFall</a> — a one-click remote code execution in Claude Code, with variants across Gemini CLI, Cursor, and GitHub Copilot: clone a repository, open it, click &ldquo;Yes, I trust this folder&rdquo;, and an attacker-controlled process runs with your full OS privileges.</em></p>
<p><em>Anthropic declined the finding as outside their threat model and the behavior as functioning by design. This post digs into that response — and argues that the core issue is architectural: a perimeter security model that can&rsquo;t carry the weight placed on it, and that makes the vulnerability structurally hard to surface through threat modeling. It looks at what a zero trust alternative would look like for agentic tools.</em></p>
<hr>
<h2 id="1-the-vulnerability">1. The vulnerability<a href="#1-the-vulnerability" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Project-level config files — <code>.mcp.json</code> and <code>.claude/settings.json</code> — committed to a repository can activate attacker-controlled MCP servers. When a developer clones the repo and clicks through the trust dialog, an unsandboxed Node.js process spawns with full user OS privileges — no further prompts. Three developer actions: clone, open, click. The attack also has a zero-click CI/CD variant where Claude Code runs headless in GitHub Actions against an untrusted pull request branch, bypassing the trust dialog entirely.</p>
<p>For the full technical details see <a href="https://adversa.ai/blog/trustfall-coding-agent-security-flaw-rce-claude-cursor-gemini-cli-copilot/">TrustFall</a>.</p>
<p>In threat modeling terms, the mechanism is a trust boundary problem. Project-scope config — committed to a repository — can activate MCP servers: external processes that run with full user OS privileges. The gate between untrusted repo content and those privileges is a single user prompt:</p>
<pre class="mermaid">flowchart TB
    subgraph Untrusted["Untrusted · attacker-controlled"]
        PCfg["Project config\n(committed to the repo)"]
    end

    CLI["Claude Code CLI"]

    subgraph Gate["Trust gate"]
        Prompt["'Do you trust this folder?'\nsingle Yes/No"]
    end

    subgraph Privileged["Privileged · full user OS access"]
        MCP["MCP Server process\nNode.js · no sandbox"]
        OS["~/ · ~/.ssh · ~/.aws\nread / write / exec — unrestricted"]
    end

    PCfg --> CLI
    CLI --> Prompt
    Prompt -- "on 'Yes'" --> MCP
</pre>
<p>That single Yes/No covers four distinct capability grants:</p>
<ol>
<li>Claude reading and editing project files <em>(clearly implied)</em></li>
<li>Claude following project-level behavioral settings <em>(reasonable)</em></li>
<li>Activating MCP servers defined in project config <em>(not stated)</em></li>
<li>Those servers running as unsandboxed processes with full user privileges <em>(definitely not stated)</em></li>
</ol>
<p>Two of these carry significant security consequences — and neither appears in the prompt language. The gap between what the user consents to and what the system delivers is a textbook Elevation of Privilege (the E in STRIDE methodology): the subject grants more than they know.</p>
<p>The pattern holds across the tools TrustFall examined — Gemini CLI and Cursor do mention MCP servers in their consent language, Claude Code and Copilot don&rsquo;t, but all four default to Yes or Trust.</p>
<hr>
<h2 id="2-outside-our-threat-model">2. &ldquo;Outside our threat model&rdquo;<a href="#2-outside-our-threat-model" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Anthropic&rsquo;s phrase is worth examining literally. Per TrustFall&rsquo;s analysis, the missing enforcement isn&rsquo;t outside Anthropic&rsquo;s defined boundary — it&rsquo;s inside it. The trust dialog is the perimeter; what happens after it is, by their own framing, the trusted zone. &ldquo;Outside our threat model&rdquo; means, in practice: inside our perimeter, but below the granularity we protect at.</p>
<p>That granularity isn&rsquo;t uniformly coarse — the capability is demonstrably known. <code>bypassPermissions</code> gets a dedicated warning because it is dangerous; <code>enableAllProjectMcpServers</code>, <code>enabledMcpjsonServers</code>, and <code>permissions.allow</code> activate equally dangerous behavior without equivalent disclosure. TrustFall also notes that earlier versions of Claude Code included an explicit MCP consent prompt that was later removed. These are the tell-tale signs of a threat model that&rsquo;s coarser than the reality it represents — some dangerous capabilities are visible enough to gate explicitly, others slip through the same boundary unexamined.</p>
<p>That pattern of selective gating is further undermined by the CVE record. Anthropic&rsquo;s response to TrustFall was that the behavior functions as designed — clicking &ldquo;trust this folder&rdquo; means accepting the project&rsquo;s configuration, MCP servers included. Yet over six months before TrustFall, Anthropic patched three related vulnerabilities: delayed MCP activation until after the trust dialog (CVE-2025-59536, Oct 2025), blocked <code>ANTHROPIC_BASE_URL</code> from project scope (CVE-2026-21852, Jan 2026), and blocked <code>bypassPermissions</code> from project scope (CVE-2026-33068, Mar 2026) — the same setting that already carried a UI warning. Each fix adds a specific gate or blocklist entry — the signature of a perimeter being hardened incrementally, one dangerous capability at a time, without a unifying policy. If the trust dialog truly constitutes full consent by design, there would be nothing to patch.</p>
<hr>
<h2 id="3-from-perimeter-to-zero-trust">3. From perimeter to zero trust<a href="#3-from-perimeter-to-zero-trust" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The &ldquo;functions as designed&rdquo; response places the burden on the developer: audit what you clone. That position rests on a perimeter security architecture — verify once at the gate, trust everything inside:</p>
<pre class="mermaid">flowchart LR
    A1["Repo"] -->|"✓ trust gate"| A2["CLI"] --> A3["MCP"] --> A4["OS"]
</pre>
<p>The perimeter pattern is one modern security has largely moved past. The alternative approach is a zero trust — identity propagated through the capability chain, evaluated at each grant. Git provides the primitives: clone origin, remote URL, commit author. Conceptually, it would look like this:</p>
<pre class="mermaid">flowchart LR
    B1["Repo\norigin · author"] -->|"✓ id check"| B2["CLI"] -->|"✓ capability?"| B3["MCP"] -->|"✓ scope?"| B4["OS"]
</pre>
<p>Read through the zero trust lens, Anthropic&rsquo;s position has three problems.</p>
<p><strong>Shared responsibility requires the system to carry its half.</strong>
Under a perimeter model, all verification burden falls on the user at the gate — the system has the identity signals, but leaves them unused. Zero trust distributes the burden: each capability is evaluated at the point it&rsquo;s granted.</p>
<p><strong>The consent gate can&rsquo;t convey per-capability trust.</strong>
A perimeter gate concentrates all trust decisions into one moment. Anthropic&rsquo;s gate covers MCP server activation, process spawning, and full OS access — none of it signaled. The coarser the gate, the harder it is to make consent meaningful.</p>
<p><strong>The perimeter model puts expert-level burden on non-expert users.</strong>
A perimeter gate requires the user to reason about all downstream consequences of a single click — a reasonable ask for a security engineer, not for a vibe-coder. Zero trust shifts that burden to the architecture: each capability grant is evaluated by the system, not the user.</p>
<p>The three problems compound: the system ignores available identity signals, the gate doesn&rsquo;t compensate by informing the user what it actually grants, and the users left holding that gap aren&rsquo;t equipped to close it.</p>
<hr>
<h2 id="final-thoughts">Final thoughts<a href="#final-thoughts" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>This post looked at two connected things: what the trust gate actually grants, and why the security architecture makes that easy to miss. At its core, TrustFall is a consent gap — a single prompt covering MCP server activation and unsandboxed OS access without stating either. The perimeter model is the structural reason that gap is hard to surface at design time: inside the perimeter is trusted by definition, leaving no natural STRIDE targets. Threat modeling a perimeter-architected system, finding the EoP requires a modeler to look past the gate and ask what it actually grants — that&rsquo;s a skill, not something the methodology surfaces automatically. The CVE record shows this playing out: each patch adds a specific gate or blocklist entry without restructuring the boundary, and &ldquo;functioning as designed&rdquo; remains the public response to TrustFall.</p>
<p>A zero trust security architecture changes the shape of the problem. Explicit trust boundaries at each capability grant are natural STRIDE targets — the EoP question surfaces at design time regardless of modeler experience, not because of better analysts, but because the architecture itself gives threat modeling more boundaries to work with.</p>
<p>TrustFall affected Claude Code, Cursor, Gemini CLI, and Copilot — evidently all due to the same perimeter model. The broader security industry made this architectural transition before: perimeter security dominated until systems grew complex enough that a single gate couldn&rsquo;t hold, and zero trust emerged as the answer. Agentic tools are on a similar trajectory — gaining capability and OS access fast, with the same pressure building at the trust boundary.</p>
<p>Is zero trust the natural next step in the architectural evolution of agentic tools?</p>
<hr>
<h2 id="references">References<a href="#references" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<ul>
<li>Adversa AI — <a href="https://adversa.ai/blog/trustfall-coding-agent-security-flaw-rce-claude-cursor-gemini-cli-copilot/">TrustFall: Coding Agent Security Flaw Enabling RCE in Claude, Cursor, Gemini CLI, Copilot</a></li>
<li>Anthropic — <a href="https://code.claude.com/docs/en/settings">Claude Code Settings</a></li>
</ul>
<hr>
]]></content>
		</item>
		
		<item>
			<title>Thoughts on Product Security Career</title>
			<link>https://brainoverflow.blog/posts/thoughts-on-product-security-career/</link>
			<pubDate>Tue, 12 May 2026 09:51:22 -0700</pubDate><guid>https://brainoverflow.blog/posts/thoughts-on-product-security-career/</guid>
			<description><![CDATA[&lt;no value&gt;]]></description><content type="text/html" mode="escaped"><![CDATA[<p><em>I recently wrote about <a href="/posts/my-product-security-principles/">my product security principles</a> — the operating frame I&rsquo;ve built for doing the job well. This is the post that probably should have come first: what product security actually is as a career, whether it might be the right path for you, and what ten years of doing it has taught me.</em></p>
<hr>
<p>Ten years in product security teaches you one thing above all: it is <strong>a hybrid discipline</strong>, and that is both its challenge and its appeal.</p>
<p>The role asks for coding skills — enough to read unfamiliar codebases, spot vulnerability patterns, and write the automation that makes security scale — but not at the level of a senior software engineer. It asks for offensive security knowledge — how attackers think, how systems break — but you&rsquo;re not a red teamer or a dedicated pentester. You need architectural judgment and systems-level thinking to design security solutions that fit inside complex systems, but you&rsquo;re not designing the products themselves. Program management skills come into play when you&rsquo;re owning a roadmap and driving cross-functional initiatives, but your customers are internal. Risk and compliance fluency matters — understanding risk is what drives prioritization decisions — without being a GRC officer. Enough ITSec grounding to be credible in an IR conversation, without being a SOC analyst.</p>
<p>Rarely all of these at full depth — but all of them at working depth. The breadth is the job.</p>
<p><strong>The technology surface</strong> is equally wide. Multi-cloud environments, Kubernetes and container security, CI/CD pipeline hardening, secrets management, HSM-backed key hierarchies, OS-level hardening, infrastructure-as-code, supply chain integrity, identity and access management, compliance frameworks — the list is long and grows with the industry. You don&rsquo;t need to be the expert in all of it, but you need to be fluent enough in each area to ask the right questions, spot the gaps, and know when to go deeper.</p>
<p><strong>Context switching</strong> is another constant demand. Security teams are undersized by design, so people come to you constantly: a quick auth question from a developer, a compliance clarification from legal, an architecture review that landed in your queue, an incident that just got escalated. Each requires a different mode — deep focus for a thorough threat model, quick confident judgment for the everyday interruptions. The instinct might be to guard your time against the noise. Resist it. Those questions are how you move the needle. Embrace them.</p>
<p><strong>The human side</strong> carries equal weight with technical depth — and this often goes unsaid. Influencing teams that don&rsquo;t report to you, competing for roadmap space without turning adversarial, partnering with engineering instead of policing it, enabling people rather than gatekeeping them. Add to that customer-facing and executive communication — translating technical risk into language that lands with a non-technical audience is a distinct skill, and a critical one. Product security lives inside organizations with competing priorities, and how far you move the needle depends as much on how well you work with people as on what you know.</p>
<p><strong>High stakes</strong> raise the difficulty. There&rsquo;s the obvious pressure of a security incident — high-visibility, fast-moving, unforgiving. But there&rsquo;s also the quieter, constant pressure of not missing something: a vulnerability in a design review, a misconfiguration in a new service, a risk that slips through and becomes next quarter&rsquo;s incident. The job requires staying sharp under both.</p>
<p><strong>Invisible success</strong> is the other side of that coin — and something I touched on in my earlier post. When nothing goes wrong, there&rsquo;s nothing visible to point to. Security&rsquo;s value is counterfactual by design, and that takes some getting used to.</p>
<hr>
<h2 id="if-youre-hiring-for-product-security">If you&rsquo;re hiring for product security<a href="#if-youre-hiring-for-product-security" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Understanding what the role actually requires has a direct implication for how you hire — and most interview processes get this wrong.</p>
<p><strong>The most common mistake</strong> I see is screening candidates with a LeetCode-style assessment. I&rsquo;ve worked with some of the brightest security engineers in the industry — holding them to an algorithmic coding bar doesn&rsquo;t filter for talent, it filters out the wrong people. That&rsquo;s not what you&rsquo;re hiring them for.</p>
<p>The same applies to <strong>system design</strong>. I&rsquo;d bet many of the best security engineers I&rsquo;ve worked with couldn&rsquo;t design a scalable distributed system end-to-end — but they can dissect an existing one and find its security design flaws faster than anyone in the room. The blank-whiteboard system design exercise misses the point entirely.</p>
<p><strong>What works instead:</strong> for the <strong>coding round</strong>, give them a real code sample and ask them to review it for vulnerabilities. Give them a CVE and walk through the risk assessment — what&rsquo;s the realistic impact, what systems are exposed, how would you prioritize remediation? Keep a human in the loop; you want to see how they think, what they catch, what questions they ask. For <strong>system design</strong>, hand them an actual design document or a system diagram and ask them to threat model it: identify the assets worth protecting, map the trust boundaries, enumerate the threats at each boundary, reason through attack vectors — then recommend layered defenses to mitigate the risks they&rsquo;ve identified. A candidate who can do that credibly is showing you the core of the job.</p>
<p>Software engineers and security engineers look at the same systems from different angles. Tailoring the interview process to the role isn&rsquo;t lowering the bar — it&rsquo;s raising the accuracy of the bar.</p>
<hr>
<p><em>Product security demands technical breadth, strong soft skills, and the ability to navigate complex organizational dynamics — all at once. Hiring for it requires a process calibrated to that reality. Is it the right career for you? Ten years in, it still is for me.</em></p>
]]></content>
		</item>
		
		<item>
			<title>My Product Security Principles</title>
			<link>https://brainoverflow.blog/posts/my-product-security-principles/</link>
			<pubDate>Sun, 10 May 2026 00:00:00 -0700</pubDate><guid>https://brainoverflow.blog/posts/my-product-security-principles/</guid>
			<description><![CDATA[&lt;no value&gt;]]></description><content type="text/html" mode="escaped"><![CDATA[<p><em>In my recent job search I read dozens of Product Security job descriptions. They all contain the same buzzword soup: shift-left, secure-by-default, defense in depth, paved roads. In practice, they mean different things at different companies — but what do they actually mean for the Product Security team?</em></p>
<p><em>What follows is my personal operating frame. One security engineer for every hundred in engineering is roughly where the industry sits — these are my principles for operating and succeeding in that reality. And I believe they hold in a world of vibe-coded apps and AI-accelerated production code.</em></p>
<hr>
<h2 id="1-risk-is-the-unit-of-work-not-findings">1. Risk is the unit of work, not findings<a href="#1-risk-is-the-unit-of-work-not-findings" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Everything flows from business risk: vulnerabilities, architecture gaps, compliance requirements are all risks to be scored, prioritized, and decided on. They belong in a risk register that the business actually owns, and part of the security team&rsquo;s job is ensuring it does: it should be a living record that business owners understand, contribute to, and sign off on, not a document security maintains in isolation.</p>
<p><strong>Proactive and continuous risk reduction</strong> is how I formulate the security team&rsquo;s mission.</p>
<h2 id="2-frame-risk-in-business-terms">2. Frame risk in business terms<a href="#2-frame-risk-in-business-terms" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>A CVSS score means nothing to an executive. A risk item must answer: what&rsquo;s the realistic scenario, what does it cost if it happens, what does it cost to fix, what&rsquo;s your recommendation? When security decisions carry significant business risk, frame them in business language.</p>
<p>Seek explicit executive sign-off for security exceptions and risks above a materiality threshold — it moves accountability to where the decision lives.</p>
<p><strong>Risk = Severity × Likelihood</strong>: the key formula that turns a vulnerability into a business decision.</p>
<blockquote>
<p><strong>UPD 5/30:</strong> A reader pointed out that my original formula (<em>Risk = Severity × Potential Impact</em>) was incorrect; it conflated severity and potential impact (which represent the same measurement), and missed the likelihood completely. I&rsquo;ve updated the post with the correct formula above.</p>
</blockquote>
<h2 id="3-security-architecture">3. Security Architecture<a href="#3-security-architecture" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>A whiteboard conversation at design time costs an hour; a redesign after implementation costs a sprint. Security belongs at the beginning of the design process, not at the end as a gate. The goal is to be the person engineers call when they&rsquo;re designing, not when they&rsquo;re shipping.</p>
<p>Don&rsquo;t overthink threat modeling; formal frameworks have their place, but if the overhead of the methodology is slowing teams down, drop it. A napkin sketch of trust boundaries and a list of &ldquo;what could go wrong&rdquo; is a threat model, an imperfect one done at design time beats a rigorous one that never happens.</p>
<p>Good security architecture is transparent. Document it publicly; it builds trust and is the right counterweight to security through obscurity. If the design is sound, exposure doesn&rsquo;t weaken it. If your code ever leaks, there should be no secrets in it worth finding.</p>
<h2 id="4-assume-controls-fail--design-and-test-for-it">4. Assume controls fail — design and test for it<a href="#4-assume-controls-fail--design-and-test-for-it" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The operating posture is proactive, not reactive. Find the gaps before an attacker or a customer does. No single control holds forever: when this fails, what&rsquo;s the worst reachable outcome? Isolation, least privilege, and short-lived credentials aren&rsquo;t redundancy, they&rsquo;re blast radius reduction. Treat defense in depth as a system property.</p>
<p>Designing for failure isn&rsquo;t enough — validate that your controls actually perform as designed. Security audits, red and purple team exercises, and bug bounty programs all serve the same function: actively probing your own assumptions.</p>
<h2 id="5-friction-is-the-enemy">5. Friction is the enemy<a href="#5-friction-is-the-enemy" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Culture is a big one. Most engineers <em>want</em> to build secure software — they&rsquo;re just operating under deadlines, competing priorities, and finite cognitive bandwidth. When security loses, it&rsquo;s usually not because engineers don&rsquo;t care; it&rsquo;s because the secure path was harder than it needed to be, or they simply didn&rsquo;t know what it was. Security expertise isn&rsquo;t a given — engineers are experts in their domain, not ours.</p>
<p>Every process, template, and gate should make the secure choice the default, not the tax. Security has to live inside the workflows engineers already use. A separate system they have to visit is a system that will fail adoption. Friction reduction is the mechanism; the goal is cultural: security becoming a natural part of how the team ships.</p>
<h2 id="6-influence-over-formal-authority">6. Influence over formal authority<a href="#6-influence-over-formal-authority" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Security teams often have no direct power over engineering decisions. Authority comes from technical credibility, consistent judgment, and being right often enough that people seek your input. A security control engineers chose is worth more than one you mandated.</p>
<p>Influence runs in both directions. Top-down: executive sponsorship sets the tone and makes security non-negotiable at the policy level. Bottom-up: invest in building relationships with engineering teams — understand their roadmaps, empathize with their pressures — that&rsquo;s where actual adoption happens.</p>
<h2 id="7-partners-not-adversaries">7. Partners, not adversaries<a href="#7-partners-not-adversaries" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Competing with engineering for resources — security work versus features on the roadmap — comes with the territory. That tension is structural and it never fully goes away. Recognize it as part of the job; the risk is letting it harden into an us-versus-them mentality that undermines collaboration. Security and engineering look at the same problems from different angles, but there is one goal: ship secure software. Learn each other&rsquo;s stack, understand the roadmap, show up as a collaborator rather than a reviewer. The security team engineers want to call is more effective than the one they&rsquo;re required to consult.</p>
<h2 id="8-know-when-to-stand-down--and-when-to-push-back">8. Know when to stand down — and when to push back<a href="#8-know-when-to-stand-down--and-when-to-push-back" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The willingness to say &ldquo;network-layer isolation is sufficient here&rdquo; or &ldquo;this threat is acceptable risk&rdquo; is what earns credibility for the fights that matter. Security maximalism destroys trust; knowing when to stand down builds it.</p>
<p>When you do push back, come with data — exploit likelihood, realistic impact, cost to fix. And maximize the context you hand to developers: a finding with a clear severity rationale, a realistic attack scenario, and a suggested remediation gets acted on. A bare vulnerability ID with no explanation gets triaged into a backlog and forgotten. The goal isn&rsquo;t to be right — it&rsquo;s to be useful.</p>
<h2 id="9-disagree-and-commit--deliberately">9. Disagree and commit — deliberately<a href="#9-disagree-and-commit--deliberately" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Sometimes a feature ships with known security gaps. That&rsquo;s a business decision, and it&rsquo;s often the right one. The security team&rsquo;s job in that moment isn&rsquo;t to block or to silently acquiesce — it&rsquo;s to make the decision deliberate: agree on the minimal security bar, add basic compensating controls, document the residual risk, and put the remediation work on the roadmap. Ship it, then follow through. The danger isn&rsquo;t shipping with known gaps — it&rsquo;s shipping with undocumented gaps and no agreed plan to close them.</p>
<h2 id="10-scale-through-systems-not-headcount">10. Scale through systems, not headcount<a href="#10-scale-through-systems-not-headcount" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>A small security team can&rsquo;t review everything a hundred engineers build. Security scales through parallel tracks:</p>
<ol>
<li>
<p>Enablement: templates, reference architectures, security champions, and training that make good security judgment transferable — so engineers make secure decisions without needing a security review at every turn.</p>
</li>
<li>
<p>Automation: SAST, dependency scanning, secrets detection, security gates in CI/CD that run on every PR, and most recently, LLMs.</p>
</li>
<li>
<p>Holistic remediation: when a vulnerability pattern surfaces in a functional area, drive an initiative to close the class — a shared library, a framework guardrail, a linting rule. Closing the class beats closing the tickets.</p>
</li>
</ol>
<h2 id="11-security-success-is-invisible--until-it-is-a-failure">11. Security success is invisible — until it is a failure<a href="#11-security-success-is-invisible--until-it-is-a-failure" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Security&rsquo;s value is counterfactual by design. You&rsquo;re selling the absence of bad outcomes, which is invisible until it isn&rsquo;t. The &ldquo;we didn&rsquo;t get hacked — why do we even need a security team?&rdquo; question is a predictable trap. When things are quiet, there&rsquo;s nothing visible to point to; when something goes wrong, the case for security makes itself — but at too high a cost.</p>
<p>The measurement gap is real — work around it. SLA compliance, MTTR, vulnerability age, findings caught pre-production are useful signals. Tell the risk reduction story proactively: here&rsquo;s what we found before it became a breach, here&rsquo;s how the attack surface changed over the past year, here&rsquo;s what we closed before a researcher or an attacker got there first.</p>
<p>Security that only shows up in the numbers after an incident has already lost the framing war — and ironically, that&rsquo;s often when companies make their first Product Security hire.</p>
<hr>
<p>Done well, product security is invisible: engineers ship without friction, teams collaborate without tension, and executives make informed decisions without needing a crisis to focus them. Getting there is a journey, but not an impossible one.</p>
]]></content>
		</item>
		
		<item>
			<title>Honeypots for AI Agents</title>
			<link>https://brainoverflow.blog/posts/ai-agent-honeypots/</link>
			<pubDate>Wed, 06 May 2026 08:00:07 -0700</pubDate><guid>https://brainoverflow.blog/posts/ai-agent-honeypots/</guid>
			<description><![CDATA[&lt;no value&gt;]]></description><content type="text/html" mode="escaped"><![CDATA[<p><em>AI agents run with broad access to the local environment — credentials, config
files, shell history, dotfiles — and nothing stops a misbehaving one from
reading things it was never meant to touch. The question is: how do you catch it?</em></p>
<hr>
<p>My professional career in network security started in 2005 when I joined
Juniper Networks&rsquo; IDP team — which is probably why, when thinking about how to
detect a misbehaving agent, my mind went straight to a technique from that era.</p>
<p>The IDS world solved an analogous problem decades ago. A network intruder
probes hosts and services they have no business touching; a <strong>honeypot</strong> sits in
those places looking like an attractive target — an exposed SSH server, an
unprotected admin console — but it&rsquo;s a decoy, and it alerts when anything
interacts with it. Honeypots are traps, tripwires — there&rsquo;s no legitimate use
for a fake host, so the false-positive rate is near zero.</p>
<p>The same idea can be applied to AI agents: plant bogus credentials where a rogue
agent would look, and treat any access as a signal. Rather than trying to define
what legitimate agent behavior looks like — which is hard, because legitimate
agents do a lot of things — it defines what illegitimate behavior looks like:
touching credentials that were never surfaced as part of the task. That&rsquo;s a
tighter definition, and it maps well to the threat model.</p>
<hr>
<h2 id="1-the-threat">1. The threat<a href="#1-the-threat" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>An AI agent running on a developer machine or in CI has access to everything its
process can read: <code>~/.aws/</code>, <code>~/.ssh/</code>, <code>~/.kube/</code>, <code>~/.config/</code>, environment
variables, dotfiles, every file in the project directory. That access is usually
not scoped to what the agent actually needs to do its job — it&rsquo;s scoped to the
developer&rsquo;s own permissions, because that&rsquo;s how agents tend to run inside an IDE.</p>
<p>There are three failure modes this matters for, in increasing order of intent.</p>
<ul>
<li>
<p><strong>Overprivileged agents.</strong> A legitimate, well-behaved agent still runs with the developer&rsquo;s full filesystem permissions. It may passively sweep up credential files while reading context, or actively reason that credentials it found are relevant to the task — either way, not out of malice, just because nothing stops it. The agent&rsquo;s judgment about what it&rsquo;s authorized to do is not the same as what it&rsquo;s actually authorized to do.</p>
</li>
<li>
<p><strong>Prompt injection.</strong> A malicious instruction embedded in content the agent
processes — a README, a web page, a database row — redirects the agent to look
for credentials and exfiltrate them. The agent is not compromised; it&rsquo;s
following instructions that were smuggled into its context. It tops the
<a href="https://genai.owasp.org/llmrisk/llm01-prompt-injection/">OWASP Top 10 for LLM Applications</a>
as LLM01:2025, and for good reason — it exploits the design of LLMs rather than
a flaw that can be patched.</p>
</li>
<li>
<p><strong>Compromised agents.</strong> A model that has been fine-tuned adversarially, or a
scaffolding layer with a supply-chain compromise, probes the environment
deliberately. Skills and MCP servers install from the internet with no signing
and no scanning — malicious ones are a real and active delivery mechanism, not
a future concern. The emergence of dedicated skills security solutions is evidence
enough that this threat is already being taken seriously.</p>
</li>
</ul>
<p>The distinguishing behavior is the same in all three cases: the agent accesses
resources it was never explicitly directed to use.</p>
<hr>
<h2 id="2-why-defenses-fall-short">2. Why defenses fall short<a href="#2-why-defenses-fall-short" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>What makes this more dangerous is how creative agents get in pursuing their goals.
Anthropic&rsquo;s <a href="https://www.anthropic.com/research/agentic-misalignment">agentic misalignment research</a>
stress-tested models in simulated corporate environments and found that agents would pursue their assigned
goals through unexpected means when straightforward paths were blocked: leaking
sensitive information to competitors, attempting to influence their own
evaluation, sabotaging research they disagreed with. The models weren&rsquo;t broken;
they were doing what they were optimized to do, just not in the way their
operators intended.</p>
<p>This creativity makes naive defenses, such as blacklisting certain
paths, blocking specific tool calls, or simply instructing the agent not to
touch certain things — largely moot. A sufficiently motivated agent will reason
around them, find an indirect path, or reframe the task in a way that makes the
restricted action seem justified.</p>
<p>This compounds with a structural problem on the human side: agents increasingly
run with minimal oversight. Skip-permissions modes bypass approval prompts.
Long-running background tasks accumulate hundreds of tool calls nobody reviewed.
Users experiencing approval fatigue click through without reading. The result is
an agent with broad local access, a disposition to find creative paths to its
goals, and nobody watching closely.</p>
<p>Simon Willison coined the <a href="https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/">lethal trifecta</a> as a
framework for exactly this risk: private data access, exposure to untrusted
content, and exfiltration capability. When all three are present, an attacker
who can inject instructions into the agent&rsquo;s context has essentially won. It has
become a widely accepted shorthand for why agentic deployments require a
different security posture than traditional software.</p>
<hr>
<h2 id="3-existing-tools">3. Existing tools<a href="#3-existing-tools" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Network honeypots have been around since the early 2000s. The idea is unchanged
across thirty years: deploy something that looks real but has no legitimate use,
and alert on any interaction. A low-interaction honeypot like Honeyd emulates
network services; a high-interaction one like Cowrie runs a full fake SSH
daemon. Either way, any connection is anomalous by definition — legitimate users
don&rsquo;t hit the honeypot.</p>
<p><a href="https://canarytokens.org">Canarytokens</a> applied this to
credentials and files rather than network services. You generate a fake AWS
access key or a Word document with a beacon embedded; when someone uses the key
or opens the document, you get an alert. The AWS canary creates a real IAM user
and monitors CloudTrail — there&rsquo;s a lag of minutes, and it requires external AWS
infrastructure.</p>
<p><a href="https://github.com/peg/snare">Snare</a> is a newer honeypot built specifically for
the AI agent threat model, with a few meaningful differences from Canarytokens.
It covers 18 credential types in one shot — including AI-native ones like
OpenAI, Anthropic, and MCP server configs that Canarytokens doesn&rsquo;t have —
placing canaries in all the standard locations an agent would probe. The AWS
canary fires at credential-resolution time via a local shell hook, before any
API call is made, which is faster and doesn&rsquo;t require external AWS
infrastructure. Alerts include the SDK user agent and ASN, with a &ldquo;Likely AI
agent&rdquo; flag when the request originates from cloud infrastructure — context that
Canarytokens doesn&rsquo;t surface.</p>
<p>The conceptual lineage runs from Honeyd to Canarytokens to Snare with the same
core insight at each step: if you can define what legitimate access looks like,
anything outside that definition is a signal.</p>
<hr>
<h2 id="4-limitations">4. Limitations<a href="#4-limitations" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p><strong>It detects, it doesn&rsquo;t prevent.</strong> The agent already misbehaved by the time you get the alert. These are detection controls, not prevention controls. Knowing an agent touched a credential is useful — it&rsquo;s not the same as stopping it. That said, detection data has value beyond the alert itself: observing what agents actually reach for in practice — even legitimate ones — tells you where the real access boundaries need to be. That&rsquo;s useful input for building guardrails, scoping permissions, or writing policies grounded in observed behavior rather than guesswork.</p>
<p><strong>Placement is manual.</strong> Canaries live where tools naturally look for
credentials. If an agent is directed to a custom config path or a non-standard
environment variable, the canary won&rsquo;t be there. Coverage is bounded by where
you planted the wires — the same fundamental limitation as any tripwire-based
detection.</p>
<p><strong>Detection confidence varies.</strong> Precision depends on the canary design and how
deeply it hooks into the agent&rsquo;s execution environment.
Not all credential access paths are equally observable.</p>
<p><strong>Shared machines.</strong> The low false-positive guarantee relies on the canary being invisible to legitimate users. On a machine shared by multiple developers, someone may stumble across a planted credential and use it for a real task — generating an alert that has nothing to do with a misbehaving agent. Dedicated agent environments sidestep this; shared workstations require more care.</p>
<hr>
<h2 id="conclusion">Conclusion<a href="#conclusion" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The IDS-era idea is simple: legitimate users only access resources they have business
accessing — so any interaction with a decoy is a signal by definition.
What&rsquo;s new is the target — an agent running under your
own account, following instructions that arrived via a file you never meant to
treat as executable, in a world where the line between &ldquo;following instructions&rdquo;
and &ldquo;going rogue&rdquo; is invisible to a monitoring system that only observes API calls.</p>
<p>The tools are already there. <a href="https://github.com/peg/snare">Snare</a> in particular
caught my attention — built specifically for the AI agent threat model, it covers the
credential surface an agent would probe and fires faster than any CloudTrail-based
approach. An old technique that still holds up in the agentic age.</p>
<hr>
<h2 id="references">References<a href="#references" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<ul>
<li><a href="https://genai.owasp.org/llmrisk/llm01-prompt-injection/">LLM01:2025 Prompt Injection</a> — OWASP Top 10 for LLM Applications</li>
<li><a href="https://www.anthropic.com/research/agentic-misalignment">Agentic Misalignment: How LLMs Could Be Insider Threats</a> — Anthropic</li>
<li><a href="https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/">The lethal trifecta for AI agents</a> — Simon Willison</li>
<li><a href="https://github.com/peg/snare">Snare — honeypot canaries for AI agents</a></li>
</ul>
]]></content>
		</item>
		
		<item>
			<title>copy.fail: From kernel CVE to Kubernetes Container Escape</title>
			<link>https://brainoverflow.blog/posts/copy-fail-kubernetes-escape/</link>
			<pubDate>Sat, 02 May 2026 11:58:11 -0700</pubDate><guid>https://brainoverflow.blog/posts/copy-fail-kubernetes-escape/</guid>
			<description><![CDATA[&lt;no value&gt;]]></description><content type="text/html" mode="escaped"><![CDATA[<p><em>My <a href="/posts/prefix-cache-timing-side-channel/">previous posts</a> looked at what happens when a shared object — the LLM KV cache — has no per-tenant namespace: co-tenants can read each other&rsquo;s data and starve each other&rsquo;s resources. Coincidentally, the newly dropped CVE-2026-31431 (copy.fail) is the same pattern at a lower layer. The shared object is the Linux page cache (yup, a cache again!). The co-tenants are Kubernetes pods. The isolation boundary that does not exist is the host kernel.</em></p>
<p><em>The xint.io team published <a href="https://xint.io/blog/copy-fail-linux-distributions">a writeup of the LPE vector</a>; as of time of this post their container-escape follow-up is not yet out. This post covers a concrete container escape chain against Talos Linux and what the vulnerability class says about shared-kernel container security.</em></p>
<hr>
<h2 id="tldr">TL;DR<a href="#tldr" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p><em>copy.fail</em> is a local privilege escalation — but the xint.io authors note it can also be used to escape Kubernetes pods. In that scenario, the LPE itself is not the point: the attacker is already running in a pod and doesn&rsquo;t need to escalate within it. Instead, <em>copy.fail</em> is used as a page cache corruption primitive. Containers on the same node share the host kernel, and through it, the page cache: the kernel&rsquo;s in-memory representation of file contents. If two containers share an image layer, they share page-cache pages for every file in it. Corrupt the right file in a layer shared with a privileged container, and you get code execution in that container&rsquo;s context — no LPE in the attacker&rsquo;s pod required.</p>
<p>Talos Linux — a minimal, immutable OS for Kubernetes with no shell, no SSH, and a read-only root filesystem — is a concrete example where this plays out. On Talos worker nodes, kube-proxy and user workload containers share an overlayfs layer containing <code>/usr/sbin/nft</code>. kube-proxy calls <code>nft</code> as root every few seconds to reconcile nftables rules. An attacker in an unprivileged pod overwrites <code>nft</code>&rsquo;s page-cache pages with shellcode and waits. The next reconciliation tick executes it as root, with access to the host filesystem.</p>
<p>At the end I discuss how microVM and sandboxed runtime architectures address this class of vulnerability by design.</p>
<hr>
<h2 id="1-background-the-copyfail-primitive">1. Background: the copy.fail primitive<a href="#1-background-the-copyfail-primitive" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The full mechanics are covered in the xint.io writeup, and IMO the PoC exploit is very elegant. The short version:</p>
<p>Linux exposes kernel crypto to unprivileged userspace via AF_ALG sockets. A 2017 &ldquo;in-place optimization&rdquo; allowed the AEAD encryption engine to use the destination buffer as scratch space during intermediate steps — a reasonable choice, unless that destination buffer is backed by page cache pages.</p>
<p>The exploit feeds file data into an encryption operation using <code>splice()</code>, which passes page-cache pages directly rather than copying them. The AEAD engine writes a small amount of attacker-controlled data into those pages as scratch. The file on disk is untouched. The kernel&rsquo;s in-memory view of it changes. No filesystem permissions are checked; no root is required; any process that can open an AF_ALG socket and read a file can do this.</p>
<hr>
<h2 id="2-why-the-page-cache-crosses-container-boundaries">2. Why the page cache crosses container boundaries<a href="#2-why-the-page-cache-crosses-container-boundaries" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The Linux page cache is the kernel&rsquo;s in-memory cache of file contents. When a process reads a file, the kernel loads it into the page cache and serves future reads from there. Writes to the page cache via <em>copy.fail</em> are immediately visible to any other process reading the same file — regardless of which container that process is in.</p>
<p>Linux namespaces isolate process trees, network interfaces, mount points, and user IDs. The page cache has no namespace. There is no per-container view of cached file contents. Every container on a node shares one page cache, managed by one kernel.</p>
<p>Container images are composed of layers. containerd stores each unique layer once on disk; multiple containers that share a base image mount the same underlying inodes through overlayfs. Shared inodes mean shared page cache pages. If two containers on the same node have pulled images that share a layer, any file in that layer lives in memory exactly once, visible to both.</p>
<hr>
<h2 id="3-the-talos-escape-chain">3. The Talos escape chain<a href="#3-the-talos-escape-chain" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p><a href="https://www.talos.dev/">Talos Linux</a> is built around the claim that &ldquo;Talos Linux is the best OS for Kubernetes&rdquo;. It ships with security-focused design: no SSH daemon, no package manager, no operator shell, a read-only root filesystem, and a gRPC-only management plane. Unfortunately all of that hardening operates at the OS level — and copy.fail operates below it, at the kernel. The Talos security team documented the vector in advisory <a href="https://github.com/siderolabs/talos/security/advisories/GHSA-m38g-vww2-mvgx">GHSA-m38g-vww2-mvgx</a> and patched it in v1.12.7 and v1.13.0, which ship the updated Linux kernel.</p>
<p>How the attack works in killchain terms: initial access to an unprivileged pod → page cache corruption via <em>copy.fail</em> → hijack code execution in a privileged pod via shared layer → container escape to the host. The escape has four components:</p>
<p><strong>Shared layer with a privileged trigger.</strong> kube-proxy runs as root on every Talos node, and its image shares a layer containing <code>/usr/sbin/nft</code> with images built on compatible base distributions. kube-proxy calls <code>nft</code> automatically every few seconds to reconcile nftables rules with the cluster&rsquo;s Service objects.</p>
<p><strong>The exploit loop.</strong> The attacker&rsquo;s process — running as an unprivileged user with no capabilities — opens an AF_ALG socket, reads the <code>nft</code> binary&rsquo;s file descriptor, and splices its pages into an AEAD encryption operation. Each call writes four bytes of shellcode into the page-cache image of <code>nft</code> at a chosen offset. After enough iterations to cover the payload, the entire in-memory binary has been replaced. The disk binary is untouched.</p>
<p><strong>Automatic execution.</strong> kube-proxy&rsquo;s next reconciliation tick — within five seconds — causes the kernel to exec what it believes is <code>nft</code>. It is the attacker&rsquo;s shellcode, running as root inside a privileged pod.</p>
<p><strong>Host filesystem access.</strong> kube-proxy runs with <code>privileged: true</code>, which grants <code>CAP_SYS_ADMIN</code> and access to host block devices. The shellcode can mount the host root filesystem and read or modify anything on it — kubeconfig credentials, certificates, Talos configuration, etcd data. Full node compromise.</p>
<p><em>Architecture: attacker pod and kube-proxy share the same page-cache pages through a common overlayfs layer, giving the attacker write access to memory that kube-proxy will execute.</em></p>
<pre class="mermaid">graph TB
    subgraph node["Talos Node"]
        subgraph ap["Attacker Pod<br/>unprivileged · no capabilities<br/><br/>Runs copy.fail exploit to replace nft in-memory with shellcode"]
        end

        subgraph kp["kube-proxy<br/>root · privileged: true · hostNetwork: true<br/><br/>Runs nft on schedule and executes shellcode"]
        end

        subgraph layer["Shared overlayfs lower layer"]
            nft["/usr/sbin/nft · same inode"]
        end

        subgraph kern["Linux Kernel"]
            pc["Page Cache<br/>nft inode · same RAM"]
        end

        hfs["Host Filesystem<br/>kubeconfig · certs · Talos config<br/><br/>Accessible via block device mount"]

        ap ~~~ kp
        ap --- layer
        kp --- layer
        layer --- pc
        kp --- hfs
    end
</pre>
<p><em>Attack sequence: the exploit loop overwrites <code>nft</code>&rsquo;s page-cache pages 4 bytes at a time; kube-proxy&rsquo;s next scheduled execution runs the shellcode and gains access to the host filesystem.</em></p>
<pre class="mermaid">sequenceDiagram
    participant A as Attacker pod<br/>(unprivileged)
    participant OL as overlayfs<br/>lower layer
    participant PC as Page Cache<br/>(nft inode)
    participant KP as kube-proxy pod<br/>(root · privileged)
    participant H as Host Filesystem

    A->>OL: open("/usr/sbin/nft", O_RDONLY)
    OL-->>A: fd → shared lower-layer inode

    loop payload: 4 bytes at offset i
        A->>PC: socket(AF_ALG) + splice(fd→pipe→ALG)
        Note over PC: authencesn scratch write<br/>payload[i:i+4] → page cache[i:i+4]
    end

    Note over A: page cache poisoned<br/>disk binary untouched

    Note over KP: ~5 seconds later<br/>(scheduled)
    KP->>PC: exec("/usr/sbin/nft")
    PC-->>KP: serves shellcode<br/>(not real nft binary)

    Note over KP: shellcode runs as root<br/>inside privileged pod
    KP->>H: mount /dev/{host root} → /mnt/host
    KP->>H: read/write host filesystem<br/>(kubeconfig, certs, Talos config)
</pre>
<hr>
<h2 id="4-attack-surface-in-production-clusters">4. Attack surface in production clusters<a href="#4-attack-surface-in-production-clusters" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The Talos chain requires layer overlap between the attacker&rsquo;s container and a privileged process. In a production cluster where the attacker finds themselves in a pod they didn&rsquo;t build, that overlap is not guaranteed — but the surface is larger than it looks.</p>
<p><strong>Shared base images.</strong> Most organizations build both application containers and cluster infrastructure (DaemonSets, agents, operators) from a small number of internal base images, often the same one, to standardize patching cadence. That creates the layer overlap this attack needs.</p>
<p><strong>CNI plugins.</strong> Cilium, Calico, Flannel, and Weave run as privileged DaemonSets on every node with CAP_NET_ADMIN or CAP_SYS_ADMIN. They ship binaries — eBPF loaders, nftables wrappers, VXLAN tools — that may appear in a shared layer if the attacker&rsquo;s image is based on a compatible distribution.</p>
<p><strong>Monitoring and logging agents.</strong> Prometheus node_exporter, Datadog agents, Falco, Fluent-d — all privileged DaemonSets, all touching the filesystem on a schedule. A monitoring agent that calls a shell or a compression tool from a shared layer on a periodic schedule is structurally identical to the kube-proxy/nft pattern.</p>
<blockquote>
<p><strong>There is an irony here</strong>: a workload running with no added capabilities and a distroless image is a poor target for this attack. The security agents deployed to watch it are better targets — always present and privileged by definition. The tooling added to monitor for anomalies itself becomes the attack surface.</p>
</blockquote>
<p><strong>CSI drivers.</strong> AWS, GCP, and Azure CSI storage drivers are privileged DaemonSets that invoke <code>mount</code>, <code>resize2fs</code>, and related tools regularly.</p>
<p><strong>Setuid binaries as deferred triggers.</strong> Even without a continuously-running privileged process as a trigger, a setuid binary in a shared layer is exploitable when a pod restart or rolling deployment causes a privileged init container to exec it. Kubernetes restarts pods constantly.</p>
<p><strong>The attacker&rsquo;s recon.</strong> From inside a container, <code>/proc/*/exe</code> symlinks and inode comparisons reveal which binaries are being executed by other processes and whether the attacker shares page-cache pages with them. The recon is cheap and requires no special permissions.</p>
<p>The overlap requirement makes this harder than a pure LPE. In a cluster with strict image provenance — application images and system DaemonSets built from entirely separate supply chains — the overlap may genuinely not exist. But clusters tend toward sprawl, and the surface grows with every additional agent and plugin.</p>
<p>I&rsquo;m curious which of these vectors the xint.io team will cover in their part 2 — or whether they&rsquo;ll use a completely different approach.</p>
<hr>
<h2 id="5-the-shared-kernel-problem-and-what-to-do-about-it">5. The shared kernel problem and what to do about it<a href="#5-the-shared-kernel-problem-and-what-to-do-about-it" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The container boundary is a namespace boundary, not a kernel boundary. Every piece of OS hardening — Talos&rsquo;s read-only root, absent shell, gRPC-only management — operates above the kernel. <em>copy.fail</em> operates at the kernel. This is not specific to Talos: any shared-kernel container OS is subject to the same class of attack. Patching <em>copy.fail</em> addresses this specific CVE; the architectural question is what prevents the next one.</p>
<img src="images/page-cache-anniversary.jpg" alt="10 Year Anniversary — DirtyCoW, DirtyPipe, copy.fail" width="480" style="max-width:100%;display:block;margin:0 auto;border-radius:12px;">

<p>This vulnerability class has a ten-year track record. <strong>DirtyCow</strong> (CVE-2016-0728) exploited a race condition in copy-on-write handling to corrupt page cache pages and write to read-only files — container escapes followed. <strong>DirtyPipe</strong> (CVE-2022-0847) exploited a pipe buffer flag bug to achieve the same page cache write primitive; Replit <a href="https://blog.replit.com/dirtypipe-kernel-vulnerability">documented</a> how modifications were immediately visible across all containers on the same host, even with unprivileged containers and hardening in place. <em><strong>copy.fail</strong></em> (2026) uses the AEAD in-place optimization. Three separate bugs, a decade apart, all exploiting the same architectural property: the page cache has no tenant boundary. Each time, the kernel was patched. No structural change followed. So what solutions emerged to actually address this class of vulnerability?</p>
<p><strong>Sandboxed runtimes (<a href="https://gvisor.dev/">gVisor</a>).</strong> gVisor&rsquo;s <code>runsc</code> runtime intercepts all syscalls in a userspace kernel called the Sentry, which implements the Linux syscall surface without delegating to the host kernel. An AF_ALG socket call from inside a gVisor container is handled entirely by the Sentry — the host kernel&rsquo;s <code>algif_aead.c</code> is never invoked. The copy.fail primitive does not exist from inside a gVisor container. gVisor is used in production at Google and is available as a node pool option in GKE.</p>
<p><strong>MicroVMs (<a href="https://firecracker-microvm.github.io/">Firecracker</a>).</strong> Firecracker runs each workload inside a lightweight virtual machine with its own kernel — typically adding only ~125ms to cold start with negligible steady-state overhead. The page cache is per-VM; it cannot cross VM boundaries. A copy.fail exploit in one VM writes into that VM&rsquo;s private kernel memory and goes no further. The host kernel and all other workloads are unaffected. Firecracker is the runtime behind AWS Lambda and Fargate.</p>
<p>Both approaches trade some performance or compatibility for a structural guarantee that kernel CVEs in one workload cannot reach other workloads — something no amount of OS hardening on a shared-kernel architecture can provide.</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Container</th>
					<th>gVisor</th>
					<th>Firecracker</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Kernel shared</td>
					<td>Yes</td>
					<td>No</td>
					<td>No</td>
			</tr>
			<tr>
					<td><em>copy.fail</em> reachable</td>
					<td>Yes</td>
					<td>No</td>
					<td>No</td>
			</tr>
			<tr>
					<td>Boundary type</td>
					<td>Software (seccomp)</td>
					<td>Software (Sentry)</td>
					<td>Hardware (KVM)</td>
			</tr>
			<tr>
					<td>Host attack surface</td>
					<td>Large</td>
					<td>~50 syscalls</td>
					<td>KVM + minimal VMM</td>
			</tr>
			<tr>
					<td>Guest kernel</td>
					<td>Shared host</td>
					<td>Sentry (userspace)</td>
					<td>Vendor-built, stripped</td>
			</tr>
			<tr>
					<td>Memory overhead</td>
					<td>~MB</td>
					<td>~15 MB</td>
					<td>~125 MB</td>
			</tr>
			<tr>
					<td>Syscall compat</td>
					<td>Full</td>
					<td>Partial</td>
					<td>Full</td>
			</tr>
	</tbody>
</table>
<hr>
<blockquote>
<p><strong>UPDATE — June 3, 2026</strong></p>
<p>This post was published on May 2nd, while waiting for xint&rsquo;s container-escape follow-up. Both that and a community PoC have since landed.</p>
<p><strong>xint.io part 2 is out.</strong> Their <a href="https://xint.io/blog/copy-fail-pod-to-host">container escape follow-up</a> (May 19) covers three paths. Two of them — shared-layer DaemonSet poisoning (what this post covers) and hostPath-mounted DaemonSets reaching host-side binaries — require layer overlap between the attacker&rsquo;s image and a privileged workload. The third doesn&rsquo;t: runc is bind-mounted read-only into every container via <code>/proc/self/exe</code>, so the attacker can poison the host runc binary directly through the page cache with no shared layer needed. That removes the biggest constraint on the container escape.</p>
<p><strong>PoC validated across major cloud providers.</strong> Percivalll published a <a href="https://github.com/Percivalll/Copy-Fail-CVE-2026-31431-Kubernetes-PoC/">proof-of-concept</a> demonstrating all three escape paths against production k8s clusters on Alibaba Cloud ACK, Amazon EKS, and Google GKE, including the runc path.</p>
</blockquote>
<hr>
<h2 id="references">References<a href="#references" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<ul>
<li>copy.fail vulnerability: <a href="https://copy.fail">copy.fail</a></li>
<li>xint.io LPE writeup (part 1): <a href="https://xint.io/blog/copy-fail-linux-distributions">xint.io/blog/copy-fail-linux-distributions</a></li>
<li>Talos security advisory: <a href="https://github.com/siderolabs/talos/security/advisories/GHSA-m38g-vww2-mvgx">GHSA-m38g-vww2-mvgx</a></li>
<li>DirtyPipe cross-container contamination by Replit: <a href="https://blog.replit.com/dirtypipe-kernel-vulnerability">blog.replit.com/dirtypipe-kernel-vulnerability</a></li>
<li>DirtyCow (CVE-2016-0728): <a href="https://dirtycow.ninja/">dirtycow.ninja</a></li>
<li>gVisor sandboxed runtime: <a href="https://gvisor.dev/">gvisor.dev</a></li>
<li>Firecracker microVM: <a href="https://firecracker-microvm.github.io/">firecracker-microvm.github.io</a></li>
</ul>
]]></content>
		</item>
		
		<item>
			<title>KV Cache Flood: DoS Against Multi-Tenant LLMs</title>
			<link>https://brainoverflow.blog/posts/kv-cache-flood-dos/</link>
			<pubDate>Mon, 27 Apr 2026 01:53:01 -0700</pubDate><guid>https://brainoverflow.blog/posts/kv-cache-flood-dos/</guid>
			<description><![CDATA[&lt;no value&gt;]]></description><content type="text/html" mode="escaped"><![CDATA[<p><em><a href="/posts/prefix-cache-timing-side-channel/">My previous post</a> covered the KV cache timing side-channel — a known attack class, for which I built a PoC and DIY test tooling. This post covers a different application of the same shared-cache vulnerability: using cache eviction as a DoS attack against co-tenants, with cost structure which may be asymmetric in the attacker&rsquo;s favor.</em></p>
<hr>
<h2 id="tldr">TL;DR<a href="#tldr" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Every token prefilled by an LLM inference server can be cached. If you evict another tenant&rsquo;s cached prefix, every one of their subsequent requests pays full cold-prefill cost until they re-warm it — at which point you evict it again. The attacker sends requests that generate only a single output token (near-zero compute cost) but carry long prompts that fill cache blocks. The victim pays for repeated full re-prefill at scale. On a shared RTX 3090 running vLLM, 4 flood threads caused significant TTFT degradation peaking at <strong>9.7×</strong> within the first minute.</p>
<p>Weaponized cache eviction is a well-known attack class: Prime+Probe fills CPU cache sets with attacker data to evict a victim&rsquo;s lines; CDN cache-busting floods origin servers by bypassing edge caches with unique URLs. I haven&rsquo;t seen it applied to LLM KV caches, which is what I describe here.</p>
<blockquote>
<p>⚠️ <strong>DISCLAIMER: Research purposes only.</strong> Run <code>flooder.py</code> only against infrastructure you own or have explicit written permission to test. Pointing it at a shared provider is a DoS attack, whether or not their cache is isolated.</p>
</blockquote>
<hr>
<h2 id="1-the-shared-cache-surface">1. The shared-cache surface<a href="#1-the-shared-cache-surface" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The foundation is covered in <a href="/posts/prefix-cache-timing-side-channel/">my earlier post</a>, but here is a quick recap: production LLM inference servers (vLLM, SGLang, llama.cpp, TensorRT-LLM) cache the KV tensors produced during prefill and reuse them when a later request shares a prefix. In their default configurations, these caches are keyed only by the token sequence — not by tenant identity. When the same cache serves multiple tenants, two tenants with identical prompt prefixes hit the same cache entry, and two tenants with different prompt prefixes compete for the same LRU eviction pool.</p>
<pre class="mermaid">flowchart LR
    subgraph Tenants["Tenants (isolated)"]
        direction TB
        V["Victim Pod"]
        A["Attacker Pod"]
        V ~~~ A
    end

    subgraph Cluster["Shared Inference Cluster"]
        direction TB
        S["Inference Server"]
        K[("Shared KV Cache")]
        S <--> K
    end

    V <--> S
    A <--> S
</pre>
<p>That last point is this post. The timing side-channel I described earlier is a <em>read</em> on the shared cache. This attack is a <em>write</em> — deliberately filling the shared pool to evict a target tenant&rsquo;s entries.</p>
<hr>
<h2 id="2-the-attack-mechanically">2. The attack, mechanically<a href="#2-the-attack-mechanically" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The mechanism is LRU eviction pressure. vLLM (and most other servers) use an LRU policy over a fixed KV cache pool. When the pool is full, the least recently used blocks are evicted. The attacker submits a sustained stream of requests with unique long prefixes, each filling cache blocks with attacker-owned entries. Under sustained pressure, the victim&rsquo;s hot prefix blocks are displaced. The victim re-prefills from cold on their next request — potentially thousands of tokens — then re-warms, at which point the flood displaces them again.</p>
<pre class="mermaid">flowchart LR
    subgraph S1["1. Warm cache"]
        direction TB
        a_top["[ MRU ]"]:::label
        a1["Victim prefix block"]:::victim
        a2["Victim prefix block"]:::victim
        a3["Victim prefix block"]:::victim
        a_bot["[ LRU ]"]:::label
        a_top --- a1
        a1 --- a2
        a2 --- a3
        a3 --- a_bot
    end

    subgraph S2["2. Flood writes garbage"]
        direction TB
        b_top["[ MRU ]"]:::label
        b1["Attacker garbage"]:::attacker
        b2["Attacker garbage"]:::attacker
        b3["Victim prefix block"]:::victim
        b_bot["[ LRU - evict next ]"]:::label
        b_top --- b1
        b1 --- b2
        b2 --- b3
        b3 --- b_bot
    end

    subgraph S3["3. Victim evicted"]
        direction TB
        c_top["[ MRU ]"]:::label
        c1["Attacker garbage"]:::attacker
        c2["Attacker garbage"]:::attacker
        c3["Attacker garbage"]:::attacker
        c_bot["[ LRU ]"]:::label
        c_top --- c1
        c1 --- c2
        c2 --- c3
        c3 --- c_bot
    end

    S1 ==>|"flood begins"| S2
    S2 ==>|"flood continues,<br/>victim ages out"| S3

    classDef victim fill:#cfe8ff,stroke:#3b82f6,color:#1e40af
    classDef attacker fill:#ffe4cc,stroke:#ea580c,color:#9a3412
</pre>
<p>The attacker&rsquo;s requests use <code>max_tokens=1</code> asking the model to stop after generating a single output token. Output generation is where most GPU compute goes; by minimizing output, the attacker lowers their own cost while maximizing cache pressure.</p>
<p><code>flooder.py</code> implements the core flood loop in a few lines:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">flood_worker</span>(stop_event, counter):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">while</span> <span style="color:#f92672">not</span> stop_event<span style="color:#f92672">.</span>is_set():
</span></span><span style="display:flex;"><span>        prompt <span style="color:#f92672">=</span> unique_garbage_prompt()   <span style="color:#75715e"># long unique prefix — fills cache blocks, never matches victim</span>
</span></span><span style="display:flex;"><span>        send_request(prompt, max_tokens<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>) <span style="color:#75715e"># fire and forget; 1 output token = minimal compute cost</span>
</span></span><span style="display:flex;"><span>        counter[<span style="color:#ae81ff">0</span>] <span style="color:#f92672">+=</span> <span style="color:#ae81ff">1</span>
</span></span></code></pre></div><p>in 4 concurrent looping threads. The script measures TTFT before the flood, during, and after — as a stand-in for what a real victim tenant would observe. In a real multi-tenant deployment, the victim is a co-tenant sending their own requests: their TTFT is fast when their prefix is in cache, and spikes when the attacker&rsquo;s flood displaces those blocks — with no visibility into why. That is the impact the measurements below represent.</p>
<hr>
<h2 id="3-attack-economics">3. Attack economics<a href="#3-attack-economics" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The core impact is TTFT degradation and throughput loss. Like any DoS attack, the attacker must sustain the pressure — once the flood stops, the victim re-warms on their next request and recovers immediately. The cost angle is worth noting but depends heavily on the provider&rsquo;s pricing structure: providers that discount cached input tokens make cache misses expensive for the victim; providers that charge a premium for cache writes make flooding expensive for the attacker; pay-per-hour GPU deployments see the impact as throughput loss rather than a direct billing difference. The primary harm is latency degradation regardless of billing model.</p>
<p><strong>Model size amplifies the attack surface.</strong> KV cache memory scales linearly with sequence length — each additional token adds a fixed amount of VRAM. What changes with model size is how much VRAM is left over for the cache after model weights are loaded. Larger models leave less headroom, and the attacker&rsquo;s flood only needs to fill that available space to trigger eviction. Operators running large models on minimum-viable hardware are the most exposed.</p>
<hr>
<h2 id="4-test-results">4. Test results<a href="#4-test-results" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>I ran the flood PoC in two environments: a local llama.cpp setup on my Apple M4 Pro laptop, and a GPU pod running vLLM on an RTX 3090. Both confirmed the attack. The local result is stronger in relative terms because llama.cpp is single-threaded, so flood threads add queuing contention on top of the cache eviction effect.</p>
<h3 id="41-llamacpp--apple-m4-pro--tinyllama-11b">4.1 llama.cpp · Apple M4 Pro · TinyLlama 1.1B<a href="#41-llamacpp--apple-m4-pro--tinyllama-11b" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p>4 flood threads, 60-second flood window, ~400-token garbage prompts. The victim&rsquo;s <code>SECRET_PREFIX</code> is ~619 tokens.</p>
<p><img src="images/flood_timeline_local.png" alt="Cache flood attack timeline — local"></p>
<table>
	<thead>
			<tr>
					<th>Phase</th>
					<th>Victim TTFT</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Before flood (cache warm)</td>
					<td>31 ms</td>
			</tr>
			<tr>
					<td>During flood — t=20s</td>
					<td>1178 ms</td>
			</tr>
			<tr>
					<td>During flood — t=45s</td>
					<td>1315 ms</td>
			</tr>
			<tr>
					<td>During flood — t=68s</td>
					<td>1342 ms</td>
			</tr>
			<tr>
					<td>After flood — first probe</td>
					<td>284 ms</td>
			</tr>
			<tr>
					<td>After re-prime (recovered)</td>
					<td>33 ms</td>
			</tr>
			<tr>
					<td><strong>Sustained degradation factor</strong></td>
					<td><strong>42.8×</strong></td>
			</tr>
	</tbody>
</table>
<p>The flood sent 110 requests in 60 seconds. The extreme degradation factor reflects both eviction and contention: llama.cpp processes requests sequentially, so 4 concurrent flood threads also act as a request queue, stacking latency on the victim&rsquo;s probes during the flood window.</p>
<h3 id="42-vllm-0191--rtx-3090--qwen25-3b-instruct">4.2 vLLM 0.19.1 · RTX 3090 · Qwen2.5-3B-Instruct<a href="#42-vllm-0191--rtx-3090--qwen25-3b-instruct" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p>Same parameters. vLLM batches requests, so flood threads contribute less queuing contention — the degradation reflects mostly cache eviction.</p>
<p><img src="images/flood_timeline.png" alt="Cache flood attack timeline — GPU"></p>
<table>
	<thead>
			<tr>
					<th>Phase</th>
					<th>Victim TTFT</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Before flood (cache warm)</td>
					<td>73 ms</td>
			</tr>
			<tr>
					<td>During flood — t=20s</td>
					<td>670 ms</td>
			</tr>
			<tr>
					<td>During flood — t=42s</td>
					<td>701 ms</td>
			</tr>
			<tr>
					<td>During flood — t=64s</td>
					<td>582 ms</td>
			</tr>
			<tr>
					<td>After flood — first probe</td>
					<td>113 ms</td>
			</tr>
			<tr>
					<td>After re-prime (recovered)</td>
					<td>93 ms</td>
			</tr>
			<tr>
					<td><strong>Sustained degradation factor</strong></td>
					<td><strong>9.7×</strong></td>
			</tr>
	</tbody>
</table>
<p>The flood sent 423 requests in 60 seconds.</p>
<p>Two effects compound during the flood. The primary effect is cache eviction: the victim&rsquo;s cached prefix blocks are displaced, forcing full cold prefill. The secondary effect is request queuing contention: flood threads compete for GPU compute. Both effects are visible in the peak reading of 701 ms — well above the 113 ms cold-miss latency measured immediately after the flood stopped. An attacker with more threads or a higher request rate would see higher contention and correspondingly higher victim TTFT.</p>
<p>The first post-flood probe confirms eviction (cold miss in both environments). Recovery takes exactly one request. This means the attacker must sustain the flood continuously to maintain the degradation — but nothing about the attack requires it to be a one-shot event.</p>
<hr>
<h2 id="5-why-detection-is-hard">5. Why detection is hard<a href="#5-why-detection-is-hard" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>From the provider&rsquo;s side, the flooder looks like a high-volume legitimate customer: diverse prompts, many requests, short outputs — indistinguishable from a batch inference workload without per-tenant cache-hit-rate monitoring. Standard rate limiting misses it if the attacker stays within their tier. The victim sees elevated TTFT and higher-than-expected input token costs but has no direct visibility into what caused the eviction.</p>
<p>The distinguishing signals that <em>could</em> catch it:</p>
<ul>
<li><strong>Per-tenant cache write rate</strong> (bytes/sec and blocks/sec) anomalously high from one API key</li>
<li><strong>Per-tenant cache hit rate</strong> for the victim anomalously low relative to their historical baseline</li>
<li><strong>Correlation</strong> between victim hit-rate drops and attacker write-rate spikes</li>
</ul>
<p>None of these are surfaced by default in vLLM&rsquo;s metrics.</p>
<hr>
<h2 id="6-defenses">6. Defenses<a href="#6-defenses" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>If you&rsquo;re building a multi-tenant product on top of a self-hosted inference server — an API wrapper, an agent platform, a SaaS product where multiple customers share a vLLM or SGLang backend — this attack applies to you directly. The server doesn&rsquo;t know your tenants exist; it sees one request queue and one cache pool. A single misbehaving or malicious customer can degrade service for everyone else, and nothing in the default configuration stops them. The mitigations below are things you need to add.</p>
<p><strong>Per-tenant cache footprint quotas</strong> directly address the flood. Each tenant gets a quota of cache blocks; eviction targets are chosen proportionally within the offending tenant&rsquo;s allocation rather than globally. A tenant filling the pool with garbage only displaces their own entries, not other tenants&rsquo;. Rate-limiting cache writes per tenant (blocks/sec) is the enforcement mechanism.</p>
<p><strong>Tenant-scoped cache keys</strong> (the fix described in the companion post) also eliminate the flood attack entirely as a side effect. If each tenant&rsquo;s entries are keyed with their identity, there is no shared pool to flood — a garbage entry from attacker key A can never displace a legitimate entry from victim key B, because they exist in logically separate namespaces. This is the cleaner fix, but it requires changes to the cache key derivation that per-tenant quotas do not.</p>
<p><strong>Defenses observed in practice.</strong> Anthropic&rsquo;s <a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching">prompt caching</a> largely neutralizes any economic asymmetry in the flood attack. Cache writes are billed at a premium (1.25× base input rate for 5-minute TTL, 2× for 1-hour), so the attacker pays more per token than a normal request. There is a <strong>minimum cacheable prompt size</strong> (1024–4096 tokens depending on model), so each flood request must be substantial to even register a cache write. The net economics: the attacker pays 1.25× base input rate per flood request; each flooded victim request that misses cache pays 1.0× instead of the usual 0.1× — a 10× cost increase for the victim. The attacker&rsquo;s per-token cost is only marginally higher than the per-victim damage they cause, though the aggregate victim impact grows with victim count. In practice, sustaining enough flood volume on a managed API to matter would likely trip rate limits before the economics become interesting.</p>
<hr>
<h2 id="conclusion">Conclusion<a href="#conclusion" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The shared KV cache is not just an information channel — it is also a resource that can be weaponized against co-tenants. The attack requires one API key and a loop. The attacker minimizes output tokens; the victim pays for repeated full re-prefill. Whether that translates into a cost advantage for the attacker depends on the deployment&rsquo;s pricing structure, but the latency degradation is real regardless.</p>
<p>The root cause is the same as for the timing side-channel: cache entries are keyed by token sequence without tenant identity. The mitigations are tenant-scoped cache keys, per-tenant cache quotas, and rate limits — or economic leverage that raises the attacker&rsquo;s cost: write premiums and minimum cacheable prompt sizes.</p>
<hr>
]]></content>
		</item>
		
		<item>
			<title>KV Cache Timing Side-Channel in Multi-Tenant LLMs</title>
			<link>https://brainoverflow.blog/posts/prefix-cache-timing-side-channel/</link>
			<pubDate>Sat, 25 Apr 2026 01:51:22 -0700</pubDate><guid>https://brainoverflow.blog/posts/prefix-cache-timing-side-channel/</guid>
			<description><![CDATA[&lt;no value&gt;]]></description><content type="text/html" mode="escaped"><![CDATA[<p><em>I built a small tool to test whether a managed inference provider&rsquo;s prefix cache leaks timing information across tenant boundaries. Here&rsquo;s what I found, how to run the same test yourself, and what providers should do about it.</em></p>
<p><em>This post covers the timing side-channel — a known attack class I&rsquo;m adding a concrete PoC and DIY testing guide to. My <a href="/posts/kv-cache-flood-dos/">next post</a> covers a novel application of the same shared-cache vulnerability in a DoS attack.</em></p>
<hr>
<h2 id="tldr">TL;DR<a href="#tldr" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Modern LLM inference servers cache the KV tensors produced during prompt prefill and reuse them when a later request shares a prefix. Cache hits can be 10–40× faster than cold prefill — a legitimate and widely deployed optimization. If the cache is shared across tenants without per-tenant key scoping, it becomes a cross-tenant information channel.</p>
<p>The timing side-channel works a bit like a chosen-plaintext attack in classical crypto. Recovering a system prompt from scratch is infeasible: the token-space is astronomical. But an attacker with a reasonable hypothesis — drawn from a competitor&rsquo;s public product, a job posting, a leaked doc — needs only to <em>confirm</em> it. The shared cache becomes a binary oracle: submit the candidate, observe time-to-first-token (TTFT), and the cache tells you whether you are right. On a local llama.cpp setup I measured a 17.8× hit/miss ratio; the correct candidate ranked first in tens of requests total.</p>
<p>The fix — tenant-scoped cache keys — is not deployed by default in most stacks. This post is about why that matters and how to check whether your provider has it.</p>
<p><strong>Get the code:</strong> PoC scripts live in my repo. See <a href="#7-testing-your-provider">Testing your provider</a> for how to run them.</p>
<hr>
<h2 id="1-prior-art">1. Prior art<a href="#1-prior-art" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>This attack class is not new; the academic literature here is solid:</p>
<p><strong>PROMPTPEEK</strong> (NDSS 2025, Wu et al.) is the most directly relevant prior work. It demonstrates a complete prompt recovery pipeline — not just hypothesis confirmation — using a local LLM as an inference oracle to refine partial-match candidates. It targets multi-tenant SaaS LLM deployments, achieves ~99% prompt recovery accuracy, and works against vLLM and SGLang.</p>
<p><strong>InputSnatch</strong> (Zheng et al., arXiv:2411.18191, 2024) demonstrates timing side-channel attacks against prefix caching in vLLM and SGLang, including token-by-token prompt reconstruction in chatbot scenarios. This is the canonical academic reference for the timing oracle in a single-node scope.</p>
<p><strong>CPU cache side-channels</strong> (Flush+Reload, Prime+Probe, etc.) from the 2010s provide the conceptual template. The LLM case is mechanically different but shares the structure: a shared microarchitectural resource, timing as the observation channel, tenant isolation as the missing primitive.</p>
<p>This post adds a concrete, runnable PoC against open-source inference software, empirical measurements on both consumer CPU hardware and a GPU, and a provider testing guide you can run against a real managed API.</p>
<hr>
<h2 id="2-background-prefix-caching-is-standard">2. Background: prefix caching is standard<a href="#2-background-prefix-caching-is-standard" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Every production inference server does prefix caching. The reasoning: prefill is quadratic-ish in prompt length, decode is roughly linear in output length, and most production workloads have long system prompts that are identical across requests. Caching the KV tensors for the shared prefix lets each subsequent request skip straight to decoding the user-specific suffix.</p>
<p>Concrete implementations:</p>
<ul>
<li><strong>vLLM</strong> — <code>--enable-prefix-caching</code> (on by default in recent versions). Block-granular at 16 tokens. Keyed by token hash. Optional per-tenant isolation via a cache salt; see Section 8.1.</li>
<li><strong>SGLang</strong> — RadixAttention. Tree-structured, automatic.</li>
<li><strong>llama.cpp</strong> — <code>--cache-reuse N</code>. Reuses KV when ≥N prefix tokens match something in cache.</li>
<li><strong>TensorRT-LLM</strong> — KV cache reuse via <code>kvCacheConfig.enableBlockReuse</code>.</li>
<li><strong>LMCache</strong> — cluster-level extension turning per-node caches into a shared pool across nodes.</li>
</ul>
<p>All of the above, in their default configurations, key the cache only by the token sequence. If two tenants submit requests that share a prefix, they hit the same cache entry. The cache does not know, or care, which tenant put the entry there.</p>
<p>That property is desirable for batched inference inside a single tenant. It is a security gap when the same cache serves multiple distinct trust domains.</p>
<hr>
<h2 id="3-threat-model">3. Threat model<a href="#3-threat-model" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<pre class="mermaid">flowchart LR
    subgraph Tenants["Tenants (isolated)"]
        direction TB
        V["Victim Pod"]
        A["Attacker Pod"]
        V ~~~ A
    end

    subgraph Cluster["Shared Inference Cluster"]
        direction TB
        S["Inference Server"]
        K[("Shared KV Cache")]
        S <--> K
    end

    V <--> S
    A <--> S
</pre>
<p><strong>System.</strong> A multi-tenant inference service. Each tenant has API access and submits standard completion requests. Tenants may share a node, or sit on different nodes behind a cache-aware router. TTFT is observable to the tenant — either via streaming response, or by timing any request with <code>max_tokens=1</code>.</p>
<p><strong>Attacker capability.</strong> One valid API key. No privileged access. No ability to inspect server state. Ability to time HTTP responses at millisecond resolution — trivially available from any HTTP client.</p>
<p><strong>Attacker goal.</strong> Confirm or recover another tenant&rsquo;s system prompt or shared context. System prompts frequently contain business logic and tenant-specific instructions, embedded credentials or internal URLs (a common deployment mistake), references to internal architecture, and in RAG setups verbatim passages from private documents.</p>
<p><strong>Out of scope.</strong> Attacks requiring host access (kernel exploits, firmware, physical fabric taps). Attacks against model weights. Cache-based availability and cost attacks are covered in the companion post.</p>
<hr>
<h2 id="4-the-attack-mechanically">4. The attack, mechanically<a href="#4-the-attack-mechanically" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>The attacker:</p>
<ol>
<li>Constructs a candidate prefix — a hypothesis about another tenant&rsquo;s system prompt. Sources: the victim&rsquo;s public product, job postings, leaked docs, known templates.</li>
<li>Appends a unique suffix (to avoid matching the attacker&rsquo;s own prior probes).</li>
<li>Submits and records TTFT.</li>
<li>Compares against a pre-established baseline: is this TTFT in the cache-hit band, or the miss band?</li>
</ol>
<p>A result in the hit band means the candidate matches something cached by another tenant. Full prompt recovery — binary search, token-by-token enumeration, local-LLM-guided refinement — is out of scope here; PROMPTPEEK covers that end-to-end. This post focuses on a prerequisite question: is the environment vulnerable to cross-tenant cache attacks in the first place? If the timing signal is absent, none of the more sophisticated attacks are viable.</p>
<hr>
<h2 id="5-proof-of-concept">5. Proof of concept<a href="#5-proof-of-concept" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>I ran this in two environments: first on my MacBook Pro (Apple M4 Pro, llama.cpp with Metal acceleration) to establish the baseline signal locally without any cloud spend, then on a rented RTX 3090 GPU pod running vLLM to confirm the attack holds on the inference stack that managed providers actually use. Both show the same result — one probe, correct identification — at different absolute latency scales.</p>
<p><strong>Setup:</strong></p>
<ul>
<li><code>llama-server</code> from llama.cpp, release build with Metal acceleration</li>
<li>Model: TinyLlama 1.1B (GGUF, ~670 MB)</li>
<li>Hardware: Apple M4 Pro, 24 GB unified memory</li>
<li>Server flags: <code>-c 4096 --cache-reuse 16 -ngl 99 --port 8000</code></li>
</ul>
<p>Two client processes (&ldquo;tenants&rdquo;):</p>
<ul>
<li><strong>Victim</strong> (<code>victim.py</code>): loops every ~10 seconds, sends <code>SECRET_PREFIX + &lt;random user query&gt;</code>. Uses <code>max_tokens=1</code> for measurement convenience — a real victim generates full responses, but the cache behavior is the same: the prefix is cached after prefill, before any output is produced. <code>SECRET_PREFIX</code> is a 743-token system prompt — realistic for a customer-support or internal tooling bot.</li>
<li><strong>Attacker</strong> (<code>attacker.py</code>): runs a baseline to characterize hit/miss TTFT distributions, then submits five candidate prefixes.</li>
</ul>
<p>The five candidates:</p>
<table>
	<thead>
			<tr>
					<th>#</th>
					<th>Candidate</th>
					<th>Relationship to secret</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>0</td>
					<td>Same company name, different prompt body</td>
					<td>Near-miss</td>
			</tr>
			<tr>
					<td>1</td>
					<td>Same company, wrong escalation policy</td>
					<td>Shallow partial match</td>
			</tr>
			<tr>
					<td>2</td>
					<td><code>SECRET_PREFIX</code></td>
					<td>Exact match</td>
			</tr>
			<tr>
					<td>3</td>
					<td>Different company name, similar structure</td>
					<td>Unrelated</td>
			</tr>
			<tr>
					<td>4</td>
					<td>Entirely different domain</td>
					<td>Unrelated</td>
			</tr>
	</tbody>
</table>
<p>All candidates padded to approximately the same token count as <code>SECRET_PREFIX</code> (~743 tokens). Why this matters is covered in Section 6.</p>
<p><strong>Baseline (5 samples each):</strong></p>
<p><img src="images/baseline_local.png" alt="Baseline hit vs miss TTFT distributions"></p>
<table>
	<thead>
			<tr>
					<th>Condition</th>
					<th>Median TTFT</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Cold miss (unique UUID prefix, never cached)</td>
					<td>195 ms</td>
			</tr>
			<tr>
					<td>Warm hit (prefix primed, then measured)</td>
					<td>11 ms</td>
			</tr>
			<tr>
					<td>Ratio</td>
					<td><strong>17.8×</strong></td>
			</tr>
	</tbody>
</table>
<p>The 17.8× ratio gives a detection threshold of ~97 ms (half the miss median). Any probe below that is, with high confidence, a cache hit.</p>
<p><strong>Detection (1 probe per candidate, padded to ~743–765 tokens):</strong></p>
<p><img src="images/detect_local.png" alt="Candidate detection results"></p>
<table>
	<thead>
			<tr>
					<th>Candidate</th>
					<th>Raw tokens</th>
					<th>Norm tokens</th>
					<th>TTFT</th>
					<th>Classification</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ACME near-miss, different body</td>
					<td>148</td>
					<td>748</td>
					<td>268 ms</td>
					<td>Miss</td>
			</tr>
			<tr>
					<td>ACME assistant, wrong escalation policy</td>
					<td>386</td>
					<td>746</td>
					<td>269 ms</td>
					<td>Miss</td>
			</tr>
			<tr>
					<td>Exact match (<code>SECRET_PREFIX</code>)</td>
					<td>743</td>
					<td>743</td>
					<td><strong>45 ms</strong></td>
					<td>Cache hit — ranked #1</td>
			</tr>
			<tr>
					<td>Northwind Logistics (different company)</td>
					<td>225</td>
					<td>765</td>
					<td>276 ms</td>
					<td>Miss</td>
			</tr>
			<tr>
					<td>Writing tutor, unrelated domain</td>
					<td>146</td>
					<td>746</td>
					<td>268 ms</td>
					<td>Miss</td>
			</tr>
	</tbody>
</table>
<p>The exact-match candidate ranked first at ~45 ms, clearly separated from the cold-miss cluster of 268–276 ms. The miss candidates are tightly clustered because normalization brought all of them to within 20 tokens of each other.</p>
<p>The local result confirms the PoC works, but treat the absolute numbers with some skepticism: llama.cpp is single-threaded, so concurrent requests queue and inflate miss latency; Apple Silicon&rsquo;s unified memory architecture produces different prefill timing characteristics than a discrete GPU. The vLLM replication below is the more representative data point for production inference stacks.</p>
<h3 id="51-vllm-replication-on-gpu">5.1 vLLM replication on GPU<a href="#51-vllm-replication-on-gpu" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p>This is a small-scale experiment — a single rented GPU pod, low concurrent load, a 3B model. Real production deployments differ in model size, cluster topology, and load patterns, all of which affect the absolute numbers. The goal here is to confirm the signal exists on the inference stack that providers actually use, not to quantify it at production scale.</p>
<p><strong>Setup:</strong></p>
<ul>
<li>vLLM 0.19.1 with <code>--enable-prefix-caching</code>, block size 16 tokens</li>
<li>Model: <code>Qwen/Qwen2.5-3B-Instruct</code></li>
<li>Hardware: RTX 3090 (24 GB VRAM), 32 vCPUs</li>
<li>Server flags: <code>--enable-prefix-caching --max-model-len 4096</code></li>
</ul>
<p><code>SECRET_PREFIX</code> is 3,714 characters / <strong>619 tokens</strong> (exact count via vLLM&rsquo;s <code>/tokenize</code> API — the common 4 chars/token English heuristic significantly overestimates for natural-language prose; this model tokenizes at ~6 chars/token). The victim&rsquo;s initial cold prefill of this prefix took ~1,460 ms, reflecting both CUDA warmup overhead on first inference and the 619-token prefill pass.</p>
<p><strong>Baseline (5 samples each, prompts padded to 3,714 chars / ~619 tokens):</strong></p>
<p><img src="images/baseline_vllm.png" alt="Baseline hit vs miss TTFT distributions"></p>
<table>
	<thead>
			<tr>
					<th>Condition</th>
					<th>Median TTFT</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Cold miss (unique UUID-prefixed, never cached)</td>
					<td>120 ms</td>
			</tr>
			<tr>
					<td>Warm hit (SECRET_PREFIX primed, then measured)</td>
					<td>69 ms</td>
			</tr>
			<tr>
					<td>Ratio</td>
					<td><strong>1.75×</strong></td>
			</tr>
	</tbody>
</table>
<p>The 1.75× ratio is specific to this setup — available VRAM, cluster load, hardware, virtualization layer, and network latency all affect the absolute numbers. In general, the ratio will vary, and a lower ratio doesn&rsquo;t mean the attack doesn&rsquo;t work: what matters is whether the hit and miss clusters are separable, which they were in both environments tested here.</p>
<p><strong>Detection (1 probe per candidate, candidates token-padded to ~619–636 tokens):</strong></p>
<p><img src="images/detect_vllm.png" alt="Candidate detection results"></p>
<table>
	<thead>
			<tr>
					<th>Candidate</th>
					<th>Raw tokens</th>
					<th>TTFT</th>
					<th>Classification</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ACME near-miss, different body</td>
					<td>126 → 630 padded</td>
					<td>133 ms</td>
					<td>Miss</td>
			</tr>
			<tr>
					<td>ACME assistant, wrong escalation policy</td>
					<td>348 → 636 padded</td>
					<td>133 ms</td>
					<td>Miss</td>
			</tr>
			<tr>
					<td>Exact match (<code>SECRET_PREFIX</code>)</td>
					<td>619</td>
					<td><strong>76 ms</strong></td>
					<td>Cache hit — ranked #1</td>
			</tr>
			<tr>
					<td>Northwind Logistics (different company)</td>
					<td>194 → 626 padded</td>
					<td>136 ms</td>
					<td>Miss</td>
			</tr>
			<tr>
					<td>Writing tutor, unrelated domain</td>
					<td>127 → 631 padded</td>
					<td>136 ms</td>
					<td>Miss</td>
			</tr>
	</tbody>
</table>
<p>The exact-match candidate ranked first at ~76 ms, clearly separated from the cold-miss cluster of 133–136 ms. The miss candidates are tightly clustered because they are padded to nearly equal token counts with neutral filler — equalizing length is critical; a shorter cold-miss candidate prefills faster than a longer cache-hit candidate and inverts the expected ranking.</p>
<hr>
<h2 id="6-measurement-methodology">6. Measurement methodology<a href="#6-measurement-methodology" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Building this PoC surfaced three requirements for reliable signal worth documenting.</p>
<p><strong>Pad all candidates to equal length.</strong> Prefill time scales with token count — a short miss can prefill faster than a long hit, inverting the ranking. Candidates must be padded to match the target prefix length. Character-count padding is imprecise since tokenization density varies by content type. The PoC calls the server&rsquo;s tokenizer API (<code>/tokenize</code>) to measure and pad to an exact token count.</p>
<p><strong>One probe per candidate.</strong> The first probe caches that candidate on the server, polluting the oracle — a second probe always hits the attacker&rsquo;s own entry. One measurement per candidate per API key; re-probe with a fresh key or wait for eviction.</p>
<p><strong>Victim contention adds noise.</strong> In-flight victim requests can queue ahead and inflate the matching candidate&rsquo;s TTFT on a given round. Taking <code>min(TTFT)</code> across multiple rounds handles it: misses never produce a reading near the hit floor regardless of how many rounds you take; genuine hits will produce at least one clean reading there.</p>
<hr>
<h2 id="7-testing-your-provider">7. Testing your provider<a href="#7-testing-your-provider" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>This is the part I built the tool for. If you use a managed GPU inference API and send sensitive system prompts, you can test whether the provider&rsquo;s shared cache leaks across tenant boundaries. You need two separate accounts on the same provider and model — production and staging, two colleague accounts, whatever. Step-by-step instructions are in the <a href="https://github.com/obormot/llm-kv-cache-attacks-poc">repo README</a>.</p>
<p><strong>This is a self-assessment, not an attack.</strong> You&rsquo;re testing infrastructure you pay for. Do not probe another organization&rsquo;s prompts. If you find a shared cache without isolation, ask your provider whether they offer configurable per-tenant cache isolation — it may already exist as an opt-in feature, a contract tier, or a flag you can request.</p>
<p>The attacker script runs baseline characterization and candidate detection in one shot. The ratio alone is not the signal; what matters is whether the matching candidate ranks clearly below the cold-miss cluster. If all candidates cluster together, either the cache is isolated or the victim hasn&rsquo;t warmed it yet — confirm at least one &ldquo;keepalive ok&rdquo; log line and retry.</p>
<hr>
<h2 id="8-defenses">8. Defenses<a href="#8-defenses" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<h3 id="81-tenant-scoped-cache-keys">8.1 Tenant-scoped cache keys<a href="#81-tenant-scoped-cache-keys" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p>This is the fix that categorically eliminates the timing side-channel.</p>
<p>Key the cache by <code>HMAC(tenant_id, token_sequence)</code> rather than by the token sequence alone. Cross-tenant hits become impossible; the side channel disappears because the shared primitive is gone.</p>
<p><strong>Cost:</strong> zero within-tenant. Every tenant keeps the full prefix-caching benefit for their own traffic. You lose cross-tenant sharing — which should be the default stance anyway, and can be opted into for genuinely public prompts by scoping those entries to a <code>public</code> tenant identifier.</p>
<p><strong>vLLM&rsquo;s implementation.</strong> vLLM ships this as a first-class feature documented under <a href="https://docs.vllm.ai/en/latest/design/prefix_caching/">&ldquo;Cache Isolation for Security&rdquo;</a>. The salt must derive from the tenant identity — e.g. <code>HMAC(server_secret, tenant_id)</code>.</p>
<h3 id="82-ttft-jitter--constant-time-padding">8.2 TTFT jitter / Constant-time padding<a href="#82-ttft-jitter--constant-time-padding" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p>Add uniform random delay to first-token emission. Jitter raises the number of probes an attacker needs to distinguish hit from miss — the wider the jitter window relative to the hit/miss gap, the more samples required.</p>
<p>The trade-off: jitter adds artificial latency to every response, including legitimate ones. A window wide enough to meaningfully obscure a 50–100ms hit/miss gap degrades TTFT SLA for all tenants.</p>
<p>Constant-time padding — holding first-token emission to a fixed ceiling — eliminates the signal entirely but effectively nullifies the business case for prefix caching.</p>
<p>Neither approach addresses the root cause. The shared cache namespace remains intact; you are making it harder to read, not removing it. Both are anti-attack measures that raise attacker cost, but hurt legitimate users, making the measures impractical.</p>
<h3 id="83-anomaly-detection-on-probing-patterns">8.3 Anomaly detection on probing patterns<a href="#83-anomaly-detection-on-probing-patterns" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p>Probing has a characteristic signature: many requests from the same API key with the same prefix and monotonically varying suffixes, or the same suffix with systematically varying prefixes. Useful as a tripwire; not reliable prevention against a well-resourced attacker distributing probes across keys and time.</p>
<hr>
<h2 id="9-discussion">9. Discussion<a href="#9-discussion" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<h3 id="91-why-this-is-a-multi-tenant-problem">9.1 Why this is a multi-tenant problem<a href="#91-why-this-is-a-multi-tenant-problem" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p>Within a single tenant, prefix cache hits are a pure win — faster responses, lower compute cost, no security concern. The attack requires a cache serving two distinct trust domains from a shared namespace. That describes: managed inference APIs serving multiple paying customers; internal multi-team platforms where teams run on shared infrastructure; any inference endpoint behind a cache-aware router in front of a shared pool.</p>
<h3 id="92-why-it-hasnt-been-fixed">9.2 Why it hasn&rsquo;t been fixed<a href="#92-why-it-hasnt-been-fixed" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p>Here is a Spectre/Meltdown parallel. CPU architects optimized for performance — speculative execution, shared branch predictors, unified caches — and the security consequences of sharing microarchitectural state across trust boundaries were not addressed. The fixes such as retpoline imposed 10–30% performance regressions that made deployment a business decision, not just an engineering one.</p>
<p>The same tension applies here. Tenant-scoped cache keys are the correct fix and not technically difficult. But they eliminate cross-tenant prefix sharing, which is where a meaningful fraction of the advertised throughput and latency gains come from in a densely-packed multi-tenant cluster. Deploying the fix means accepting a performance regression that has to be sold to product and finance — and in a market where TTFT and cost-per-token are the headline numbers, that conversation is slow.</p>
<p>The result is the same pattern seen post-Spectre: the vulnerability is documented, the fix is known, deployment lags because the economic incentive to ship the fix is weaker than the incentive to maintain the performance headline.</p>
<h3 id="93-on-disclosure">9.3 On disclosure<a href="#93-on-disclosure" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h3>
<p>This attack is public — PROMPTPEEK and InputSnatch cover it thoroughly, and the PoC here adds reproducibility and a runnable verification tool without introducing a novel primitive. Nothing here requires formal coordinated disclosure, but operators of multi-tenant inference APIs who have not implemented tenant-scoped cache keys should treat this as a prompt to do so.</p>
<hr>
<h2 id="conclusion">Conclusion<a href="#conclusion" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p>Prefix caching is one of the single most impactful optimizations in modern LLM serving. It is also a cross-tenant side channel by default, because the cache key is usually only the token sequence.</p>
<p>Confirming a hypothesis about another tenant&rsquo;s cached system prompt requires an API key, an HTTP client, and a handful of well-constructed probes — the correct candidate separated clearly from the field in both test environments. The PoC scripts in the repo make it reproducible without a research lab setup, and the provider testing guide in Section 7 gives anyone running a sensitive workload a practical way to check whether their provider has the fix deployed.</p>
<p>Tenant-scoped cache keys is a straighforward fix. The reason it is not yet default is not technical. This post is a small contribution to making the conversation louder.</p>
<p>My next post examines what else an attacker can do with a shared cache once confidentiality is off the table.</p>
<hr>
<h2 id="appendix-a--environment">Appendix A — Environment<a href="#appendix-a--environment" class="anchor" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
      stroke-linecap="round" stroke-linejoin="round" class="feather">
      <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
      <line x1="8" y1="12" x2="16" y2="12"></line>
   </svg></a></h2>
<p><strong>llama.cpp setup (Section 5):</strong></p>
<ul>
<li>llama.cpp, release build with Metal acceleration</li>
<li>Model: TinyLlama 1.1B (GGUF, ~670 MB)</li>
<li>Hardware: Apple M4 Pro, 24 GB unified memory</li>
<li>Server flags: <code>-c 4096 --cache-reuse 16 -ngl 99 --port 8000</code></li>
</ul>
<p><strong>vLLM setup (Section 5.1):</strong></p>
<ul>
<li>vLLM version: 0.19.1</li>
<li>Model: <code>Qwen/Qwen2.5-3B-Instruct</code></li>
<li>Hardware: RTX 3090 (24 GB VRAM), 32 vCPUs</li>
<li>Server flags: <code>--enable-prefix-caching --max-model-len 4096 --port 8000</code></li>
<li>KV cache block size: 16 tokens (vLLM default)</li>
<li>No cache salt / tenant isolation configured (intentional — demonstrates the unmitigated attack surface)</li>
</ul>
<hr>
]]></content>
		</item>
		
	</channel>
</rss>
