전체 글

GitHub Actions 스크립트 인젝션 취약점, Copilot 리뷰도 놓친 이유

xXfacebook페이스북

오픈소스 저장소의 GitHub Actions 워크플로우가 이슈 제목 한 줄 때문에 Jira 자격증명을 외부로 흘렸습니다. Snowflake 의 공개 저장소 snowflakedb/snowflake-connector-net 에서 일어난 일이고, 노출 기간은 5일(2026-06-18~06-23)이었습니다. Snowflake 는 노출 기간 중 제3자 접근은 없었다고 확인했습니다.

이 사고가 개발자에게 특히 의미 있는 이유는 두 가지입니다. 첫째, 취약점을 발견하고 악용한 주체가 사람이 아니라 자율적으로 움직이는 AI 에이전트(Wiz 의 Red Agent)였습니다. 둘째, 문제의 코드 변경이 담긴 PR 을 GitHub Copilot 이 검토하고 이상 없음으로 판정했습니다. 여기서는 보안 회사 Wiz 가 공개한 보고를 바탕으로, 무엇이 어떻게 잘못됐고 내 워크플로우에서는 무엇을 확인해야 하는지 정리합니다.

GitHub Actions 워크플로우가 이슈 제목을 셸에 보간하면서 자격증명이 외부 콜백으로 빠져나가는 전체 흐름을 요약한 개념도

이슈 제목이 셸을 거쳐 자격증명 유출로 이어지는 전체 흐름을 한눈에 정리한 그림이다.

무슨 일이 있었나

시작은 안전했던 코드를 다시 취약하게 되돌린 커밋이었습니다. 문제의 워크플로우 파일은 .github/workflows/jira_issue.yml 이고, 이 파일은 GitHub 이슈가 열리면 그 내용을 Jira 티켓으로 옮기는 자동화였습니다. 원래 이 워크플로우는 이슈 제목 같은 신뢰할 수 없는 입력을 환경변수로 받아 안전하게 다루고 있었습니다.

그런데 PR #1218("SNOW-2069227 : Update jira workflows", 2026-06-18 병합)이 그 안전한 패턴을 제거하고, 이슈 제목을 셸 명령줄에 직접 끼워 넣는 방식으로 되돌렸습니다. 이 변경이 병합된 그날부터 저장소는 스크립트 인젝션에 노출됐습니다. 닷새 뒤인 06-23 에 Wiz 가 이 문제를 발견해 HackerOne 으로 신고했고, 같은 날 패치 PR #1402("NO-SNOW adjust jira yml files")가 병합되며 안전한 패턴이 원복됐습니다. 자격증명 회전은 06-24 에 이뤄졌고, 사건은 2026-07-25 에 공개됐습니다.

PR #1218 병합부터 Wiz 발견, 패치, 토큰 회전, 공개까지 이어지는 사건 타임라인

유입(06-18)부터 공개(07-25)까지의 타임라인. 신고·회전·공개일은 Wiz 블로그 단일 출처다.

취약점의 정확한 구조

핵심은 "신뢰할 수 없는 값을 어떻게 셸에 전달했느냐"입니다. 이슈 제목은 저장소 외부의 누구나 채울 수 있는 입력이므로, GitHub Actions 에서는 이런 값을 신뢰 불가 입력으로 취급해야 합니다. 되돌려지기 전의 안전한 코드는 제목을 환경변수로 받아 셸이 문자열로만 다루게 했습니다.

# 안전한 패턴 — 제목을 env 변수로 받아 셸은 값으로만 읽는다
env:
  ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
  # jq --arg 로 JSON 을 조립하면 제목 안의 특수문자가 코드로 실행되지 않는다
  jq -n --arg issuetitle "$ISSUE_TITLE" '{ "summary": $issuetitle }'

PR #1218 이 추가한 취약한 패턴은 반대였습니다. 제목을 환경변수를 거치지 않고 셸 명령줄의 작은따옴표 사이에 그대로 보간했습니다. 아래 코드에서 ${{ github.event.issue.title }} 자리는 워크플로우가 실행되기 전에 GitHub 이 텍스트로 치환하므로, 제목에 작은따옴표가 들어 있으면 셸 구문 자체가 깨지거나 조작됩니다.

# 취약한 패턴 — 제목을 명령줄에 직접 보간한다
run: |
  # sed 로 이스케이프해도 작은따옴표 탈출은 막지 못한다
  TITLE=$(echo '${{ github.event.issue.title }}' | sed 's/.../.../')
  # jq --arg 대신 문자열 보간으로 JSON 을 만든다
  curl ... --data '{ "summary": "'"$TITLE"'" }'

