[CS300 #257] 공급망 보안 — 내가 쓰지 않은 코드가 내 서버에서 돈다
컴퓨터공학 300 주제 시리즈의 257번째 글이다. 전체 지도는 여기.
한 줄 요약
소프트웨어 공급망 보안은 내 제품에 들어가는 남의 코드(의존성)와 그것을 만들고 옮기는 과정(빌드·배포) 을 믿을 근거를 만드는 일이며, 핵심 도구는 의존성 고정과 해시 검증, SBOM, 빌드 출처 증명(provenance), 그리고 서명과 검증이다.
왜 필요한가
현대 애플리케이션 코드의 대부분은 직접 쓴 것이 아니다. 패키지 관리자로 받은 라이브러리, 그 라이브러리의 라이브러리, 기반 컨테이너 이미지, CI 에서 쓰는 액션과 플러그인. 이 중 하나만 오염돼도 내 서비스가 오염된다. 공격자 입장에서 인기 라이브러리 하나를 노리면 수천 곳을 한 번에 칠 수 있으니 효율이 좋다.
실제 사례가 이를 보여 준다. 2024년 공개된 xz 압축 라이브러리 사건(CVE-2024-3094)에서 NVD 설명에 따르면 악성 코드가 xz 5.6.0 부터 업스트림 배포 tarball 에 들어 있었고, 소스 안의 위장된 테스트 파일에서 미리 빌드된 객체를 꺼내 liblzma 함수를 변조하는 방식이었다. 저장소의 코드를 읽는 것만으로는 잡기 어려웠다. 빌드 과정과 배포물 자체를 검증해야 한다는 교훈이다.
OWASP Top 10:2025 는 이를 반영해 “소프트웨어 공급망 실패” 를 새 범주로 3위에 올렸다.
핵심 개념
공격 지점 지도
[개발자] → [소스 저장소] → [빌드 시스템] → [패키지·이미지 저장소] → [배포] → [운영]
│ │ │ │
계정 탈취 악성 커밋 빌드 스크립트 변조 바꿔치기, 유사 이름 패키지
의존성 혼동
| 공격 | 설명 | 대책 |
|---|---|---|
| 타이포스쿼팅 | requests 대신 reqeusts 같은 유사 이름 패키지 |
의존성 추가 리뷰, 허용 레지스트리 |
| 의존성 혼동 | 사내 패키지 이름을 공개 저장소에 더 높은 버전으로 올림 | 사내 패키지는 사내 레지스트리로만, 네임스페이스 고정 |
| 관리자 계정 탈취 | 정상 패키지에 악성 버전 게시 | 레지스트리 MFA, 신규 버전 지연 반영 |
| 빌드 시스템 침해 | 소스는 깨끗한데 빌드 산출물이 오염 | 격리된 빌드, 출처 증명 |
| 취약한 구성 요소 | 악의는 없지만 알려진 취약점 포함 | SBOM, 취약점 스캔, 업데이트 체계 |
의존성 고정과 해시 검증
버전 범위(>=1.2)로 설치하면 빌드할 때마다 다른 코드가 들어올 수 있다. 잠금 파일(package-lock.json, poetry.lock, go.sum 등)로 정확한 버전을, 가능하면 해시까지 고정한다. pip 는 --require-hashes 모드에서 요구 사항 파일의 해시와 다르면 설치를 거부한다.
SBOM
SBOM(Software Bill of Materials)은 제품에 들어간 구성 요소의 목록이다. 새 취약점이 발표됐을 때 “우리 제품 중 어디에 이 라이브러리가 들어 있나” 에 몇 분 만에 답하게 해 준다. 대표 형식은 SPDX 와 CycloneDX 이고, 구성 요소는 보통 purl(package URL, 예: pkg:pypi/requests@2.32.3)로 식별한다.
SLSA: 빌드의 무결성 단계
SLSA(Supply-chain Levels for Software Artifacts) v1.0 은 빌드 트랙을 네 단계로 정의한다.
| 단계 | 이름 | 의미 |
|---|---|---|
| Build L0 | No guarantees | 보장 없음 |
| Build L1 | Provenance exists | 어떻게 빌드됐는지 기술한 출처 증명이 있다 |
| Build L2 | Hosted build platform | 호스팅된 빌드 플랫폼이 출처 증명을 생성·서명한다 |
| Build L3 | Hardened builds | 빌드끼리 격리되고 서명 키가 빌드 스크립트로부터 보호된다 |
출처 증명(provenance) 은 “이 산출물은 이 저장소의 이 커밋을 이 빌드 플랫폼에서 이 절차로 만들었다” 는 서명된 기록이다. 배포 단계에서 이를 검증하면 개발자 노트북에서 몰래 만든 바이너리가 운영에 들어가는 것을 막는다.
서명과 검증
Sigstore 의 cosign 은 컨테이너 이미지와 아티팩트에 서명한다. 키리스(keyless) 방식은 OIDC 신원으로 단기 인증서를 받아 서명하고, 그 기록을 투명성 로그에 남긴다. 중요한 건 검증을 강제하는 쪽이다. 서명만 하고 배포 단계에서 확인하지 않으면 아무 효과가 없다. 쿠버네티스에서는 어드미션 정책으로 “우리 CI 신원으로 서명된 이미지만 실행” 을 강제한다.
직접 해 보기
두 가지를 해 본다. (1) 잠금 파일의 해시로 바꿔치기된 배포물을 거부하기 (2) 현재 파이썬 환경의 설치 패키지로 CycloneDX 형태의 최소 SBOM 만들기.
import hashlib, json, os, tempfile
from importlib import metadata
# 1) 해시 고정 설치 흉내: 잠금 파일의 해시와 내려받은 파일을 대조한다
# (pip install --require-hashes 가 하는 일의 핵심)
art = os.path.join(tempfile.mkdtemp(), "demo_pkg-1.0.0.tar.gz")
open(art, "wb").write(b"original release contents")
LOCK = {"demo_pkg==1.0.0": "sha256:" + hashlib.sha256(b"original release contents").hexdigest()}
def install(spec, path):
got = "sha256:" + hashlib.sha256(open(path, "rb").read()).hexdigest()
if got != LOCK.get(spec):
raise RuntimeError(f"{spec}: 해시 불일치, 설치 중단")
return f"{spec}: 해시 일치, 설치"
print(install("demo_pkg==1.0.0", art))
open(art, "wb").write(b"original release contents + backdoor") # 저장소에서 바꿔치기됐다고 가정
try:
install("demo_pkg==1.0.0", art)
except RuntimeError as e:
print(e)
# 2) 지금 환경의 설치 패키지로 CycloneDX 형태의 최소 SBOM 만들기
comps = []
for dist in metadata.distributions():
name, ver = dist.metadata["Name"], dist.version
if name:
comps.append({"type": "library", "name": name, "version": ver,
"purl": f"pkg:pypi/{name.lower()}@{ver}"})
comps.sort(key=lambda c: c["name"].lower())
sbom = {"bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1, "components": comps}
print("구성 요소 수:", len(comps))
print(json.dumps(sbom["components"][:2], ensure_ascii=False, indent=1))
실행 결과(Python 3.12. 구성 요소 수와 목록은 환경마다 다르다):
demo_pkg==1.0.0: 해시 일치, 설치
demo_pkg==1.0.0: 해시 불일치, 설치 중단
구성 요소 수: 127
[
{
"type": "library",
"name": "acme",
"version": "2.9.0",
"purl": "pkg:pypi/acme@2.9.0"
},
...
]
평범한 서버의 파이썬 환경에도 백 개가 넘는 패키지가 있다. 이 목록 없이 “우리는 영향받지 않는다” 를 확인할 방법은 없다. 다만 해시 고정에는 한계가 있다. xz 사례처럼 정상 게시자가 처음부터 오염된 배포물을 올리면 해시는 그 오염본과 일치한다. 해시는 “고정한 그것과 같다” 만 보장하고 “그것이 안전하다” 는 보장하지 않는다. 그래서 출처 증명·평판 지표·변경 리뷰가 함께 필요하다. 실무에서는 손으로 짠 SBOM 대신 Syft, cdxgen 같은 도구가 이미지·잠금 파일까지 분석해 표준 형식으로 만든다.
현업에서는
- CI 의 서드파티 액션: CI 워크플로가 참조하는 외부 액션을 태그(
@v4)가 아니라 커밋 SHA 로 고정한다. 태그는 옮겨질 수 있다. CI 토큰 권한도 최소화한다. - 이미지 업데이트 자동화: Renovate·Dependabot 같은 도구로 의존성 업데이트 PR 을 자동으로 만들되, 새 버전 게시 직후 며칠은 기다렸다 반영하는 지연 정책을 두는 팀도 있다. 악성 버전이 발견되고 삭제될 시간을 버는 것이다.
- 홈랩에서도: 헬름 차트와 컨테이너 이미지를 다이제스트로 고정하고, 레지스트리에서 받아 오는 이미지 목록을 주기적으로 스캔한다.
latest태그는 언제 무엇이 바뀌었는지 추적할 수 없게 만든다. - 취약점 대응 속도: 큰 라이브러리 취약점이 공개되면 SBOM 이 있는 조직은 영향 범위를 즉시 파악하고, 없는 조직은 서버마다 들어가 찾는다.
확인 문제
- 의존성 혼동(dependency confusion) 공격의 원리와 대책은?
- 해시 고정이 막는 공격과 막지 못하는 공격을 하나씩 들라.
- SBOM 이 사고 대응에서 주는 가치는?
- SLSA Build L1 과 L3 의 차이는?
- 이미지 서명만 하고 검증을 강제하지 않으면 왜 의미가 없는가?
풀이
- 사내 전용 패키지 이름을 공개 저장소에 더 높은 버전으로 올려 패키지 관리자가 공개본을 고르게 한다. 사내 패키지는 사내 레지스트리에서만 받도록 출처를 고정하고 네임스페이스를 선점한다.
- 막는 것: 저장소나 전송 중 배포물 바꿔치기. 못 막는 것: 정상 게시자가 처음부터 악성 버전을 올린 경우(해시가 그 악성본과 일치).
- 새 취약점이 공개됐을 때 영향받는 제품·버전을 빠르게 찾을 수 있다.
- L1 은 출처 증명이 존재하는 수준이고, L3 는 빌드 플랫폼이 빌드 간 격리와 서명 키 보호까지 보장해 출처 증명 위조가 어렵다.
- 공격자가 서명 없는 이미지나 다른 키로 서명한 이미지를 넣어도 배포가 진행된다. 효과는 검증 시점에서 생긴다.
더 읽을거리 (References)
- SLSA, SLSA v1.0 Security Levels
- Sigstore, Cosign Signing Overview
- OWASP CycloneDX, Specification Overview
- NIST NVD, CVE-2024-3094 (xz)
- pip, Secure installs (–require-hashes)