- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 클릭 한 번에 토큰이 사라졌다
- VSCode 1-Click 토큰 탈취의 공격 체인
- 개발 환경에서 토큰은 어디에 사는가
- PAT 권한 설계 — classic vs fine-grained
- 토큰 수명과 회전
- GitHub Secret Scanning과 Push Protection
- .env 유출 방지 3중 방어
- git-credential helper 안전 설정
- IDE 확장 공급망 위험
- CI 시크릿 — OIDC로 장기 토큰 없애기
- 사고 대응 runbook
- 체크리스트
- 함정과 반론
- 마치며
- 참고 자료
들어가며 — 클릭 한 번에 토큰이 사라졌다
2026년 6월, Hacker News에서 화제가 된 보안 분석 글이 있습니다. VSCode의 한 버그를 악용해, 피해자가 링크를 클릭하는 단 한 번의 동작만으로 GitHub 토큰을 탈취할 수 있었다는 내용입니다. 보안 연구자 Ammar Askar가 공개한 이 분석은 "내 IDE가 나를 배신할 수 있다"는 불편한 진실을 다시 일깨웠습니다.
핵심은 IDE와 브라우저, 그리고 OAuth 흐름 사이의 경계가 생각보다 허술하다는 점입니다. 우리는 토큰을 "잘 숨기면 된다"고 생각하지만, 실제로 토큰은 우리가 자각하지 못하는 수많은 경로로 새어 나갑니다. 평문 설정 파일, 환경 변수, 셸 히스토리, 그리고 이번 사건처럼 IDE의 URL 핸들러까지.
이 글은 두 부분으로 구성됩니다. 먼저 VSCode 토큰 탈취 사건의 공격 체인을 개념적으로 해부하고, 그다음 개발자가 일상적으로 적용할 수 있는 시크릿 위생 전략을 설정 예제 중심으로 정리합니다. 결론을 먼저 말하면 이렇습니다. 토큰을 잘 숨기는 것보다, 토큰의 권한을 줄이고 수명을 짧게 하고 아예 없애는 방향이 훨씬 강력합니다.
VSCode 1-Click 토큰 탈취의 공격 체인
공격의 구체적 익스플로잇 코드 대신, 어떤 구조적 결함이 결합되어 사고가 가능했는지를 개념 수준에서 설명하겠습니다. 방어를 이해하려면 공격의 형태를 알아야 하기 때문입니다.
[1] 피해자가 악성 링크 클릭 (이메일, 채팅, 웹페이지)
|
v
[2] 커스텀 URL 스킴 (vscode://) 으로 IDE가 포그라운드로 호출됨
|
v
[3] IDE 확장 또는 내장 핸들러가 URL 파라미터를 충분히 검증하지 않음
|
v
[4] OAuth 콜백/인증 흐름이 공격자가 제어하는 리다이렉트로 유도됨
|
v
[5] GitHub 인증 토큰이 공격자 엔드포인트로 전달됨
|
v
[6] 공격자가 토큰으로 피해자의 리포지토리에 접근
이 체인에서 주목할 구조적 약점은 세 가지입니다.
첫째, 커스텀 URL 스킴은 강력하지만 위험합니다. vscode:// 같은 스킴은 브라우저가 OS를 통해 IDE를 직접 호출하게 합니다. 이때 전달되는 파라미터를 IDE 측이 신뢰해 버리면, 외부에서 IDE의 내부 동작을 조종할 수 있게 됩니다.
둘째, OAuth 리다이렉트 검증의 허점입니다. OAuth는 인증 후 토큰을 특정 리다이렉트 URI로 돌려주는데, 이 URI 검증이 느슨하면(부분 문자열 매칭, 와일드카드 허용 등) 공격자가 자신의 엔드포인트로 토큰을 빼돌릴 수 있습니다.
셋째, 사용자 상호작용의 최소화가 역설적으로 위험합니다. "원클릭" 편의성을 위해 확인 단계를 생략하면, 피해자는 자신이 무엇을 승인했는지 인지할 기회를 잃습니다.
이 사건의 교훈은 명확합니다. 어떤 토큰이든 한 번 노출되면 그 즉시 침해된 것으로 간주해야 하고, 따라서 우리는 "노출되어도 피해가 작은" 토큰을 발급하고 운영해야 합니다.
개발 환경에서 토큰은 어디에 사는가
먼저 현실을 직시합시다. 평범한 개발자의 머신에서 토큰이 보관되는 위치를 나열하면 다음과 같습니다.
| 보관 위치 | 보안 수준 | 위험 |
|---|---|---|
| 평문 설정 파일 (.npmrc, .git-credentials) | 매우 낮음 | 디스크 접근, 백업, 동기화로 유출 |
| 환경 변수 (.bashrc, .zshrc export) | 낮음 | 자식 프로세스 전파, 로그 노출 |
| .env 파일 | 낮음 | 실수로 커밋, 도커 이미지에 포함 |
| OS 키체인 (macOS Keychain 등) | 높음 | 앱 권한 모델에 의존 |
| 시크릿 매니저 (Vault, 클라우드 KMS) | 매우 높음 | 운영 복잡도 |
대부분의 유출 사고는 위 표의 위쪽 세 줄에서 발생합니다. 그중에서도 가장 흔한 것이 환경 변수에 토큰을 export하는 습관입니다.
# 안티패턴 — 절대 이렇게 하지 마세요
export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
export AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxx
이 방식의 문제는 토큰이 모든 자식 프로세스에 자동 상속된다는 점입니다. 무심코 실행한 스크립트, npm 패키지의 install script, 심지어 침해된 CLI 도구까지 이 토큰을 읽을 수 있습니다. 앞서 다룬 npm 공급망 공격에서 환경 변수가 주요 탈취 대상이 되는 이유가 바로 이것입니다.
기본 원칙은 이렇습니다. 토큰은 필요한 프로세스에만, 필요한 시점에만 주입하고, 가능하면 OS 키체인이나 시크릿 매니저에 위임합니다.
PAT 권한 설계 — classic vs fine-grained
GitHub Personal Access Token에는 두 종류가 있습니다. 이 둘의 차이를 이해하는 것이 토큰 보안의 출발점입니다.
| 구분 | Classic PAT | Fine-grained PAT |
|---|---|---|
| 권한 범위 | 스코프 단위 (repo, workflow 등) | 리포별 + 세분화된 권한 |
| 대상 리포 | 계정의 모든 리포 | 선택한 리포만 |
| 만료 | 설정 가능 (무기한도 허용) | 최대 1년, 만료 필수 |
| 조직 승인 | 제한적 | 조직 정책으로 통제 가능 |
| 권장도 | 비권장 (레거시) | 권장 |
Classic PAT의 repo 스코프는 사실상 계정의 모든 리포지토리에 대한 읽기/쓰기 권한을 줍니다. 토큰 하나가 유출되면 모든 리포가 노출되는 구조입니다. 반면 Fine-grained PAT는 "이 리포에 대해, 이 권한만"을 명시할 수 있습니다.
권한 설계의 원칙은 단순합니다.
1. 최소 권한: 작업에 필요한 최소한의 권한만 부여
2. 최소 범위: 대상 리포를 명시적으로 선택
3. 짧은 수명: 가능한 한 짧은 만료 기간 (예: 7일, 30일)
4. 단일 목적: 하나의 토큰은 하나의 용도로만
5. 추적 가능: 토큰에 용도를 알 수 있는 이름 부여
예를 들어 CI에서 특정 리포에 릴리스를 푸시하는 토큰이라면, contents:write와 그 리포 하나만 선택하면 충분합니다. issues나 다른 리포에 대한 권한은 전혀 필요 없습니다.
토큰 수명과 회전
토큰 보안에서 가장 과소평가되는 것이 수명입니다. 무기한 토큰은 시한폭탄입니다. 언제 유출됐는지도 모른 채 수년간 유효한 토큰이 사고의 단골 원인입니다.
회전(rotation) 전략을 정리하면 다음과 같습니다.
- 개인 PAT: 90일 이내 만료 + 분기별 회전
- CI/자동화 토큰: 30일 이내 또는 OIDC로 대체 (후술)
- 서비스 계정 토큰: 시크릿 매니저로 자동 회전
- 유출 의심 시: 즉시 폐기 + 새 토큰 발급, 예외 없음
회전을 자동화하지 않으면 결국 아무도 하지 않습니다. GitHub CLI를 쓰면 토큰 상태를 주기적으로 점검하는 스크립트를 둘 수 있습니다.
# 현재 인증 상태와 토큰 스코프 확인
gh auth status
# 토큰을 키체인 등 안전한 저장소에 위임 (평문 파일 회피)
gh auth login --hostname github.com --git-protocol https
GitHub Secret Scanning과 Push Protection
GitHub은 리포지토리에 푸시되는 코드에서 알려진 형태의 시크릿을 자동 탐지합니다. 핵심은 push protection입니다. 시크릿이 커밋에 포함되면 푸시 자체를 거부해서, 유출을 발생 전에 차단합니다.
조직/리포 차원에서 활성화하는 것 외에, 로컬에서도 동일한 보호를 받으려면 사전 차단 도구를 병행하는 것이 좋습니다. 다음은 GitHub의 push protection이 어떻게 작동하는지 보여주는 개념도입니다.
git push
|
v
[GitHub 서버] -- 푸시되는 diff에서 시크릿 패턴 탐지
|
+-- 시크릿 발견 --> 푸시 거부, 개발자에게 위치 안내
|
+-- 깨끗함 --> 푸시 성공
이미 유출된 경우의 대응도 GitHub이 일부 지원합니다. 파트너 프로그램에 등록된 토큰 형식(예: 클라우드 키)이 공개 리포에서 발견되면, GitHub이 해당 서비스 제공자에게 통지해 자동 폐기를 유도합니다. 하지만 이것은 최후의 안전망이지 1차 방어가 아닙니다. 시크릿은 애초에 커밋되지 않아야 합니다.
.env 유출 방지 3중 방어
.env 파일은 개발자 시크릿 유출의 1순위 경로입니다. 3중으로 막습니다.
1단계 — gitignore
가장 기본이지만 가장 자주 누락됩니다.
# .gitignore
.env
.env.*
!.env.example
*.pem
*.key
.git-credentials
마지막에서 두 번째 줄의 부정 패턴이 중요합니다. .env.example은 커밋해서 팀이 어떤 변수가 필요한지 공유하되, 실제 값이 든 파일은 모두 차단합니다.
2단계 — pre-commit hook으로 사전 차단
커밋 시점에 시크릿을 스캔해서 차단합니다. gitleaks가 사실상 표준입니다.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
설치 후 다음으로 훅을 등록합니다.
pip install pre-commit
pre-commit install
# 이제 매 커밋마다 gitleaks가 자동 실행됨
3단계 — gitleaks 커스텀 설정
기본 룰셋 외에 사내 토큰 형식을 추가할 수 있습니다.
# .gitleaks.toml
title = "사내 gitleaks 설정"
[extend]
useDefault = true
[[rules]]
id = "internal-api-key"
description = "사내 API 키 패턴"
regex = '''myco_(live|test)_[0-9a-zA-Z]{32}'''
tags = ["key", "internal"]
[allowlist]
description = "테스트 픽스처 허용"
paths = [
'''test/fixtures/.*''',
]
CI에서도 전체 히스토리를 스캔해 이중으로 점검합니다.
# .github/workflows/gitleaks.yml
name: Secret Scan
on: [pull_request]
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
git-credential helper 안전 설정
git이 자격 증명을 어떻게 보관하는지는 credential.helper 설정에 달려 있습니다. 가장 위험한 것은 store 헬퍼입니다.
# 위험 — 자격 증명을 평문으로 ~/.git-credentials에 저장
git config --global credential.helper store
이 설정은 토큰을 홈 디렉터리에 평문으로 기록합니다. 백업, 클라우드 동기화, 다른 프로세스의 디스크 접근으로 그대로 유출됩니다. 대신 OS별 안전한 헬퍼를 씁니다.
# macOS — Keychain에 위임
git config --global credential.helper osxkeychain
# Windows — Credential Manager
git config --global credential.helper manager
# Linux — libsecret (GNOME Keyring 등)
git config --global credential.helper libsecret
# 또는 캐시만 사용 (메모리에 짧게 보관, 디스크 기록 없음)
git config --global credential.helper 'cache --timeout=3600'
추가로, 자격 증명을 호스트별로 분리하면 한 호스트가 침해돼도 다른 호스트가 안전합니다.
# ~/.gitconfig
[credential "https://github.com"]
helper = osxkeychain
[credential "https://gitlab.internal.example.com"]
helper = osxkeychain
IDE 확장 공급망 위험
VSCode 사건의 본질은 확장 생태계의 신뢰 모델 문제이기도 합니다. 확장은 IDE의 전체 권한으로 실행됩니다. 파일 시스템 읽기/쓰기, 네트워크 통신, 셸 명령 실행, 그리고 다른 확장이 보관한 시크릿 접근까지 가능합니다.
확장 권한 모델의 현실을 정리하면 이렇습니다.
설치된 VSCode 확장의 능력:
- 워크스페이스의 모든 파일 읽기 (.env 포함)
- 임의의 네트워크 요청 전송
- 통합 터미널에서 명령 실행
- SecretStorage API로 보관된 토큰 접근 (확장 간 격리는 있으나 완전하지 않음)
- 자동 업데이트 — 어제 안전했던 확장이 오늘 악성일 수 있음
실무 가이드라인은 다음과 같습니다.
[ ] 확장은 게시자(publisher) 신원이 검증된 것만 설치
[ ] 다운로드 수와 업데이트 빈도, 오픈소스 여부 확인
[ ] 워크스페이스 신뢰(Workspace Trust) 기능 활성화 — 신뢰하지 않는 폴더에서 확장 제한
[ ] 사용하지 않는 확장은 비활성화/삭제
[ ] 민감 프로젝트는 별도 프로필 또는 별도 머신에서 작업
[ ] 확장 자동 업데이트를 끄고 변경 사항을 검토 (고민감 환경)
워크스페이스 신뢰는 특히 중요합니다. 클론한 낯선 저장소를 열 때, 해당 폴더의 설정이 자동으로 코드를 실행하지 못하게 막아 줍니다.
CI 시크릿 — OIDC로 장기 토큰 없애기
여기까지가 토큰을 안전하게 다루는 법이었다면, 이제 토큰을 아예 없애는 방법을 봅니다. CI에서 클라우드에 배포할 때, 전통적으로는 장기 액세스 키를 CI 시크릿에 저장했습니다. 이 키가 유출되면 끝입니다.
OIDC 페더레이션(OpenID Connect federation)은 이 문제를 근본적으로 해결합니다. CI 실행마다 클라우드 제공자가 단기 자격 증명을 발급하고, 작업이 끝나면 만료됩니다. 저장된 장기 비밀이 존재하지 않습니다.
[기존 방식 — 장기 키]
CI 시크릿에 AWS 액세스 키 저장 --> 유출 시 영구 침해
[OIDC 방식 — 단기 토큰]
GitHub Actions가 OIDC 토큰 발급
|
v
클라우드가 OIDC 토큰의 발급자/리포/브랜치 검증
|
v
검증 통과 시 단기 자격 증명 발급 (수십 분 만료)
|
v
작업 종료와 함께 자격 증명 소멸
GitHub Actions에서 AWS에 OIDC로 연결하는 설정 예시입니다.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write # OIDC 토큰 발급에 필수
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: ap-northeast-2
# 액세스 키 없음 — OIDC로 역할을 직접 가정
- run: aws s3 sync ./dist s3://my-bucket/
클라우드 측에서는 신뢰 정책으로 어느 리포/브랜치가 이 역할을 가정할 수 있는지 제한합니다.
{
"Version": "2012-10-17",
"Statement": [
{
"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:myorg/myrepo:ref:refs/heads/main"
}
}
}
]
}
여기서 sub 조건이 핵심 방어입니다. 특정 리포의 특정 브랜치에서 실행된 워크플로만 이 역할을 가정할 수 있습니다. 포크된 PR이나 다른 리포에서는 자격 증명을 받을 수 없습니다. 조건을 너무 느슨하게(예: 모든 브랜치 와일드카드) 두면 의미가 없으니 주의해야 합니다.
GCP, Azure도 동일한 패턴(Workload Identity Federation, federated credentials)을 제공합니다. 새 프로젝트라면 장기 키를 발급하기 전에 OIDC가 가능한지부터 확인하는 것이 옳습니다.
사고 대응 runbook
토큰 유출이 의심될 때의 절차입니다. 평소에 외워둘 필요는 없지만, 어딘가에 적혀 있어야 합니다.
[즉시] 폐기
- 유출/의심 토큰을 즉시 revoke (GitHub Settings, 클라우드 콘솔)
- 폐기가 새 토큰 발급보다 먼저 — 노출 창을 최소화
[5분] 영향 범위 파악
- 해당 토큰의 권한 범위 확인 (어느 리포, 어느 작업 가능했나)
- GitHub 감사 로그 / 클라우드 CloudTrail에서 비정상 활동 조회
- 토큰으로 가능했던 행위 목록화 (push, 패키지 publish, 리소스 생성)
[30분] 봉쇄와 점검
- 동일 토큰을 쓰던 모든 시스템에서 토큰 교체
- 의심스러운 커밋/릴리스/배포 식별 및 검토
- 새 SSH 키, deploy key 추가 여부 점검 (백도어)
[사후] 재발 방지
- 평문 보관 경로 제거, 키체인/시크릿 매니저로 이전
- 가능한 토큰을 OIDC로 대체
- gitleaks pre-commit + CI 스캔 도입 확인
- git 히스토리에 시크릿이 남았다면 히스토리 재작성 검토
마지막 항목에 주의가 필요합니다. 시크릿이 커밋 히스토리에 한 번 들어가면, 파일을 지운 새 커밋을 추가해도 과거 커밋에는 그대로 남습니다. 히스토리 재작성(git filter-repo 등)이 필요하고, 무엇보다 노출된 토큰은 재작성 여부와 무관하게 폐기가 우선입니다.
체크리스트
개인 개발자:
[ ] 환경 변수에 장기 토큰 export 금지
[ ] git credential.helper를 store가 아닌 OS 키체인으로 설정
[ ] PAT는 fine-grained로, 최소 권한 + 만료 설정
[ ] .gitignore에 .env, *.pem, *.key, .git-credentials 포함
[ ] gitleaks pre-commit hook 설치
[ ] VSCode 워크스페이스 신뢰 활성화, 확장 최소화
[ ] 분기별 토큰 점검 및 회전
조직:
[ ] secret scanning + push protection 전 리포 활성화
[ ] CI 장기 키를 OIDC 페더레이션으로 대체
[ ] fine-grained PAT 정책 + classic PAT 제한
[ ] gitleaks를 PR 필수 체크로 등록
[ ] 토큰 유출 사고 대응 runbook 문서화
[ ] 개발자 온보딩에 시크릿 위생 교육 포함
함정과 반론
첫째, 키체인도 만능이 아닙니다. 키체인은 OS 잠금 상태에서만 보호됩니다. 머신이 잠금 해제된 상태에서 악성 코드가 실행되면, 정상 앱 권한으로 키체인을 읽을 수 있습니다. 키체인은 평문 파일보다 훨씬 낫지만, 머신 자체가 침해되면 한계가 있습니다.
둘째, OIDC도 만능이 아닙니다. sub 조건을 느슨하게 설정하면(브랜치 와일드카드, 환경 미지정) 포크 PR이나 의도하지 않은 워크플로가 자격 증명을 받을 수 있습니다. OIDC의 안전성은 신뢰 정책의 엄격함에 전적으로 달려 있습니다. 도입했다는 사실보다 조건을 정확히 좁혔는지가 중요합니다.
셋째, gitleaks는 패턴 기반입니다. 알려진 형식의 시크릿은 잘 잡지만, 형식이 불규칙한 사내 비밀이나 base64로 인코딩되어 우회된 값은 놓칠 수 있습니다. 커스텀 룰을 더해도 완벽하지 않으므로, 탐지 도구는 최후 방어선이지 면죄부가 아닙니다.
넷째, 편의성과 보안의 긴장은 영원합니다. 토큰 수명을 짧게 하면 회전 부담이 늘고, 권한을 좁히면 작업이 막힙니다. 이 긴장을 무시한 정책은 우회를 낳습니다. 자동화(자동 회전, OIDC, 시크릿 매니저 연동)로 보안의 비용을 낮추는 것이 지속 가능한 유일한 길입니다.
다섯째, 이번 VSCode 사건이 보여주듯, 우리가 신뢰하는 도구 자체가 공격 표면입니다. IDE, 확장, CLI, 패키지 매니저 모두 토큰에 접근합니다. 토큰을 다루는 도구의 수를 줄이고, 각 도구의 권한을 줄이는 것이 근본적인 방향입니다.
마치며
VSCode 1-click 토큰 탈취 사건은 "토큰을 어디에 숨길까"라는 질문이 틀렸음을 보여줍니다. 올바른 질문은 "이 토큰이 새도 피해가 작으려면 어떻게 설계할까"입니다.
세 가지로 요약합니다.
- 권한을 줄여라: fine-grained PAT, 최소 스코프, 단일 목적.
- 수명을 줄여라: 짧은 만료, 정기 회전, 유출 시 즉시 폐기.
- 토큰을 없애라: CI는 OIDC로, 보관은 키체인/시크릿 매니저로.
이 세 방향이 모두 향하는 곳은 하나입니다. 토큰이 노출되는 것을 완벽히 막을 수는 없으니, 노출됐을 때의 피해를 구조적으로 작게 만드는 것입니다. 그것이 1-click 시대의 시크릿 위생입니다.
참고 자료
- Ammar Askar — GitHub token stealing 분석: https://blog.ammaraskar.com/github-token-stealing/
- GitHub Secret Scanning 문서: https://docs.github.com/en/code-security/secret-scanning
- gitleaks: https://github.com/gitleaks/gitleaks
- GitHub Fine-grained PAT 문서: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens
- GitHub Actions OIDC 문서: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
- aws-actions/configure-aws-credentials: https://github.com/aws-actions/configure-aws-credentials
- git-credential 문서: https://git-scm.com/docs/gitcredentials
- pre-commit 프레임워크: https://pre-commit.com/
- VSCode Workspace Trust 문서: https://code.visualstudio.com/docs/editor/workspace-trust
- Hacker News: https://news.ycombinator.com/
- GeekNews: https://news.hada.io/