이 엔지니어를 위한 이야기 공장 AI 기술에 인문학의 온기를

깃허브 저장소 3만여 곳에 심긴 가짜 '보안 점검' 워크플로

깃허브 저장소 3만여 곳에 심긴 가짜 '보안 점검' 워크플로

깃허브 저장소 3만여 곳에 똑같은 파일 한 장이 올라갔습니다. 이름은 security-audit.yml, 커밋 메시지는 "Add security audit workflow". 보안 점검을 돌려 주는 워크플로처럼 보이지만 하는 일은 그 저장소의 자격증명을 긁어 바깥으로 보내는 것뿐입니다. GitGuardian이 10월 13일 갱신한 집계로는 사용자·조직 약 3,000곳, 공격에 동원된 깃허브 계정이 약 1,900개예요. 고스트액션(GhostAction)이라는 이름이 붙은 캠페인이고, 2025년 9월에 처음 관측된 뒤로 사실상 멈춘 적이 없습니다.

YAML 파일 한 장 들어온 게 왜 그렇게 큰일일까요. (깃허브 액션을 써 보신 분이라면 벌써 짐작하고 계실지도 모르겠네요.)

깃허브 액션에서 워크플로 정의는 서버에 따로 등록하는 설정이 아니라 .github/workflows/ 아래 들어 있는 텍스트 파일입니다. 그 파일이 기본 브랜치에 올라오는 순간 그 자체가 실행 허가가 되고요. 이번 파일은 트리거를 on: push로 걸되 브랜치나 경로 필터를 하나도 두지 않았고 workflow_dispatch까지 열어 뒀어요. 아무 커밋이나 하나 올라오면 돈다는 뜻이죠.

그럼 시크릿은 어떻게 될까요. 등록해 둔 시크릿은 워크플로가 돌 때 러너 안으로 값이 들어옵니다. 깃허브가 해 주는 보호는 로그에 찍힌 값을 가려 주는 것이고, 공식 문서도 딱 거기까지만 약속해요. 그나마도 값이 변형되는 경로가 여럿이라 가림이 보장되지는 않는다고 적혀 있습니다. 러너에서 바깥으로 나가는 네트워크 전송은 애초에 보호 대상이 아니고요. curl 한 줄이면 그대로 나갑니다.

그래서 공격자가 가장 먼저 하는 일이 조금 의외입니다. 저장소에 원래 있던 워크플로 파일들을 읽어서 어떤 이름의 시크릿을 쓰는지 알아낸 뒤, 그 이름을 페이로드에 하나씩 옮겨 적어요. 깃허브는 워크플로가 그 시크릿을 명시적으로 포함할 때만 값을 읽어 주도록 설계돼 있기 때문입니다. pyxel이라는 게임 엔진 저장소에서는 원래 쓰던 시크릿 이름 네 개가 그렇게 베껴졌고요. 열쇠를 복제하기 전에 이름표부터 베낀 셈이죠.

페이로드에 박혀 있던 시크릿 이름의 분포를 보여주는 막대그래프
페이로드에 박혀 있던 시크릿 이름의 분포를 보여주는 막대그래프

페이로드에 박혀 있던 시크릿 이름들. SSH_PRIVATE_KEY가 가장 많았습니다. 출처: GitGuardian

여기까지는 이전 파동과 같습니다. 10월 변종에 새로 붙은 쪽이 더 고약해요. 체크아웃 단계에 fetch-depth: 0이 들어가 있습니다. actions/checkout의 기본값은 1입니다. 빌드에는 최신 커밋 하나면 충분하니 평소에는 그 하나만 받아오죠. 0으로 바꾸면 모든 브랜치, 모든 태그, 모든 커밋을 통째로 받아옵니다. 그러고 나서 러너에서 git log -p --all을 돌립니다. 전체 히스토리의 diff를 앞에서부터 쭉 훑는 명령이에요.

