안녕하세요. 인프랩 백엔드 개발자 후니입니다.

최근 공급망 공격(Supply-Chain-Attack)에 대한 보안 사고가 연이어 발생하고 있습니다. 올해 초 가장 논란이 되었던 LiteLLM 공급망 공격 사건, 자바스크립트 생태계에서 가장 널리 사용되는 HTTP 클라이언트 라이브러리인 Axios 패키지 배포망 오염까지 정말 많은 보안 사고가 발생했습니다. 이 글에서는 공급망 공격이 어떻게 일어나는지 살펴보고, 인프랩에서 어떤 방어 대책을 세우고 있는지 공유하고자 합니다.

소프트웨어 공급망 공격(Software Supply-Chain-Attack)

소프트웨어 공급망 공격(Software Supply-Chain-Attack)은 최종 공격 대상이 되는 서비스나 기업의 인프라를 직접 타격하지 않고, 대상이 개발 과정에서 신뢰하고 끌어다 쓰는 외부 자산(오픈소스 패키지, 빌드 환경, 서드파티 서비스 등)을 먼저 침해한 뒤 이를 징검다리 삼아 최종 내부 망으로 침투하는 고도의 우회 공격 기법입니다. 신뢰할 수 있는 개발 공급망의 일부가 오염되면 해당 공급망에 의존하고 있는 인프랩 시스템 역시 연쇄적으로 오염될 수밖에 없는 구조를 가지고 있습니다.

Axios 패키지의 사례

올해 3월경 발생한 Axios 사례를 간단히 설명하겠습니다. 이 사고는 개발자가 직접 작성한 코드나 명시적으로 선언한 직접 의존성이 아니라, 그 하위에 숨겨진 간접 의존성(transitive dependency) 트리를 오염시키는 방식으로 진행되었습니다. 해당 사례는 저희 사내 슬랙에서도 긴급히 공지되었는데요.

img.png

이 공격 사례를 간단히 정리하면, 아래와 같은 흐름으로 비밀 키들이 유출되었습니다.

  • 악성 공격자가 axios 리드 메인테이너의 npm 계정 탈취
  • 공격자가 axios 의존성 트리에 악성 코드인 plain-crypto-js@4.2.1 강제 주입
  • 탈취한 계정 토큰으로 npm CLI로 직접 수동 배포
  • 이 사실을 몰랐던 사용자 환경에서 npm install 실행
  • postinstall 스크립트를 통해 백그라운드에서 악성 코드를 실행
  • 로컬 .env, AWS Credential, SSH Key 유출 후 C2 서버로 전송

해당 링크에서 Axios 공격 사례를 자세히 볼 수 있습니다.

이처럼 악성 코드가 사용자도 모르게 자동으로 실행될 수 있었던 것은 공격자가 npm lifecycle scripts를 악용했기 때문입니다.

lifecycle scripts의 실행 순서

lifecycle scripts는 패키지 설치나 배포 등 특정 상황이나 이벤트가 발생할 때, 그 전후에 자동으로 맞물려 실행되도록 미리 약속된 특별한 스크립트 단계입니다.

npm installnpm ci 명령어를 실행하면 아래 이미지와 같은 순서로 스크립트가 실행됩니다.

life-cycle-operation-order.png

이 스크립트들은 node_modules에 실제 모듈이 설치된 직후에 실행됩니다

예를 들어 아래와 같이 package.json이 작성된 패키지를 npm install 하면, 각 단계의 스크립트가 정해진 순서대로 실행되어 터미널에 출력되는 결과를 쉽게 예상할 수 있습니다.

life-cycle-hook-scripts-example.png

lifecycle scripts 사용 예시

여기까지가 lifecycle scripts의 기본 동작입니다. 그렇다면 이 메커니즘을 악용한 악성 버전의 Axios는 node_modules를 어떤 구조로 만들어 두었을까요?

