|
| 1 | +--- |
| 2 | +title: 'is 패키지의 Supply Chain Attack 과정 분석' |
| 3 | +description: '메인테이너는 어떻게 토큰을 탈취당했을까?' |
| 4 | +tags: ['is', 'supply-chain-attack', 'npm'] |
| 5 | +coverImage: 'cover.webp' |
| 6 | +date: '2025-09-10T11:02:32.000Z' |
| 7 | +--- |
| 8 | + |
| 9 | + |
| 10 | + |
| 11 | +## 들어가며 |
| 12 | + |
| 13 | + |
| 14 | + |
| 15 | +새벽 2시반, 잠들기 직전에 Node.js 디코에서 `@everyone`이 찍혔다. 이런 경우는 디코가 생긴 이후로 최초이므로 호기심이 발동해 직관을 하게 되었다. |
| 16 | + |
| 17 | +- 내용인즉 chalk, debug, color 등 을 관리하는 메인테이너의 계정이 탈취, 악성코드가 담긴 버전이 배포 됨 |
| 18 | + - 영향 받는 범위는 @babel/core, express, Node.js, Next.js 등 엄청나게 컸음 |
| 19 | + - 주간 다운을 합치면 20억이 넘음 |
| 20 | +- 멀웨어 스크립트에는 `typeof window !== 'undefined` 코드가 있는데, CICD 환경 등에 침투하여 암호화폐 탈취를 위한 스크립트가 있었음. |
| 21 | +- 관련 링크들 |
| 22 | + - [해킹당한 메인테이너의 대응이 궁금하면 여기](https://github.com/debug-js/debug/issues/1005) |
| 23 | + - https://github.com/advisories/GHSA-8mgj-vmr8-frr6 |
| 24 | + - [사건 요약](https://www.aikido.dev/blog/npm-debug-and-chalk-packages-compromised) |
| 25 | + |
| 26 | +최근 <u>메인테이너 계정이 탈취되고 패치버전이 올라가는 문제</u>들이 많이 생기고 있다. |
| 27 | + |
| 28 | +이번 포스트에서는 비슷한 경우인 `is` 패키지의 토큰 탈취 과정과 공격 코드 분석을 해보려고 한다. |
| 29 | + |
| 30 | +## TL;DR: 무슨 일이 벌어졌나? |
| 31 | + |
| 32 | +- [npm](https://www.npmjs.com/) 패키지 [eslint-config-prettier](https://www.npmjs.com/package/eslint-config-prettier)(주간다운2000만), [is](https://www.npmjs.com/package/is)(250만) 등에 악성코드가 추가됨 |
| 33 | + - is 패키지는 타입 체크를 도와주는 기분적인 유틸 패키지 |
| 34 | + - is.symbol(…), is.nan(…) 등<ExternalAnchor href="https://github.com/enricomarino/is/blob/cb4d31da1fe9e315e4bd917b063c636bf110e2b2/index.js#L558" /> |
| 35 | +- 공격자는 이전 메인테이너의 계정을 탈취해 악성 코드가 포함된 버전을 배포 |
| 36 | +- 유저가 해당 패키지를 설치하게 되면 각종 정보가 해커에게 전송 되고 실행 명령어가 그대로 노출 됨 |
| 37 | + |
| 38 | +## 공급망 공격이란? |
| 39 | + |
| 40 | +Supply Chain Attack(공급망 공격)의 정의는 아래와 같다. |
| 41 | + |
| 42 | +- 해커가 SW나 HW 개발 단계부터 악성코드를 제품의 악의적 변조 또는 제품 내부에 악성코드 등을 숨기는 공격기법 |
| 43 | +- 대표적인 예로 [스턱스 넷](https://ko.wikipedia.org/wiki/%EC%8A%A4%ED%84%B1%EC%8A%A4%EB%84%B7)이 있으며, 스턱스 넷의 경우 초기 개발단계부터 악성코드가 유입된 사례 |
| 44 | + - [KISA > 정보보호 용어 > 공급망 사슬 공격](https://www.krcert.or.kr/kr/bbs/list.do?bbsId=B0001020&menuNo=205025&pageIndex=1&searchCnd=1&searchWrd=supply%20chain%20attack) |
| 45 | +- 제 3자 공격이라고도 불리며 주요 벤더사의 종속성에 침투하여 클라이언트의 시스템과 정보에 접근한다 |
| 46 | + - [공급망 공격이란? | Cloudflare](https://www.cloudflare.com/ko-kr/learning/security/glossary/supply-chain-attack/) |
| 47 | + |
| 48 | +## 메인테이너 계정 탈취 과정 |
| 49 | + |
| 50 | + |
| 51 | + |
| 52 | +1. 메인테이너들에게 자격증명을 요청하는 이메일이 발송 됨 |
| 53 | + - 이메일은 support@npmjs.org 주소를 위조 |
| 54 | +2. 링크를 누르면 `npnjs`(npmjs가 아님)로 이동 |
| 55 | + - 이러한 오타 피싱을 타이포스쿼팅 공격(Typosquating Attack)이라 함 |
| 56 | + - 피싱 사이트에 <u>토큰이 쿼리로</u> 되어 있는데 이는 특정 메인테이너를 추적하기 위함 |
| 57 | +3. 메인테이너는 피싱 사이트를 npmjs.org 로 오인해 로그인 진행 |
| 58 | + |
| 59 | + |
| 60 | + |
| 61 | + 4. 계정 탈취 및 토큰 생성 완료 |
| 62 | + |
| 63 | +- `automation` 타입의 토큰은 `npm publish` 사용시 **2FA 정책을 사용하지 않는다.** |
| 64 | + |
| 65 | +BOOM! |
| 66 | + |
| 67 | +## 공격 코드 분석 |
| 68 | + |
| 69 | +그렇다면 악성 코드는 어떤 형태일까? 이번에는 공격 코드를 분석해보자. |
| 70 | + |
| 71 | +코드 전문은 https://socket.dev/npm/package/is/files/5.0.0/index.js 에서 확인 가능하다. |
| 72 | + |
| 73 | +### 1. 패키지 설치와 postinstall |
| 74 | + |
| 75 | +```json |
| 76 | +// is/package.json |
| 77 | +{ |
| 78 | + "name": "is", |
| 79 | + "version": "5.0.0", |
| 80 | + "scripts": { |
| 81 | + "postinstall": "node index.js" |
| 82 | + // ... |
| 83 | +} |
| 84 | +``` |
| 85 | + |
| 86 | +1. 사용자가 `npm i is`를 하게 되면 |
| 87 | +2. `postinstall`이 실행되면서 |
| 88 | +3. 해킹 코드가 있는 `index.js` 파일을 실행하게 된다. |
| 89 | + |
| 90 | +### 2. 동적 함수 선언과 실행 |
| 91 | + |
| 92 | +```js |
| 93 | +// index.js |
| 94 | +Function( |
| 95 | + 'of', |
| 96 | + `var throw,do,public,case,new,void,function,of,var,in;function for(throw,do,public){for(public=0x0;public<do;public++)throw.push(throw.shift());return throw} |
| 97 | + const arguments=["\\u006c\\u0065\\u006e\\ |
| 98 | + ... |
| 99 | + arguments[new(arguments[0x89])]||arguments)} |
| 100 | + })();`, // 약 8000 줄 |
| 101 | +)({ |
| 102 | + get void() { |
| 103 | + return window; |
| 104 | + }, |
| 105 | + get switch() { |
| 106 | + return require; |
| 107 | + }, |
| 108 | +}); |
| 109 | +``` |
| 110 | + |
| 111 | +악성 코드의 주요 흐름은 아래와 같다. |
| 112 | + |
| 113 | +1. 함수 생성자를 활용하여 동적 함수 생성 및 호출 |
| 114 | + - 메모리 단계에서 사라지도록 유도 |
| 115 | +2. 인자로 넘긴 객체 `{ get void() { return window; }, … }`를 `of`라는 인자로 넘김 |
| 116 | +3. 본문의 8000줄의 코드에서 `of.void() // window` 반환 등으로 유저의 전역 객체에 접근해 정보를 캔다. |
| 117 | + - 이를 통해 정적 분석을 회피한다 |
| 118 | + - `window`에 직접 접근하는 코드는 보안 모니터링에 걸리기 때문 |
| 119 | + |
| 120 | +<Callout className="mt-4" info> |
| 121 | + |
| 122 | +동적 함수는 런타임에 함수를 생성하는 함수다. |
| 123 | + |
| 124 | +아래와 같이 함수 생성자로 함수 생성 및 즉시 실행이 가능하다. |
| 125 | + |
| 126 | +<code className="p-2">Function("a", "b", "return a + b")(1, 2) // 3</code> |
| 127 | + |
| 128 | +`eval` 과 같이 스크립트를 "문자열"로 처리(실행)할 수 있기 때문에 위험한 코드다. |
| 129 | + |
| 130 | +</Callout> |
| 131 | + |
| 132 | +### 3. 예약어 기만 |
| 133 | + |
| 134 | +```js |
| 135 | +// Function 함수의 두 번째 인자. 문자열로 되어 있음. |
| 136 | +var throw, // 실제로는 throw + "보이지 않는 문자"들로 되어 있음. |
| 137 | + do, |
| 138 | + public, |
| 139 | + case, |
| 140 | + new, |
| 141 | + void, |
| 142 | + function, |
| 143 | + of, |
| 144 | + var, |
| 145 | + in; |
| 146 | +// ... |
| 147 | +const arguments = [ |
| 148 | + '\\u006c\\u0065\\u006e\\u0067\\u0074\\u0068', // length |
| 149 | + 0x1, |
| 150 | + '\\u0062', |
| 151 | + 0x0, |
| 152 | + '\\u0068', |
| 153 | + 0x3, |
| 154 | +``` |
| 155 | + |
| 156 | +`throw` 같이 자주 사용되는 예약어에 <u>보이지 않는 문자</u>를 추가해 난독화 함으로써 유저가 쉽게 파악할 수 없도록 한다. |
| 157 | + |
| 158 | +<Callout className="mt-4" info> |
| 159 | + |
| 160 | +기본적으로 해커의 문자열 코드에는 모든 값에 "보이지 않는 문자"가 추가되어 있다. |
| 161 | + |
| 162 | + |
| 163 | + |
| 164 | + |
| 165 | + |
| 166 | +그 이유는 `\u200C` 유니코드로 조합했기 때문이다. |
| 167 | + |
| 168 | +<div className="flex flex-row"> |
| 169 | + <p className="text-red-600 font-bold inline"> |
| 170 | + 값이 존재하지만 표시되지 않는 특징 |
| 171 | + </p> |
| 172 | + 을 활용해 검색되지 않도록 했다. |
| 173 | +</div> |
| 174 | + |
| 175 | + / 아래(정상)') |
| 176 | + |
| 177 | +</Callout> |
| 178 | + |
| 179 | +#### 예약어를 위와 같이 덮어쓰는건 몇가지 공격자 이점이 있다 |
| 180 | + |
| 181 | +- 일반 개발자들은 번들된 파일의 위험을 전혀 예측하지 못함 |
| 182 | +- 코드 추적이 어려워져 보안패치까지 시간이 걸림 |
| 183 | + - 진짜 예약어인지 덮어쓰여진 변수인지 구분이 어려움(private 경우 처럼) |
| 184 | + - 동일한 `private` 처럼 보여도 `p\u200Crivate`, `pri\u200Cvate` 처럼 무수히 많은 가지수가 생김 |
| 185 | + - 전형적인 난독화 기법 |
| 186 | +- 민감한 예약어(throw, require, …)가 없는 척 하면서 런타임 단계에서 수정 실행 하는 위험이 있다. |
| 187 | + |
| 188 | +### 4. 전역 객체 접근 함수 선언 |
| 189 | + |
| 190 | + |
| 191 | + |
| 192 | +<br /> |
| 193 | + |
| 194 | +<Callout error> |
| 195 | + |
| 196 | +전역 객체 호출이 왜 위험한가? |
| 197 | + |
| 198 | +```js |
| 199 | +// 이제 다음에 접근 가능 |
| 200 | +globalObj.process.env; // 환경변수 전체 |
| 201 | +globalObj.process.cwd(); // 현재 디렉토리 |
| 202 | +globalObj.require; // require 함수 |
| 203 | + |
| 204 | +// 가능한 예상 수집 대상 정보들 |
| 205 | +const payload = { |
| 206 | + env: { |
| 207 | + AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID, |
| 208 | + AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY, |
| 209 | + DATABASE_URL: process.env.DATABASE_URL, |
| 210 | + // ... 모든 환경변수 |
| 211 | + }, |
| 212 | + system: { |
| 213 | + hostname: os.hostname(), |
| 214 | + username: os.userInfo().username, |
| 215 | + cwd: process.cwd(), |
| 216 | + platform: process.platform, |
| 217 | + arch: process.arch, |
| 218 | + }, |
| 219 | + project: { |
| 220 | + packageJson: require('./package.json'), |
| 221 | + dependencies: /* 의존성 정보 */, |
| 222 | + scripts: /_ npm scripts \_/, |
| 223 | + } |
| 224 | +}; |
| 225 | + |
| 226 | +``` |
| 227 | + |
| 228 | +</Callout> |
| 229 | + |
| 230 | +### 5. 웹 소켓 호출 |
| 231 | + |
| 232 | + |
| 233 | + |
| 234 | +소켓을 연결하는 코드가 있는 것을 확인할 수 있다. |
| 235 | + |
| 236 | +#### 소켓이 연결 되면 아래와 같은 문제가 생긴다 |
| 237 | + |
| 238 | +1. 데이터 전송과정을 거치며 유저의 데이터를 탈취한다. |
| 239 | +2. 위에서 "전역 객체"에 접근하는 코드를 확인했다. 이 값과 소켓 연결을 통해 유저의 컴퓨터를 제어할 수 있다. |
| 240 | +3. 모든 메시지가 실행 가능한 JavaScript로 처리되므로 해커에게 각종 명령어를 제공하게 된다. |
| 241 | + |
| 242 | +(대충 멸망) |
| 243 | + |
| 244 | +## 방어 방법 |
| 245 | + |
| 246 | +- 종속성 버전을 pin 하여 고정 버전으로 사용한다. |
| 247 | + - [Renovate](/posts/renovate) 등의 도구로 자동으로 버전 업데이트하는 것을 추천한다. |
| 248 | +- ci 명령어 혹은 production 인스톨을 한다. |
| 249 | + - `postinstall` 스크립트는 `npm ci` 명령어나 `npm install --production` 명령어에서는 실행되지 않는다 |
| 250 | + |
| 251 | +## 시사점 |
| 252 | + |
| 253 | +### 1. 의존성 하나가 전체 앱의 보안에 영향을 줄 수 있다 |
| 254 | + |
| 255 | +- 프론트엔드 개발 환경은 빌드 툴(Vite, Webpack), 유틸 라이브러리, 폴리필 등 수많은 npm 패키지를 사용함 |
| 256 | +- 작은 유틸 하나(is)도 감염되면 전체 앱이 영향 받음 |
| 257 | + |
| 258 | +### 2. 계정 탈취는 생각보다 흔하다 |
| 259 | + |
| 260 | +- 이 사례처럼 오래된 메인테이너의 계정이 공격받는 일이 종종 발생 |
| 261 | +- 개인 개발자가 관리하던 패키지도 공격자에 의해 감염 될 수 있음 |
| 262 | + |
| 263 | +### 3. 잠재적 위험 탐지 도구 사용 |
| 264 | + |
| 265 | + |
| 266 | + |
| 267 | +`npm audit`, `pnpm audit` 등으로 의존성의 리스크를 점검 |
| 268 | + |
| 269 | +[Renovate](/posts/renovate)를 적용하여 주기적 업데이트 적용 |
| 270 | + |
| 271 | +## 마치며 |
| 272 | + |
| 273 | +"특정 패키지가 공격당했다"고 자주 들었지만 실제로 어떤 악성 코드가 있는지는 잘 몰라 항상 궁금했다. |
| 274 | + |
| 275 | +이번 기회에 악성 코드를 분석하고 방어 방법을 고민해보게 되어 의미있는 시간이었다. |
| 276 | + |
| 277 | +디펜던시 버전을 고정하여 관리하고 Renovate 등의 도구로 안전하게 버전 관리 하자. |
| 278 | + |
| 279 | +## 참고 |
| 280 | + |
| 281 | +- https://socket.dev/blog/npm-phishing-email-targets-developers-with-typosquatted-domain |
| 282 | +- https://www.aikido.dev/blog/npm-debug-and-chalk-packages-compromised |
0 commit comments