3월 19일 오후 6시 24분(UTC). 전 세계 수천 개 CI/CD 파이프라인에서 docker pull aquasec/trivy:latest가 실행됐다. 평소와 다를 게 없었다. 이미지 취약점 스캔하고, 결과 리포트 뽑고, 다음 스테이지로 넘어가는 루틴. 그런데 그날 pull된 바이너리는 진짜가 아니었다. 취약점을 찾아주는 도구가 아니라, 시크릿을 훔쳐가는 인포스틸러였다.
공격 경로: GitHub Actions → Docker Hub → 너의 클러스터
TeamPCP라는 위협 그룹의 작업이다. 2월 말에 Aqua Security의 GitHub Actions 환경에서 설정 오류를 이용해 권한 토큰을 탈취했다. Aqua 보안팀이 토큰을 로테이션했지만 일부 크레덴셜이 남아 있었고, 그게 3월 공격의 시작점이 됐다.
공격자는 탈취한 크레덴셜로 aquasec/trivy v0.69.4를 Docker Hub, GHCR, ECR에 동시 푸시했다. latest 태그도 덮어씌웠다. GitHub Actions 쪽도 건드렸는데, trivy-action의 76개 태그 중 75개를 악성 버전으로 force-push했고 setup-trivy의 태그 7개도 변조됐다. 커밋 히스토리에는 기존 컨트리뷰터의 아이덴티티를 스푸핑해서 밀어넣었기 때문에, git log만 봐서는 정상 커밋과 구별할 수 없었다.
한 가지 짚고 넘어갈 게 있다. 이건 단순히 "도커 이미지 하나 오염됐네" 수준이 아니다. 공식 릴리스 파이프라인 자체가 장악된 거다. 서명된 이미지, 검증된 레지스트리, 자동화된 빌드 — 우리가 신뢰하는 모든 메커니즘이 공격자의 배포 채널로 전환됐다.
악성코드가 실제로 한 일
세 단계로 동작했다. 읽다 보면 꽤 소름끼친다.
수집 단계. Runner.Worker 프로세스의 /proc/<pid>/mem을 직접 읽으면서 {"value":"<secret>","isSecret":true} 패턴을 검색한다. GitHub Actions가 시크릿을 런타임에 마스킹하는 내부 구조를 역이용한 방식이다. 그 다음 파일시스템을 50개 넘는 경로에서 훑는다. AWS 크레덴셜, GCP 서비스 어카운트 JSON, Azure 토큰, kubeconfig, Docker config.json, SSH 키, 암호화폐 지갑 파일까지 전부.
암호화 및 패키징. 수집한 데이터를 AES-256-CBC와 RSA-4096 하이브리드로 암호화해서 tpcp.tar.gz로 묶는다. 포렌식 분석을 어렵게 만들려는 의도가 명확하다.
유출. scan.aquasecurtiy[.]org으로 POST 요청을 보낸다. 도메인을 자세히 보면 "securtiy" — t와 i가 뒤바뀐 타이포스쿼팅이다. 이 C2가 실패하면 폴백 메커니즘이 돌아간다. 피해자의 GitHub 계정에 tpcp-docs라는 레포를 자동 생성하고, 탈취 데이터를 릴리스 에셋으로 업로드한다. 자기 집 창고에 장물을 보관시키는 수법이다.
CI/CD 환경이 아닌 개발자 로컬에서 실행됐을 경우에는 ~/.config/systemd/user/sysmon.py에 파이썬 드로퍼를 심고 systemd 유저 유닛으로 등록한다. 5분 간격으로 ICP 호스팅 서버를 폴링하면서 추가 페이로드를 대기한다. 퍼시스턴스까지 확보하는 거다.
피해 윈도우
3월 19일 18:24 UTC부터 23일 01:36 UTC까지, 약 4일간 공격 창이 열려 있었다. 이 기간에 해당 태그를 pull한 환경은 전부 잠재적 피해 대상이다. 영향받은 태그는 0.69.4, 0.69.5, 0.69.6, 그리고 latest.
후폭풍도 만만치 않았다. 탈취된 npm publish 토큰으로 CanisterWorm이라는 자기 전파형 웜이 퍼졌고, Aqua Security의 GitHub 레포 44개가 디페이스됐다. 하나의 크레덴셜 유출이 연쇄적으로 다른 생태계까지 오염시킨 전형적인 서플라이 체인 캐스케이드다.
여기서 불편한 질문이 나온다. 우리 파이프라인은 괜찮은가? 3월 19일부터 23일 사이에 Trivy를 한 번이라도 실행한 조직이라면 "아마 괜찮겠지"가 아니라 "확인해봐야 안다"가 맞는 답이다.
당장 확인해야 할 것들
아직 점검 안 했다면 지금 해야 한다.
# 조직 내 Trivy 이미지 사용 내역
docker image ls | grep trivy
# GitHub Actions 워크플로우에서 trivy-action 참조 여부
grep -r "aquasecurity/trivy-action" .github/workflows/
grep -r "aquasecurity/setup-trivy" .github/workflows/
# 유출 성공 지표 — tpcp-docs 레포 생성 여부
gh repo list --json name | jq '.[].name' | grep tpcp
v0.69.4를 pull한 적이 있다면 다음을 전부 돌려야 한다:
CI/CD 파이프라인 시크릿 전량 로테이션
클라우드 프로바이더 IAM 키 재발급 (AWS Access Key, GCP SA Key, Azure SP 전부)
SSH 키 재생성 및 authorized_keys 갱신
3월 19~23일 워크플로우 실행 로그 전수 검토
SHA 핀닝을 안 하면 같은 일이 또 일어난다
이번 사건에서 딱 하나만 기억할 게 있다면.
# 이렇게 쓰면 태그 재할당 공격에 무방비
- uses: aquasecurity/trivy-action@v0.69.4
# 풀 커밋 SHA로 핀닝
- uses: aquasecurity/trivy-action@a1b2c3d4e5f6...
버전 태그는 mutable 포인터다. 어제는 정상 커밋을 가리키다가 오늘은 악성 커밋을 가리킬 수 있다. SHA 핀닝은 이 벡터를 원천 차단한다. Dependabot이나 Renovate로 SHA 업데이트를 자동화하면 운영 부담도 거의 없다.
도커 이미지도 마찬가지다. :latest나 :0.69 같은 뮤터블 태그 대신 @sha256:abcdef... 다이제스트로 고정하는 습관을 들여야 한다. 귀찮다고? CI/CD 시크릿 전부 로테이션하는 것보다는 덜 귀찮다.
보안 스캐너라고 무조건 신뢰할 수 있는 건 아니다. 결국 소프트웨어이고, 소프트웨어는 뚫린다. "취약점을 찾는 도구가 취약점이 된다"는 아이러니를 교과서에서만 볼 줄 알았는데, 현실이 됐다. 클라우드 네이티브 생태계가 소수의 오픈소스 프로젝트에 집중적으로 의존하고 있다는 구조적 문제도 다시 드러났다. Trivy 하나가 뚫렸을 뿐인데 Docker Hub, GitHub Actions, npm 레지스트리까지 도미노처럼 영향이 퍼졌다.
zero trust는 네트워크에만 적용하는 개념이 아니다. 빌드 파이프라인에도, 써드파티 액션에도, 컨테이너 이미지에도 같은 원칙이 필요하다. SHA 핀닝하고, 이미지 다이제스트 고정하고, 시크릿 스코프를 최소화하는 게 오늘 당장 할 수 있는 일이다.