← 문제로

39. 배치 개수 필드의 곱셈 오버플로와 경계 검증 우회 (과대 할당·OOB) (C#)

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

해설 — 배치 개수 필드의 곱셈 오버플로와 경계 검증 우회 (과대 할당·OOB) (C#)

난이도: 중

요약

needed = 4 + count * RecordSize 에서 count 는 공격자가 정하는 값이고 count * RecordSizeint 산술이라 오버플로한다. 오버플로로 needed 가 작아지면 payload.Length < needed 검사가 통과되고, 이어서 new EntityRecord[count](과대 할당) 와 버퍼 밖 읽기가 발생한다.

문제점

(A) 필요 바이트 계산의 정수 오버플로 — integer overflow / 검증 우회

  • 증상: 길이 검사를 통과했는데도 이후 로직이 버퍼 밖을 참조하거나, 수백 MB~GB 배열을 할당하려다 OutOfMemoryException 으로 서버가 죽는다.
  • 재현 조건: count = 0x20000000(536,870,912). count * 12 = 6,442,450,944int(32비트) 범위를 넘어 오버플로 → 하위 32비트만 남아 needed 가 실제 필요량과 전혀 다른 작은(혹은 음수) 값이 된다. payload.Length(예: 4)가 그 값보다 크거나 같아 검사를 통과한다.
  • 근본 원인: 신뢰할 수 없는 입력으로 크기를 곱하기 전에 상한을 두지 않았고, 오버플로가 나지 않는 넓은 타입(long)이나 checked 로 계산하지 않았다.

(B) 과대 할당 + 경계 밖 순회 — unbounded allocation / OOB

  • 증상: 검사를 통과한 뒤 new EntityRecord[count] 로 거대한 배열을 만들거나(메모리 고갈 DoS), payload.Slice(offset, 4) 가 버퍼 끝을 넘어 ArgumentOutOfRangeException 을 던진다.
  • 재현 조건: (A)로 검사를 통과한 어떤 큰 count. Span 의 Slice 는 범위를 넘으면 예외를 던지므로 C# 에서는 메모리 침범 대신 예외/DoS 로 나타난다.
  • 근본 원인: count 를 "실제 페이로드가 담을 수 있는 최대 레코드 수"로 먼저 clamp/검증하지 않았다.

수정안

곱셈을 넓은 타입으로 하고, count 상한과 정확한 길이 일치를 함께 검사한다.

public static bool TryParse(ReadOnlySpan<byte> payload, out EntityRecord[] result)
{
    result = Array.Empty<EntityRecord>();
    if (payload.Length < 4) return false;

    uint count = BitConverter.ToUInt32(payload.Slice(0, 4));

    // 1) 절대 상한(프로토콜 정책)으로 먼저 컷 — 과대 할당·오버플로 원천 차단
    const uint MaxRecords = 4096;
    if (count > MaxRecords) return false;

    // 2) 넓은 타입(long)으로 곱해 오버플로 제거, 정확히 일치할 때만 통과
    long needed = 4L + (long)count * RecordSize;
    if (payload.Length != needed) return false;   // 초과·부족 모두 거부

    var arr = new EntityRecord[count];
    int offset = 4;
    for (int i = 0; i < count; i++)
    {
        arr[i].EntityId = BitConverter.ToInt32(payload.Slice(offset, 4));
        arr[i].X = BitConverter.ToSingle(payload.Slice(offset + 4, 4));
        arr[i].Y = BitConverter.ToSingle(payload.Slice(offset + 8, 4));
        offset += RecordSize;
    }
    result = arr;
    return true;
}
  • 핵심 3가지: 상한(clamp), 넓은 타입 곱셈(오버플로 제거), 정확한 길이 일치(!=). 셋 중 하나만 빠져도 우회가 가능하다.
  • 예외를 던지는 대신 bool 로 실패를 반환해 손상 패킷이 서버 스레드를 죽이지 않게 한다.
  • 필요하면 count 로부터 크기를 역산하지 말고 남은 길이에서 레코드 수를 나눗셈으로 유도((payload.Length-4)/RecordSize)해 count 를 교차 검증한다.

더 나은 설계

  • 상한을 프로토콜 스펙에 못박기: 한 패킷의 최대 엔티티 수·최대 프레임 크기를 명시하고 파서·수신 계층 양쪽에서 강제한다.
  • checked 블록: 크기 계산을 checked((int)...) 로 감싸면 오버플로 시 예외로 즉시 드러난다(단 예외를 잡아 드롭 처리).
  • 스키마 기반 역직렬화: 길이·개수 검증이 내장된 직렬화 라이브러리(FlatBuffers/protobuf)를 쓰면 수작업 경계 계산 실수를 줄인다. 트레이드오프는 포맷 자유도·의존성.

면접 포인트

  • "길이 검사를 했는데 왜 뚫리나?" → 검사에 쓰는 값 자체가 오버플로로 오염되기 때문. 검증 전에 넓은 타입/상한이 필요하다.
  • 신뢰 경계에서 length-prefixed 데이터를 다룰 때의 3원칙: 상한 clamp, 오버플로 없는 산술, 정확한 일치 검사.
  • C# 에서는 Span 이 OOB 를 예외로 막아 메모리 침범 대신 DoS 로 나타나는 반면, C/C++ 원시 포인터는 실제 메모리 침범(UB)로 이어진다는 언어 차이.