에어갭 환경에서 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# 669b86a41a22750ff4a7fb5660bca21d14016388adc6b39a31eed0ce8c7b22342단계(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 재비교 |
참고 자료
관련 콘텐츠
EC2가 해킹당한 뒤 만든 DevSecOps 파이프라인
사이드 프로젝트 개발 서버가 털린 경험을 계기로 DevSecOps 파이프라인을 구축한 이야기입니다.
DevOpsn8n v1 → v2 업그레이드 — Kubernetes에서 메이저 버전 올리기
n8n v1.120.4에서 v2.9.4로 2단계 순차 업그레이드한 기록입니다. 연쇄 CVE 대응, Migration Report 활용, community node emptyDir 트레이드오프, Queue 모드 호환성 튜닝을 다룹니다.
DevOpsLinkedIn에서 본 Tencent WeKnora로 GraphRAG PoC 하고 PR 머지까지
LinkedIn에서 발견한 Tencent WeKnora를 홈 Kubernetes 클러스터에서 PoC 하고 Helm Chart PR까지 머지한 여정입니다.