Summary
The 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.
Details
The vulnerable code is in the fetch command handler:
// index.js:847-873
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
}
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
Three problems compose:
- The regex allow-list permits
.,[^a-zA-Z0-9_\-\.]only strips characters outside this set, so the literal dot is preserved. Askill_idof..(verified:'..'.replace(/[^a-zA-Z0-9_\-\.]/g,'_') === '..') survives sanitization. path.joincollapses..traversal,path.join('.', 'skills', '..')evaluates to'.'(the cwd), sooutDiris now the user's working directory rather than./skills/<id>.- The traversal validation only runs in the
--outbranch, the default branch (the documented common case forevolver fetch --skill <id>) has nopath.relative(...).startsWith('..')check.
The bundled-files write loop:
// index.js:881-906
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');
}
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.
There 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.
PoC
Reproduces the exact code path from index.js:849-905:
cd /tmp && rm -rf evolver-poc-validate && mkdir evolver-poc-validate && \
cp /path/to/EvoMap-evolver-src/index.js evolver-poc-validate/
cd evolver-poc-validate
wc -l index.js # 1098 index.js (legitimate)
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);
}"
wc -l index.js # 1 index.js (clobbered)
head -3 index.js
# #!/usr/bin/env node
# console.log("PWNED");
Verified 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.
End-to-end attack:
- Attacker uploads a skill to the A2A Hub whose stored
skill_idis..(or operates a malicious Hub / MitMs the connection / supplies a maliciousA2A_HUB_URL). - The malicious response also carries
bundled_files: [{name: 'index.js', content: '<attacker JS>'}]. - Victim runs
node index.js fetch --skill=anythingfrom the evolver checkout (the documented usage). ./index.jsis overwritten in place.- Victim's next
node index.jsinvocation, even justnode index.js --helpor therun --loopdaemon, executes attacker code with the victim's privileges.
Impact
- 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. - Attacker can also overwrite
package.json(allowed extension),SKILL.md,.env-adjacent.json/.yaml/.ymlconfig files, and any whitelisted file already present in the cwd. - Trust boundary violation:
evolver fetchis presented as a download operation; users would not expect it to overwrite the application binary or project files. The--outbranch was hardened against exactly this; the default branch was missed. - A single malicious skill upload compromises every user that fetches it.
GHSA-CFCJ-HQPF-HCCF has a CVSS score of 8.8 (High). The vector is network-reachable, no privileges required, and user interaction required. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (1.70.0-beta.5); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Remediation advice
Reject 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:
const safeId = String(data.skill_id || skillId).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
if (
safeId === '' ||
safeId === '.' ||
safeId === '..' ||
safeId.includes('/') ||
safeId.includes('\\') ||
safeId.includes('\0')
) {
console.error('[fetch] Hub returned an invalid skill_id: ' + JSON.stringify(safeId));
process.exit(1);
}
Defense in depth, apply the existing traversal check to the default branch as well:
} else {
const candidate = path.resolve(process.cwd(), 'skills', safeId);
const skillsRoot = path.resolve(process.cwd(), 'skills');
const rel = path.relative(skillsRoot, candidate);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
console.error('[fetch] Hub returned a skill_id that escapes the skills/ directory');
process.exit(1);
}
outDir = candidate;
}
Additionally, consider:
- Removing
.from the regex allow-list (skill IDs typically don't need dots). - Verifying a Hub-supplied signature over the response payload before writing any file to disk.
- Disallowing bundled-file
safeNamevalues that match top-level project files (index.js,package.json,package-lock.json, etc.) regardless ofoutDir.
Frequently Asked Questions
- What is GHSA-CFCJ-HQPF-HCCF? GHSA-CFCJ-HQPF-HCCF is a high-severity security vulnerability in @evomap/evolver (npm), affecting versions <= 1.70.0-beta.4. It is fixed in 1.70.0-beta.5.
- How severe is GHSA-CFCJ-HQPF-HCCF? GHSA-CFCJ-HQPF-HCCF has a CVSS score of 8.8 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of @evomap/evolver are affected by GHSA-CFCJ-HQPF-HCCF? @evomap/evolver (npm) versions <= 1.70.0-beta.4 is affected.
- Is there a fix for GHSA-CFCJ-HQPF-HCCF? Yes. GHSA-CFCJ-HQPF-HCCF is fixed in 1.70.0-beta.5. Upgrade to this version or later.
- Is GHSA-CFCJ-HQPF-HCCF exploitable, and should I be worried? Whether GHSA-CFCJ-HQPF-HCCF is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether GHSA-CFCJ-HQPF-HCCF is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix GHSA-CFCJ-HQPF-HCCF? Upgrade
@evomap/evolverto 1.70.0-beta.5 or later.