{"id":"GHSA-cfcj-hqpf-hccf","aliases":[],"url":"https://o3.security/vulnerability/GHSA-cfcj-hqpf-hccf","summary":"@evomap/evolver: Path Traversal in `evolver fetch` default-branch `safeId` allows Hub-controlled overwrite of project files (RCE)","details":"## Summary\n\nThe `evolver fetch` subcommand in `index.js` writes Hub-supplied `bundled_files[]` into a directory derived from a Hub-supplied `skill_id`. When `--out` is not used, the path-sanitizing regex permits `.` characters, allowing a `skill_id` of `..` to escape the `skills/` subdirectory and resolve to the user's current working directory. Combined with the file-extension allow-list (which includes `.js`/`.json`/`.sh`/`.py`/`.md`), this lets a malicious Hub overwrite the victim's `index.js`, `package.json`, or other files in cwd, achieving remote code execution on the next invocation of the evolver.\n\n## Details\n\nThe vulnerable code is in the `fetch` command handler:\n\n```js\n// index.js:847-873\nconst data = await resp.json();\nconst outFlag = args.find(a => typeof a === 'string' && a.startsWith('--out='));\nconst safeId = String(data.skill_id || skillId).replace(/[^a-zA-Z0-9_\\-\\.]/g, '_');\nlet outDir;\nif (outFlag) {\n  const rawOut = outFlag.slice('--out='.length);\n  // ...\n  const resolvedOut = path.resolve(process.cwd(), rawOut);\n  const cwd = path.resolve(process.cwd());\n  const rel = path.relative(cwd, resolvedOut);\n  if (rel.startsWith('..') || path.isAbsolute(rel)) {     // <-- traversal check exists for --out\n    console.error('[fetch] --out= must resolve to a path inside the current working directory');\n    process.exit(1);\n  }\n  outDir = resolvedOut;\n} else {\n  outDir = path.join('.', 'skills', safeId);              // <-- NO traversal check\n}\n\nif (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });\n```\n\nThree problems compose:\n\n1. **The regex allow-list permits `.`** — `[^a-zA-Z0-9_\\-\\.]` only strips characters *outside* this set, so the literal dot is preserved. A `skill_id` of `..` (verified: `'..'.replace(/[^a-zA-Z0-9_\\-\\.]/g,'_') === '..'`) survives sanitization.\n2. **`path.join` collapses `..` traversal** — `path.join('.', 'skills', '..')` evaluates to `'.'` (the cwd), so `outDir` is now the user's working directory rather than `./skills/<id>`.\n3. **The traversal validation only runs in the `--out` branch** — the default branch (the documented common case for `evolver fetch --skill <id>`) has no `path.relative(...).startsWith('..')` check.\n\nThe bundled-files write loop:\n\n```js\n// index.js:881-906\nconst ALLOWED_SKILL_EXTENSIONS = new Set([\n  '.js', '.mjs', '.cjs', '.ts', '.json', '.md', '.txt',\n  '.sh', '.py', '.yml', '.yaml',\n]);\n// ...\nfor (const file of bundled) {\n  if (!file || !file.name || typeof file.content !== 'string') continue;\n  const safeName = path.basename(file.name);                       // basename of \"index.js\" is \"index.js\"\n  const ext = path.extname(safeName).toLowerCase();\n  if (!ALLOWED_SKILL_EXTENSIONS.has(ext)) { /* skip */ continue; }\n  if (Buffer.byteLength(file.content, 'utf8') > MAX_SKILL_FILE_BYTES) { /* skip */ continue; }\n  fs.writeFileSync(path.join(outDir, safeName), file.content, 'utf8');\n}\n```\n\n`path.basename` strips directory components from the *file name*, but a basename of `index.js` is still `index.js`. The extension allow-list contains `.js`, so an attacker can write `./index.js` (the evolver entry point itself), `./package.json`, `./SKILL.md`, etc.\n\nThere is no signature verification on the Hub response. `buildHubHeaders()` only authenticates the *outgoing* request; the response body is trusted as-is. The Hub stores skills uploaded by network participants, so any participant who can set a stored `skill_id` field to `..` triggers this on every download.\n\n## PoC\n\nReproduces the exact code path from `index.js:849-905`:\n\n```bash\ncd /tmp && rm -rf evolver-poc-validate && mkdir evolver-poc-validate && \\\n  cp /path/to/EvoMap-evolver-src/index.js evolver-poc-validate/\ncd evolver-poc-validate\nwc -l index.js                                  # 1098 index.js (legitimate)\n\nnode -e \"\nconst fs=require('fs'),path=require('path');\nconst data={\n  skill_id:'..',\n  content:'x',\n  bundled_files:[{name:'index.js',content:'#!/usr/bin/env node\\nconsole.log(\\\"PWNED\\\");'}]\n};\nconst safeId=String(data.skill_id||'x').replace(/[^a-zA-Z0-9_\\-\\.]/g,'_');\nconst outDir=path.join('.','skills',safeId);\nconsole.log('safeId:',JSON.stringify(safeId));   // '..'\nconsole.log('outDir:',JSON.stringify(outDir));   // '.'\nif(!fs.existsSync(outDir))fs.mkdirSync(outDir,{recursive:true});\nfor(const f of data.bundled_files){\n  const n=path.basename(f.name);\n  fs.writeFileSync(path.join(outDir,n),f.content);\n}\"\n\nwc -l index.js                                  # 1 index.js  (clobbered)\nhead -3 index.js\n# #!/usr/bin/env node\n# console.log(\"PWNED\");\n```\n\nVerified output: 1098 → 1 line; the legitimate evolver entry point is replaced with attacker-controlled JavaScript. Any subsequent `node index.js <command>` (including the `--loop` daemon mode that users run continuously) executes the attacker payload.\n\nEnd-to-end attack:\n1. Attacker uploads a skill to the A2A Hub whose stored `skill_id` is `..` (or operates a malicious Hub / MitMs the connection / supplies a malicious `A2A_HUB_URL`).\n2. The malicious response also carries `bundled_files: [{name: 'index.js', content: '<attacker JS>'}]`.\n3. Victim runs `node index.js fetch --skill=anything` from the evolver checkout (the documented usage).\n4. `./index.js` is overwritten in place.\n5. Victim's next `node index.js` invocation — even just `node index.js --help` or the `run --loop` daemon — executes attacker code with the victim's privileges.\n\n## Impact\n\n- **Remote code execution** in the victim's environment with the privileges of the evolver process. Because the loop daemon (`node index.js run --loop`) is the documented long-running mode, the malicious code typically gets executed within seconds of the next iteration.\n- Attacker can also overwrite `package.json` (allowed extension), `SKILL.md`, `.env`-adjacent `.json`/`.yaml`/`.yml` config files, and any whitelisted file already present in the cwd.\n- Trust boundary violation: `evolver fetch` is presented as a *download* operation; users would not expect it to overwrite the application binary or project files. The `--out` branch was hardened against exactly this; the default branch was missed.\n- A single malicious skill upload compromises every user that fetches it.\n\n## Recommended Fix\n\nReject `safeId` values that are not single non-traversing path segments before joining, or reuse the same `path.relative` check used in the `--out` branch. Minimal patch around `index.js:849`:\n\n```js\nconst safeId = String(data.skill_id || skillId).replace(/[^a-zA-Z0-9_\\-\\.]/g, '_');\nif (\n  safeId === '' ||\n  safeId === '.' ||\n  safeId === '..' ||\n  safeId.includes('/') ||\n  safeId.includes('\\\\') ||\n  safeId.includes('\\0')\n) {\n  console.error('[fetch] Hub returned an invalid skill_id: ' + JSON.stringify(safeId));\n  process.exit(1);\n}\n```\n\nDefense in depth — apply the existing traversal check to the default branch as well:\n\n```js\n} else {\n  const candidate = path.resolve(process.cwd(), 'skills', safeId);\n  const skillsRoot = path.resolve(process.cwd(), 'skills');\n  const rel = path.relative(skillsRoot, candidate);\n  if (rel.startsWith('..') || path.isAbsolute(rel)) {\n    console.error('[fetch] Hub returned a skill_id that escapes the skills/ directory');\n    process.exit(1);\n  }\n  outDir = candidate;\n}\n```\n\nAdditionally, consider:\n- Removing `.` from the regex allow-list (skill IDs typically don't need dots).\n- Verifying a Hub-supplied signature over the response payload before writing any file to disk.\n- Disallowing bundled-file `safeName` values that match top-level project files (`index.js`, `package.json`, `package-lock.json`, etc.) regardless of `outDir`.","published":"2026-05-05T21:15:09Z","modified":"2026-05-05T21:34:46.557963Z","cvss":{"score":8.8,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"@evomap/evolver","fixedVersion":"1.70.0-beta.5"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/EvoMap/evolver/security/advisories/GHSA-cfcj-hqpf-hccf"},{"type":"PACKAGE","url":"https://github.com/EvoMap/evolver"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-05-05T21:34:46.557963Z"}}