node_modules/
├── axios/                    <- 1.14.1 (악성 게시 버전)
│   ├── dist/
│   ├── lib/
│   ├── index.js
│   ├── package.json          ⚠️ dependencies에 plain-crypto-js 추가됨
│   ├── LICENSE
│   └── README.md
├── plain-crypto-js/          ⚠️ 4.2.1 (악성 의존성 crypto-js 타이포스쿼팅)
│   ├── setup.js              ⚠️ 드로퍼 (약 4209 bytes) postinstall로 자동 실행
│   ├── package.json          ⚠️ "postinstall": "node setup.js" 포함
│   ├── package.md            ⚠️ 깨끗한 4.2.0용 package.json 사본 (위장 대기)
│   ├── index.js                  (crypto-js 원본 그대로 복사)
│   ├── core.js
│   ├── aes.js
│   └── ... (나머지 crypto-js 파일들)
├── follow-redirects/         ✅ 정상 axios 의존성
├── form-data/                ✅ 정상 axios 의존성
└── proxy-from-env/           ✅ 정상 axios 의존성

이 구조에서 주목할 점은, 공격자가 plain-crypto-js@4.2.1을 코드 어디에서도 import하지 않았다는 것입니다. 오로지 postinstall로 자동 실행되는 것만을 노리고, 악성 게시 버전의 의존성에 몰래 추가해 두었습니다. 그리고 그 postinstall 단계에서 실제 공격을 수행한 것이 바로 setup.js 드로퍼입니다.

setup.js 드로퍼는 무슨 일을 했나

setup.js(약 4.2KB)는 postinstall 단계에서 자동 실행되며, 대략 다음과 같은 순서로 동작하는 전형적인 자격증명 탈취형 드로퍼(credential-stealing dropper) 였습니다.

  1. 실행 환경 판별 - 운영체제, CI 환경 여부, 분석 샌드박스로 의심되는 흔적 등을 확인합니다. 분석 환경으로 판단되면 아무 일도 하지 않고 조용히 종료해 탐지를 회피합니다.

  2. 민감 정보 수집 - 작업 디렉터리의 .env 파일, ~/.aws/credentials, ~/.ssh 등 자격증명이 위치하는 알려진 경로를 훑어 토큰, 키, 시크릿을 긁어모읍니다.

  3. 외부 전송(Exfiltration) - 수집한 데이터를 인코딩해 공격자가 통제하는 C2 서버로 전송합니다. 보통 한 번의 단발성 요청으로 끝나 흔적을 최소화합니다.

  4. 흔적 은폐 - 함께 들어있던 package.md(깨끗한 4.2.0용 package.json 사본)로 자신의 package.json을 덮어써, 사후 점검 시 정상 패키지처럼 보이도록 위장합니다.

여기서 무서운 점은, 이 모든 과정이 개발자가 코드를 단 한 줄도 실행하기 전에, 그저 npm install 한 번으로 백그라운드에서 모든 공격을 끝냈다는 점입니다.

그래서 인프랩은 어떻게 대비하였나요?

앞서 사례로 언급드린 Axios의 경우, 악성 버전이 배포된 지 6분 만에 정적 분석 도구에 의해 악성 코드로 스캐닝되었다고 합니다. 또한 주요 공급망 공격 10건을 분석한 결과, 이중 8건의 사례가 악성 버전이 배포된 뒤 발견되어 제거되기까지 1주일채 걸리지 않았다고 보고되었습니다.

결국 저희가 세운 방어 전략은 다음 두 가지로 모아집니다.

  1. Cool-Down 적용 - 갓 게시된 버전이 곧바로 설치되지 않도록 지연시켜, 그 사이에 보안 커뮤니티의 탐지 및 신고가 이루어지도록 합니다.
  2. 설치 시점 임의 코드 실행 차단 - Cool-Down 기간이 지나 악성 버전이 설치되더라도, postinstall 같은 lifecycle scripts가 자동 실행되지 못하게 막습니다.

이 두 전략은 pnpm 10부터 공식 설정으로 지원되기 시작했습니다. 저희 팀에서도 Axios 사건 직후 Cool-Down 도입에 대한 논의가 빠르게 이루어졌습니다.

yakpoong-slack.png

동료의 쿨다운 제안.

패키지 매니저 통합: pnpm으로의 마이그레이션

