← 문제로

21. 패킹된 비트플래그/필드 언패킹

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

해설 — 패킹된 비트플래그/필드 언패킹

난이도: 중

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

디코더가 비트 폭·부호·예약 비트·신뢰 경계를 모두 잘못 다룬다. (A) 두 바이트를 short(부호 있는 16비트) 로 결합한다. 최상위 비트(bit15)가 켜져 있으면 packed 가 음수가 되고, 이어지는 packed >> 3산술 시프트(부호 확장) 가 되어 상위 필드 추출이 전부 어긋난다. (B) slotIndex 는 4비트(0..15)인데 마스크가 0x1F(5비트) 라 0..31 까지 나온다 → 장비 배열(크기 16) 범위를 넘는다. (C) ServerTrusted클라가 보낸 패킷 비트에서 읽는다 — 클라가 bit8 을 세우면 서버 검증을 건너뛰게 만들 수 있다(서버 권위 위반). 게다가 reserved 비트(8..15)를 검증하지 않는다. (D) ApplyServerTrusted 면 범위 검사를 건너뛰고 _equip[SlotIndex] 에 접근 → IndexOutOfRange/ 엉뚱한 슬롯. 정답 한 줄: 부호 없는 타입으로 결합하고, 정확한 폭의 마스크로 추출하고, reserved 비트를 검증하고, 권한성 표식은 절대 와이어에서 읽지 마라.

변별: problem4(패킷 검증: 길이/시퀀스/페이로드), problem7(엔디안/정렬)과 달리, 본 문제는 비트 단위 언패킹의 부호 확장·마스크 폭·예약 비트·신뢰 경계 가 핵심이다.


문제점

(A) short 로 결합 → 부호 확장 (직렬화/정수 의미) ★간판

  • 분류 태그: signed integer / sign extension.
  • 증상: bit15=1 이면 packed < 0. C# 에서 시프트는 피연산자를 int 로 승격하는데 음수 short 는 음수 int 로 부호 확장되고, >> 는 산술 시프트라 상위 비트가 1로 채워져 SlotIndex/reserved 추출이 깨진다.
  • 재현 조건: 첫 바이트의 최상위 비트(=bit15)가 켜진 입력.
  • 근본 원인: 비트 패킹은 부호 없는 타입(ushort/uint)으로 다뤄야 한다.

(B) 과대 마스크 0x1F — 필드 폭 초과 (검증/경계) ★간판

  • 분류 태그: incorrect bitmask / out-of-range field.
  • 증상: 4비트 필드를 5비트로 추출해 SlotIndex 가 16~31 이 될 수 있다.
  • 근본 원인: 마스크 폭이 필드 폭과 불일치. (bits 3..6 → (packed >> 3) & 0xF)

(C) 신뢰 표식을 와이어에서 읽음 + reserved 미검증 (서버 권위/검증) ★간판

  • 분류 태그: client-controlled trust / missing reserved-bit validation.
  • 증상: 클라가 bit8 을 세우면 ServerTrusted=true 가 되어 Apply 의 범위 검사를 건너뛴다. reserved(8..15)를 검사하지 않으니 "정의되지 않은 비트"가 그대로 동작에 반영.
  • 근본 원인: 권한·신뢰는 서버 내부 상태다. 클라 입력의 어떤 비트도 검증을 면제하면 안 됨.

(D) 검증 우회 후 배열 접근 — OOB (메모리/안정성)

  • 분류 태그: out-of-bounds access.
  • 증상: Apply 의 범위 검사는 ServerTrusted 면 건너뛴다. 클라가 신뢰 비트(C)를 세우면 SlotIndex 가 (B)의 과대 마스크로 16~31 까지 나온 채 _equip[SlotIndex](크기 16)에 직접 접근 → 예외/크래시(OOB).
  • 근본 원인: 신뢰 비트로 검증을 건너뛴 것 + 과대 마스크의 조합.

수정안 (정확한 코드)

public ActionPacket Decode(byte[] buf, int offset)
{
    if (buf == null || offset < 0 || offset + 2 > buf.Length)
        throw new ArgumentException("buffer too short");

    // 부호 없는 타입으로 결합
    ushort packed = (ushort)((buf[offset] << 8) | buf[offset + 1]);

    // reserved(8..15) 는 0이어야 한다
    if ((packed & 0xFF00) != 0)
        throw new InvalidOperationException("reserved bits set");

    var p = new ActionPacket
    {
        ActionType = packed & 0x7,
        SlotIndex  = (packed >> 3) & 0xF,        // 정확히 4비트
        Urgent     = ((packed >> 7) & 1) != 0,
        ServerTrusted = false                    // 와이어에서 읽지 않는다
    };

    // 의미 범위 검증(필요 시 actionType 화이트리스트도)
    if (p.SlotIndex >= 16) throw new InvalidOperationException("slot out of range");
    return p;
}

public void Apply(ActionPacket p)
{
    // 신뢰 여부와 무관하게 항상 범위 검사
    if ((uint)p.SlotIndex >= (uint)_equip.Length) return;
    var name = _equip[p.SlotIndex];
    _ = name;
}

핵심: 부호 없는 결합 + 정확한 폭 마스크 + reserved 검증 + 신뢰 비트 제거 + 항상 범위 검사.

더 나은 설계 (+ 트레이드오프)

  • 결합·추출을 헬퍼로 캡슐화: BinaryPrimitives.ReadUInt16BigEndian 으로 엔디안/부호를 한 곳에서 처리하고, 필드별 (shift,width) 를 상수/제너레이터로 정의해 마스크 실수를 차단.
  • reserved 정책 명문화: "must be zero" 를 강제(거부)할지 "must ignore"(무시) 할지 프로토콜에 못 박는다. 게임 클라/서버 버전이 다를 수 있으면 보통 무시가 안전하지만, 보안 경계 필드는 거부가 낫다(트레이드오프: 하위호환 vs 엄격성).
  • 권한/신뢰는 세션 상태로: "이 요청이 신뢰됐는가"는 세션 인증·서버 내부 플래그로만 결정. 와이어에는 권한성 비트를 아예 두지 않는다.
  • 퍼징/속성 테스트: 16비트 전수(0..65535) 또는 랜덤 입력으로 디코더가 절대 OOB/예외 전파/음수 인덱스를 만들지 않음을 검증.

면접 포인트

  1. C# 시프트의 함정 두 가지 — ① 피연산자가 int 미만이면 int 로 승격(부호 확장 주의), ② 시프트 카운트는 int& 31, long이면 & 63 로 마스킹된다(x >> 32 == x).
  2. 비트 필드는 항상 부호 없는 타입으로. 마스크 폭은 필드 폭과 정확히 일치시킨다.
  3. "검증을 건너뛰게 하는 비트"를 클라가 제어하게 두지 마라 — 신뢰·권한은 서버 권위. reserved 비트 정책(거부/무시)도 프로토콜에 명시.