← 문제로

41. 채팅 패킷 문자열 길이의 단위 혼동 (C#)

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 채팅 패킷 문자열 길이의 단위 혼동 (C#)

난이도: 중

요약

스펙은 msgLen 을 UTF-8 바이트 수로 정의했는데, 구현은 곳곳에서 string.Length (= UTF-16 코드유닛 수)를 그 자리에 대입한다. 한글은 1문자=3바이트, 이모지는 2코드유닛=4바이트이므로 두 값은 언제나 다르다. 그 결과 ① 인코더가 실제 바디 길이와 다른 msgLen 을 써서 수신 측 프레임 경계가 어긋나고, ② 디코더가 소비 바이트 수를 과소 계산해 같은 desync 를 스스로 만들고, ③ 상한 검사도 스펙과 다른 단위로 이뤄져 클라·서버가 서로 다른 규칙을 갖게 된다. 여기에 Substring 으로 자르면서 서로게이트 페어가 반으로 갈라지고, .NET 기본 인코더가 그걸 예외 없이 U+FFFD 로 치환하기 때문에 서버 로그에는 아무 흔적도 남지 않는다.

문제점

(A) 길이 단위 불일치 — 코드유닛 수를 바이트 수 자리에 쓴다

  • 분류: 프로토콜 스펙 위반 · 프레임 desync · 입력 검증 누락

  • 증상:

    • "안녕하세요" (5코드유닛 / 15바이트)를 브로드캐스트하면 msgLen = 5, 바디는 15바이트로 나간다. 수신 측이 스펙대로 5바이트만 읽으면 안녕(6바이트 중 5바이트)조차 온전하지 않은 깨진 문자열을 얻고, 남은 10바이트를 다음 패킷의 헤더로 해석한다. 이후 연결이 끊길 때까지 모든 패킷이 어긋난다.
    • 이모지 한 글자 "😀" 는 UTF-16 2코드유닛 / UTF-8 4바이트다. 문자 수로 세는 순간 항상 어긋난다.
    • TryDecode 의 p += text.Length 도 같은 오류다. msgLen 바이트를 읽어놓고 코드유닛 수만큼만 소비 위치를 전진시키므로 consumed 가 2 + payloadLen 보다 작아진다. 남은 꼬리 바이트가 누적 버퍼에 남아 다음 TryDecode 의 payloadLen 으로 읽힌다 — 자기 자신이 만든 desync.
    • 상한 검사 text.Length > 256 은 스펙(256바이트)과 다른 단위다. 한글 200자(600바이트)는 통과하고, ASCII 300자(300바이트)는 거부된다. 클라이언트가 스펙대로 바이트 기준으로 자르면 서버가 통과시키고, 서버가 자르면 클라가 통과시킨다.
  • 재현 조건: 다국어 클라 아무거나. ASCII 전용 테스트(영문 QA)에서는 두 값이 같아 절대 재현되지 않는다. 한국/일본 서비스 오픈 직후에만 터지는 전형적 유형이다.

  • 근본 원인:

    1. string.Length 는 문자 수가 아니라 UTF-16 코드유닛 수다. "문자 수"라고 착각하기 쉬운 이름이 그대로 바이트 수 자리에 들어갔다.
    2. 바이트 수를 구하는 API(Encoding.UTF8.GetByteCount)가 존재하는데 쓰이지 않았다.
    3. 직렬화·역직렬화·검증 세 곳이 같은 길이 계산을 각자 다시 구현한다. 한 곳만 고쳐도 나머지가 어긋난다.
  • 추가로 경계 검증 자체가 빠져 있다. TryDecode 는 count < 2 + payloadLen 만 확인하고 msgLen 이 payloadLen 안에 들어가는지는 보지 않는다. 악의적 클라가 payloadLen = 4, msgLen = 60000 을 보내면 Encoding.UTF8.GetString(buf, p, 60000) 이 누적 버퍼의 다른 세션 데이터/미초기화 영역까지 읽거나 ArgumentOutOfRangeException 으로 워커 스레드를 죽인다. 길이 필드는 언제나 "상한"과 "상위 프레임 내부에 들어맞는가"를 둘 다 검사해야 한다.

(B) 자르기가 코드유닛 단위 — 서로게이트 페어 절단과 무음 치환

  • 분류: 인코딩 처리 오류 · 무음 데이터 손상
  • 증상:
    • text.Substring(0, 256) 은 UTF-16 코드유닛 256번째에서 자른다. 그 자리가 서로게이트 페어의 한가운데면 결과 문자열의 마지막은 짝 없는 상위 서로게이트(lone high surrogate)가 된다.
    • 이어지는 Encoding.UTF8.GetBytes 는 기본 대체 폴백(replacement fallback)을 쓰므로 예외를 던지지 않고 그 자리에 U+FFFD(EF BF BD)를 넣는다. 서버 로그에는 아무 것도 안 남고, 유저 화면에만 "…🐉" 대신 "…�" 가 뜬다.
    • 자른 뒤에도 바이트 길이는 전혀 줄지 않는다. 한글 256자를 잘라도 768바이트라 스펙 상한 256바이트를 3배 초과한 채 나간다. 상한을 지키려던 코드가 상한을 못 지킨다.
    • WriteRelayLog 의 BinaryWriter.Write(string) 은 또 다른 단위를 쓴다. 이 API 는 문자열 앞에 7비트 가변 길이 인코딩(LEB128 류)으로 UTF-8 바이트 수를 붙인다. 즉 채팅 프로토콜의 고정 2바이트 msgLen 과 형식이 다르다. 릴레이 스트림을 채팅 코덱으로 읽으면 즉시 깨지고, 255바이트를 넘는 순간 길이 프리픽스가 1바이트에서 2바이트로 늘어 오프셋이 또 밀린다.
    • 수신 측 Encoding.UTF8.GetString 역시 잘못된 바이트열을 예외 없이 U+FFFD 로 바꾼다. 깨진 입력이 정상 처리된 것처럼 통과해 DB·로그·다른 서버로 퍼진다.
  • 재현 조건: 이모지가 포함된 긴 채팅(길드 공지 복붙 등), 또는 한글 100자 이상 메시지.
  • 근본 원인:
    1. "자른다"의 단위가 스펙(바이트)이 아니라 코드유닛이다.
    2. 텍스트의 최소 단위는 코드유닛도 코드포인트도 아니고 사용자 인지 기준으로는 자소 클러스터(grapheme cluster) 다. 👨‍👩‍👧 같은 ZWJ 이모지는 코드포인트 단위로 잘라도 깨진다.
    3. 인코더/디코더가 관대 모드(replacement fallback)라 손상이 조용히 통과한다.

수정안

핵심은 ① 길이 계산을 한 곳으로 모으고 전부 바이트 기준으로, ② 길이 필드의 내부 정합성까지 검증, ③ 자르기는 UTF-8 시퀀스 경계(가능하면 자소 클러스터 경계)에서, ④ 인코딩은 엄격 모드.

public static class ChatProtocol
{
    public const int MaxMessageLen = 256;   // UTF-8 바이트 수

    // 엄격 모드: 잘못된 바이트/서로게이트를 조용히 치환하지 않고 예외를 던진다.
    public static readonly Encoding Utf8Strict =
        new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
}
public bool TryDecode(byte[] buf, int offset, int count, out ChatMessage msg, out int consumed)
{
    msg = null;
    consumed = 0;
    if (count < 2) return false;

    int p = offset;
    ushort payloadLen = BitConverter.ToUInt16(buf, p);
    if (payloadLen < 3) throw new InvalidDataException("payload too small");
    if (count < 2 + payloadLen) return false;      // 아직 다 안 옴
    p += 2;

    byte channel = buf[p]; p += 1;
    ushort msgLen = BitConverter.ToUInt16(buf, p); p += 2;

    // ① 상한 검사 + ② 상위 프레임 내부 정합성 검사 (둘 다 필요)
    if (msgLen > ChatProtocol.MaxMessageLen)
        throw new InvalidDataException($"chat message too long: {msgLen} bytes");
    if (msgLen != payloadLen - 3)
        throw new InvalidDataException("msgLen does not match payloadLen");

    // 엄격 디코딩: 깨진 UTF-8 이면 예외 → 해당 세션만 끊는다
    string text = ChatProtocol.Utf8Strict.GetString(buf, p, msgLen);

    msg = new ChatMessage { Channel = channel, Text = text };
    consumed = 2 + payloadLen;                     // 소비량은 프레임 길이로 확정
    return true;
}
public byte[] Encode(ChatMessage msg)
{
    // 자르기와 인코딩을 한 번에 — 바이트 배열이 곧 진실이다
    byte[] body = EncodeTruncatedUtf8(msg.Text, ChatProtocol.MaxMessageLen);

    int payloadLen = 1 + 2 + body.Length;
    var outBuf = new byte[2 + payloadLen];
    int p = 0;
    BitConverter.GetBytes((ushort)payloadLen).CopyTo(outBuf, p); p += 2;
    outBuf[p] = msg.Channel; p += 1;
    BitConverter.GetBytes((ushort)body.Length).CopyTo(outBuf, p); p += 2;   // 바이트 수
    Buffer.BlockCopy(body, 0, outBuf, p, body.Length);
    return outBuf;
}

// UTF-8 바이트 상한을 지키되, 멀티바이트 시퀀스 중간에서 자르지 않는다.
private static byte[] EncodeTruncatedUtf8(string s, int maxBytes)
{
    var enc = ChatProtocol.Utf8Strict;
    byte[] bytes = enc.GetBytes(s);                // 서로게이트가 깨져 있으면 여기서 예외
    if (bytes.Length <= maxBytes) return bytes;

    int cut = maxBytes;
    // 연속 바이트(10xxxxxx) 위라면 시작 바이트까지 되돌린다
    while (cut > 0 && (bytes[cut] & 0xC0) == 0x80) cut--;

    var result = new byte[cut];
    Buffer.BlockCopy(bytes, 0, result, 0, cut);
    return result;
}

자소 클러스터까지 지키려면 StringInfo.GetTextElementEnumerator 로 클러스터를 순회하며 바이트 예산을 소진하는 방식이 정확하다(👨‍👩‍👧 가 반토막 나지 않는다).

private static string TruncateByGrapheme(string s, int maxBytes)
{
    var enc = ChatProtocol.Utf8Strict;
    if (enc.GetByteCount(s) <= maxBytes) return s;

    var sb = new StringBuilder();
    int used = 0;
    var e = StringInfo.GetTextElementEnumerator(s);
    while (e.MoveNext())
    {
        string cluster = (string)e.Current;
        int need = enc.GetByteCount(cluster);
        if (used + need > maxBytes) break;
        sb.Append(cluster);
        used += need;
    }
    return sb.ToString();
}

릴레이 로그는 BinaryWriter.Write(string) 대신 채팅 프로토콜과 동일한 프레이밍을 쓰거나, 아예 별도 포맷임을 명시하고 그 포맷의 리더를 함께 두어야 한다.

byte[] body = EncodeTruncatedUtf8(msg.Text, ChatProtocol.MaxMessageLen);
w.Write(ChatProtocol.OpChatSay);
w.Write(msg.SenderId);
w.Write(msg.Channel);
w.Write((ushort)body.Length);      // 프로토콜과 같은 고정 2바이트 바이트 수
w.Write(body);

더 나은 설계

(1) 문자열을 "길이+바이트"로만 다루는 타입을 만들고 string 을 프로토콜 경계에서 추방한다. Utf8Text 같은 값 타입이 항상 검증된 UTF-8 바이트를 들고 다니면, string.Length 를 쓸 기회 자체가 사라진다. 코덱은 Utf8Text 만 읽고 쓴다.

  • 트레이드오프: 로직 코드가 string 을 원할 때마다 변환 비용이 생기고 타입이 하나 늘어난다. 대신 이 문제 유형이 컴파일 타임에 불가능해진다. 대규모 서버에서는 남는 장사다.

(2) 코덱을 손으로 쓰지 말고 IDL(Protobuf/FlatBuffers/커스텀 IDL) 에서 생성한다. 길이 단위·엔디안·경계 검증이 생성기 한 곳에만 존재하므로 서버/클라가 어긋날 수 없다.

  • 트레이드오프: 빌드 파이프라인과 스키마 버저닝 관리가 필요하고, 패킷 레이아웃을 손으로 최적화하기 어려워진다. 반대로 스펙 문서와 코드가 자동으로 동기화되는 이점이 매우 크다.

(3) 상한을 "바이트 상한"과 "표시 길이 상한" 두 개로 분리한다. 전송 안전(버퍼 크기)은 바이트로, UX(너무 긴 채팅 금지)는 자소 클러스터 수로 각각 건다. 클라·서버가 같은 규칙을 공유하도록 상수와 자르기 함수를 공용 라이브러리에 둔다.

  • 트레이드오프: 검사가 두 번이라 약간 느리고 개념이 하나 늘어난다. 대신 "이모지 도배로 1자당 4바이트씩 먹는" 어뷰징과 "긴 글 도배"를 서로 다른 축으로 막을 수 있다.

면접 포인트

  • "string.Length 는 무엇을 세는가?" — UTF-16 코드유닛 수다. 문자 수도, 바이트 수도, 사용자가 보는 글자 수도 아니다. 이모지 하나가 2, ZWJ 가족 이모지는 7~8이 나온다. 프로토콜 길이 필드에는 반드시 Encoding.UTF8.GetByteCount 를 쓴다.
  • "UTF-8 문자열을 N바이트로 자르려면?" — 바이트 배열로 만든 뒤 (b & 0xC0) == 0x80 (연속 바이트)를 만나면 시작 바이트까지 되돌아간다. 정확히 하려면 자소 클러스터 단위로 센다.
  • "잘못된 UTF-8 을 만나면 .NET 은 어떻게 되는가?" — 기본 인코딩은 예외 없이 U+FFFD 로 치환한다. 프로토콜 경계에서는 new UTF8Encoding(false, throwOnInvalidBytes: true) 로 엄격 모드를 써서 손상을 즉시 드러내야 한다. 조용히 통과하는 손상이 가장 찾기 어렵다.
  • "길이 필드 검증은 상한만 보면 되는가?" — 아니다. 상한 + 상위 프레임 내부에 들어맞는가를 둘 다 봐야 한다. 후자를 빼면 인접 메모리 과다 읽기(정보 유출)로 이어진다.