당시 인프랩 Node.js 백엔드 저장소들은 어떤 곳은 pnpm을, 어떤 곳은 yarn을 사용하는 식으로 패키지 매니저가 통일된 상태는 아니었습니다. 패키지 매니저마다 보안 설정 방식이 다르기 때문에, 이런 상태에서는 앞서 이야기한 전략을 모든 저장소에 똑같이 적용하기 어려웠습니다. 그래서 보안 설정을 일관되게 적용하려면 패키지 매니저부터 하나로 맞출 필요가 있었고, 저희는 이번 기회에 모든 저장소를 pnpm으로 통합하기로 결정하였습니다. 위 사유와 더불어 아래와 같은 배경들 덕분에 패키지 매니저 통합 결정을 빠르게 할 수 있었습니다.

  • 레거시 코드가 존재하는 일부 저장소를 제외하면, 대부분의 저장소가 이미 pnpm을 사용 중이었습니다.
  • 논의를 진행하는 동안 pnpm 11 버전이 릴리스되었고, 이 버전에서는 10 버전에서 도입된 보안 설정들이 기본값으로 더욱 엄격하게 적용되었습니다.
  • pnpm 11은 Node.js 22 버전부터 지원하는데, 이미 일부 서비스에서 Node.js 22를 운영 환경에 적용하여 안정성이 어느 정도 검증된 상태였습니다.

pnpm 11의 보안 강화 기능

pnpm 11에서는 공급망 공격에 대비할 수 있는 보안 설정들이 기본으로 활성화됩니다. 커뮤니티에서 추천하는 설정과 각 옵션의 역할을 소개합니다.

# pnpm-workspace.yaml
minimumReleaseAge: 1440
minimumReleaseAgeStrict: true
blockExoticSubdeps: true
strictDepBuilds: true
dangerouslyAllowAllBuilds: false
trustPolicy: no-downgrade

# CI에서는 install이 암묵적으로 돌기보다 실패하게 하고 싶다면:
verifyDepsBeforeRun: error

minimumReleaseAge

항목
기본값 1440 (pnpm 11부터, 이전 버전은 0)
설정값 분 단위 숫자

레지스트리에 게시된 지 지정된 시간(분)이 지나지 않은 버전은 설치 대상에서 제외합니다. 1440은 24시간을 의미합니다. ^1.14.0 같은 캐럿 범위로 선언된 의존성이라 하더라도, 게시된 지 24시간이 지나지 않은 버전은 설치되지 않습니다. Axios 사례처럼 악성 버전이 게시 후 6분 만에 탐지된다면, 24시간의 Cool-Down은 탐지와 대응에 넉넉한 시간을 확보해 줍니다.

minimumReleaseAgeStrict

항목
기본값 true (minimumReleaseAge를 명시적으로 설정한 경우), false (그 외)
설정값 true / false

Cool-Down 조건을 만족하는 버전이 하나도 없을 때의 동작을 결정합니다.

  • true: 설치를 실패 시킵니다. 안전하지 않은 버전이 조용히 설치되는 것보다 설치가 실패하는 편이 안전하므로, true로 두는 것이 권장됩니다.
  • false: 경고를 출력하되, 조건을 만족하지 않는 버전으로 폴백하여 설치를 진행합니다.

strictDepBuilds

항목
기본값 true (pnpm 11부터)
설정값 true / false

의존성 패키지에 빌드 스크립트(preinstall, install, postinstall)가 포함되어 있는데 아직 허용 목록에 등록되지 않은 경우, 설치를 비정상 종료(non-zero exit) 시킵니다. Axios 사례에서 plain-crypto-jspostinstall로 악성 코드를 실행했던 것과 같은 공격을 원천 차단할 수 있습니다.

allowBuilds

항목
기본값 빈 목록 (아무 패키지도 빌드 스크립트를 실행하지 못함)
설정값 패키지명: true 형태의 목록

strictDepBuilds가 모든 빌드 스크립트를 막아버리기 때문에, 빌드 스크립트가 정상적인 동작에 꼭 필요한 패키지까지 함께 차단되는 문제가 생깁니다. 예를 들어 esbuild, sharp처럼 네이티브 바이너리를 직접 컴파일하는 패키지는 postinstall로 바이너리를 받거나 빌드해야 정상 동작합니다. 이런 패키지를 allowBuilds에 등록하면, 해당 패키지에 한해 빌드 스크립트 실행을 명시적으로 허용할 수 있습니다.

