← 문제로

4. 게임플레이 패킷 검증 (길이/시퀀스/페이로드)

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

해설 — 게임플레이 패킷 검증 (길이/시퀀스/페이로드)

난이도: 상

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

요약

신뢰할 수 없는 클라 입력을 다루는 게임플레이 핸들러인데, 길이 미검증 BitConverter 읽기(A), 길이 필드 신뢰로 인한 음수 길이/오파싱(C), payload 크기 미검증 읽기(D), 범위 검증 없는 배열 인덱싱(E)으로 IndexOutOfRange/ArgumentOutOfRange 크래시와 임의 슬롯 변조가 가능하고, 시퀀스 검사가 재전송 공격을 막지 못한다(B: < 로직 결함). 결과는 서버 워커 크래시, 아이템 복제/수량 위조 같은 경제 사고다. C++ 트윈과 달리 C#은 배열 경계 검사가 있어 RCE 급 메모리 손상 대신 예외(DoS)도메인 무결성 붕괴로 나타나지만, 결함의 계보(길이/인덱스/시퀀스 무검증)는 동일하다. 클라 입력은 전부 적대적이라는 전제에서 모든 필드를 산술 안전하게 검증해야 한다.


문제점

(A) 길이 미검증 BitConverter 읽기 + 엔디안 가정 — 보안/안정성

  • 증상: recvLen이 8바이트(헤더)보다 작아도 BitConverter.ToUInt32(recvBuf, 4) 같은 호출이 그대로 실행되어 ArgumentOutOfRangeException 으로 핸들러 워커가 크래시. 또 BitConverter호스트 엔디안으로 변환하므로(IsLittleEndian), 와이어가 little-endian 으로 합의됐는데 빅엔디안 머신에서 돌면 정수가 뒤집힌다.
  • 재현조건: 공격자가 2~3바이트짜리 truncated 패킷을 보냄.
  • 근본원인: 파싱 전 recvLen >= HeaderSize 미확인 + 엔디안을 호스트에 의존.

(C) totalLen 신뢰 → 음수 payloadLen / 오파싱 — 보안 (심각)

  • 증상: payloadLen = totalLen - HeaderSize. totalLen이 8 미만이면 int 뺄셈이 음수가 되어 이후 BitConverter.ToUInt32(buf, offset)가 버퍼 끝을 넘거나, 음수 길이를 다른 검사에 흘려 오파싱. C++의 size_t 언더플로(거대값)와 달리 C#은 음수가 되지만, 결국 후속 읽기에서 예외/오동작으로 이어진다.
  • 재현조건: 변조 클라가 totalLen = 0 헤더를 보냄.
  • 근본원인: totalLen이 실제 recvLen과 일치하는지, 최소 헤더 크기 이상인지 검증 안 함. 와이어의 길이 필드를 신뢰해 산술에 바로 사용.

(D) payload 크기 미검증 읽기 — 보안

  • 증상: BitConverter.ToUInt32(buf, offset)(4B) + ToUInt16(buf, offset+4)(2B) = 6B를 읽는데 payloadLen >= 6 확인이 없다. payload가 6B 미만이면 ArgumentOutOfRangeException.
  • 재현조건: totalLen = 8(payload 0바이트)인 C_UseItem 패킷.
  • 근본원인: "패킷ID에 기대한 payload 크기"와 실제 수신 크기를 대조하지 않음.

(E) 배열 인덱스 범위 미검증 → 슬롯 변조/예외 — 보안 (심각)

  • 증상: slots[slot] -= count에서 slot이 0..63 범위인지 검사 없음. itemSlot = 0x40000000 같은 값이면 IndexOutOfRangeException 으로 크래시(DoS). (C++ 트윈은 경계 검사가 없어 임의 메모리 쓰기/RCE 였지만, C#은 런타임 경계 검사로 예외가 난다 — 메모리 손상은 막히되 가용성은 깨진다.) 또한 count만큼 빼는데 보유량 검사가 없어 음수 수량(아이템 마이너스 보유)도 가능 → 경제 버그.
  • 재현조건: 임의 itemSlot을 담아 전송.
  • 근본원인: 신뢰 불가 입력을 배열 인덱스로 직접 사용. 도메인 검증(슬롯 존재/보유 수량) 부재.

(B) 시퀀스 재전송 방어 결함 — 보안/정확성

  • 증상: if (seq < s.lastSeq) return; s.lastSeq = seq;
    • seq == lastSeq(정확히 같은 패킷 재전송)는 <가 거짓이라 통과중복 처리(아이템 중복 소비/복제).
    • 공격자가 seq를 매우 큰 값으로 한 번 보내면 lastSeq가 점프 → 이후 정상 패킷이 전부 <로 막힘(DoS).
  • 근본원인: 단조 증가 가정만 있고 중복(==) 차단, 점프 방어가 없다. TCP면 와이어 재전송은 커널이 처리하므로 앱 레벨 seq의 진짜 목적(중복 행동/리플레이 차단)이 모호.

수정안

private const int HeaderSize = 8;
private const uint kMaxSeqGap = 1024;

public void OnPacket(Session s, byte[] recvBuf, int recvLen)
{
    // (A) 헤더 크기 보장
    if (recvLen < HeaderSize) { s.Disconnect(); return; }

    // 엔디안 명시: 와이어가 LE 이므로 LE 로 읽는다(빅엔디안 머신에서도 안전)
    ushort totalLen = BinaryPrimitives.ReadUInt16LittleEndian(recvBuf.AsSpan(0));
    ushort packetId = BinaryPrimitives.ReadUInt16LittleEndian(recvBuf.AsSpan(2));
    uint   seq      = BinaryPrimitives.ReadUInt32LittleEndian(recvBuf.AsSpan(4));

    // (C) totalLen 정합성: 최소 헤더 이상 && 실제 수신과 일치
    if (totalLen < HeaderSize || totalLen != recvLen) { s.Disconnect(); return; }
    int payloadLen = totalLen - HeaderSize;            // 이제 음수 불가

    // (B) 시퀀스: 단조 증가 + 중복(==) 차단 + 비정상 점프 제한
    if (seq <= s.lastSeq) return;                      // <= : 중복/과거 모두 무시
    if (seq - s.lastSeq > kMaxSeqGap) { s.Disconnect(); return; }  // 점프 방어
    s.lastSeq = seq;

    switch (packetId)
    {
        case 1001: HandleUseItem(s, recvBuf, HeaderSize, payloadLen); break;
        default:   s.Disconnect(); break;              // 미지 패킷은 끊는다(정책에 따라 무시)
    }
}

private void HandleUseItem(Session s, byte[] buf, int offset, int payloadLen)
{
    // (D) payload 크기 검증 (uint 4B + ushort 2B = 6B)
    if (payloadLen < 6) { s.Disconnect(); return; }

    uint   slot  = BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(offset));
    ushort count = BinaryPrimitives.ReadUInt16LittleEndian(buf.AsSpan(offset + 4));

    // (E) 도메인 검증: 인덱스 범위 + 수량 유효성 + 보유량
    if (slot >= 64 || count == 0) { s.Disconnect(); return; }
    if (s.inv.slots[slot] < count) return;             // 보유량 부족 → 거부(음수 방지)

    s.inv.slots[slot] -= count;
}

