에어갭 환경에서 Helm Chart와 컨테이너 이미지 무결성 검증하기
공격자 관점으로 이해하는 GPG 서명, Cosign 검증, SHA256 해시 비교의 필요성과 실무 적용법
들어가며
“이 Helm Chart가 정말 GitLab이 배포한 게 맞나요?”
금융, 공공, 제조업 등 보안 요구사항이 높은 환경에서는 외부 네트워크와 완전히 분리된 에어갭(Air-gapped) 환경을 운영한다. 이런 환경에서 Helm Chart나 컨테이너 이미지를 반입할 때, 전달 과정에서 변조되지 않았는지 검증하는 것은 필수다.
하지만 “SHA256 해시만 비교하면 되는 거 아니야?”라고 생각할 수 있다. 결론부터 말하면, 해시 비교만으로는 부족하다. 이 글에서는 공격자 관점에서 각 검증 단계가 왜 필요한지 설명하고, 실제 검증 방법을 함께 정리한다.
검증 대상과 방법
에어갭 환경에서 검증해야 할 항목은 크게 두 가지다.
| 검증 대상 | 검증 방법 | 목적 |
|---|---|---|
| Helm Chart | GPG 서명 + SHA256 해시 | 배포자 신원 및 무결성 |
| 컨테이너 이미지 | Cosign 서명 + Digest 비교 | 배포자 신원 및 무결성 |
핵심 원칙: 다운로드 전에 원격에서 기준값을 먼저 확인해야 한다. 다운로드 후에 비교하면 “원본이 무엇인지”를 알 수 없다.
Helm Chart 무결성 검증
공격 시나리오: 왜 GPG 서명이 필요한가
시나리오 1: tgz 파일만 변조
공격자가 .tgz 파일에 백도어를 삽입하면 어떻게 될까?
원본: gitlab-8.11.8.tgz (SHA256: 669b86a4...)변조: gitlab-8.11.8.tgz (SHA256: b07c727d...) ← 백도어 삽입이 경우 SHA256 비교만으로 탐지할 수 있다. .prov 파일의 해시와 다르기 때문이다.
시나리오 2: tgz와 .prov 둘 다 변조
공격자가 더 똑똑해졌다. .tgz를 변조하면서 .prov 파일의 해시도 함께 수정한다.
원본 .prov: files: gitlab-8.11.8.tgz: sha256:669b86a4... (정상)
변조된 .prov: files: gitlab-8.11.8.tgz: sha256:b07c727d... (변조된 tgz에 맞춤)SHA256만 비교하면 통과된다! 하지만 GPG 서명 검증을 하면:
gpg --verify gitlab-8.11.8.tgz.prov# 결과: BAD signature# .prov 내용이 변경되면 서명이 깨짐공격자는 GitLab의 개인키가 없으므로 변조된 .prov에 유효한 서명을 넣을 수 없다.
시나리오 3: 위조된 GPG 키 배포
공격자가 또 한 수 진화했다. 자신의 GPG 키로 서명한 가짜 .prov 파일을 배포한다.
공격자의 키: FAKE1234...공격자의 .prov: 자신의 키로 정상 서명됨피해자가 “GPG 서명이 통과했네”라고 안심하면? 게임 끝이다.
방어: fingerprint를 공식 문서와 대조
gpg --fingerprint FAKE1234...# 출력된 fingerprint가 GitLab 공식 문서의 값과 다름 → 탐지됨
# GitLab 공식 fingerprint:# 5E46 F79E F583 6E98 6A66 3B4A E30F 9C68 7683 D663Provenance 파일 구조
Helm Chart는 .tgz 패키지와 함께 .prov (Provenance) 파일을 제공한다. 구조를 이해하면 왜 GPG 서명이 효과적인지 알 수 있다.
┌─────────────────────────────────────────┐│ .prov 파일 구조 │├─────────────────────────────────────────┤│ -----BEGIN PGP SIGNED MESSAGE----- ││ Hash: SHA512 ││ ││ [Chart.yaml 내용] ││ apiVersion: v1 ││ name: gitlab ││ version: 8.11.8 ││ ... ││ ││ files: ││ gitlab-8.11.8.tgz: sha256:669b86... ││ ││ -----BEGIN PGP SIGNATURE----- ││ [서명 데이터] ││ -----END PGP SIGNATURE----- │└─────────────────────────────────────────┘.prov 파일에는 Chart 메타데이터, .tgz 파일의 SHA256 해시, 그리고 이 모든 내용에 대한 PGP 서명이 포함된다. 따라서 내용을 변조하면 서명이 깨진다.
실제 검증 과정
GPG 공개키 준비 및 신뢰성 확인
# 1. GitLab Helm Chart 공개키 다운로드gpg --keyserver hkps://keys.openpgp.org \ --recv-keys '5E46F79EF5836E986A663B4AE30F9C687683D663'
# 2. fingerprint를 공식 문서와 대조 (필수!)gpg --fingerprint '5E46F79EF5836E986A663B4AE30F9C687683D663'# 출력: 5E46 F79E F583 6E98 6A66 3B4A E30F 9C68 7683 D663# GitLab 공식 문서의 값과 일치하는지 확인
# 3. Helm이 사용할 수 있는 .gpg 형식으로 내보내기gpg --export --output ~/.gnupg/gitlab.pubring.gpg \ '5E46F79EF5836E986A663B4AE30F9C687683D663'fingerprint 대조 없이 키를 신뢰하면, 공격자가 위조한 키로 서명된 악성 Chart를 검증 통과시킬 수 있다.
Chart 다운로드 및 검증
# --verify 옵션으로 GPG 검증 수행helm pull gitlab/gitlab --version 8.11.8 \ --verify --keyring ~/.gnupg/gitlab.pubring.gpg검증 성공 시 출력:
Signed by: GitLab, Inc. Helm charts <distribution@gitlab.com>Using Key With Fingerprint: 5E46F79EF5836E986A663B4AE30F9C687683D663Chart Hash Verified: sha256:669b86a41a22750ff4a7fb5660bca21d14016388adc6b39a31eed0ce8c7b2234파일이 변조된 경우:
Error: sha256 sum does not match for gitlab-8.11.8.tgz:"sha256:669b86a41a22750ff4a7fb5660bca21d14016388adc6b39a31eed0ce8c7b2234" !="sha256:b07c727de3be8753fa2c7123082e905e5e62d4940c4e499b38823801612d3c0e"수동 검증 (helm verify 없이)
helm pull --verify가 모든 것을 자동으로 해주지만, 수동으로 검증할 때는 반드시 .prov 파일의 GPG 서명을 먼저 검증해야 한다.
# 1. .prov 파일 다운로드curl -O https://gitlab-charts.s3.amazonaws.com/gitlab-8.11.8.tgz.prov
# 2. .prov 파일의 GPG 서명 검증 (필수!)gpg --verify gitlab-8.11.8.tgz.prov# gpg: Good signature from "GitLab, Inc. Helm charts <distribution@gitlab.com>"
# 3. 서명 검증 통과 후, .prov에서 기준 해시 추출awk -F': ' '/sha256:/{print $2}' gitlab-8.11.8.tgz.prov# sha256:669b86a41a22750ff4a7fb5660bca21d14016388adc6b39a31eed0ce8c7b2234
# 4. tgz 다운로드curl -O https://gitlab-charts.s3.amazonaws.com/gitlab-8.11.8.tgz
# 5. 해시 비교shasum -a 256 gitlab-8.11.8.tgz# 669b86a41a22750ff4a7fb5660bca21d14016388adc6b39a31eed0ce8c7b2234중요: 2단계(GPG 서명 검증) 없이 해시만 비교하면, 시나리오 2처럼 .prov와 .tgz를 둘 다 변조하는 공격에 취약하다.
래퍼 차트와 helm dependency update
helm-secrets 등을 사용하는 래퍼 차트 구조에서는 helm dependency update로 의존성을 다운로드한다.
# Chart.yaml (래퍼 차트)apiVersion: v2name: gitlab-wrapperdependencies: - name: gitlab version: 8.11.8 repository: https://charts.gitlab.iohelm pull vs helm dependency update 해시 비교
원격 레포에서 다운로드하는 경우, 두 명령어의 결과물은 동일한 해시를 갖는다.
# helm pull 결과shasum -a 256 gitlab-8.11.8.tgz# 669b86a41a22750ff4a7fb5660bca21d14016388adc6b39a31eed0ce8c7b2234
# helm dependency update 결과shasum -a 256 charts/gitlab-8.11.8.tgz# 669b86a41a22750ff4a7fb5660bca21d14016388adc6b39a31eed0ce8c7b2234이는 원격 레포에서 이미 패키징된 .tgz를 그대로 다운로드하기 때문이다.
참고:
file://로컬 의존성을 사용하는 경우에는 Helm이 재패키징하면서 해시가 달라질 수 있다. (GitHub Issue #8850)
컨테이너 이미지 무결성 검증
공격 시나리오: 왜 Cosign 서명이 필요한가
시나리오 4: 다운로드 중 이미지 변조 (MITM)
공격자가 다운로드 중간에 이미지를 가로채서 변조한다.
원본 이미지: sha256:7bf54efc...변조된 이미지: sha256:abc12345... ← 악성 레이어 삽입방어: 다운로드 전에 원격 Digest를 먼저 확인
# 다운로드 전 원격에서 확인한 Digestskopeo inspect --raw docker://registry.gitlab.com/.../gitlab-webservice-ee:v17.11.7# sha256:7bf54efc...
# 다운로드 후 로컬 Digestdocker inspect --format='{{index .RepoDigests 0}}' ...# sha256:abc12345... ← 불일치! 탐지됨시나리오 5: 레지스트리 해킹
공격자가 GitLab 레지스트리를 해킹하여 악성 이미지를 업로드한다.
registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ee:v17.11.7→ 정상 이미지가 악성 이미지로 교체됨→ Digest도 악성 이미지의 것으로 표시됨Digest 비교만으로는 탐지 불가! 레지스트리의 Digest가 이미 변조되었기 때문이다.
방어: Cosign 서명 검증
cosign verify --key cosign.pub \ registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ee:v17.11.7
# 결과: signature verification failed# 공격자는 GitLab의 Cosign 개인키가 없으므로 유효한 서명 생성 불가Digest 비교는 “동일성”만 확인한다. “배포자 신원”은 서명 검증으로만 확인할 수 있다.
Cosign 서명 검증 실습
GitLab CNG(Cloud Native GitLab) 이미지는 Cosign으로 서명되어 있고, public이므로 인증 없이 접근 가능하다.
# 1. GitLab Cosign 공개키 다운로드curl -O https://charts.gitlab.io/cosign.pub
# 2. 이미지 서명 검증cosign verify --key cosign.pub \ registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ee:v17.11.7
# 검증 성공 시 출력:# Verification for registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ee:v17.11.7 --# The following checks were performed on each of these signatures:# - The cosign claims were validated# - Existence of the claims in the transparency log was verified offline# - The signatures were verified against the specified public keyDigest 종류 이해
Multi-architecture 이미지는 여러 레벨의 Digest를 가진다:
| Digest 종류 | 설명 | 확인 방법 |
|---|---|---|
| Manifest List | multi-arch index의 해시 | docker pull 출력 |
| Platform Digest | 특정 아키텍처 manifest 해시 | docker manifest inspect |
| RepoDigests | 레지스트리별 참조 | docker inspect |
# Manifest List의 개별 아키텍처 Digestdocker manifest inspect redis:7-alpine | jq '.manifests[] | "\(.platform.architecture): \(.digest)"'# amd64: sha256:4706ecab5371690fecfdd782268929c94ad5b5ce9ce0b35bfdfe191c4ad17851# arm64: sha256:...
# 로컬 이미지의 RepoDigests (manifest list 기준)docker inspect redis:7-alpine | jq '.[0].RepoDigests'# ["redis@sha256:ee64a64eaab618d88051c3ade8f6352d11531fcf79d9a4818b9b183d8c1d18ba"]검증 워크플로우
인터넷 환경 (이미지 반입 준비)
# 1. 다운로드 전에 원격 digest 확인 (기준값)skopeo inspect --raw docker://registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ee:v17.11.7 \ | jq -r '.manifests[] | select(.platform.architecture == "amd64") | .digest'# sha256:7bf54efca5e7ebc56e107198dc81f6ebe73c73d48dd79c893b8efe6768d3181a
# 2. 이미지 pulldocker pull registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ee:v17.11.7
# 3. Cosign 서명 검증 (배포자 신원 확인)cosign verify --key cosign.pub \ registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ee:v17.11.7
# 4. Digest 비교 (다운로드 무결성)docker inspect --format='{{index .RepoDigests 0}}' \ registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ee:v17.11.7
# 5. 이미지 저장 (에어갭 전달용)docker save registry.gitlab.com/.../gitlab-webservice-ee:v17.11.7 \ -o gitlab-webservice-ee-v17.11.7.tarskopeo inspect --raw는 이미지를 다운로드하지 않고 레지스트리에서 manifest만 조회한다.
에어갭 전달 과정 보호
시나리오 6: 전달 과정 변조
인터넷 환경에서 모든 검증을 통과한 파일이라도, USB나 망연계 과정에서 변조될 수 있다.
인터넷 환경: 정상 파일 다운로드 완료전달 과정: USB에서 파일 교체폐쇄망: 변조된 파일 수신방어: 인터넷 환경에서 확인한 해시를 별도로 기록하고, 폐쇄망에서 재검증
폐쇄망 환경 (검증)
# 인터넷 환경에서 기록한 해시# SHA256: 669b86a41a22750ff4a7fb5660bca21d14016388adc6b39a31eed0ce8c7b2234
# 폐쇄망에서 재계산shasum -a 256 gitlab-8.11.8.tgz# 669b86a4... → 일치하면 전달 과정 무결성 확인
# 이미지 로드 및 내부 레지스트리 pushdocker load -i gitlab-webservice-ee-v17.11.7.tardocker tag registry.gitlab.com/.../gitlab-webservice-ee:v17.11.7 \ harbor.internal/gitlab/gitlab-webservice-ee:v17.11.7docker push harbor.internal/gitlab/gitlab-webservice-ee:v17.11.7
# Digest 재검증docker inspect harbor.internal/gitlab/gitlab-webservice-ee:v17.11.7 \ | jq -r '.[0].RepoDigests[0]'실행 중 Pod 검증
# Pod에서 실행 중인 이미지의 digest 확인kubectl get pod gitlab-webservice-xxx -n gitlab \ -o jsonpath='{.status.containerStatuses[0].imageID}'# harbor.internal/gitlab/gitlab-webservice-ee@sha256:7bf54efca5e7ebc56e107198dc81f6ebe73c73d48dd79c893b8efe6768d3181a에어갭 환경 전체 워크플로우
flowchart TB subgraph Internet["인터넷 환경"] A0[".prov 다운로드"] --> A1["GPG 서명 검증<br/>(배포자 신원)"] A1 --> A2[".prov에서 SHA256 추출"] A2 --> A3[tgz 다운로드] A3 --> A4["SHA256 비교<br/>(다운로드 무결성)"]
B0["Cosign 공개키 다운로드"] --> B1["skopeo inspect --raw<br/>(기준 Digest)"] B1 --> B2[docker pull] B2 --> B3["cosign verify<br/>(배포자 신원)"] B3 --> B4["Digest 비교<br/>(다운로드 무결성)"] B4 --> B5[docker save] end
subgraph Transfer["에어갭 전달"] A4 --> T1[USB/망연계] B5 --> T1 end
subgraph Airgap["폐쇄망 환경"] T1 --> C1["SHA256 재검증"] T1 --> D1[docker load]
C1 --> C2[charts/ 폴더에 복사] D1 --> D2[내부 레지스트리 push]
D2 --> E1["Digest 재검증"] end정리
각 검증이 방어하는 공격
| 검증 단계 | 방어하는 공격 |
|---|---|
| SHA256 비교 | tgz 파일 단독 변조 |
| GPG 서명 검증 | tgz + .prov 동시 변조 |
| fingerprint 대조 | 위조된 GPG 키 배포 |
| Digest 비교 | 다운로드 중 이미지 변조 (MITM) |
| Cosign 서명 검증 | 레지스트리 해킹 (악성 이미지 업로드) |
| 폐쇄망 재검증 | 에어갭 전달 과정 변조 |
검증을 생략하면?
| 생략한 검증 | 가능한 공격 |
|---|---|
| GPG 서명 | .prov 파일 변조로 악성 Chart 배포 |
| fingerprint 대조 | 공격자 키로 서명된 Chart 신뢰 |
| Cosign 서명 | 레지스트리 해킹 시 탐지 불가 |
| 다운로드 전 기준값 확인 | ”원본이 무엇인지” 알 수 없음 |
| 폐쇄망 재검증 | 전달 과정 변조 탐지 불가 |
모든 단계가 필요한 이유: 각 검증은 서로 다른 공격 벡터를 방어한다. 하나라도 생략하면 해당 공격에 취약해진다.
신뢰 체인 (Chain of Trust)
[Helm Chart]공식 문서 → fingerprint 대조 → GPG 키 신뢰 → .prov 서명 검증 → SHA256 신뢰 → tgz 무결성
[이미지]charts.gitlab.io → Cosign 키 신뢰 → 이미지 서명 검증 → Digest 신뢰 → 이미지 무결성검증 단계별 요약
| 단계 | Helm Chart | 컨테이너 이미지 |
|---|---|---|
| 공개키 준비 | GPG 키 + fingerprint 대조 | Cosign 키 다운로드 |
| 배포자 신원 | .prov GPG 서명 검증 | cosign verify |
| 기준값 확인 | .prov에서 SHA256 추출 | skopeo inspect --raw |
| 다운로드 | curl 또는 helm pull | docker pull |
| 무결성 검증 | SHA256 비교 | Digest 비교 |
| 폐쇄망 재검증 | SHA256 재비교 | Digest 재비교 |
참고 자료
관련 콘텐츠
LinkedIn에서 발견한 Tencent WeKnora, GraphRAG PoC하고 PR까지 Merged
LinkedIn에서 발견한 Tencent WeKnora를 홈 Kubernetes 클러스터에서 PoC하고, Helm Chart PR까지 Merge한 여정
DevOpsn8n v1 → v2 업그레이드: Kubernetes에서 메이저 버전 넘기
n8n v1.120.4에서 v2.9.4로 2단계 순차 업그레이드한 기록. 연쇄 CVE 대응, Migration Report 활용, community node emptyDir 트레이드오프, Queue 모드 호환성 튜닝.
DevOpsEC2 해킹당하고, DevSecOps 파이프라인을 구축하다
사이드 프로젝트 개발 서버가 털린 경험을 계기로 DevSecOps 파이프라인을 구축한 이야기입니다.