38. UTF-8 문자열 필드의 인코딩 유효성 검증 부재 (C#)
난이도 중해설 — UTF-8 문자열 필드의 인코딩 유효성 검증 부재 (C#)
난이도: 중상
요약
DecodeString 은 길이 경계만 신뢰하고 바이트가 유효한 UTF-8 인지를 검증하지 않는다. Encoding.UTF8.GetString 은 기본적으로 잘못된 바이트를 예외 없이 U+FFFD(대체 문자)로 치환해 통과시킨다. 그 결과 오버롱 인코딩·불완전 멀티바이트·제어문자·양방향(Bidi) 제어·제로폭 문자가 그대로 저장·재전송돼, 다운스트림에서 스푸핑(동형 문자 닉네임)·로그 오염·필터 우회·다른 파서(다른 클라/DB)의 처리 붕괴를 일으킨다.
문제점
-
[입력 검증/인코딩] 바이트 유효성·문자 허용목록 미검증 → 오염 문자열 전파
- 증상:
(B)는 어떤 바이트열이든 문자열로 만든다. 잘못된 시퀀스는 U+FFFD 로 조용히 바뀌어 "복원 성공"처럼 보이고, 유효하지만 위험한 코드포인트(제어문자, 양방향 오버라이드 U+202E, 제로폭 U+200B, 동형 이체자)는 그대로 통과한다. - 재현: 닉네임에 U+202E(RTL override)를 넣어 표시를 뒤집거나, 제로폭 문자로 금지어 필터를 우회하거나, 제어문자로 로그/콘솔을 오염. 잘못된/오버롱 시퀀스는 느슨한 디코더나 바이트 단위 필터를 우회할 수 있고, 기본
Encoding.UTF8은 이를 예외 대신 U+FFFD 로 조용히 치환해 "복원 성공"처럼 보이게 만든다. - 근본원인: "길이가 맞다 ≠ 내용이 안전하다". 인코딩 유효성(엄격 디코딩)과 도메인 허용 문자 정책이 둘 다 없다.
- 증상:
-
[다운스트림 불일치] 관대한 디코딩 → 파서마다 다르게 해석
- 증상: 서버는 U+FFFD 로 치환했는데 다른 클라이언트/DB 는 다르게 처리 → 표시 불일치, 저장 실패, 인젝션.
- 근본원인: 관대(replace) 디코딩은 파서 간 해석 차이를 낳는다. 신뢰 경계에서는 엄격 디코딩이 원칙.
수정안
엄격 디코딩(잘못된 바이트는 예외)으로 유효성을 강제하고, 도메인 정책(정규화·허용 문자·길이)을 적용한다.
private static readonly Encoding StrictUtf8 =
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
public string DecodeString(ReadOnlySpan<byte> frame, int offset, int byteLen)
{
ReadOnlySpan<byte> bytes = frame.Slice(offset, byteLen);
string s;
try { s = StrictUtf8.GetString(bytes); } // 잘못된 UTF-8 → 예외로 거부
catch (DecoderFallbackException) { throw new InvalidDataException("invalid utf-8"); }
s = s.Normalize(NormalizationForm.FormC); // 유니코드 정규화
foreach (char c in s)
if (char.IsControl(c) || IsDisallowed(c)) // 제어/Bidi/제로폭 등 정책 차단
throw new InvalidDataException("disallowed char");
if (s.Length > MaxChars) throw new InvalidDataException("too long");
return s;
}
더 나은 설계
- 닉네임/표시명 등 정체성 문자열은 더 엄격하게: 스크립트 혼용 제한(라틴+키릴 혼용 차단), confusable(동형 이체) 검사, 정규화 후 유일성 검사로 스푸핑을 막는다.
- 채팅 본문은 표시 계층에서 제어/Bidi 문자를 무력화하고, 저장은 원문+정규화본을 분리 보관해 검색·필터 일관성 확보.
- 경계 원칙: 신뢰할 수 없는 입력은 "엄격 파싱 → 정규화 → 허용목록 검증 → 저장" 순서를 항상 거친다(디코딩을 관대하게 하지 말 것).
면접 포인트
- "길이 검증 통과 = 안전"이 아니다. 인코딩 유효성(엄격 UTF-8)과 코드포인트 정책(제어/Bidi/제로폭/동형)이 별개의 방어선이다.
Encoding.UTF8기본이 예외 대신 U+FFFD 치환이라는 점을 알고, 신뢰 경계에서는throwOnInvalidBytes: true를 써야 함을 설명할 수 있어야 한다.- 오버롱 인코딩이 왜 필터 우회에 쓰이는지, 유니코드 정규화가 왜 유일성/필터 일관성에 필요한지 말할 수 있어야 한다.
이 항목은 입력 검증/방어 코드 설계 학습이 목적이며 실제 공격 재현이 아니다.
해설 — UTF-8 문자열 필드의 인코딩 유효성 검증 부재 (C++)
난이도: 중상
요약
decodeString 은 바이트를 그대로 std::string(바이트 컨테이너)에 담기만 하고 유효한 UTF-8 인지, 허용된 코드포인트인지를 전혀 검사하지 않는다. C++ std::string 은 인코딩 개념이 없어 어떤 바이트열이든 받아들인다. 그 결과 잘못된 시퀀스·오버롱 인코딩·제어문자·양방향(Bidi) 제어·제로폭 문자가 저장·재전송·로그로 그대로 흘러가, 스푸핑·필터 우회·다운스트림(다른 클라/DB/터미널) 처리 붕괴를 일으킨다.
문제점
-
[입력 검증/인코딩] UTF-8 유효성·문자 정책 미검증 → 오염 문자열 전파
- 증상:
(B)는 바이트를 무비판적으로 문자열로 만든다.std::string은 검증하지 않으므로 잘못된 UTF-8 도 "성공"한다. 유효하지만 위험한 코드포인트(제어문자, U+202E RTL override, 제로폭 U+200B)도 통과. - 재현: 닉네임에 RTL override 로 표시 반전, 제로폭 문자로 금지어 필터 우회, 제어문자(\x1b)로 로그/터미널 오염, 오버롱 UTF-8 로 경로/필터 검사 우회.
- 근본원인: "길이 검증 통과 ≠ 내용 안전". 인코딩 유효성 검사와 도메인 허용 문자 정책이 둘 다 없다. C++ 에서는 인코딩 인지 자체가 표준 라이브러리에 없어 명시적으로 검사해야 한다.
- 증상:
-
[다운스트림 불일치] 검증 없는 재전송 → 파서마다 다르게 해석
- 근본원인: 신뢰 경계에서 검증하지 않으면 각 다운스트림이 제각기 해석해 표시 불일치·저장 실패·인젝션이 난다.
수정안
UTF-8 유효성을 명시적으로 검사(엄격 디코딩)하고, 코드포인트 정책·정규화·길이를 적용한다.
#include <stdexcept>
// 최소한의 엄격 UTF-8 검증: 유효하지 않으면 false. (오버롱/서로게이트/범위초과 거부)
static bool isValidUtf8(const uint8_t* p, size_t n) {
size_t i = 0;
while (i < n) {
uint8_t c = p[i];
int len; uint32_t cp; uint32_t lo;
if (c < 0x80) { len = 1; cp = c; lo = 0x0; }
else if ((c & 0xE0) == 0xC0) { len = 2; cp = c & 0x1F; lo = 0x80; }
else if ((c & 0xF0) == 0xE0) { len = 3; cp = c & 0x0F; lo = 0x800; }
else if ((c & 0xF8) == 0xF0) { len = 4; cp = c & 0x07; lo = 0x10000; }
else return false; // 잘못된 선두 바이트
if (i + (size_t)len > n) return false; // 불완전 시퀀스
for (int k = 1; k < len; ++k) {
if ((p[i + k] & 0xC0) != 0x80) return false; // 잘못된 연속 바이트
cp = (cp << 6) | (p[i + k] & 0x3F);
}
if (cp < lo) return false; // 오버롱 인코딩
if (cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) return false; // 범위/서로게이트
i += (size_t)len;
}
return true;
}
std::string decodeString(const uint8_t* frame, size_t offset, size_t byteLen) {
const uint8_t* b = frame + offset;
if (!isValidUtf8(b, byteLen)) throw std::runtime_error("invalid utf-8");
std::string s(reinterpret_cast<const char*>(b), byteLen);
// 이어서: 정규화(ICU 등) → 제어/Bidi/제로폭 차단 → 최대 코드포인트 수 제한
return s;
}
더 나은 설계
- 정체성 문자열(닉네임): ICU 로 NFC 정규화 + confusable(동형) 검사 + 스크립트 혼용 제한으로 스푸핑 차단, 정규화본 기준 유일성 검사.
- 채팅 본문: 저장은 원문+정규화본 분리, 표시 계층에서 제어/Bidi 무력화.
- 경계 원칙: "엄격 파싱 → 정규화 → 허용목록 검증 → 저장". 검증을 각 다운스트림에 미루지 않는다.
- 실무에선 직접 UTF-8 검증기를 짜기보다 ICU/
simdutf같은 검증된 라이브러리를 쓴다.
면접 포인트
std::string은 인코딩을 모른다 — C++ 에서는 UTF-8 유효성을 반드시 명시적으로 검사해야 한다(언어가 안 해준다).- "길이 검증 통과 = 안전"이 아니라는 점, 인코딩 유효성과 코드포인트 정책이 별개 방어선임을 설명.
- 오버롱 인코딩·서로게이트·범위 초과가 왜 위험/무효인지, 정규화가 왜 필요한지 말할 수 있어야 한다.
입력 검증/방어 코드 설계 학습이 목적이며 실제 공격 재현이 아니다.