더 나은 설계 — 검증/안티치트 패턴

  • 길이 검증의 3계층: ① 수신 루프에서 0 < len <= Max, ② 핸들러 진입에서 totalLen == recvLen 정합성, ③ 패킷별 기대 payload 크기 대조. 각 단계가 다음 단계의 전제를 보장.
  • 음수/언더플로 산술 차단: 뺄셈 전 if (totalLen < HeaderSize) 검사. C#은 음수, C++은 거대값으로 다르게 깨지므로 언어 무관하게 뺄셈 입력을 먼저 검증.
  • 인덱스·수량은 항상 도메인 검증: 슬롯 범위, 수량 상한, 보유량/쿨다운/소유권. 클라가 보낸 값은 "요청"일 뿐 권위는 서버에 있다. 결과는 서버가 계산해 다시 통지.
  • 재전송/리플레이 방어:
    • TCP면 와이어 중복은 커널이 막으므로 앱 seq의 목적은 멱등성(같은 행동의 중복 적용 방지). 중요한 상태변경(거래/사용/구매)은 요청 ID 기반 멱등 처리로 한 번만 적용.
    • UDP면 슬라이딩 윈도우 + 비트맵으로 윈도 내 중복을 정밀 차단(단순 lastSeq 비교 불가).
  • 엔디안 명시: BitConverter(호스트 의존) 대신 BinaryPrimitives.Read*LittleEndian.
  • Span<byte> 기반 파서: 복사 없이 안전한 오프셋 읽기. 가능하면 코드 생성/소스 제너레이터로 패킷별 크기·필드 검증을 자동화.

트레이드오프

  • 패킷마다 MAC/CRC는 변조 탐지력↑ 대신 CPU/대역폭 비용. 실시간 다수 패킷엔 부담 → 민감 패킷(거래/결제)만 강검증, 위치 동기화 등은 경량 검증 + 서버 권위 시뮬레이션으로 분리.
  • 멱등성 키 저장은 메모리/조회 비용이 있어 TTL·창으로 제한.

면접 포인트

  1. "클라가 보낸 길이 필드를 어디까지 믿나? 안 믿으면 어떻게 검증하나?" → 전혀 안 믿는다. recvLen과 totalLen 교차검증, 음수/언더플로 차단, 패킷별 크기 대조.
  2. "재전송 공격으로 아이템이 복제됐다. TCP인데 왜 막혔어야 했나?" → 와이어 중복이 아니라 '행동의 중복 적용'. seq <= 차단 + 상태변경 멱등 처리.
  3. "C#에서 클라가 보낸 슬롯 번호를 그대로 배열 인덱스로 쓰면? C++과 결과가 어떻게 다른가?" → C#은 경계 검사로 IndexOutOfRangeException(DoS), C++은 OOB 쓰기로 메모리 손상/RCE. 둘 다 도메인 범위 검증으로 막아야 함.

내가 놓친 항목 (복습용)

  • [ ] (A) recvLen 미검증 BitConverter → ArgumentOutOfRange + 엔디안 호스트 의존
  • [ ] (C) totalLen 신뢰 → 음수 payloadLen + recvLen 교차검증 부재
  • [ ] (D) payload 크기 미검증 읽기
  • [ ] (E) 슬롯 인덱스 OOB(IndexOutOfRange) / 보유량·수량 도메인 검증 부재
  • [ ] (B) seq == 중복 통과 + 점프 방어 부재(재전송/DoS)