인지야공

인지야공/오늘의 AI 이슈/5번째 글

Plugin4Shell — SHA로 못박은 플러그인이 어떻게 뚫렸나

실험: python 딥러닝/daily/2026-09-20_plugin4shell.py (git 필요, 수 초) 글의 수치·재현은 전부 그 스크립트를 돌려 얻은 것이고, 사건 내용은 2026년 9월 20일까지의 1차 자료로 교차 확인했다.


1. 무슨 일이 있었나

2026년 9월 18일, 보안 기업 AIR 이 Plugin4Shell 을 공개했다. AI 코딩 에이전트 네 개 — Claude Code · OpenAI Codex · GitHub Copilot · Gemini CLI — 에 걸린 제로클릭 원격 코드 실행(RCE) 이다. 사용자가 클릭·승인·재설치를 하나도 하지 않아도 악성 플러그인 코드가 실행된다. AIR 은 이를 “첫 유형의 AI 공급망 공격”이라 불렀다.

에이전트상태
Claude Code패치됨 (2.1.179)
OpenAI Codex패치됨 (0.146.0)
GitHub Copilot미패치 (저장소 이름 제약이 공격면을 줄인다는 입장)
Gemini CLI미패치 (지원 종료 → Antigravity 로 이전 안내)

놀라운 점은 암호 해시를 깨지 않았다는 것이다. 플러그인을 안전하게 묶어 둔다는 SHA 고정(SHA pinning) 을, 해시는 그대로 둔 채 git 의 허점으로 우회했다. 이 글은 그 구조를 실제 git 으로 재현한다.


2. SHA 고정은 무엇이고 왜 믿었나

플러그인 마켓은 코드를 특정 커밋에 못박아 신뢰를 만든다.

한 커밋의 코드를 검토하고, 그 커밋을 핀으로 고정하고, 그 핀이 영원히 그 코드라고 믿는다.

에이전트는 이렇게 설치한다.

git clone <플러그인 저장소>
git checkout 578b3c30ae24f54b5075d663f79d37d2a7fa0b94   # 40자리 커밋 해시(핀)

이 40자리 16진수는 커밋의 SHA-1 해시다. git 은 모든 것을 내용으로 주소를 매기는(content-addressed) 방식으로 저장한다 — 내용이 1바이트라도 바뀌면 해시가 완전히 달라진다. 그래서 “이 해시 = 이 코드”라는 약속(commitment) 이 성립한다. 이 약속을 깨려면 같은 해시를 갖는 다른 코드를 만들어야 하는데, 그건 해시 충돌을 찾는 일이다.

얼마나 어려운가. nn 비트 해시에서 충돌을 찾는 데 필요한 시도 수는 birthday bound(생일 문제에서 나오는 경계)로 정해진다.

시도 수≈π2  2n/2\text{시도 수} \approx \sqrt{\frac{\pi}{2}}\; 2^{n/2}
기호뜻
nn해시 비트 수 (SHA-1 은 160비트)
2n/22^{n/2}birthday paradox: 전수(2n2^n)가 아니라 제곱근만큼만 모아도 충돌이 생긴다

직접 재 보기 B (먼저, 왜 해시는 못 깨나)

SHA-1 을 nn 비트로 잘라, 무작위 입력으로 첫 충돌까지 걸린 시도 수를 실측해 birthday bound 와 비교했다.

birthday bound

비트 nn8162432로그 기울기
실측 시도 수20.1324.15,296.580,770.90.504
birthday bound20.1320.85,133.682,137.2(이론 0.5)

실측 기울기가 0.504 ≈ 0.5 로, 비트가 늘 때마다 시도 수가 2n/22^{n/2} 로 지수적으로 커진다. 40자리 = 160비트라 위조에는 약 280≈1.2×10242^{80} \approx 1.2 \times 10^{24} 번이 필요하다. 불가능하다. 그러니 공격자는 해시를 깨지 않았다. 대신 해시를 아예 건드리지 않는 길을 찾았다.


3. 진짜 허점 — 이름과 주소가 겹친다

git 에서 578b3c... 같은 문자열은 두 가지로 읽힐 수 있다.

  • 객체 이름(object id): 그 해시를 가진 커밋
  • 참조 이름(ref, 브랜치 이름): 우연히(혹은 고의로) 그렇게 이름 붙인 브랜치
git checkout 578b3c30…(40자) 객체(주소): 커밋 578b3c… 검토·고정된 SAFE 코드 git 은 이걸 원함 참조(이름): 브랜치 "578b3c…" 공격자가 만든 MALICIOUS 커밋 git 이 실제로 고름 (ref 우선) →

git 의 규칙은 이렇다.

한 이름이 유효한 참조이면서 동시에 객체 id 이면, git 은 참조를 우선하고 refname is ambiguous 경고 한 줄만 낸다.

공격자는 자기 저장소에 핀 해시와 똑같은 이름의 브랜치를 만들어 악성 커밋을 가리키게 하고, 그걸 기본 브랜치로 둔다. 그러면 git checkout <핀 해시> 는 핀 커밋이 아니라 그 브랜치를 꺼내 온다. 게다가 체크아웃은 성공한 것처럼 보인다 — 핀 커밋이 저장소에 남아 있든 말든, 브랜치가 꺼내진다.

