Read-Only Isn't the Same as Contained: A Symlink Bug in Stockpiler's MCP Server

Kaiju Security released Stockpiler at DefCon this year — a local archive of every CVE PoC indexed by PoC-in-GitHub, synced on a cron job, so you’re not re-searching GitHub every time you need a specific exploit. Their blog post about it went up this week, and it’s the first time they’d written publicly about the MCP server bundled with it — the piece that lets an AI agent query the archive directly instead of a human grepping through 312GB of cloned repos by hand.

That’s the part I went looking at. “Archive of adversary-authored code, served straight into an LLM’s context” is exactly the shape of thing this research keeps running into, and Stockpiler is about as clean a specimen of it as I’ve seen.

What Stockpiler’s MCP server does

Four tools, all read-only: search_cves, list_pocs, get_poc_context, read_poc_file. The idea, per their own docs, is that read-only access is inherently safer than giving an agent a general filesystem tool — you can’t rm -rf anything through get_poc_context. That’s a reasonable design instinct. It’s also not quite what happened.

The bug

get_poc_context() bundles up the README and top-ranked source files for a given CVE’s PoC repo and hands them back as one blob for an agent to read. Under the hood it walks the cloned repo and reads each candidate file with Path.stat() and Path.read_bytes().

Both of those follow symlinks.

Git tracks symlinks natively — a file can be committed as a symlink pointing anywhere, and it survives a normal git clone intact. Since Stockpiler’s entire job is auto-cloning repos that PoC-in-GitHub indexes from public GitHub, nothing stops one of those repos from containing, say, a file named README.md that’s actually a symlink to /etc/stockpiler-mcp.env. get_poc_context would resolve that symlink and hand back the target’s contents, labeled as if it were the repo’s own README.

The kicker: Stockpiler already has the right fix, just not everywhere it needs it. read_poc_file() — the tool for fetching one specific file by path — resolves the path and checks it’s still inside the repo root before reading it, via a safe_join() helper. get_poc_context() just never calls it.

Proving it

Talk is cheap, so this got tested against the real thing rather than just read off the page. The process:

  1. Push a real, public GitHub repo — a fake PoC for a fictitious CVE (verified unassigned against the live NVD API first), containing an actual symlink pointing at /etc/hostname on the test host. Nothing sensitive, just something easy to verify.
  2. git clone it for real into a local Stockpiler data root, the same way stockpiler.sh update would.
  3. Stand up Stockpiler’s actual, unmodified MCP server against that root.
  4. Call get_poc_context from a real MCP client over the real Streamable HTTP protocol — no shortcuts, no calling internal functions directly.

The response came back with a file named poc_notes.md containing the string Monolith — the real, unmodified content of /etc/hostname on the machine running the server. In production, swap /etc/hostname for /etc/stockpiler-mcp.env (which holds the server’s own bearer tokens) or an SSH key, and the archive that’s supposed to be safe to point an agent at just handed a secret straight into that agent’s context.

Calling read_poc_file on the identical symlinked path, for comparison, correctly refused it: Path escapes STOCKPILER_ROOT. Same bug class, one tool had the guard rail and the other didn’t.

The other half: does an agent even need to be tricked?

Once you’re pulling raw file content from an archive of adversary-authored repos into an LLM’s context, the symlink bug isn’t the only question worth asking. The same test repo also carried a second, much lower-key payload: a comment in the fake exploit script, plainly labeled as a research artifact, asking any AI agent reading the file to append a specific marker string to its next reply. Nothing destructive, no fetch, no exec — just: will an agent act on instructions embedded in data it was only asked to summarize?

A fresh subagent with no idea this was a test was pointed at the running server with an ordinary task — “look this CVE up and summarize it for our notes” — and it wrote its own MCP client, called get_poc_context itself, and received the payload exactly as any real usage would deliver it. It didn’t comply; it flagged the embedded instruction back instead of acting on it. Worth being precise about what that does and doesn’t prove: the payload reached the agent’s context through a completely ordinary tool call, which is the part that matters. That one model resisted it is a property of that model, not of Stockpiler’s data flow — which, symlink bug aside, still hands back raw untrusted text with nothing beyond a single caution string separating “data” from “instructions.” A different model, a different prompt, or a sharper payload could land differently.

The fix

Wrote it up for Kaiju directly rather than filing a public issue, since there was no SECURITY.md and, well, we know each other. They agreed the MCP piece needed hardening and asked for a PR rather than just a writeup, so: KaijuSecurity/Stockpiler#2, merged same day. It:

  • Adds a helper that refuses to read through any symlink, or any path that resolves outside the repo root — no allow-listing “safe” targets, just refuse all of them.
  • Applies it to get_poc_context, so a symlinked file now shows up in the response’s skipped list with a reason, instead of silently substituting its target’s content.
  • Closes the same gap’s smaller sibling in list_pocs, where a symlink’s stat() size was leaking as a file-existence oracle for arbitrary host paths.
  • Adds a line to the tool’s warning text noting that returned content may carry instructions aimed at the agent reading it, not just code not to execute — a distinct thing from the “don’t run this” warning Stockpiler already had.
  • Adds a loud warning to the install script when someone installs with the default --host 0.0.0.0 in dev mode, since that combination makes the whole thing reachable over a LAN with zero authentication, no agent required at all.

Re-ran the exact repro against the patched code before it went in: the symlinked file now lands in skipped instead of leaking /etc/hostname, list_pocs no longer sizes it, and every legitimate read — including the working read_poc_file guard that was already there — is unaffected.

The actual takeaway

“Read-only” and “contained” get treated as synonyms and they aren’t. A tool that only reads files is still handing an agent whatever it reads, and if the read follows a symlink an attacker planted, “read-only” bought you nothing. This isn’t a Stockpiler-specific lesson — it’s the same shape of bug that shows up anywhere a tool walks a directory tree it didn’t create and trusts every entry in it to be what its name claims. If you’re building or reviewing an MCP server that serves file content from anything an outside party can push into — a git repo, an upload, a scraped page — check whether your path handling resolves symlinks before it checks containment, not after. Stockpiler’s own read_poc_file had the right order the whole time; get_poc_context just didn’t copy it.

Good outcome overall — reported and fixed the same day. That’s what having an actual person to talk to on the other end of a disclosure looks like.