가상의 예를 들어 보겠습니다. 팀원이 변경 로그 한 단락을 PR 설명에 붙여 넣습니다. 제3자 웹 페이지를 AI 대화로 요약해 얻은 글입니다. 영어는 자연스럽게 읽힙니다. diff는 깨끗합니다. CI도 통과합니다. 2주 후 보안 보고서가 그 글머리 기호 중 하나를 가리키며, 릴리스 노트에 U+E0000 범위 — 유니코드 태그 블록 — 의 41자짜리 보이지 않는 문자열이 왜 들어 있는지 묻습니다. 팀원 중 누구도 입력하지 않았습니다. 렌더링된 마크다운에서 누구도 볼 수 없습니다. 복사-붙여넣기를 따라 들어와서 에디터, 린터, 리뷰어의 눈, 정적 사이트 생성기를 모두 통과해 결국 고객에게 출시한 아티팩트에 체크섬으로 박혀 버린 것입니다. 같은 주에 또 다른 감사 결과 한 오류 메시지가 적발됩니다. 누군가 리치 텍스트 에디터에서 번역된 문자열을 붙여 넣었고 U+202E RIGHT-TO-LEFT OVERRIDE가 함께 따라온 것입니다. 그 결과 해당 문자열 이후 터미널에 출력되는 모든 오류 코드의 보이는 문자 순서가 뒤집힙니다.
특이한 문제가 아닙니다. AI 어시스턴트, 다국어 콘텐츠, 리치 텍스트 소스를 다루다 보면 일상적으로 마주치는 결과물입니다. ZeroTool의 탐지기는 붙여 넣은 텍스트를 받아서 어떤 코드 포인트가 보이지 않는지, 어느 카테고리에 속하는지, 정리 후 문자열이 어떤 모습인지를 정확하게 알려 줍니다 — 모두 브라우저 안에서, 어떠한 업로드도 없이.
”보이지 않는” 문자란 무엇인가
유니코드 표준은 의도적으로 너비가 0이거나, 글리프를 만들어 내지 않으면서 렌더링에 부수 효과를 일으키는 문자를 정의합니다. 이런 문자들은 정당한 이유로 존재합니다 — 아랍어 셰이핑, 데바나가리 합자, 소프트 줄바꿈 힌트, 이모지 ZWJ 시퀀스, 파일 인코딩 마커 — 그리고 작성자가 의도하지 않은 경계, 가령 plain-text 내보내기, 소스 코드, ASCII를 기대하는 데이터베이스 컬럼 등을 넘어갈 때에야 문제가 됩니다.
탐지기는 보이지 않는 코드 포인트를 다섯 가지 카테고리로 분류합니다. 각 카테고리는 고유한 공격 표면과 정당한 용도를 가지고 있습니다:
| 카테고리 | 코드 포인트 | 정당한 용도 | 몰래 들어왔을 때의 위험 |
|---|---|---|---|
| Zero-width | U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+2060 WJ, U+FEFF BOM/ZWNBSP, U+3164 Hangul Filler, U+115F / U+1160 / U+FFA0 한글 채움 문자, U+180E MVS, U+2061–U+2064 invisible math | 소프트 래핑; 아랍어/인도계 문자 셰이핑; 이모지 ZWJ 시퀀스; 파일 BOM | 식별자 충돌, 워터마킹, 파서 어긋남, 핑거프린팅 |
| Bidirectional | U+200E LRM, U+200F RLM, U+202A–U+202E LRE/RLE/PDF/LRO/RLO, U+2066–U+2069 LRI/RLI/FSI/PDI | LTR/RTL 혼합 단락, 아랍어/히브리어/페르시아어 텍스트 | Trojan-Source (CVE-2021-42574) — 소스 코드를 시각적으로 재정렬 |
| Tag 문자 | U+E0000–U+E007F | 본래 plain text의 언어 태깅용 (유니코드는 U+E0001 LANGUAGE TAG를 폐기했고 태그 문자로 언어 태그를 표시하는 것을 강력히 권장하지 않음); 현재는 이모지 행정구역 깃발 시퀀스에 사용 | ASCII 텍스트 숨기기(ASCII smuggling): 사람에게는 보이지 않지만 LLM은 읽는 프롬프트 인젝션 지시 |
| Variation selector | U+FE00–U+FE0F (VS1–VS16), U+E0100–U+E01EF (VS17–VS256) | 이모지 글리프 변형(텍스트 vs 이모지 표현) 및 CJK 한자 변형 선택 | 문자열 비교를 깨고 바이트 길이를 부풀림 |
| Formatting | U+00AD SOFT HYPHEN, U+034F COMBINING GRAPHEME JOINER, U+206A–U+206F 폐지된 서식 제어 문자 | 권장 하이픈 위치, 그래핌 클러스터 제어 | plain text로 붙여넣어도 남아 부분 문자열 검색과 토큰화를 깨뜨림 |
다섯 범주를 합치면 유니코드 18.0이 DerivedCoreProperties.txt에서 Default_Ignorable_Code_Point로 지정한 4,174개 코드 포인트가 됩니다. 보이지 않는 것은 아니지만 여전히 악의적일 수 있는 코드 포인트도 있습니다 — 호모글리프 공격은 라틴 a (U+0061) 대신 키릴 а (U+0430)를 치환하며, 대부분의 폰트 크기에서 동일하게 보입니다. 이는 다른 문제이며 (혼동 문자 탐지, 명세는 UTS #39) 이 도구가 다루는 영역이 아닙니다. 탐지기는 엄격하게 글리프를 만들지 않거나, 다른 문자의 렌더링에만 부수 효과를 일으키는 코드 포인트를 대상으로 합니다.
왜 중요한가 — 세 가지 실세계 위협
Trojan-Source (CVE-2021-42574)
2021년 11월 케임브리지의 Nicholas Boucher와 Ross Anderson은 Trojan Source를 발표하여, 당시 거의 모든 컴파일러, IDE, 코드 리뷰 도구가 Unicode Bidirectional Algorithm에 따라 양방향 유니코드 제어 문자를 렌더링한다는 것을 — 그것도 소스 코드의 주석과 문자열 리터럴 내부에서 — 입증했습니다. RLI, LRI, PDI, RLO 제어 문자를 삽입함으로써 공격자는 컴파일러가 읽는 바이트와 리뷰어가 보는 글리프가 서로 다른 의미를 가지는 소스 파일을 작성할 수 있습니다.
대표적인 예는 주석을 재정렬하여 return 문이 /* ... */ 안에 있는 것처럼 보이게 하지만, 컴파일러는 이를 실행 코드로 읽습니다:
// JavaScript example, with U+202E (RLO) visualised as ⮜
const isAdmin = false;
/* Check if user is admin ⮜ begin admins only */
if (isAdmin) {
console.log("You are an admin.");
/* end admins only ⮜ */ }
컴파일러에는 검사가 추가되었습니다. Rust 1.56.1은 기본으로 오류를 내는 lint text_direction_codepoint_in_literal과 text_direction_codepoint_in_comment를 도입했습니다(Rust 보안 공지). 그러나 이런 검사는 에디터와 컴파일러만 대상으로 합니다 — 도구 체인의 나머지를 흘러가는 문서, README, 마크다운 파일, 설정 파일, JSON, 셸 스니펫은 다루지 않습니다. JSON 설정이나 YAML 릴리스 매니페스트에 숨겨진 bidi 제어 문자는 여전히 리뷰하는 대부분의 사람에게는 보이지 않습니다.
탐지 측면의 해결은 기계적입니다: bidi 블록 전체가 잘 정의되어 있으며, 이를 제거하면 보이는 순서가 바이트 순서와 일치하는 문자열이 만들어집니다. 직접 작성하지 않은 모든 외부 텍스트에 도구를 돌려서, bidi 개수가 0이 아니라면 더 깊이 들여다봐야 한다는 신호로 삼으십시오.
태그 문자를 통한 ASCII 텍스트 밀반입
유니코드 태그 블록 U+E0000–U+E007F는 plain text의 언어 태깅 용도로 설계되었습니다(RFC 2482, 1999년). 이 블록의 유니코드 코드 차트는 현재 U+E0001 LANGUAGE TAG를 폐기(deprecated)로 표시하고, 태그 문자로 언어 태그를 표시하는 것을 강력히 권장하지 않는다고 적고 있습니다. 이 블록은 이모지 행정구역 깃발 시퀀스(스코틀랜드 🏴 등에서 사용하는 태그 시퀀스)에서 쓰입니다. 나머지 태그 문자는 보이지 않는 공간입니다: 할당되어 있지만 아무것도 렌더링하지 않고, 주변 텍스트와도 상호작용하지 않는 코드 포인트입니다.
이 점이 이 블록을 거의 완벽한 스테가노그래피 채널로 만듭니다. 각 ASCII 바이트는 코드 포인트에 U+E0000을 더해 인코딩할 수 있으며, U+E0020–U+E007E가 출력 가능한 ASCII 95자와 1:1로 대응합니다. 32자짜리 토큰은 평범한 문장 안에 실려 들어가는 32개의 보이지 않는 태그 문자로 인코딩됩니다.
2024년 1월 Riley Goodside는 태그 문자로 쓰여 붙여넣은 텍스트에 숨겨진 지시가 ChatGPT에 프롬프트 인젝션을 일으킬 수 있음을 보였습니다. 이어서 Johann Rehberger가 이런 페이로드를 인코딩·디코딩하는 도구 ASCII Smuggler를 공개하고, LLM이 답변 안에 태그 문자를 출력할 수도 있음을 보여 주었습니다. 숨긴 텍스트가 대화로 들어갈 수도, 대화에서 나올 수도 있다는 뜻입니다. 그는 이 기법을 LLM용 지시를 숨기고 데이터를 눈앞에서 밀반입하는 방법으로 설명하고, LLM 애플리케이션이 프롬프트와 응답 양쪽에서 태그 문자를 걸러 내야 한다고 권고합니다.
태그 문자가 AI 워터마크라는 공개 자료는 없습니다. OpenAI는 ChatGPT 출력에 태그 문자로 표시를 한다고 공개한 적이 없습니다. 2025년 4월 o3와 o4-mini 출력에 특수 문자가 들어 있다는 보고가 있었지만, 해당 문자는 태그 문자가 아닌 U+202F NARROW NO-BREAK SPACE였습니다. 이를 보고한 Rumi는 이후 OpenAI로부터 그 문자가 워터마크가 아니라 “a quirk of large-scale reinforcement learning”(대규모 강화 학습의 부산물)이라는 답을 받았다고 덧붙였습니다. 어떤 탐지기도 문자만으로 텍스트를 AI가 썼는지 판별할 수 없습니다.
웹 페이지 내용, 문서, 대화 출력을 프롬프트, 저장소, 게시물에 붙여 넣는다면 먼저 태그 문자가 있는지 확인하고 싶을 것입니다. 탐지기는 U+E0000–U+E007F 전체 범위(잉글랜드·스코틀랜드·웨일스 깃발 안은 제외)를 표시하고, 각 태그 문자가 나타내는 ASCII 글자를 보여 주며, 한 가지 모드로 그 부분을 제거합니다.
복사-붙여넣기 오염
흔한 원인 중 하나는 평범한 복사-붙여넣기입니다. 2024년 8월 Qiita 글은 PowerPoint의 줄을 VS Code에 붙여넣었더니 줄바꿈마다 앞에 U+200B가 끼어 있었고, Shift_JIS로 저장하자 각 줄 끝이 물음표가 되었다고 적고 있습니다. Shift_JIS에는 제로폭 공백이 없기 때문입니다.
그 텍스트가 리치 텍스트 환경을 떠나 plain-text 목적지 — 데이터베이스 varchar, YAML 파일, 마크다운 포스트, 코드 주석, HTTP 헤더 — 에 도착하면, 보이지 않는 문자들도 함께 따라와 조용히 일을 망칩니다:
- 부분 문자열 검색이 매치를 놓칩니다:
"production"은"pr\u200bduction"과 같지 않습니다. - 시각적으로 동일한 두 문자열이 서로 다른 다이제스트를 만들어 해시 및 서명 검증이 간헐적으로 실패합니다.
- 소스 바이트가 보이는 문자보다 길기 때문에 컴파일러 오류가 잘못된 컬럼 번호를 가리킵니다.
교훈은 일반적입니다. 리치 텍스트 소스에서 보안상 중요한 컨텍스트로 텍스트가 흘러가는 어디서든, 실제로 무엇이 있는지 볼 수 있는 수단이 필요합니다.
탐지하고 제거하는 방법 — 워크플로
보이지 않는 문자 감지기를 열고 입력란에 텍스트를 붙여넣습니다. 입력란 아래 줄에 찾은 개수가 나오고, 그 옆에 ‘정리된 텍스트 복사’ 버튼이 있습니다. 그 아래에는 보이지 않는 문자마다 라벨 칩을 붙인 주석 렌더링, 범주별 개수를 보여 주는 통계 패널, 제거 모드 선택기가 있습니다.
아무 텍스트나 붙여 넣으십시오. 탐지는 동기식으로 매 키 입력마다 실행됩니다. 유용한 네 가지 정보를 볼 수 있습니다:
- 총 개수 — 보이지 않는 코드 포인트가 몇 개나 발견되었는지, 카테고리별로 분류됩니다. 깨끗한 문서는 0을 보고합니다.
- 문자별 주석 — 모든 보이지 않는 코드 포인트는 그 자리에
ZWSP,RLO,TAG-h같은 약칭 칩으로 표시됩니다. 칩에 마우스를 올리면 코드 포인트와 정식 이름이 나옵니다. - 범주별 개수 — 통계 패널은 범주별 개수와 보이는 문자 수, 전체 코드 포인트 수를 보여 줍니다. 태그 문자가 길게 연속되면 숨긴 ASCII 텍스트일 가능성이 높고, 맨 앞에 BOM 하나만 있으면 대개 파일 인코딩 표시입니다.
- 정리된 출력 — 선택한 카테고리가 제거된 같은 텍스트로, 복사할 준비가 되어 있습니다.
제거 모드 선택기에는 다섯 가지 위치가 있습니다:
- All — 카테고리에 관계없이 모든 보이지 않는 코드 포인트를 제거. 소스가 plain text이고 이런 문자들이 존재할 정당한 이유가 전혀 없을 때 사용합니다. 대부분의 코드, 설정 파일, JSON, YAML, 로그 라인이 이 범주에 속합니다.
- Zero-width only — ZWSP, ZWNJ, ZWJ, WJ, BOM, Hangul filler, MVS, invisible math를 제거. bidi 제어 문자(RTL 텍스트가 정당하게 필요할 수 있으므로)와 variation selector(이모지 표현이 의존하므로)는 보존. 레이아웃 의도를 유지하면서 스크립트가 혼합된 글을 정리할 때 사용합니다.
- Bidi only — 양방향 블록만 제거. 소스 코드, 설정 파일, 그리고 보이는 순서가 바이트 순서와 일치해야 하지만 이모지나 데바나가리 안의 정당한 ZWJ 시퀀스는 그대로 두어야 하는 모든 곳에 사용합니다.
- Tag only — U+E0000–U+E007F 범위를 제거(위의 세 깃발은 남음). 웹이나 대화에서 복사한 텍스트에서 의심스러운 카테고리가 숨긴 태그 텍스트뿐일 때 사용합니다. 나머지는 모두 보존합니다.
- Variation only — U+FE00–U+FE0F, U+E0100–U+E01EF, 몽골 문자 자유 변형 선택자를 제거. 이모지를 텍스트 표시로 되돌리고 싶을 때나, 보이지 않는 선택자 때문에 문자열 비교가 실패할 때 유용합니다.
모드를 선택하면 정리된 출력이 즉시 갱신됩니다. 버튼으로 복사하거나, 바이너리 클린 전송을 위해 .txt로 다운로드하십시오.
도구 없이 탐지하고 제거하기
이 도구가 존재하는 이유는 클릭이 스크립트 작성보다 빠르기 때문입니다. 하지만 그 바탕에 깔린 탐지는 어느 언어에서든 정규식으로 사소합니다. 아래는 CI 단계, pre-commit 훅, 사용자 입력 콘텐츠를 감사하는 스크립트에 바로 넣을 수 있는 세 가지 참조 구현입니다.
Python 버전은 표준 라이브러리만 사용하며 카테고리별 개수와 정리된 문자열을 출력합니다. python detect_invisible.py < input.txt로 실행합니다:
import re
import sys
import unicodedata
CATEGORIES = {
"zero-width": r"[\u200B-\u200D\u2060-\u2064\uFEFF\u180E\u3164]",
"bidi": r"[\u200E\u200F\u202A-\u202E\u2066-\u2069]",
"tag": r"[\U000E0000-\U000E007F]",
"variation": r"[\uFE00-\uFE0F\U000E0100-\U000E01EF]",
"formatting": r"[\u00AD\u034F\u115F\u1160]",
}
def scan(text: str) -> dict[str, list[tuple[int, str, str]]]:
findings: dict[str, list[tuple[int, str, str]]] = {k: [] for k in CATEGORIES}
for name, pattern in CATEGORIES.items():
for match in re.finditer(pattern, text):
cp = match.group(0)
findings[name].append((
match.start(),
f"U+{ord(cp):04X}",
unicodedata.name(cp, "<unknown>"),
))
return findings
def strip_all(text: str) -> str:
combined = "|".join(p.strip("[]") for p in CATEGORIES.values())
return re.sub(f"[{combined}]", "", text)
if __name__ == "__main__":
src = sys.stdin.read()
report = scan(src)
total = sum(len(v) for v in report.values())
print(f"invisible code points: {total}")
for cat, hits in report.items():
if hits:
print(f" {cat}: {len(hits)}")
for offset, cp, name in hits[:5]:
print(f" @{offset} {cp} {name}")
sys.stdout.write(strip_all(src))
JavaScript / TypeScript 버전은 Node 20+와 브라우저를 대상으로 합니다. 같은 정규식이 동작하며, 한 가지 차이는 JS 소스 파일이 U+FFFF 위 코드 포인트에 대해 u 플래그와 서로게이트 쌍 인식 문법을 필요로 한다는 것입니다:
const CATEGORIES = {
"zero-width": /[\u200B-\u200D\u2060-\u2064\uFEFF\u180E\u3164]/gu,
"bidi": /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu,
"tag": /[\u{E0000}-\u{E007F}]/gu,
"variation": /[\uFE00-\uFE0F\u{E0100}-\u{E01EF}]/gu,
"formatting": /[\u00AD\u034F\u115F\u1160]/gu,
};
const ALL = new RegExp(
Object.values(CATEGORIES).map(r => r.source).join("|"),
"gu"
);
export function detectInvisible(text) {
const findings = {};
for (const [name, re] of Object.entries(CATEGORIES)) {
findings[name] = [...text.matchAll(re)].map(m => ({
offset: m.index,
codePoint: "U+" + m[0].codePointAt(0).toString(16).toUpperCase().padStart(4, "0"),
}));
}
return findings;
}
export function stripInvisible(text) {
return text.replace(ALL, "");
}
예를 들어 마크다운 포스트에 태그 문자가 하나라도 있으면 CI 단계를 실패시키는 Bash 한 줄 가드를 원한다면, UTF-8 로케일에서 PCRE를 지원하는 GNU grep을 쓰면 됩니다(macOS에서는 Homebrew로 설치):
# Fail if any tag character (U+E0000–U+E007F) appears
if grep -P '[\x{E0000}-\x{E007F}]' "$file" >/dev/null; then
echo "tag characters detected in $file" >&2
exit 1
fi
# Strip every category in place with perl
perl -CSDA -i -pe '
s/[\x{200B}-\x{200D}\x{2060}-\x{2064}\x{FEFF}\x{180E}\x{3164}]//g;
s/[\x{200E}\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}]//g;
s/[\x{E0000}-\x{E007F}]//g;
s/[\x{FE00}-\x{FE0F}\x{E0100}-\x{E01EF}]//g;
s/[\x{00AD}\x{034F}\x{115F}\x{1160}]//g;
' "$file"
perl -CSDA는 STDIN, STDOUT, @ARGV에 UTF-8을 활성화하여 명령줄에서 Perl이 멀티바이트 입력을 깨뜨리지 않도록 하는 이식성 있는 방법입니다. 동일한 스크립트가 Git pre-commit 훅, GitHub Actions, Vercel 빌드 단계 안에서 추가 의존성 없이 동작합니다.
함정
규모 있게 보이지 않는 문자를 정리할 때 염두에 두어야 할 다섯 가지 경계 사례:
이모지 ZWJ 시퀀스는 정당한 ZWJ입니다. 가족 이모지 👨👩👧👦는 MAN U+200D WOMAN U+200D GIRL U+200D BOY — 네 개의 기본 이모지를 세 개의 zero-width joiner로 붙인 형태로 인코딩됩니다. 이모지가 포함된 문자열에서 ZWJ를 제거하면 네 개의 이모지가 옆으로 따로 렌더링됩니다. 🏳️🌈(흰 깃발 + ZWJ + 무지개)와 직업·헤어스타일 이모지 시퀀스도 마찬가지입니다. 탐지기는 이모지 안의 ZWJ도 표시하는데, “의도된 시퀀스”인지 “몰래 들어온 바이트”인지를 구별할 방법이 없기 때문입니다 — 시각적으로는 둘 다 독립된 글리프를 만들지 않습니다. 보존하고 싶은 이모지가 포함된 텍스트를 정리할 때는 Bidi only 또는 Tag only를 사용하거나, 사후 처리로 참조 목록에서 정식 이모지 시퀀스를 다시 적용하십시오.
파일 BOM은 때로 의도적입니다. Windows PowerShell 5.1은 BOM 없는 스크립트를 예전 “ANSI” 코드 페이지로 읽기 때문에, Microsoft는 비 ASCII 문자가 들어간 스크립트를 BOM 있는 UTF-8로 저장하라고 권합니다. 텍스트가 클립보드가 아닌 파일에서 왔다면, 선행 BOM을 제거하기 전에 그것이 의미를 가지는지 명시적으로 판단하십시오. 탐지기는 위치와 관계없이 BOM을 zero-width 코드 포인트로 보고합니다. 그 보고가 경고인지 아티팩트인지는 당신이 결정합니다.
Soft hyphen은 리치 텍스트에서 정상입니다. U+00AD는 렌더링 엔진에 하이픈 위치를 제안하는 권장 방법입니다. 조판된 PDF나 EPUB 책은 수백 개의 soft hyphen을 정당하게 담고 있을 수 있습니다. soft hyphen은 대상이 plain text — 코드, 설정, 데이터베이스 필드, 로그 라인 — 일 때만 제거하십시오. 조판된 문서 안에서 이를 제거하면 보안상 이득 없이 줄바꿈 품질만 떨어집니다.
태그 문자가 항상 숨긴 텍스트인 것은 아닙니다. U+E0000–U+E007F 범위는 여전히 한 가지 공식 용도를 가집니다: 이모지 행정구역 깃발 시퀀스. 웨일스 깃발 🏴은 검은 깃발(U+1F3F4), 태그 인코딩된 ISO 행정 코드 gbwls, CANCEL TAG(U+E007F) 종료자로 구성됩니다. 태그 블록 전체를 제거하면 이런 깃발이 삭제됩니다. 다만 🏴과 U+E007F 사이의 태그 문자가 곧 깃발인 것은 아닙니다. 누구나 그 자리에 임의의 태그 텍스트를 넣을 수 있습니다. 일반 교환용으로 권장되는 것은 세 개뿐입니다(emoji-sequences.txt의 RGI_Emoji_Tag_Sequence, Emoji 18.0): 잉글랜드 gbeng, 스코틀랜드 gbsct, 웨일스 gbwls. 탐지기는 모든 모드에서 이 세 개만 남기고, 숨긴 텍스트를 감싼 가짜 깃발을 포함해 나머지 태그 문자는 모두 표시합니다.
클라이언트 사이드 정리는 상류를 고치지 않습니다. CMS, 번역 메모리, LLM API가 보이지 않는 문자의 출처라면 브라우저에서 제거하는 것은 지금 손에 든 사본만 깨끗하게 합니다. 같은 출처에서 가져온 다음 사본도 같은 문제를 가집니다. 탐지기를 필터가 아니라 현미경으로 다루십시오 — 출처에 대한 가설을 확인하는 데 사용한 다음, 실제 제거 단계는 당신이 통제하는 경계(webhook, CI 단계, pre-commit 훅, 위의 구현 중 하나를 사용하는 서버 측 정규화 루틴)에 두십시오.
언급할 만한 여섯 번째 함정: 바이트 길이는 문자 길이가 아니며 보이는 너비도 아닙니다. 50개의 보이는 문자와 U+200B 같은 BMP 안의 보이지 않는 문자 80개로 이루어진 문자열은 JavaScript의 String.length로는 130, Python의 len()으로도 130이지만, 터미널의 wcswidth로는 50입니다. 해시 함수, content-length 헤더, 데이터베이스 VARCHAR(N) 제한, 인증 서명은 모두 보이지 않는 문자까지 셉니다. 보이는 너비로 정규화된 문자열을 바이트 수로 저장된 문자열과 비교하면, 달라야 할 입력에 거짓 동등성을 얻거나, 사람이 같다고 부를 입력에 거짓 부등성을 얻게 됩니다. Unicode Normalization의 NFC / NFKC 정규화는 일부 사례(결합 표시, 호환성 분해)를 처리하지만 보이지 않는 코드 포인트를 제거하지는 않습니다. 제거는 별도의 패스입니다.
다른 탐지기와의 비교
2026-09-29에 이 도구의 ‘숨겨진 Tag 텍스트’ 샘플(가족 이모지와 태그 문자 25개가 든 채팅 답장)을 다른 두 도구에 붙여넣어 봤습니다.
Invisible Character Viewer는 제로폭 결합자 두 개와 태그 글자 하나하나를 표시했습니다. ‘Strip Invisible Characters’를 누르자 Thanks for the fix 👍 Team: 👨👩👧 See you Monday.가 되었습니다. 숨은 문장과 함께 결합자도 지워져 가족 이모지가 세 사람으로 나뉜 것입니다. U+00A0, U+3000 같은 눈에 보이는 공백도 표시하는데, 이 탐지기는 그런 공백을 다루지 않습니다.
ASCII Smuggler는 태그 문자를 읽을 수 있는 한 문장으로 복호화하고, 유니코드 태그 25개와 기타 보이지 않는 문자 2개를 셌습니다. 복호화 결과는 읽기용으로 보여 줄 뿐 정리된 텍스트는 내주지 않습니다.
이 탐지기는 한 번에 한 범주만 제거합니다. ‘Tag만’을 고르면 👨👩👧는 남기고 숨은 문장만 지웁니다.
더 읽을거리
내부:
- Unicode 텍스트 변환기 — 텍스트를 𝐁𝐨𝐥𝐝 같은 장식 유니코드 문자로 바꿉니다. 눈에 보이는 문자라서 이 탐지기는 표시하지 않습니다.
- 문자열 이스케이프 — JavaScript, JSON, HTML 엔티티 문자열의 이스케이프와 복원. JavaScript 모드는 U+200B를
\u200b로 써 주므로 찾은 문자를 테스트에 넣을 때 편합니다.
외부:
- Trojan Source: Invisible Vulnerabilities — CVE-2021-42574와 CVE-2021-42694를 기술하는 Boucher & Anderson 논문, 공격 템플릿과 완화 가이드 포함.
- Unicode Standard Annex #9 — Unicode Bidirectional Algorithm — Trojan-Source가 악용하는 임베딩 및 isolate 연산자를 포함한 bidi 제어 문자의 정식 명세.
- Unicode Technical Report 36 — Unicode Security Considerations — 보이지 않는 문자, confusable, 식별자 스푸핑에 대한 표준 자체의 위협 모델.
- Johann Rehberger — ASCII Smuggler Tool: Crafting Invisible Text and Decoding Hidden Codes — 태그 문자로 LLM용 지시를 숨기는 방법, 인코더/디코더와 LLM이 숨긴 텍스트를 출력하는 데모 포함.
- 유니코드 코드 차트: Tags(U+E0000–U+E007F) — 블록의 문자 목록, U+E0001 LANGUAGE TAG의 폐기 주석 포함.
- RFC 2482 — Language Tagging in Unicode Plain Text — 태그 문자를 언어 태그로 쓰자는 최초 제안.