여기서 흔한 오해 하나를 짚어야 합니다. 두 코드의 차이는 sed 이스케이프를 했느냐 안 했느냐가 아닙니다. 진짜 차이는 신뢰 불가 값을 환경변수로 받았는지(안전), 아니면 명령줄에 직접 보간했는지(취약)입니다. sed 로 아무리 문자를 치환해도, 명령줄에 직접 박힌 작은따옴표는 셸이 먼저 해석해 버리기 때문에 탈출을 막을 수 없습니다.

여기에 조건문 허점이 겹쳤습니다. 워크플로우에는 특정 봇을 걸러내려는 조건이 있었는데, 대략 github.event_name == 'issues' && github.event.pull_request.user.login != 'whitesource-for-github-com[bot]' 형태였습니다. 문제는 issues 이벤트에서 github.event.pull_request 값이 null 이라는 점입니다. null != '...' 는 언제나 참이므로, 이 게이트는 사실상 모든 사용자를 통과시켰습니다. 조건문 자체는 PR #1218 에서 바뀌지 않았지만, 되돌려진 취약 패턴과 이 허점이 만나면서 외부인이 게이트를 지나 인젝션까지 도달할 수 있었습니다.

issues 이벤트에서 pull_request 가 null 이 되어 봇 차단 조건이 항상 참으로 평가되는 우회 흐름도

issues 이벤트에서는 pull_request 가 null 이라 null != 봇이름 이 항상 참이 된다. 게이트가 사실상 열려 있는 셈이다.

Red Agent 는 어떻게 뚫었나

이번 사건에서 눈여겨볼 부분은 발견부터 악용까지가 사람 없이 진행됐다는 점입니다. Wiz 의 Red Agent 는 먼저 jira_issue.yml 을 신뢰 불가 입력 기반 스크립트 인젝션에 취약한 파일로 자동 식별했습니다. 그다음 이슈 제목에 작은따옴표를 탈출시키는 페이로드와 외부 콜백을 넣어 시도했습니다.

첫 시도는 곧바로 성공하지 않았습니다. 처음에는 뒤따르는 셸 구문을 # 주석으로 무력화하려 했는데, TITLE=$(...) 를 닫는 괄호 ) 까지 주석에 먹히면서 bash 가 unexpected EOF 오류를 냈습니다. 그러자 에이전트는 이 구문 오류를 스스로 분석해, 페이로드를 ; echo ' 로 바꿔 셸 구문을 올바르게 닫는 방식으로 수정했습니다. 이렇게 고친 페이로드가 통하면서, 수초 안에 러너에서 base64 로 인코딩된 자격증명이 외부로 콜백됐습니다.

이 "실패한 페이로드를 자율적으로 수정해 재시도"하는 대목이 핵심입니다. 사람 공격자가 오류 메시지를 읽고 페이로드를 손보는 과정을, 에이전트가 사람 개입 없이 해냈다는 뜻이기 때문입니다. 정확한 페이로드 원문과 콜백 도메인은 공개되지 않았고, Wiz 는 subdomain.oast.me 같은 플레이스홀더로만 제시했습니다. 일반적인 분류로는 이런 결함을 OS 명령어 삽입(OS Command Injection) 계열로 봅니다.

탈취된 것과 노출 범위

콜백으로 빠져나간 것은 이 워크플로우가 쓰던 Jira 자격증명이었습니다. 구체적으로는 JIRA_API_TOKEN, JIRA_USER_EMAIL, JIRA_BASE_URL 세 가지입니다. 이 토큰은 [email protected] 계정으로 snowflakecomputing.atlassian.net 에 인증했고, Snowflake 의 엔지니어링·보안 컴플라이언스·버그바운티 프로젝트에 읽기 권한을 갖고 있었습니다.

노출 기간은 PR #1218 이 병합된 2026-06-18 부터 패치가 이뤄진 06-23 까지 5일입니다. Snowflake 는 이 기간 동안 제3자의 접근은 없었다고 확인했습니다. 다만 이 결론과 함께 기억해 둘 점은, 러너에서 자격증명이 콜백되기까지 걸린 시간이 수초 수준이었다는 사실입니다. 자동화된 발견과 악용이 이 정도 속도라면, 노출 창이 며칠이든 몇 시간이든 방어자가 손쓸 여유는 넉넉하지 않습니다. 커밋 해시나 러너 IP 같은 세부는 Wiz 블로그 단일 출처이므로, 인용할 때는 그 출처 기준임을 전제로 봐야 합니다.

Copilot 이 놓친 지점

