☰
jena.run
/
♡

AWS 키는 안 털렸는데 AssumeRole이 찍혔다

GitHub Actions 하나가 만든 새벽 침해대응

AWS 키는 안 털렸는데 AssumeRole이 찍혔다


새벽에 보안 알람이 울리는 것 자체는 별로 특별한 일이 아니다.

대부분은 이런 거다.

FailedLogin
PortScan
WAFBlock
ImpossibleTravel
KnownScanner

확인하고 false positive면 닫는다.

그런데 이번 알람은 조금 이상했다.

AWS 쪽에서 평소 배포 시간도 아닌데 production role에 대한 STS 호출이 잡혔다.

AssumeRoleWithWebIdentity
Role: deploy-prod
Source: GitHub Actions OIDC
Environment: production

첫 생각은 단순했다.

"누가 새벽 배포했나?"

Slack 배포 채널을 확인했다.

아무것도 없었다.

GitHub deployment도 없다.

온콜 개발자에게 물어봤다.

“혹시 prod 배포 돌렸어요?”

답은 바로 왔다.

“아뇨.”

그 순간부터 장애 대응이 아니라 incident response가 시작됐다.


이상한 점 하나

처음에는 credential leak을 의심했다.

AWS 사고에서 흔히 보게 되는 그림은 이런 식이다.

Access Key Leak
      |
      v
Credential Abuse
      |
      v
AWS API Calls

그런데 이번에는 AKIA...로 시작하는 장기 Access Key가 아니었다.

CloudTrail에는 AssumeRoleWithWebIdentity가 찍혀 있었다.

즉 GitHub Actions가 OIDC token을 이용해 AWS STS에서 임시 credential을 받아간 것이다.

GitHub Actions의 OIDC는 원래 장기 AWS credential을 GitHub Secret에 저장하지 않기 위해 사용하는 꽤 좋은 구조다. GitHub 역시 OIDC 사용 시 cloud provider가 특정 repository나 branch 같은 신뢰 조건을 검사하도록 설정해야 한다고 권고한다.

그래서 처음에는 오히려 더 이상했다.

AWS key가 유출된 것도 아닌데 어떻게 production role을 먹었지?


CloudTrail부터 역으로 올라갔다

우선 해당 세션에서 어떤 API가 호출됐는지 확인했다.

예시는 단순화하면 이런 느낌이었다.

02:13:41 AssumeRoleWithWebIdentity
02:13:43 GetCallerIdentity
02:13:46 DescribeClusters
02:13:49 ListBuckets
02:13:51 GetAuthorizationToken
02:14:02 DescribeServices

이 시점에서 자동화된 정상 deployment가 아니라는 확신이 조금 더 생겼다.

정상 배포라면 호출 패턴이 어느 정도 정해져 있다.

GetCallerIdentity
GetAuthorizationToken
PushImage
UpdateService
DescribeServices

그런데

ListBuckets
DescribeClusters

가 중간에 섞여 있었다.

배포가 아니라 enumeration에 가까웠다.

공격자는 AWS에 들어온 다음 바로 뭔가 부수지 않았다.

먼저

“내가 어디까지 갈 수 있지?”

를 보고 있었다.


다음은 GitHub였다

OIDC session이라면 시작점은 GitHub Actions다.

문제가 된 repository의 workflow를 뒤졌다.

몇 분 지나지 않아 이상한 YAML 하나가 보였다.

name: PR Validation

on:
  pull_request_target:
    types: [opened, synchronize, reopened]

permissions:
  contents: read
  id-token: write

jobs:
  validate:
    runs-on: [self-hosted, linux]

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - run: npm ci

      - run: npm test

처음 보면 그렇게 이상해 보이지 않는다.

PR 올라오면 dependency 설치하고 테스트한다.

CI라면 당연히 하는 일이다.

문제는 세 가지가 한꺼번에 붙어 있었다.

pull_request_target
        +
Untrusted PR Checkout
        +
id-token: write

GitHub 문서에서도 pull_request_target 같은 privileged workflow가 PR head를 checkout한 뒤 build/test 등으로 실행하면 공격자가 PR에 넣은 코드를 privileged context에서 실행할 수 있다고 경고한다. 흔히 pwn request라고 부르는 패턴이다.


PR 하나가 사실상 코드 실행권이었다

공격자가 해야 할 일은 복잡하지 않았다.

repository를 fork한다.

PR을 하나 만든다.

