← All writeups
Medium HackerOne · Vercel Open Source Duplicate (Independent Discovery)

Reading /root/.ssh/id_rsa Through an AI Agent: Path Traversal in Vercel's vercel-optimize Skill (CWE-22/73)

path-traversallocal-file-readai-agent-securityprompt-injectionnode

By Kavennesh Balachandar · HackerOne · Vercel Open Source Bug Bounty Program

# TL;DR

vercel-optimize (in vercel-labs/agent-skills) checks the recommendations its LLM sub-agents produce by reading the files those recommendations reference. The path helper repoPaths() applies no containment — absolute paths are returned verbatim, and relative paths are joined without rejecting ../. Because those paths come from sub-agent output generated while reading a possibly-untrusted codebase, an attacker who gets content into an analyzed repo can steer a sub-agent (indirect prompt injection) into emitting paths like /root/.ssh/id_rsa or ../../../../.aws/credentials. The verifier reads them, and its pattern_count / pattern_exists results become an existence-and-content oracle. Concrete local file disclosure via an AI agent — exactly the "prompt injection with real impact" class the program invites. Severity: Medium (CVSS 5.0), gated at AC:H because it needs the victim to run the skill on attacker-influenced content.

# Why this is an AI-agent bug, not a plain path-traversal bug

Path traversal is old. What makes this one interesting is where the tainted path comes from. vercel-optimize is an "agent skill" — a workflow that spawns LLM sub-agents to analyze a user's codebase and propose optimizations. To avoid hallucinated recommendations, it has a nice idea: verify each claim by actually reading the file it points at and checking the pattern is there.

The catch is that the file paths being verified are authored by the sub-agents, and the sub-agents author them while reading the target repository. So the repo's contents can influence what paths get emitted. A traversal payload doesn't arrive in an HTTP parameter here — it arrives as model output that was shaped by attacker-controlled input the model read. The verifier then treats that output as a trustworthy filesystem path.

# The vulnerable code

skills/vercel-optimize/lib/verify-claim.mjs:

function repoPaths(repoRoot, file, projectRootDirectory = null) {
  if (!file) return [];
  if (isAbsolute(file)) return [file];        // (1) absolute path returned verbatim
  const out = [join(repoRoot, file)];         // (2) '../' in `file` escapes repoRoot
  // ...
}

async function readClaimFile(claim) {
  const path = await firstAccessiblePath(claim);
  return { path, content: await readFile(path, 'utf-8') };   // sink
}

Two independent escapes: an absolute path is returned and read as-is, and a relative path is joined without a normalize-then-verify step, so join(repoRoot, "../../../../etc/passwd") walks straight out of repoRoot. The verifier functions verifyFileExists, verifyPatternCount, and verifyPatternExists call this on attacker-influenced claim.file / claim.repoRoot — and pattern_count returns the raw match count in its result, which is a direct content-exfiltration oracle.

# The taint path — untrusted repo content to file read

  1. vercel-optimize spawns sub-agents to read the user's codebase.
  2. scripts/collect-sub-agent-outputs.mjs assembles recommendations.json verbatim from those sub-agents' output (isRecordObject only checks a key exists).
  3. lib/extract-claims.mjs copies each recommendation's affectedFiles[], findingRefs[], and verifiableClaims[].{file,repoRoot} into claims with no sanitization.
  4. scripts/verify-and-regen.mjsverifyClaim()readClaimFile()readFile(unconstrainedPath).

So a malicious file in an analyzed repo — a dependency, an external contributor's PR branch, an MDX doc, any attacker-supplied project — is indirect-prompt-injection input that can make a sub-agent emit file: "/root/.ssh/id_rsa".

Amplifier: lib/repo-root.mjs detectRepoRoot() lets an attacker-controlled affectedFiles[0] (e.g. /etc/passwd) pin repoRoot to /, after which even plain relative claim paths resolve from the filesystem root.

# Proof of concept

The PoC drives the real shipped CLI (scripts/verify-finding.mjsverifyClaim) with attacker-controlled claims against fixtures — a fake "analyzed project" plus an out-of-repo .aws/credentials:

git clone https://github.com/vercel-labs/agent-skills
VOPT_DIR="$PWD/agent-skills/skills/vercel-optimize" node poc_file_read.mjs
✓ absolute path /etc/passwd read -> verified ("/etc/passwd exists")
✓ content of out-of-repo credentials read -> match count leaked: actual=1
✓ '../' from repoRoot escaped to the secret -> verified
✓ pattern_exists oracle reflects real bytes (correct guess=verified, wrong guess=failed)

Each verifier mode is a different primitive:

  • file_exists on an absolute out-of-project path → an existence oracle.
  • pattern_count returns the real match count of an out-of-project file → content read plus a numeric oracle.
  • file_exists with repoRoot=<project>/app, file="../../.../credentials" → the ../ escape.
  • pattern_exists returns verified for the correct secret bytes and failed for a wrong guess → anchored-regex, byte-by-byte secret reconstruction.

The leaked results are written to verify.json and flow back into the orchestrating agent's context and the rendered report — so the exfiltrated bytes don't just sit in a log, they re-enter the agent loop.

# Impact

Disclosure of the existence and content of any file readable by the agent process — SSH private keys, cloud credentials, .env secrets, tokens, and files belonging to other projects on the same machine.

Severity: Medium — CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N (5.0). The AC:H / UI:R reflect that exploitation requires the victim to run vercel-optimize on attacker-influenced content. The honest escalation note: if this skill is ever run server-side on untrusted repositories, the same defect becomes a remote, cross-tenant file read (High/Critical). That's flagged for the team, not claimed here.

# Remediation

Resolve-then-verify containment for every candidate path, and refuse absolute paths:

import { resolve, sep } from 'node:path';
function repoPaths(repoRoot, file) {
  if (!file) return [];
  const base = resolve(repoRoot);
  const p = resolve(base, file);                          // collapses '../'
  if (p !== base && !p.startsWith(base + sep)) return []; // reject escapes + absolute
  return [p];
}

Additionally: don't let a recommendation override repoRoot, and don't use untrusted affectedFiles[0] as a repo-root probe.

# The broader lesson

Any agent step that turns model output into a filesystem, network, or shell operation is a trust boundary, even when the model is "yours." Verification code that reads model-chosen paths has to treat those paths as fully untrusted input — because upstream, they were shaped by whatever the model read. The fix is the same discipline as any traversal sink: normalize, then confine to an allowed root.

# Disclosure timeline

  • 2026-06-26 — Reported to Vercel Open Source via HackerOne (#3827691), against vercel-labs/agent-skillsskills/vercel-optimize.
  • Closed as duplicate (independent discovery).
  • Public disclosure pending program approval.

# References