leejk/ jk lee

코딩 에이전트 인젝션: 방어는 읽는 텍스트가 아니라 내보내는 툴콜에 둬야 닫힌다

PR 코멘트·이슈·SKILL.md 운반체를 45개로 재현 — 입력 스캐너는 33% 우회되고, 툴콜 와이어 가드는 100% 막는다

·
#research#prompt-injection#coding-agents#agent-security#mcp

코딩 에이전트가 읽는 untrusted 운반체(PR 코멘트·이슈·SKILL.md·README·CI 로그)에 같은 인젝션을 9가지로 난독화해 5개 운반체에 뿌린 45건 재현에서, 입력 텍스트 스캐너는 정규화를 해도 base64·동의어·번역에 33%(15/45)가 우회됐다. 반면 툴콜 와이어를 보는 결정론 가드는 45/45를 막았는데, 이 100%는 라이브 측정이 아니라 45개 텍스트 변종이 행동 하나로 수렴한다는 위협 모델 구성의 성질이다. 입력 방어는 무한한 텍스트 공간과, 와이어 방어는 유한한 행동 공간과 싸우며 — 방어는 읽는 텍스트가 아니라 내보내는 툴콜에 둬야 닫힌다.

TL;DR. 코딩 에이전트가 읽는 untrusted 운반체(PR 코멘트·이슈 본문·SKILL.md·의존성 README·CI 로그)에 인젝션을 숨기고, 같은 의도를 9가지로 난독화해 5개 운반체에 뿌렸다(45건). 입력 텍스트를 보는 스캐너는 정규화를 해도 15/45(33%)가 우회됐다 — base64·동의어·번역이 어휘 시그니처를 지운다. 툴콜 와이어를 보는 결정론 가드는 45/45(100%)를 막았다. 이유는 단순하다 — 45개 텍스트 변종이 행동 하나로 수렴한다. 입력 방어는 무한한 텍스트 공간과 싸우고, 와이어 방어는 유한한 행동 공간과 싸운다.

아직 안 정해진 질문

프롬프트 인젝션 개요에서 결론은 "보장은 모델 밖 결정론 레이어에서 온다, 남은 건 그 경계를 어디에 두느냐"였다. 코딩 에이전트는 그 질문이 가장 날카로운 자리다 — 에이전트가 읽는 거의 모든 것(PR 코멘트·이슈·README·CI 로그·설치한 SKILL.md)이 공격자가 쓸 수 있는 텍스트이고, 동시에 에이전트는 셸·파일쓰기·네트워크 같은 위험한 툴을 쥔다. Comment and Control(2026-04)은 PR 코멘트·이슈 본문에 숨긴 지시만으로 Claude Code·Gemini CLI·GitHub Copilot 에이전트를 RCE·시크릿 유출로 몰았고, 세 벤더 모두 구조적 한계로 취급했다.

현장의 1차 반사는 "에이전트가 읽기 전에 그 텍스트를 검사하자"다. 이게 실제로 닫히는가, 아니면 천장이 있는가 — 헤드라인으로는 안 나온다. 직접 재현해야 나온다.

어떻게 팠는가