그리고 정상 코드 수정처럼 보이는 변경 안에 build 과정에서 실행되는 코드를 끼워 넣는다.

예를 들면 공격 표면은 꼭 application source일 필요도 없다.

package.json
Makefile
setup.py
build.gradle
scripts/
test config
custom build hook

CI가 그것을 실행하면 된다.

흐름은 결국 이렇게 된다.

Attacker Fork
      |
      v
Pull Request
      |
      v
Privileged Workflow
      |
      v
Checkout Untrusted Code
      |
      v
Code Execution on Runner

여기에 id-token: write까지 붙어 있었다.

GitHub Actions에서는 이 권한이 있는 job이 OIDC JWT를 요청할 수 있다.

즉 AWS Access Key를 훔칠 필요도 없었다.

CI 자체가 공격자를 대신해 정상적인 임시 AWS credential을 발급받을 수 있는 위치에 있었다.


그런데 AWS가 막아줘야 하는 것 아닌가?

맞다.

그래서 AWS IAM trust policy를 확인했다.

그리고 두 번째 문제가 나왔다.

예시를 단순화하면 이런 구조였다.

{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    },
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:example-org/*:*"
    }
  }
}

너무 넓었다.

의도는 이해할 수 있다.

“우리 조직 GitHub repository면 deployment role을 사용할 수 있게 하자.”

운영할 때는 편하다.

repository가 늘어날 때 IAM policy를 매번 수정할 필요도 없다.

그런데 보안 관점에서는 이야기가 완전히 달라진다.

Trusted Organization
        !=
Trusted Execution Context

GitHub도 OIDC를 구성할 때 cloud provider가 무조건 token을 받아주는 게 아니라 예측 가능한 repository, branch, environment 등의 조건으로 제한해야 한다고 명시한다. Production environment를 사용할 경우 protection rule을 적용하는 것도 권장한다.

우리 환경에서는 GitHub 쪽 privileged workflow와 AWS 쪽 넓은 trust policy가 정확히 맞물렸다.

각 설정 하나씩 보면 운영상 이해되는 설정이었다.

붙여놓으니 취약점이 됐다.


그런데 여기서 끝이 아니었다

보통 여기까지 찾으면 원인을 찾았다고 생각하기 쉽다.

Root Cause Found
Incident Closed

Senior 레벨에서 실제로 피곤해지는 부분은 여기부터다.

공격자가 AWS에 접근했다는 사실보다, 어디까지 접근했는지가 중요하다.

deploy-prod role에 permission이 뭐가 붙어 있는지 확인했다.

ECR.

ECS.

일부 S3.

Secrets Manager 일부 read.

그리고 Kubernetes 관련 권한까지 조금 있었다.

즉 질문이 바뀌었다.

“AWS에 들어왔나?”

가 아니라

“이 credential을 이용해서 무엇을 봤고, 무엇을 바꿀 수 있었나?”

가 된다.


일단 끊는다

포렌식을 완벽하게 끝낸 다음 차단하는 식으로 움직일 수는 없다.

먼저 blast radius부터 줄여야 한다.

첫 조치는 production role의 GitHub OIDC trust를 임시로 차단하는 것이었다.

Disable Trust
     |
     v
Block New Sessions

그다음 문제가 된 workflow를 disable.

self-hosted runner group도 격리했다.

그리고 해당 시간대의 deployment를 전부 중단했다.

중요한 건 credential만 rotate하고 끝내면 안 된다는 것이다.

OIDC 기반 공격이라면 장기 AWS key가 없을 수도 있다.

root cause가 살아 있으면 공격자가 새로운 token을 다시 발급받으면 된다.


그리고 self-hosted runner가 있었다

이 부분에서 일이 한 단계 더 커졌다.

workflow가 GitHub-hosted runner가 아니라

self-hosted

였다.

GitHub-hosted runner라면 job 단위로 새 VM을 사용하는 구조를 기대할 수 있지만 self-hosted runner는 운영 방식에 따라 같은 머신이 반복 사용될 수 있다.

GitHub도 self-hosted runner에서 untrusted code를 실행하면 환경이 지속적으로 compromise될 수 있으므로 특히 주의해야 한다고 명시하고 있다.

즉 공격자가 첫 번째 CI job에서 AWS credential만 얻고 끝냈다고 가정할 수 없었다.

예를 들어 runner에 이런 걸 남겼다고 생각해보자.

/tmp/
Runner workspace
Shell profile
Background process
Docker socket
Build cache
Git hooks
Credential helper

다음 정상 deployment가 같은 runner에서 실행된다면 공격자는 훨씬 강한 credential을 기다릴 수도 있다.

구조는 이런 식이다.

Malicious PR
     |
     v
Compromise Runner
     |
     v
Wait
     |
     v
Trusted Production Job
     |
     v
Steal New Credentials

그래서 runner를 “청소해서 다시 쓰는 것”이 아니라 폐기하고 새로 만드는 쪽으로 결정했다.


이제 조직 전체를 뒤져야 한다

하나 발견됐다고 하나만 고쳐서는 의미가 없다.

비슷한 workflow가 다른 repository에 복사돼 있을 가능성이 높다.

실제로 대기업이나 repository가 많은 조직에서는 CI workflow가 복붙되면서 같은 보안 문제가 수십 군데로 퍼지는 일이 흔하다.

그래서 organization 전체에서 다음 패턴을 검색했다.

pull_request_target

id-token: write

runs-on: self-hosted

pull_request.head.sha

repository: ${{ github.event.pull_request.head.repo.full_name }}

gh pr checkout

git fetch

특히 이런 조합을 우선순위 높게 봤다.

Untrusted Input
      +
Code Execution
      +
Privileged Token

개별 keyword가 문제가 아니라 trust boundary가 어디서 교차하는지 보는 작업이다.


Third-party Action도 다시 봤다

Incident를 조사하다 보면 욕심이 생긴다.

“어차피 지금 GitHub Actions 전체 보는 김에 이것도 보자.”

workflow를 뒤지다 보니 이런 것도 많이 나왔다.

- uses: vendor/example-action@v3

버전 tag만 pinning돼 있었다.

편하지만 tag는 immutable하지 않다.

실제로 2025년 tj-actions/changed-files 공급망 공격에서는 공격자가 여러 version tag를 악성 commit으로 돌려 CI/CD secret이 workflow log에 노출될 수 있게 만들었고, GitHub Advisory는 약 23,000개 repository가 영향을 받을 수 있었던 사건으로 기록하고 있다.

GitHub는 third-party Action을 사용할 때 full-length commit SHA pinning을 가장 안전한 방식으로 권고한다. 현재 Action을 immutable release에 고정하는 방법 역시 full SHA pinning이다.

그래서

- uses: vendor/example-action@v3

대신

- uses: vendor/example-action@8f4b7c2e6a9d...

형태로 전환하는 작업도 같이 들어갔다.


사고의 원인은 YAML 한 줄이 아니었다

Incident review에서 가장 경계하는 결론이 있다.

“개발자가 workflow를 잘못 작성했습니다.”

이렇게 결론내리면 거의 반드시 다시 터진다.

실제 원인은 여러 개의 정상적인 선택이 겹친 것이었다.

Convenient PR Workflow
        |
        v
pull_request_target
        |
        +
Fast CI Infrastructure
        |
        v
Self-hosted Runner
        |
        +
Passwordless Cloud Auth
        |
        v
GitHub OIDC
        |
        +
Easy Repository Management
        |
        v
Broad IAM Trust Policy

각각은 이유가 있었다.

문제는 모든 편의 기능이 같은 trust boundary 위에 올라갔다는 것이다.

보안 사고는 종종 하나의 치명적인 설정 때문에 발생하지 않는다.

“이 정도는 괜찮겠지”가 다섯 번 쌓여서 발생한다.


그래서 구조 자체를 바꿨다

첫 번째는 PR workflow와 deployment workflow를 완전히 분리했다.

Untrusted PR
      |
      v
Isolated CI
      |
      v
No Cloud Credentials

그리고 production deployment는 별도 trusted context에서만 실행한다.

Protected Branch
      |
      v
Approved Environment
      |
      v
OIDC
      |
      v
Production Role

GitHub의 privileged workflow에서는 untrusted PR code를 checkout해서 실행하지 않는다. GitHub 역시 pull_request_target 등 secret이나 privileged token을 사용할 수 있는 이벤트에서 untrusted code 실행을 피하도록 권고한다.


OIDC도 “키가 없으니 안전하다”가 아니다

OIDC로 migration하면 흔히 이런 말을 한다.

“AWS key 이제 GitHub에 없으니까 안전해졌어요.”

절반만 맞다.

Static credential 위험은 확실히 줄어든다.

하지만 새로운 질문이 생긴다.

“누가 token을 발급받을 자격이 있는가?”

OIDC에서는 credential 자체보다 trust policy가 secret에 가까워진다.

좋은 구조는 이런 식이다.

Repository
   +
Branch
   +
Environment
   +
Audience
   +
Approval
   |
   v
Short-lived AWS Session

나쁜 구조는 이렇다.

Anything from GitHub Org
           |
           v
      Production AWS

passwordless라고 해서 trustless는 아니다.


permissions:도 workflow 맨 위에 대충 두면 안 된다

Incident 이후 id-token: write를 workflow 전체에 주는 패턴도 없앴다.

기본값은 가능한 한 작게 둔다.

permissions:
  contents: read

그리고 정말 AWS authentication이 필요한 deployment job에만 추가한다.

jobs:
  deploy:
    permissions:
      contents: read
      id-token: write

GitHub 역시 GITHUB_TOKEN 권한은 최소 권한으로 명시하고, 필요한 job에서만 확대하는 방식을 권장한다.

security engineer 입장에서는 이런 한 줄이 별것 아닌 것처럼 보여도 blast radius를 결정한다.


Incident Response에서 제일 힘든 건 “뚫렸냐 안 뚫렸냐”가 아니다

개발팀에서는 당연히 가장 먼저 묻는다.

“그래서 실제로 털린 거예요?”

보안팀 입장에서는 이 질문이 제일 답하기 어렵다.

Evidence가 없다는 것과 compromise가 없었다는 것은 다르다.

그래서 답은 보통 binary가 아니다.

Confirmed
Likely
Possible
Unlikely
Ruled Out

범위로 판단한다.

이번 같은 경우라면,

AWS role assumption은 Confirmed.

resource enumeration은 Confirmed.

Secrets Manager access 여부는 CloudTrail로 확인.

실제 secret 사용 여부는 별도 조사.

self-hosted runner persistence는 Possible.

source code modification은 GitHub audit log와 commit history로 확인.

production workload modification은 CloudTrail/EKS audit log 등으로 확인한다.

Incident response는 결국 이런 불완전한 증거들 사이에서 얼마나 보수적으로 대응할지를 결정하는 일이다.


Senior가 되면 직접 해킹하는 시간보다 결정하는 시간이 늘어난다

보안 엔지니어 초반에는 기술적인 부분이 제일 어렵다고 생각하기 쉽다.

Exploit
Reverse Engineering
Cloud Security
Malware Analysis
Detection Engineering

물론 어렵다.

그런데 senior 레벨에서 더 어려운 건 이런 질문들이다.

production deployment를 지금 전부 멈출 정도인가?

runner 30대를 모두 폐기할 것인가?

200개 repository의 credential을 rotate할 것인가?

고객에게 disclosure가 필요한가?

개발팀의 release를 얼마나 막을 것인가?

증거를 더 모을 것인가, 지금 바로 infrastructure를 날릴 것인가?

기술적으로 정답이 있어도 운영적으로 정답이 없는 경우가 많다.

보안팀의 역할은 단순히

Find Vulnerability

가 아니라

Understand Risk
      |
      v
Limit Blast Radius
      |
      v
Preserve Evidence
      |
      v
Restore Trust
      |
      v
Prevent Recurrence

에 더 가깝다.


그리고 마지막에 남은 건 생각보다 단순했다

이번 incident의 최초 진입점은 엄청난 0-day도 아니었다.

AWS 자체가 뚫린 것도 아니었다.

GitHub OIDC가 취약했던 것도 아니다.

self-hosted runner라는 기술 자체가 취약했던 것도 아니다.

각 시스템은 대부분 설계대로 움직였다.

GitHub는 workflow가 요청했기 때문에 token을 발급했다.

AWS는 trust policy가 허용했기 때문에 role을 발급했다.

runner는 workflow가 시켰기 때문에 코드를 실행했다.

문제는 그 사이에 있었다.

GitHub trusted the workflow.

AWS trusted GitHub.

The runner trusted the PR.

The workflow trusted the runner.

Nobody questioned the entire chain.

보안에서 위험한 건 종종 “신뢰할 수 없는 시스템”이 아니다.

서로 너무 많이 신뢰하는 시스템이다.

그리고 senior security engineer가 하는 일은 결국 그 신뢰 관계를 하나씩 의심해 보는 일인지도 모른다.

새벽 2시에 CloudTrail 한 줄을 보면서.

“근데 얘가 왜 이 권한을 가지고 있지?”

끝까지 읽었네… 메밀 승인 🐾
+