39. 배치 개수 필드의 곱셈 오버플로와 경계 검증 우회 (과대 할당·OOB) (C#)
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 배치 개수 필드의 곱셈 오버플로와 경계 검증 우회 (과대 할당·OOB) (C#)
난이도: 중
요약
needed = 4 + count * RecordSize 에서 count 는 공격자가 정하는 값이고 count * RecordSize 는 int 산술이라 오버플로한다. 오버플로로 needed 가 작아지면 payload.Length < needed 검사가 통과되고, 이어서 new EntityRecord[count](과대 할당) 와 버퍼 밖 읽기가 발생한다.
문제점
(A) 필요 바이트 계산의 정수 오버플로 — integer overflow / 검증 우회
- 증상: 길이 검사를 통과했는데도 이후 로직이 버퍼 밖을 참조하거나, 수백 MB~GB 배열을 할당하려다
OutOfMemoryException으로 서버가 죽는다. - 재현 조건:
count = 0x20000000(536,870,912).count * 12 = 6,442,450,944는int(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)로 이어진다는 언어 차이.
해설 · C++
해설 — 배치 개수 필드의 곱셈 오버플로와 경계 검증 우회 (과대 할당·OOB read UB) (C++)
난이도: 중
요약
needed = 4 + count * RecordSize 가 uint32_t 산술이라 count 가 크면 오버플로해 작은 값이 된다. 오버플로된 needed 로 len < needed 검사가 통과되고, result.resize(count)(과대 할당) 와 memcpy(data + offset, ...)(버퍼 밖 읽기, 정의되지 않은 동작)가 이어진다.
문제점
(A) 필요 바이트 계산의 부호 없는 정수 오버플로 — integer overflow / 검증 우회
- 증상: 길이 검사 통과 후 버퍼 경계를 넘는
memcpy로 인접 메모리를 읽는다(정보 유출·크래시). 또는 거대한resize로std::bad_alloc/메모리 고갈. - 재현 조건:
count = 357913942(약 3.58억).count * 12 = 4,294,967,304 = 2^32 + 8이므로uint32_t산술에서mod 2^32로 감싸져count * RecordSize == 8, 따라서needed = 4 + 8 = 12라는 작은 값이 된다. 공격자가len = 12(≥ needed)만 보내면len < needed(12 < 12 = 거짓)로 검사를 통과한다. 이후resize(357913942)(약 4GB) 과대 할당과, 루프가data + offset를data + len너머로 읽는 OOB 가 발생한다. 부호 없는 오버플로는 UB 는 아니지만(wrap 정의됨) 그 결과로 만들어진 잘못된 크기가 이후 OOB(진짜 UB)를 유발한다. - 근본 원인: 신뢰 불가 값에 상한이 없고, 오버플로가 나지 않는 넓은 타입(
uint64_t)이나 안전한 곱셈으로 계산하지 않았다.
(B) 과대 예약 + 경계 밖 read — unbounded allocation / OOB read (UB)
- 증상:
resize(count)로 수 GB 예약(DoS), 그리고memcpy(&result[i]..., data + offset, ...)가data + len을 넘어 읽어 UB. - 재현 조건: (A)로 검사를 통과한 큰
count. C# Span 과 달리 원시 포인터data + offset는 경계 검사가 전혀 없다. - 근본 원인:
count를 최대 레코드 수로 clamp/검증하지 않고,offset + RecordSize <= len을 매 반복 확인하지 않았다.
수정안
상한 검사 → 넓은 타입 곱셈 → 정확한 길이 일치 순으로 방어한다.
static bool tryParse(const uint8_t* data, uint32_t len, std::vector<EntityRecord>& out) {
out.clear();
if (len < 4) return false;
uint32_t count = 0;
std::memcpy(&count, data, 4);
// 1) 절대 상한으로 먼저 컷(프로토콜 정책) — 과대 할당·오버플로 원천 차단
constexpr uint32_t kMaxRecords = 4096;
if (count > kMaxRecords) return false;
// 2) 64비트로 곱해 오버플로 제거, 정확히 일치할 때만 통과
uint64_t needed = 4ull + static_cast<uint64_t>(count) * RecordSize;
if (static_cast<uint64_t>(len) != needed) return false; // 초과·부족 모두 거부
out.resize(count);
uint32_t offset = 4;
for (uint32_t i = 0; i < count; ++i) {
std::memcpy(&out[i].entityId, data + offset, 4);
std::memcpy(&out[i].x, data + offset + 4, 4);
std::memcpy(&out[i].y, data + offset + 8, 4);
offset += RecordSize;
}
return true;
}
- 상한 clamp, 64비트 곱셈, 정확한 일치(
!=) 세 가지가 모두 있어야 한다. 상한(kMaxRecords)이 있으면 곱셈이 애초에 넘칠 수 없고, 넓은 타입은 이중 안전망이다. std::span<const uint8_t>(C++20)나 경계 인지 리더 헬퍼(readN(offset)가 남은 길이를 검사)를 쓰면 매 필드 접근에 경계가 강제된다.- 부동소수점 필드는 역직렬화 후 NaN/Inf·좌표 범위 검증을 별도로 한다(본 문제 범위 밖이나 실무 필수).
더 나은 설계
- 경계 인지 리더 추상화:
ByteReader{ptr,len,pos}가read<T>()/readBytes(n)마다pos+n<=len를 확인하고 실패 시 파싱을 중단. 수작업 오프셋 산술의 off-by-one·오버플로를 구조적으로 제거. - 상한을 스펙에 명문화: 최대 레코드 수·최대 프레임 크기를 정의하고 수신 계층에서 1차 컷.
- 트레이드오프: 스키마 기반 직렬화(FlatBuffers)는 검증을 내장하지만 포맷·의존성 비용이 있고, 손수 짠 바이너리는 가볍지만 이런 경계·오버플로 검증을 사람이 빠짐없이 넣어야 한다.
면접 포인트
- 부호 없는 오버플로는 "UB 는 아니지만" wrap 된 값이 이후 OOB(진짜 UB)를 유발한다 — "정의된 wrap" 과 "안전"은 다르다.
- length-prefixed 신뢰 경계 파싱의 3원칙: 상한 clamp, 오버플로 없는 넓은 타입 산술, 정확한 길이 일치.
- C++ 원시 포인터 산술에는 경계 검사가 없어 같은 논리 결함이 C# 의 예외/DoS 를 넘어 메모리 침범(UB)까지 간다 —
std::span·리더 추상화의 가치.