직접 재 보기 A (실제 git 으로 재현)

git 2.52 로, 핀 커밋(SAFE)과 똑같은 이름의 브랜치가 악성 커밋(MALICIOUS)을 가리키게 만들고 체크아웃했다.

핀 커밋   578b3c30ae24f54b5075d663f79d37d2a7fa0b94  (SAFE original code)
악성 커밋 63a2d925860abe1b0da8052a4f2cd3dc4b45fe1a  (MALICIOUS payload)

$ git checkout 578b3c30ae24f54b5075d663f79d37d2a7fa0b94
warning: refname '578b3c30…' is ambiguous.          ← 경고는 이 한 줄뿐
Switched to branch '578b3c30…'

$ cat plugin.js
MALICIOUS payload                                    ← 핀은 SAFE인데 받은 건 악성
$ git rev-parse HEAD
63a2d925…                                            ← HEAD는 악성 커밋

핀은 SAFE 를 가리키는데, 실제로 실행되는 코드는 MALICIOUS 다. 해시는 멀쩡하다. 이름이 주소를 가린 것이다. 이것은 권한 단조성 편에서 다룬 권한의 도달 범위 문제와 같은 결이다 — 신뢰의 근거 (핀 해시)가 있어도, 그 근거를 해석하는 단계에 구멍이 있으면 신뢰가 통째로 새어 나간다.


4. 어떻게 막나

AIR 이 짚은 핵심은 검증이 에이전트(클라이언트) 안에서 일어나야 한다는 것이다. 핀은 클라이언트에서 풀리므로 마켓이 대신 보장해 줄 수 없다.

직접 재 보기 C

같은 공격 상태에서 세 방식을 비교했다.

방식결과
순진한 git checkout <해시>MALICIOUS payload (뚫림)
커밋으로 못박기 <해시>^{commit}SAFE original code
체크아웃 뒤 HEAD == 핀 검증불일치 → 중단
  • <해시>^{commit} 처럼 객체로 못박아 모호성을 없애거나,
  • 체크아웃 뒤 git rev-parse HEAD 가 핀과 정확히 같은지 확인하고 다르면 중단한다.
git rev-parse HEAD  =  핀 해시아니면 중단\texttt{git rev-parse HEAD} \;=\; \text{핀 해시} \quad\text{아니면 중단}

두 번째가 AIR 이 권한 방어다 — 한 줄이면 된다. Claude Code(2.1.179)와 Codex(0.146.0)는 이 계열의 검증을 넣어 막았다.


5. 흔한 오해와 한계

  1. “SHA-1 이 깨졌다” — 아니다. 2절에서 봤듯 위조엔 2802^{80} 이 필요하다. 이 공격은 해시를 건드리지 않는다. 허점은 암호가 아니라 git 의 이름 해석(ref vs object) 에 있다.
  2. “충돌 저항이면 충분하다” — 아니다. 해시가 튼튼해도 그 해시를 어떻게 조회하느냐에 구멍이 있으면 소용없다. 보안은 가장 약한 해석 단계에서 뚫린다.
  3. “AI 에이전트만의 문제” — 근본은 git 의 오래된 모호성이다. 다만 에이전트는 사람의 확인 없이 자동으로 checkout·설치를 하므로, 경고 한 줄이 그냥 삼켜져 제로클릭이 된다. 자동화가 위험을 키웠다.
  4. 미패치 남음 — Copilot·Gemini CLI 는 이 글 시점에 패치가 없다. 마켓이 다른 곳(예: Bitbucket)에 있으면 “이름 제약” 논리도 통하지 않는다.
  5. 이 글의 실험 — 실제 공격이 아니라, 로컬 임시 저장소에서 이름 모호성 메커니즘을 재현하고 birthday bound 를 계량한 것이다.

6. 한 문단 요약

Plugin4Shell 은 AI 코딩 에이전트가 플러그인을 커밋 해시에 못박아 두던 신뢰를 무너뜨렸다. 그런데 해시를 깬 게 아니다 — 재 보니 160비트 해시 위조엔 2802^{80} 번이 필요해(birthday bound 기울기 실측 0.504) 사실상 불가능하다. 공격은 암호를 우회했다. git 에서 578b3c… 같은 문자열은 커밋(주소) 도 되고 브랜치(이름) 도 되는데, 둘이 겹치면 git 은 이름을 우선하고 경고 한 줄만 낸다. 공격자가 핀 해시와 똑같은 이름의 브랜치로 악성 커밋을 가리키면, git checkout <핀> 은 핀은 SAFE 라고 보고하면서 악성 코드를 꺼내 온다(실제 재현 확인). 사람이라면 경고를 봤겠지만, 에이전트는 자동으로 삼켜 제로클릭이 된다. 방어는 단순하다 — 체크아웃 뒤 HEAD 가 핀과 같은지 클라이언트에서 확인하고 다르면 중단한다. 신뢰의 근거가 튼튼해도, 그 근거를 해석하는 마지막 단계가 무르면 전부 새어 나간다.


참고

표시는 이 브라우저에만 남는다. 서버로 가는 것은 없다.