# pnpm-workspace.yaml
allowBuilds:
  esbuild: true
  sharp: true

즉, strictDepBuilds가 기본적으로 모든 문을 잠그는 설정이라면, allowBuilds는 신뢰할 수 있는 패키지에만 열쇠를 내어주는 화이트리스트 역할을 합니다.

다만 주의할 점이 있습니다.

  • 패키지를 등록한다는 것은 곧 그 패키지의 빌드 스크립트가 임의 코드를 실행하도록 허용한다는 의미입니다. 등록 전에 정말 빌드 스크립트가 필요한 패키지인지, 신뢰할 수 있는 출처인지 반드시 확인해야 합니다.
  • 설치가 실패한다고 해서 습관적으로 목록에 추가하지 말고, 목록은 최소한으로 유지하는 것이 좋습니다. 허용 항목이 늘어날수록 그만큼 공격 표면도 함께 넓어집니다.
  • 번거롭다는 이유로 뒤에 설명할 dangerouslyAllowAllBuilds: true로 한 번에 열어버리면 strictDepBuilds의 의미가 사라지므로, 반드시 allowBuilds로 패키지를 하나씩 허용하는 방식을 권장합니다.

dangerouslyAllowAllBuilds

항목
기본값 false
설정값 true / false

이름에 dangerously가 붙어 있는 것에서 알 수 있듯이, 이 옵션을 true로 설정하면 모든 의존성의 빌드 스크립트를 무조건 허용합니다. strictDepBuilds를 무력화하므로, 반드시 false로 유지해야 합니다.

blockExoticSubdeps

항목
기본값 true (pnpm 11부터)
설정값 true / false

간접 의존성(transitive dependency)이 npm 레지스트리가 아닌 이질적인(exotic) 소스(Git URL, HTTP tarball 등)에서 설치되는 것을 차단합니다. 직접 의존성은 개발자가 의도적으로 선언한 것이므로 허용하되, 하위 의존성 트리에서 예상치 못한 외부 소스로 우회하는 공격을 방지합니다.

trustPolicy

항목
기본값 미설정 (비활성)
설정값 no-downgrade / off

no-downgrade로 설정하면, 이전 버전 대비 패키지의 신뢰 수준이 하락한 경우 설치를 실패시킵니다.

npm 패키지의 신뢰 수준은 게시 방식에 따라 크게 세 단계로 나뉩니다. 위에서 아래로 갈수록 신뢰 수준이 낮아집니다.

신뢰 수준 게시 방식 검증 범위
Trusted Publisher GitHub Actions 등 CI/CD에서 자동 게시 게시자 신원과 빌드 출처를 모두 검증
Provenance 서명·attestation은 있으나 Trusted Publisher는 아님 빌드 출처는 검증되나 게시자 신원은 미검증
None npm CLI로 수동 게시 아무런 증명 없음, 누가 어디서 빌드했는지 알 수 없음

trustPolicy: no-downgrade는 신뢰 수준이 유지되거나 높아지는 변경은 허용하고, 낮아지는 방향의 변경만 차단합니다. 예를 들어 axios@1.14.0에서 axios@1.14.1로 올라갈 때, 신뢰 수준 변화에 따라 다음과 같이 동작합니다.

변경 전 → 변경 후 신뢰 수준 설치
Trusted Publisher → Trusted Publisher 유지 허용
Provenance → Trusted Publisher 상승 허용
None → Provenance 상승 허용
Trusted Publisher → Provenance 하락 차단
Trusted Publisher → None 하락 (Axios 공격 사례) 차단
Provenance → None 하락 차단

Axios 공격 사례에서 공격자는 탈취한 계정 토큰으로 npm CLI를 통해 수동 배포했습니다. 기존 Axios는 Trusted Publisher로 게시되고 있었으므로, 신뢰 수준이 Trusted Publisher → None으로 급락하게 됩니다. trustPolicy: no-downgrade가 설정되어 있었다면, 이 시점에서 설치가 차단되어 공격을 사전에 막을 수 있었습니다.

verifyDepsBeforeRun

항목
기본값 install
설정값 install / warn / error / prompt / false