제목에도 넣은 부분이라 오해 없이 정리하겠습니다. Wiz 원문에 따르면, GitHub Copilot 이 병합된 PR 과 코드 변경을 공동저자(co-author)로 검토해 이상 없음(all-clear)으로 판정했지만 이 치명적 취약점을 알아채지 못했습니다. 확인된 사실은 여기까지, 즉 **"AI 리뷰가 이 PR 을 통과시켰다"**는 것입니다.

여기서 넘겨짚지 말아야 할 것이 있습니다. 그 취약한 코드 변경을 AI 가 작성했는지는 불명확합니다. 원문도 그 점을 분명히 하고 있고, "AI 가 취약한 코드를 짰다"고 단정할 근거는 없습니다. 오히려 문서상 Copilot Autofix 가 기여한 대상은 취약한 jira_issue.yml 이 아니라 같은 PR 안의 별개 파일 jira_close.yml 이었습니다. 그러니 이 사건에서 확실히 말할 수 있는 것은 "AI 가 취약점을 만들었다"가 아니라 "AI 리뷰가 취약점을 걸러내지 못하고 통과시켰다"입니다.

AI 코드 리뷰가 통과시킨 PR 이 그대로 병합돼 취약점이 유입되는 관계를 보여주는 흐름도

확인된 사실은 AI 리뷰가 이 PR 을 통과시켰다는 것까지다. 취약 코드를 AI 가 작성했는지는 원문에서도 불명확하다.

이 구분이 실무에서 중요한 이유가 있습니다. AI 리뷰를 안전망으로 신뢰하고 사람 검토를 줄이면, 이번처럼 통과 판정이 곧 승인 신호로 굳어질 수 있기 때문입니다. Wiz 가 내놓은 권고 중 첫 항목도 이것입니다. AI 가 생성했거나 AI 가 검토한 PR 이라도 사람이 작성한 코드와 동일하게 정적 분석과 보안 검토를 거쳐야 한다는 것입니다.

내 워크플로우에서 확인할 것

이번 사건은 특수한 저장소만의 문제가 아니라, GitHub Actions 를 쓰는 곳이라면 어디서든 재현될 수 있는 패턴입니다. 아래는 리서치로 확인된 범위 안에서 바로 점검해 볼 항목들입니다.

  • run: 블록 안에서 ${{ github.event.* }} 를 셸에 직접 보간하는 곳을 찾습니다. 이슈 제목·본문, PR 제목, 브랜치명처럼 외부인이 채울 수 있는 값이 대상입니다. 발견하면 env: 변수로 받아 "$VAR" 형태로 참조하도록 바꿉니다.
  • JSON 을 조립할 때는 문자열 보간 대신 jq --arg 를 씁니다. 특수문자가 코드로 실행되지 않고 값으로만 다뤄집니다.
  • 조건문(if:)이 해당 이벤트에서 존재하지 않는 컨텍스트를 비교하는지 점검합니다. 이번처럼 issues 이벤트에서 github.event.pull_request 를 비교하면 null 때문에 게이트가 무력화됩니다.
  • 워크플로우가 쓰는 시크릿은 최소 권한과 단기(short-lived) 자격증명을 원칙으로 둡니다. 노출돼도 피해와 유효 시간이 줄어듭니다.

run 안 직접 보간 점검, jq 사용, 조건문 컨텍스트 점검, 최소 권한 시크릿으로 이어지는 점검 항목 체크리스트

직접 보간 제거, jq --arg, 조건문 컨텍스트 점검, 단기 시크릿까지 네 가지를 순서대로 확인한다.

Wiz 의 권고를 한 줄로 옮기면 이렇습니다. 자동화된 공격은 수 시간이 아니라 수초 단위로 움직이므로 빠른 패치 주기와 단기 자격증명이 필요하고, 안전한 코드 패턴을 명시적으로 강제하지 않으면 이번처럼 보안 의도가 조용히 유실될 수 있다는 것입니다.

정리

  • 이슈 제목 같은 신뢰 불가 입력을 셸에 직접 보간한 패턴이 PR #1218 로 되돌아오면서, Jira 자격증명이 5일간 노출됐습니다.
  • 발견부터 악용까지 Wiz 의 Red Agent 가 자율적으로 수행했고, 구문 오류까지 스스로 고쳐 자격증명을 콜백했습니다.
  • Copilot 리뷰는 이 PR 을 통과시켰지만, 취약 코드를 AI 가 작성했는지는 불명확합니다. AI 리뷰도 사람 검토를 대체하지 않는다는 점만 분명합니다.

근거와 참고

이 글 공유하기xXfacebook페이스북