외부 모델은 호출하지 않았다. 결정론으로 재현 가능한 방어의 위치 비교만 측정했다. 위협 모델 전제는 1차 출처에서 가져왔다 — 유능한 코딩 에이전트는 어떤 운반체·난독화든 같은 행동으로 디코드한다(Comment and Control이 숨은 텍스트→RCE로 보인 그대로). 그래서 각 난독화는 "텍스트는 변하고 행동은 불변"으로 모델링했다.

  • 공격면 — 운반체 5종(pr_comment·issue_body·skill_md·dep_readme·ci_log) × 의미 보존 난독화 9종(plain·base64·동형문자·zero-width·HTML 주석·문자열 분할·동의어·spotlight 구분자 탈출·번역) = 45건.
  • 방어 A — 입력 텍스트 스캐너(LLM-free): NFKC 정규화 + zero-width 제거 후 키워드·정규식 블록리스트(ignore previous·curl·| sh·https?://·base64…). "에이전트가 읽는 걸 검사한다"의 대표.
  • 방어 B — 툴콜 와이어 가드(LLM-free): 과제의 봉인된 의도(여기선 "실패한 단위테스트 고쳐라" → 허용 툴 read_file(repo/)·write_file(repo/src/)·run_tests)와 들어온 tool_call을 결정론 field-diff로 대조. 가드는 운반체 텍스트를 보지 않는다 — 결과 행동만 본다.

핵심 재현 코드는 짧다.

# 방어 A: 텍스트를 본다 — 난독화가 어휘 시그니처를 지우면 뚫린다
def input_scanner_blocks(text):
    norm = unicodedata.normalize("NFKC", text).replace("​", "")
    return any(rx.search(norm) for rx in BLOCK_PATTERNS)

# 방어 B: 행동을 본다 — 45개 텍스트가 행동 1개로 수렴, 난독화가 안 보인다
def wire_guard_blocks(action):              # action = {"tool":"run_shell","arg":"curl …| sh"}
    return action not in SEALED_ALLOWLIST   # 봉인된 의도에 없으면 차단

발견

같은 공격을 두 방어에 통과시킨 결과는 비대칭이다.

방어 위치 커버해야 할 공간 차단 우회
입력 텍스트 스캐너 텍스트 변종 9개(원리상 무한) 30/45 (67%) 15/45 (33%)
툴콜 와이어 가드 행동 1개(위험 행동 집합) 45/45 (100%) 0/45

입력 방어는 무한한 텍스트 공간과 싸운다

정규화를 넣은 스캐너도 동형문자·zero-width·문자열 분할·HTML 주석·구분자 탈출은 잡았다 — URL 문자열이 그대로 새기 때문이다. 그러나 base64·동의어·번역은 어휘 시그니처 자체를 지워 통과했다(난독화 9종 중 3종, 운반체 무관). 33%라는 수치를 천장으로 읽으면 안 된다 — 블록리스트에 base64 디코더 룰을 더하면 공격자는 ROT13로, 동의어를 막으면 다른 우회로 옮긴다. 입력 방어자는 열린 텍스트 공간에서 군비경쟁을 한다. 본질적으로 끝이 없다.

행동은 난독화돼도 한 개다

와이어 가드가 45/45를 막은 건 가드가 똑똑해서가 아니다. 난독화는 텍스트 채널에 살지만, 피해는 행동 채널에서 일어나기 때문이다. base64든 번역이든, 피해를 내려면 결국 run_shell("curl …| sh")라는 하나의 tool_call이 되어야 한다 — 그 시점엔 난독화가 이미 풀려 사라진다. 가드는 봉인된 의도(read_file·write_file(src/)·run_tests)에 없는 호출을 결정론으로 거부한다. 45개 텍스트 변종이 행동 1개로 수렴하므로, 와이어 방어자는 닫힌 행동 공간과 싸운다. 이게 개요의 와이어 가드 실측(인젝션 13/13 차단)이 코딩 에이전트 운반체에서도 동일하게 닫히는 이유다.

이 비대칭은 벤더의 실제 설계와도 맞는다. Anthropic이 6/6 오픈소스로 던진 자율 취약점 탐지 하네스조차 에이전트를 모델 정렬이 아니라 gVisor 샌드박스·네트워크 격리·egress allowlist로 가둔다 — 신뢰 경로에서 모델을 빼고 경계에 결정론을 둔 그 패턴이다.

스킬 레지스트리가 새 운반체다

운반체 중 skill_md는 가장 빠르게 자라는 공격면이다. ClawHub Security Signals(2026-06)는 ClawHub 레지스트리의 에이전트 스킬 67,453개를 세 스캐너(VirusTotal·정적분석·SkillSpector)로 훑었는데, 셋이 모두 플래그한 스킬은 *0.69%*뿐이었다. 불일치가 무작위가 아니라 구조적이다 — SkillSpector는 의심 판정(75.3%), VirusTotal은 악성 판정(72.8%)을 각각 지배한다. 단일 스캐너로는 못 닫는다는 뜻이고, 스킬이 설계 단위가 된 지금 그 단위가 곧 설치형 운반체다. 입력측에서 스킬을 스캔하는 접근이 정확히 위 33% 군비경쟁에 놓이는 자리다.

이 조사가 말하지 못하는 것

위협 모델 전제(에이전트가 난독화를 같은 행동으로 디코드한다)는 1차 출처에서 가져온 것이지 라이브로 측정한 게 아니다 — 외부 모델을 호출하지 않았다. 따라서 "에이전트가 base64를 실제로 실행하는가"의 모델별 성공률은 이 글의 측정이 아니라 개요가 인용한 페이퍼 숫자(가드 LLM 최대 100% 우회)에 둔다. 33%·100%는 방어 위치의 비대칭을 보이는 재현이지 절대 벤치가 아니다. 그리고 와이어 가드의 한계는 개요와 같다 — 허용된 행동의 내용은 판단하지 못한다(write_file(repo/src/x)로 백도어를 심으면 인젝션 가드는 통과한다). 인젝션 한 위협만 본 결과다.

다음 조사

봉인된 의도를 코딩 에이전트의 실제 루프에서 어떻게 자동 도출하느냐 — 과제 선언에서 허용 툴·경로를 정확히 좁히지 못하면 와이어 가드는 너무 넓거나(차단 누락) 너무 좁다(정상 오차단). 그리고 skill_md 운반체에서 설치 시점 결정론 검사(스킬이 선언한 행동 ⊆ 허용 행동)가 ClawHub 0.69% 불일치를 얼마나 닫는지. 다음 조사 후보다.

1차 출처

같은 주제