Unicode Security Trap: When NFKC and Confusables.txt Disagree on 31 Characters
서론: 우리는 유니코드를 너무 쉽게 생각합니다
로그인 시스템을 설계할 때, 시니어 엔지니어로서 가장 먼저 경계하는 것 중 하나가 바로 Homoglyph Attack (동형 이의어 공격)입니다. 공격자가 라틴 문자 a(U+0061) 대신 키릴 문자 а(U+0430)를 사용하여 admin 계정을 사칭하는 시나리오는 이제 보안 교과서의 첫 장에 나올법한 이야기죠.
보통 우리는 이 문제를 해결하기 위해 두 가지 표준 도구를 사용합니다.
- NFKC (Normalization Form Compatibility Composition): 문자의 호환성을 위해 정규화합니다. (예: 전각 문자
H→ 반각H) - Confusables.txt (Unicode TR39): 시각적으로 비슷해 보이는 문자들을 매핑해 둔 리스트입니다.
“NFKC로 정규화하고, Confusables 리스트로 검사하면 완벽하겠지?”라고 생각하셨나요? 저도 그랬습니다. 하지만 최근 Paul Tendo 가 발견한 흥미로운 분석에 따르면, 이 두 표준이 서로 충돌하는 31개의 문자 가 존재합니다. 오늘은 이 미묘하지만 중요한 차이가 왜 발생하는지, 그리고 우리가 시스템을 설계할 때 무엇을 놓치고 있는지 깊게 파고들어 보겠습니다.
문제의 발단: “어떻게 보이는가” vs “무엇인가”
핵심은 간단합니다. confusables.txt와 NFKC는 서로 다른 질문에 답하도록 설계되었습니다.
- Confusables.txt: “이 문자가 사용자 눈에 무엇과 비슷하게 보이는가?” (시각적 판단)
- NFKC: “이 문자의 본래 의미(Canonical Form) 는 무엇인가?” (의미론적 판단)
이 두 질문의 답이 항상 같지는 않습니다. 가장 대표적인 예시가 바로 Long S (ſ, U+017F) 입니다. 18세기 고문서에서나 볼 법한 이 문자는 현대의 s에 해당합니다.
충돌 시나리오
- Confusables.txt 는
ſ가 시각적으로f와 닮았다고 판단하여f로 매핑합니다. - NFKC 는
ſ가 언어학적으로s의 이형이라고 판단하여s로 정규화합니다.
여기서 문제가 발생합니다. 만약 여러분의 시스템이 NFKC 정규화를 먼저 수행한 후 Confusables 체크를 한다면 어떻게 될까요?
// 입력값: "ſ" (Long S)
// 1단계: NFKC 정규화
const normalized = input.normalize('NFKC'); // 결과: "s"
// 2단계: Confusables 체크
// Confusables 맵에는 "ſ" -> "f" 규칙이 있지만,
// 입력값이 이미 "s"로 변했기 때문에 이 규칙은 절대 실행되지 않습니다.
// 즉, Dead Code가 됩니다.
이런 식으로 NFKC와 Confusables의 판단이 엇갈리는 문자가 총 31개나 됩니다. 여기에는 수학 기호로 쓰이는 Bold I (𝐈) 같은 경우도 포함됩니다. Confusables는 이를 소문자 l(엘)과 비슷하다고 보지만, NFKC는 대문자 I(아이)로 정규화해버립니다.
엔지니어링 관점에서의 분석
사실 이것은 버그라기보다는 Spec의 관점 차이 입니다. 하지만 이 차이를 인지하지 못하고 라이브러리를 가져다 쓰는 엔지니어에게는 잠재적인 혼란을 줄 수 있습니다.
1. Dead Code의 발생
NFKC를 먼저 수행하는 파이프라인(대부분의 모던 시스템)에서는 Confusables.txt의 특정 엔트리들이 영원히 도달 불가능한 상태가 됩니다. Paul Tendo의 분석에 따르면, 약 6,565개의 Confusables 엔트리 중 NFKC 이후에도 유효한 것은 약 613개 정도로 줄어듭니다. 만약 여러분이 클라이언트 사이드 번들 사이즈에 민감하다면, 이 “도달 불가능한” 매핑 데이터를 굳이 포함시킬 필요가 없다는 뜻이기도 합니다.
2. 보안 vs 사용성 (Hacker News의 논쟁)
이 주제가 Hacker News에 올라왔을 때, 댓글 창은 꽤 뜨거웠습니다. 한 유저는 이렇게 말하더군요.
“사용자 입력을 맹독성 폐기물(Toxic Waste) 취급해야 한다. 그냥 [a-z0-9]만 허용하고 나머지는 다 막아버려라.”
솔직히, 백엔드 엔지니어로서 이 의견에 50% 정도는 공감합니다. 화이트리스트 방식이 보안상 가장 안전하니까요. 하지만 글로벌 서비스를 지향한다면? “본인의 이름을 모국어로 쓸 권리”를 박탈하는 것은 UX 측면에서, 그리고 포용성(Inclusivity) 측면에서 최악의 선택일 수 있습니다.
다른 유저는 데이터베이스 레벨의 정규화 위험성을 지적했습니다. 앱 서버에서는 다르게 취급했던 문자가, DB에 저장될 때 정규화되면서 기존 계정과 충돌하거나 덮어씌워질 수 있다는 것이죠. 이것이 진짜 무서운 점입니다. “Validation은 앱에서, Normalization은 DB에서” 라는 안일한 생각이 보안 구멍을 만듭니다.
해결책: 어떻게 구현해야 할까?
무조건 confusables.txt를 맹신하거나, NFKC만 믿어서는 안 됩니다. Paul Tendo가 제안하는, 그리고 제가 동의하는 방식은 “NFKC를 고려한 필터링” 입니다.
만약 여러분이 confusables.txt를 이용해 차단 목록(Blocklist)을 만들고 있다면, NFKC 정규화 과정을 먼저 거친 후의 결과를 기준으로 맵을 재작성해야 합니다.
- NFKC 선행: 입력 문자열을 먼저 NFKC로 정규화합니다.
- 필터링 된 맵 사용: NFKC에 의해 이미 ASCII로 변환되는 문자들은 Confusables 검사에서 제외해도 됩니다 (이미 안전하거나, 의미가 변했으므로).
- 시각적 검사: NFKC를 거쳤음에도 여전히 라틴 문자가 아니면서, 라틴 문자와 비슷해 보이는 잔여 문자들만 잡아냅니다.
이렇게 하면 불필요한 연산을 줄이고, 맵의 크기도 줄이면서, ſ와 s 같은 의미론적 충돌을 피할 수 있습니다.
결론: 표준은 ‘절대 진리’가 아니다
우리는 종종 “표준(Standard)을 준수했다”는 말로 보안 책임을 다했다고 생각합니다. 하지만 유니코드처럼 방대하고 역사가 깊은 표준은 내부적으로 서로 다른 목적을 가진 위원회들에 의해 만들어졌기 때문에, 이처럼 미묘한 불일치가 발생할 수밖에 없습니다.
Confusables.txt는 시각적 판단이고, NFKC는 데이터적 판단입니다.
이 둘의 차이를 이해하지 못하고 단순히 라이브러리를 체이닝(Chaining)하는 것만으로는 완벽한 보안을 달성할 수 없습니다. 시니어 엔지니어라면 라이브러리 내부의 데이터가 어떻게 생성되었고, 내 시스템의 파이프라인 순서에 따라 어떤 데이터가 유실(Dead Code)되는지 파악하고 있어야 합니다.
여러분의 인증 시스템은 지금 안전한가요? 아니면 ſ를 f로 막고 있다고 착각하고 계신가요? 오늘 한 번 코드를 들여다보시길 권합니다.