이게 왜 치명적인지는 깃이 파일을 지우는 방식을 떠올려 보면 됩니다. 실수로 키를 커밋했다가 다음 커밋에서 지워 본 적, 한 번쯤은 있으실 겁니다. 그런데 깃에서 삭제는 과거를 고치는 일이 아니라 새 커밋을 하나 더 쌓는 일입니다. 키를 담고 있던 내용 덩어리는 사라지지 않고, 그것을 집어넣었던 커밋에서 여전히 멀쩡하게 도달됩니다. 최신 파일 목록에만 없을 뿐이에요. 그래서 2019년에 올렸다가 바로 다음 날 지운 키도 git log -p --all 앞에서는 그대로 나옵니다. 페이로드는 거기서 13종의 정규식으로 AWS 액세스 키, 깃허브와 GitLab 토큰, 구글·Slack·SendGrid 키를 긁어내고, AWS는 액세스 키 ID가 나오면 앞뒤 두 줄을 함께 가져가 짝이 되는 시크릿 키까지 챙깁니다. 수확물은 하드코딩된 IP로 평문 HTTP POST 한 번에 나가요. 도메인을 쓰지 않으니 DNS 질의조차 남지 않습니다.

그렇다고 저장소 3만 곳에서 3만 건이 털렸다는 얘기는 아닙니다. GitGuardian이 올해 8월 31일부터 9월 30일까지의 파동을 실측해 보니, 실행 기록이 3,669건 쌓였지만 실제로 돌아간 것은 499번, 끝까지 완료된 것은 336번, 유출로 확인된 시크릿은 13개 저장소의 26개였습니다. 대부분은 깃허브가 승인 대기 상태로 붙잡아 뒀거든요. 10월 9일부터는 실행 자체를 막기 시작했고, 10일과 12일 파동은 시크릿을 하나도 빼내지 못했습니다. 다만 유출이 확인되지 않은 것과 그 키가 안전한 것은 다른 얘기죠. 한 번이라도 완료된 실행이 있었다면 그 저장소의 키는 이미 나갔다고 보는 편이 맞습니다.

제가 계속 들여다보게 되는 건 13종 정규식의 구성입니다. AWS와 깃허브 토큰 옆에 sk-ant-, sk-proj-, sk-or-가 나란히 올라와 있어요. 앤트로픽, 오픈AI, OpenRouter 키의 접두사입니다. 예측은 아니고 가정입니다만, CI 러너가 들고 있는 자격증명의 성격이 달라지고 있다는 신호로 읽힙니다. 배포 키는 훔쳐도 패키지를 올릴 수 있을 뿐이지만 모델 API 키는 훔친 쪽이 곧바로 돈으로 쓸 수 있으니까요.

범위도 한 번 짚고 갈 만합니다. 계정 하나가 뚫리면 그 계정이 쓰기 권한을 가진 저장소가 전부 사정거리에 들어옵니다. 한 계정에서는 318곳이 한꺼번에 당했는데 그중 279곳이 본인이 떠 둔 포크였어요. 평소에 쳐다보지도 않던 포크까지 전부 내가 커밋할 수 있는 저장소로 세어진 셈이죠. 깃허브가 손을 쓰기 전인 10월 초만 놓고 보면, 유출을 막은 것으로 관측된 저장소 쪽 설정은 워크플로 실행에 승인을 요구해 둔 경우 하나뿐이었습니다. 거기에 .github/workflows 경로에 브랜치 보호와 리뷰를 걸어 두는 것, 러너의 이그레스를 허용목록으로 묶는 것 정도가 오늘 당장 확인해 볼 만한 것들이고요.

그리고 하나 더. 히스토리에 한 번 들어간 키는 커밋으로 지울 수 없습니다. 지우는 게 아니라 폐기하고 새로 발급받는 것이 유일한 처리예요. 머리로는 다 아는 얘긴데, 이번 일을 보고 제 저장소들을 다시 뒤져 보게 되네요.


커버 이미지: 악성 워크플로의 실행을 깃허브가 보류시킨 화면 — GitGuardian

#깃허브액션 #공급망공격 #시크릿관리 #git #CI #개발자도구