pnpm run이나 pnpm exec를 실행하기 전에 의존성 상태를 검증합니다. CI 환경에서 의존성이 불완전한 상태로 스크립트가 실행되는 것을 막으려면 error로 두는 것이 권장됩니다.

  • install: 의존성이 불완전하면 자동으로 pnpm install을 실행합니다.
  • warn: 경고만 출력하고 계속 진행합니다.
  • error: 의존성이 불완전하면 실행을 중단합니다.
  • prompt: 사용자에게 설치 여부를 대화형으로 묻습니다.
  • false: 검증을 하지 않습니다.

이처럼 pnpm 11은 대부분의 보안 설정이 기본값만으로도 강력하게 보호를 할 수 있습니다. 악성 버전의 게시 자체를 막을 수는 없지만, 라이브러리를 소비하는 입장에서 즉시 자동 전파되는 구조를 “지연”으로 전환하여, 그 사이에 보안 커뮤니티의 탐지와 신고가 이루어지도록 시간을 벌 수 있습니다.

패키지 매니저 마이그레이션 팁

이미 pnpm을 쓰고 있다면 (버전 업데이트)

앞서 소개한 보안 설정들은 pnpm 11 기준입니다. 그렇다면 이미 구버전 pnpm을 사용하고 있는 저장소는 어떻게 11까지 올려야 할까요?

여기서 한 가지 유의할 점이 있습니다. pnpm의 마이그레이션 가이드는 메이저 버전을 한 단계씩 올릴 때를 기준으로 제공됩니다. 예를 들어 8 → 9, 9 → 10, 10 → 11처럼 각 단계별로 변경된 동작과 마이그레이션 방법이 문서화되어 있습니다.

따라서 pnpm 8에서 11로 곧장 점프하기보다는, 한 메이저 버전씩 차근차근 올리는 방식을 권장합니다.

  • 각 단계마다 어떤 동작이 바뀌었는지 공식 마이그레이션 가이드로 확인할 수 있습니다.
  • 한 번에 여러 버전을 건너뛰면, 어느 단계에서 문제가 생겼는지 원인을 추적하기 어렵습니다.
  • 단계별로 올리면 pnpm install과 테스트를 거치며 변경 사항을 점진적으로 검증할 수 있어, 잠금 파일(pnpm-lock.yaml)이나 의존성 해석 방식의 차이로 인한 문제를 조기에 발견할 수 있습니다.

저희도 혼용하던 패키지 매니저를 pnpm으로 통합하고 11까지 올리는 과정에서, 단계별 업데이트가 디버깅 부담을 크게 줄여준다는 것을 체감할 수 있었습니다.

다른 패키지 매니저(npm, yarn)를 쓰고 있다면

한편 아직 yarn이나 npm을 쓰고 있어 pnpm으로 처음 넘어와야 하는 저장소라면, pnpm import 명령으로 기존 yarn.lock이나 package-lock.json을 기반으로 pnpm-lock.yaml을 생성할 수 있습니다. 의존성 버전을 처음부터 다시 해석하지 않고 기존 잠금 상태를 그대로 가져올 수 있어, 마이그레이션 초기의 버전 변동을 줄이는 데 도움이 됩니다.

마무리

이번 글에서 다룬 공격 경로는 postinstall 스크립트를 악용한 설치 시점 공격이었습니다. 하지만 이 경로만 차단한다고 해서 공급망 공격을 완벽하게 막을 수 있는 것은 아닙니다. 설치 스크립트에 대한 방어가 강화될수록, 공격자들은 다음 공격 지점으로 옮겨갈 가능성이 큽니다.

일종의 두더지 잡기와 같습니다. 설치 스크립트 차단과 Cool-Down은 가장 흔한 설치 시점 공격을 효과적으로 줄여주지만, import 시점이나 런타임에서 실행되는 악성 코드에는 무력합니다. 예를 들어 패키지의 정상적인 모듈 코드 내부에 난독화된 악성 로직을 삽입하는 방식은 설치 스크립트 차단만으로는 탐지할 수 없습니다.

따라서 설치 시점 방어는 개발자들이 지켜내야할 최소한의 기본 방어선이며, 앞으로는 더 다양한 방어 체계를 고려해야 합니다.

긴 글 읽어주셔서 감사합니다.