프롬프트 속의 보이지 않는 문자들은 음모가 아니라 당신의 신뢰 경계에 대한 경고입니다
요약
Claude Code가 프롬프트에 보이지 않는 유니코드 문자를 삽입하여 메타데이터를 전달할 수 있다는 기술적 이슈를 다룹니다. 이는 단순한 추적 수단이 아니라, 텍스트 파이프라인 내에서 인밴드(in-band) 방식으로 데이터가 실려 나가는 보안 및 버그 클래스의 관점에서 이해해야 합니다.
핵심 포인트
- 유니코드 변형 선택자 및 태그 블록을 이용한 데이터 은닉 가능성
- 텍스트는 단순한 단어의 집합이 아닌 코드포인트의 채널임
- 보이지 않는 문자가 복사-붙여넣기 및 파이프라인을 통해 전달됨
- 프롬프트 내 메타데이터 삽입에 대한 보안 및 신뢰 경계 주의 필요
최근 Claude Code가 요청에 "스테가노그래피(steganographically) 방식으로 표시"를 하고 있다는 이야기가 돌고 있습니다. 즉, 모델로 전송되는 텍스트에 보이지 않는 유니코드(Unicode) 문자를 삽입하고 있다는 것입니다. 이에 대해 흔히 볼 수 있는 반응이 뒤따릅니다. API를 스크래핑(scraping)하는 사람들을 잡기 위해 지문 채취(fingerprinting), 추적(tracking), 워터마킹(watermarking)을 하고 있다는 것이죠. 누군가는 항상 나타나 "탈옥(jailbreakers) 시도자들에 맞서 증거를 구축하고 있는 것"이라고 말합니다.
그럴 수도 있습니다. 저는 Anthropic의 의도를 알지 못하며, 해당 스레드에서 주장하는 사람들도 마찬가지입니다. 하지만 그 논쟁은 미궁(rabbit hole)일 뿐이며, 여기서 가장 흥미롭지 않은 부분입니다. 진짜 흥미로운 점은 훨씬 더 지루하면서도 훨씬 더 유용합니다. 그것은 바로 당신의 프롬프트(prompt) 텍스트 안에, 콘텐츠와 구별할 수 없는 인밴드(in-band) 방식으로 메타데이터(metadata)가 실려 있으며, 당신의 파이프라인(pipeline)에 있는 거의 모든 도구는 그것이 거기 있다는 사실조차 모른다는 점입니다.
이것은 스캔들이 아닙니다. 하나의 버그 클래스(bug class)입니다. 그리고 점심시간 전까지 당신의 코드에서도 찾아낼 수 있는 종류의 것입니다.
"보이지 않는 문자"의 실제 의미
사람들이 지적하는 문자들은 변형 선택자(variation selectors, U+FE00–FE0F 및 보충 영역 U+E0100–U+E01EF), 제로 너비 접합자(zero-width joiner, U+200D), 제로 너비 공백(zero-width space, U+200B), 제로 너비 비접합자(zero-width non-joiner, U+200C), 양방향 제어 문자(bidi controls), 그리고 폐기된 태그 블록(deprecated tag block, U+E0000–U+E007F) 등입니다. 일반적인 에디터(editor)나 터미널(terminal)에서 이러한 문자들은 종종 보이지 않게 렌더링됩니다. 즉, 아무것도 나타나지 않거나 앞선 글리프(glyph)의 수정자로 표시되므로, 훑어보는 사람은 깨끗한 문장만을 보게 됩니다. "종종" 그렇다는 것이지 "항상" 그런 것은 아닙니다. 일부 렌더러(renderer)는 대체 박스(replacement boxes)를 보여주기도 하고, 일부는 텍스트 순서를 바꾸기도 하며, 검사 도구(inspection tool)를 사용하면 기꺼이 이를 드러내기도 합니다. 하지만 대부분의 사람이 읽는 기본 경로에서는 이 문자들은 사라진 것처럼 보입니다. 그럼에도 이 문자들은 복사-붙여넣기(copy-paste)를 통해 살아남습니다. 여전히 길이에 포함됩니다. 그리고 여전히 비트(bits)를 실어 나를 수 있습니다.
전형적인 용량 트릭(capacity trick)은 표준 변형 선택자(variation selectors)가 아니라 태그 블록(tag block)을 악용합니다. 128개의 태그 문자(U+E0000–U+E007F)는 ASCII를 미러링하므로, 1바이트당 1개의 코드포인트(codepoint)를 사용하여 임의의 ASCII 텍스트를 인코딩하고 전체 페이로드(payload)를 단 하나의 가시적인 글리프(glyph)에 매달 수 있습니다 — Paul Butler가 이에 대한 깔끔한 버전을 작성했습니다. 변형 선택자(Variation selectors)는 다른 메커니즘입니다. FE00–FE0F에 16개, 보충 블록에 240개가 있으며, 글리프 변형을 선택하기 위한 용도이지만 사람들은 이를 바이트 채널(byte channel)로도 전용하여 사용해 왔습니다. 핵심은 어떤 블록을 사용하느냐가 아닙니다. 핵심은 동일한 근본적 사실입니다:
텍스트는 깨끗한 채널이 아닙니다. 우리는 문자열이 곧 "단어"라고 가정합니다. 하지만 그렇지 않습니다. 그것은 코드포인트(codepoints)의 시퀀스(sequence)이며, 그중 많은 코드포인트는 설계상 보이지 않도록 되어 있습니다. "내가 볼 수 있는 텍스트"와 "내가 받은 텍스트"를 동일한 것으로 취급하는 모든 시스템에는 허점이 존재합니다.
합의된 견해는 잘못된 위협을 최적화하고 있습니다
해당 스레드는 _누가 누구에게 워터마킹(watermarking)을 하는가_에 대해 논쟁하고 있습니다. 잘못된 축입니다. Anthropic이 요청에 태그를 달고 있든 아니든, 당신의 LLM이 흡수하는 텍스트에 영향을 미칠 수 있는 누구에게나 동일한 메커니즘이 사용 가능합니다 — 당신이 스크래핑하는 웹페이지, 사용자가 업로드하는 PDF, 지원 티켓, GitHub 이슈, 당신의 RAG 시스템이 인덱싱한 리뷰 등이 그 대상입니다.
그것이 실제 노출 지점입니다. 보이지 않는 코드포인트는 프롬프트 인젝션(prompt injection) 운반체입니다. 당신은 ignore previous instructions라는 문자열을 차단하는 필터를 만들었습니다. 여기 모든 문자 사이에 제로 너비 공백(zero-width space)이 끼워진 동일한 문자열이 있습니다:
payload = "ignore previous instructions"
smuggled = "\u200b".join(payload)
# 인간의 눈에는 거의 동일하게 렌더링되지만,
...
당신의 정규 표현식(regex)은 ignore를 찾습니다. 하지만 바이트는 i\u200bg\u200bn\u200bo\u200br\u200be라고 말합니다. 일치하지 않습니다. 모델이 밀수된(smuggled) 지침에 따라 _행동_할지 여부는 토큰화(tokenization)와 모델에 달려 있습니다 — 제가 모든 모델에 대해 측정해 보지 않았고 모델마다 다르기 때문에, 모델이 항상 해당 구절을 복구한다고 주장하지는 않겠습니다. 하지만 당신은 이미 유용한 속성을 상실했습니다. 당신의 가드레일(guardrail)은 인간은 막지만, 공격자의 바이트는 손대지 않고 그대로 전달합니다.
파이프라인 실패 사례를 보여드리겠습니다
이것은 가설이 아니라 실제 상황입니다. 누구나 작성하는 최소한의 "입력값 정화 (sanitize input)" 필터를 예로 들어보겠습니다:
import re
BLOCKLIST = re.compile(r"ignore (previous|all) instructions", re.IGNORECASE)
...
정직한 공격을 입력하면 제대로 작동합니다:
>>> is_safe("please ignore previous instructions")
False # 차단됨, 양호
이제 밀수된 변형(smuggled variant)을 입력해 보겠습니다:
>>> attack = "\u200b".join("ignore previous instructions")
>>> is_safe(attack)
True # <- "안전함." 하지만 실제로는 그렇지 않습니다.
.strip()과 눈에 보이는 문구 기반의 차단 목록(blocklist)은 정화(sanitizing)가 아닙니다. 그것은 장식일 뿐입니다. 해결책은 정규화된 (normalized) 형태에 대해 탐지를 실행하는 것이며, 그 차이는 단 한 줄입니다:
def is_safe_v2(text: str) -> bool:
cleaned = clean_for_prompt(text) # 아래에 정의됨
return BLOCKLIST.search(cleaned) is None
>>> is_safe_v2(attack)
False # 차단됨
동일한 차단 목록입니다. 바뀐 유일한 점은 검사가 수행되는 대상 바이트(bytes)가 무엇인지입니다.
그리고 이 문제의 전체적인 양상을 보여주는 더 작은 증거가 있습니다. 바로 len() 함수가 당신의 눈과 일치하지 않는 경우입니다:
s = "hello\U000e0068\U000e0069world" # 중간에 두 개의 태그 문자(tag chars)가 숨겨져 있음
print(s) # 태그 문자를 숨기는 터미널에서는 다음과 같이 출력됨: helloworld
...
이것이 무엇을 증명하는지 정확히 말씀드리자면, 눈에 보이는 텍스트는 깨끗해 보이지만 숨겨진 코드 포인트(codepoints)가 문자열의 길이를 부풀리고 있다는 것을 보여줍니다. (Python str의 len()은 바이트가 아닌 코드 포인트를 계산하며, 바이트 수는 인코딩에 따라 달라집니다.) 이것은 모든 보이지 않는 문자가 동일하게 렌더링되거나 동일하게 토큰화(tokenize)된다고 주장하는 것이 아닙니다. 태그 문자, ZWJ(Zero Width Joiner), 변형 선택자(variation selectors)는 서로 다르게 동작합니다. 공통적인 위험 속성은 다음과 같습니다. 문자열이 검토자가 결코 보지 못한 내용을 담고 있다는 점입니다.
그 간극이 바로 취약점의 핵심입니다. 프롬프트 템플릿, "신뢰할 수 있는" 시스템 메시지, 허용 목록(allowlisted)에 포함된 문서 등, 당신의 스택 내에서 인간이 문자열을 눈으로 확인하고 승인하는 모든 곳은 바이트가 검토자가 읽지 못한 무언가를 말할 수 있는 지점입니다.
내일 실제로 해야 할 일
악의를 감지하려고 노력하는 것을 멈추세요. 구조를 감지하세요. 숨겨진 문자가 워터마크인지 공격인지를 알 필요는 없습니다. 채널별로 보이지 않는 포맷팅 코드포인트가 아예 그곳에 속해야 하는지 여부를 결정하면 됩니다.
경계에서 정규화(Normalize) 하세요. 방어 가능한 시작 필터는 다음과 같습니다:
import unicodedata
# 기계로 입력된 지침 텍스트에서 제거할 가치가 있는 카테고리:
...
변형 선택자 라인(variation-selector line)은 보이는 것보다 더 중요합니다. 두 블록 모두 카테고리가 Mn (비공백 마크, nonspacing mark)이며, Cf가 아닙니다. 따라서 STRIP_CATEGORIES는 그들을 무시하고 지나가며, FE00–FE0F만 다루는 범위 검사는 240 코드포인트 보충 영역(E0100–E01EF)을 완전히 열어둡니다. 이 보충 영역이 바로 기사에서 언급한 바이트 채널입니다. 이것을 놓치면, 당신이 방어하려 했던 공격 모양의 구멍을 가진 필터를 배포하게 됩니다.
그리고 사람들이 건너뛰는 부분: 삭제만 하지 말고 델타(delta)를 기록하세요.
-
모든 곳이 아니라 신뢰 경계(trust boundaries)에서 정규화(Normalize)하세요. 신뢰할 수 없는 텍스트가 프롬프트 텍스트로 변하는 지점에서 수행하십시오. 사용자가 아랍어를 입력하거나 결합 액센트(combining accents)를 사용할 수 있도록 허용된 경우, 사용자의 실제 메시지 기록을 NFKC로 훼손하지 마십시오. 그렇게 하면 정당한 콘텐츠가 손상되고 사용자가 중요하게 여기는 구분이 평탄화(flatten)됩니다. 구분해야 할 것은 문자열(string)이 아니라 채널(channel)입니다.
-
가시적인 텍스트를 대상으로 보안 필터를 작성하지 마십시오. 차단 목록(blocklist), 개인정보(PII) 스크러버, 또는 중재(moderation) 확인이 가공되지 않은 입력값(raw input)을 대상으로 실행된다면, 이는 밀수된 변형(smuggled variants)을 감지하지 못합니다 — 위의
is_safe를 참조하십시오. 탐지는 정규화된(normalized) 형태에 대해 실행한 다음, 무엇을 전달할지 결정하십시오. -
자신의 시스템 프롬프트(system prompt) 자체가 매개체가 될 수 있다고 가정하십시오. 프롬프트의 어떤 부분이 CMS, 위키, 또는 여러 사람이 편집하는 Git 리포지토리에서 가져온 템플릿으로 구성된다면, 해당 바이트를 한 번은 감사(audit)하십시오. 리포지토리 전체에서
grep -P '[\x{200B}-\x{200F}\x{FE00}-\x{FE0F}\x{E0100}-\x{E01EF}\x{E0000}-\x{E007F}]'를 실행하는 데는 몇 초밖에 걸리지 않으며, 때때로 복사-붙여넣기로 인해 유입된 무언가를 찾아낼 수 있습니다.
그리고 "프롬프트 텍스트에 보이지 않는 서식이 허용되어야 하는가?"라는 질문에 대한 답은 보편적인 것이 아니라 채널별로 다릅니다. 기계가 입력하는 명령 채널(machine-ingested instruction channels) — 시스템 프롬프트, 도구 스키마(tool schemas), 신뢰할 수 있다고 간주하는 검색된 문서(retrieved documents) — 의 경우, 대개는 '아니오'입니다. 사용자에게 보이는 다국어 콘텐츠의 경우, 이러한 코드 포인트(codepoints)가 정당한 역할을 수행하는 곳이기에 종종 '예'가 됩니다. 채널별로 결정하십시오. 하나의 전역 규칙(global rule)을 작성해 놓고 스스로를 축하하지 마십시오.
솔직한 주의사항
과장하지 않고 두 가지만 말씀드리겠습니다. 첫째, 저는 Anthropic의 의도를 말씀드릴 수 없으며, 그 스레드에서 확신을 주장하는 누구라도 신뢰하지 않을 것입니다. "보이지 않는 문자가 나타났다"는 것은 메커니즘의 증거이지, 의도의 증거가 아닙니다. 그것은 토크나이저(tokenizer)의 부산물일 수도 있고, 내부 서식 레이어에서의 우발적인 유출일 수도 있으며, 의도적인 태깅(tagging)일 수도 있습니다. 세 가지 모두 동일한 바이트를 생성합니다.
둘째, 제거(stripping)는 공짜가 아닙니다. 양방향 스크립트(Bidirectional scripts)에는 양방향 제어 문자(bidi controls)가 필요합니다. 일부 이모지 시퀀스는 네 명의 별개 인물이 아닌 하나의 가족으로 렌더링하기 위해 ZWJ(Zero Width Joiner)를 필요로 합니다. \u200d를 전역적으로 제거해 버리면 👨👩👧 이모지가 깨지게 됩니다. 일부 정당한 이모지 표현 방식은 위에서 언급한 필터가 제거해 버리는 U+FE0F(이모지 변형 선택자, emoji variation selector)에 의존하기도 합니다. 이는 지시 채널(instruction channel)에서는 괜찮을지 몰라도, 사용자에게 보여지는 디스플레이 텍스트(display text)로서는 잘못된 방식입니다. 이것이 바로 "모든 곳에서 모든 것을 제거하라"는 교훈이 틀렸으며, "신뢰할 수 없다고 지정한 경계(boundary)에서 의도적으로 정규화(normalize)하라"는 것이 올바른 교훈인 이유입니다.
여기서 얻어야 할 교훈은 _누군가 당신을 지켜보고 있다_는 것이 아닙니다. 그것은 더 오래되고 따분한 사실입니다: 문자열은 코스튬을 입고 있는 바이트 시퀀스(byte sequence)이며, 그 코스튬을 신뢰하는 모든 파이프라인(pipeline)에는 바이트가 존재하는 구멍이 있다는 것입니다. 워터마킹(watermarking) 이야기는 일주일이면 잊힐 것입니다. 하지만 그것이 가리키고 있는 신뢰 경계(trust-boundary) 버그는 내내 당신의 코드베이스에 존재해 왔습니다.
오늘 프롬프트 템플릿(prompt template) 하나를 골라 당신의 눈을 대신해 len() 함수를 실행해 보세요. 무엇이 승리하는지 확인해 보시기 바랍니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기