48. 프래그먼트 재조립의 offset/length 경계·겹침·구멍 미검증 · C#
난이도 중해설 — 프래그먼트 재조립의 offset/length 경계·겹침·구멍 미검증 · C#
난이도: 중상
요약
재조립기는 신뢰할 수 없는 프래그먼트가 준 totalSize 로 버퍼를 잡고(A), offset 위치에 무조건 복사한다(B). offset/payload.Length 가 버퍼 경계 안인지, 조각들이 겹치거나 구멍이 없는지, totalSize 가 합리적인지 전혀 검증하지 않는다. 조작된 프래그먼트로 과대 할당(메모리 고갈), 예외 크래시, 겹침 덮어쓰기, "받은 바이트 수"만으로 완성 오판이 가능하다. (같은 카테고리 problem8 이 fragIndex/fragCount 인덱스 기반 재조립이라면, 본 문제는 바이트 offset/length 주소 모델의 경계·겹침·구멍 검증이 핵심이다.)
문제점
입력 경계 검증 부재 / 재조립 무결성(겹침·구멍) 미보장 / 자원 상한 부재
- 증상과 재현:
- 과대 할당(DoS): 첫 프래그먼트가
totalSize = 2_000_000_000을 주면 (A)에서 2GB 배열을 즉시 잡는다. msgId 만 바꿔 반복하면 메모리가 고갈된다. - 경계 밖 복사 → 크래시:
offset = totalSize - 1,Payload.Length = 500이면 (B)의Array.Copy가 대상 범위를 벗어나ArgumentException을 던진다. C#은 배열 경계를 검사하므로 OOB 메모리 훼손은 아니지만, 검증되지 않은 원격 입력으로 수신 스레드가 예외로 죽는다(원격 DoS). - 겹침 덮어쓰기 / 위조: 두 프래그먼트가 같은
offset범위를 다른 내용으로 채우면 나중 것이 앞 것을 덮는다.Received는 payload 길이를 단순 누적하므로, 같은 범위를 두 번 보내면 실제로는 절반만 찼는데Received >= totalSize가 되어 구멍이 남은 채 완성으로 오판된다. 그 구멍은 0바이트(또는 이전 msgId 잔재)로 디스패치된다. - 완성 판정 오류:
Received는 "서로 다른 바이트가 몇 개 찼는가"가 아니라 "복사 호출의 payload 길이 합"이다. 겹침·중복 전송이 있으면 실제 커버리지와 어긋난다.
- 과대 할당(DoS): 첫 프래그먼트가
- 근본 원인:
totalSize,offset,payload.Length는 전부 원격이 통제하는 값인데 상한·정합성 검사 없이 메모리 연산과 완성 판정에 그대로 쓰였다.
수정안
모든 필드를 경계 검증하고, 채워진 구간을 비트맵/구간집합으로 추적해 겹침·구멍을 정확히 판정한다.
const int MaxMessageSize = 1 << 20; // 1MB 상한(프로토콜 정책)
const int MaxConcurrentReassemblies = 1024;
public byte[] OnFragment(in Fragment f)
{
// 1) 필드 검증(오버플로 안전)
if (f.TotalSize <= 0 || f.TotalSize > MaxMessageSize) return DropTooLarge();
long end = (long)f.Offset + f.Payload.Length;
if (f.Offset < 0 || f.Payload.Length <= 0 || end > f.TotalSize) return DropBad();
if (!_pending.TryGetValue(f.MsgId, out var p))
{
if (_pending.Count >= MaxConcurrentReassemblies) return DropBusy();
p = new Pending(f.TotalSize);
_pending[f.MsgId] = p;
}
// 2) 같은 메시지의 조각끼리 totalSize 일관성 검사
if (p.Buffer.Length != f.TotalSize) return DropInconsistent();
// 3) 실제로 '새로 채워진' 바이트만 커버리지에 반영(겹침은 진행에 기여 X)
int newlyFilled = p.Coverage.Mark(f.Offset, f.Payload.Length); // 구간집합/비트맵
Array.Copy(f.Payload, 0, p.Buffer, f.Offset, f.Payload.Length);
p.Filled += newlyFilled;
if (p.Filled == p.Buffer.Length && p.Coverage.IsComplete()) // 구멍 없음 확인
{
_pending.Remove(f.MsgId);
return p.Buffer;
}
return null;
}
추가로 재조립 타임아웃(오래된 미완성 msgId 를 주기적으로 폐기)과 msgId 당 조각 수 상한을 둔다. 그러지 않으면 "영원히 미완성"인 상태가 메모리에 쌓인다.
더 나은 설계
- 커버리지 추적: 단순 바이트 카운트 대신 채워진 구간을 비트맵(또는 정렬된 구간 리스트)으로 관리하면 겹침·구멍·중복을 정확히 판정할 수 있다. 완성은 "전 구간이 정확히 한 번 이상 덮였는가"로 판정한다.
- 자원 상한: 메시지 크기, 동시 재조립 수, 미완성 수명(타임아웃)에 모두 상한을 둔다. 재조립은 상태를 보유하므로 원격이 상태 폭증을 유도할 수 있다.
- 트레이드오프: 구간 추적은 약간의 CPU·메모리를 더 쓰지만, UDP 특성상 중복·재정렬·조작이 정상 범주이므로 필수 비용이다.
면접 포인트
- 조립형 프로토콜(프래그먼트, 배치, TLV)의 3대 검증: 경계(offset+len ≤ total), 정합성(조각 간 total 일치·겹침 금지), 자원 상한(크기·개수·수명). 하나라도 빠지면 DoS 또는 데이터 오염이 된다.
- "받은 바이트 수 == 전체 크기"는 겹침이 있으면 완성을 보장하지 못한다. 진행도는 반드시 "고유하게 덮인 커버리지"로 측정해야 한다.
해설 — 프래그먼트 재조립의 offset/length 경계·겹침 미검증 (C++ 힙 오버플로) · C++
난이도: 중상
요약
재조립기는 원격이 준 totalSize 로 버퍼를 resize 하고(A), offset 위치에 memcpy 한다(B). offset + payload.size() 가 버퍼 경계 안인지, 조각이 겹치는지, totalSize 가 합리적인지 검증하지 않는다. C++에서는 (B)가 경계 밖 힙 쓰기(UB) 가 되어 인접 힙 메타데이터/객체를 훼손하고, 원격 코드 실행까지 열릴 수 있다. (인덱스 기반 problem8 과 달리 본 문제는 바이트 offset/length 주소 모델의 경계·겹침 검증이 핵심이다.)
문제점
경계 검증 부재로 인한 힙 버퍼 오버플로(OOB write, UB) / 과대 할당 / 완성 오판
- 증상과 재현:
- 힙 오버플로(치명적):
totalSize = 64,offset = 60,payload.size() = 4096인 조작 프래그먼트. (A)에서 버퍼는 64바이트. (B)의memcpy(buffer.data()+60, payload, 4096)는 버퍼 끝을 4092바이트 넘겨 쓴다. C++은 경계 검사가 없으므로 그대로 인접 힙을 덮어쓰는 정의되지 않은 동작이다. 힙 메타데이터/vtable 포인터 훼손으로 크래시 또는 제어 흐름 탈취. - 정수 문제로 우회:
offset이 매우 커서buffer.data() + f.offset이 포인터 산술로 엉뚱한 주소를 가리켜도 검사가 없다. - 과대 할당:
totalSize = 4GB로resize를 유도하면bad_alloc또는 메모리 고갈. msgId 를 바꿔 반복하면 상태 폭증. - 완성 오판:
received는 payload 길이의 단순 누적이라, 같은 구간을 겹쳐 보내면 실제 커버리지보다 빨리received >= size()가 되어 구멍(초기화된 0 또는 이전 데이터)이 남은 채 디스패치된다.
- 힙 오버플로(치명적):
- 근본 원인:
totalSize,offset,payload.size()가 전부 원격 통제 값인데 상한·경계 검사 없이 메모리 연산에 직접 쓰였다. C++에서는 이 실수가 논리 버그가 아니라 메모리 안전 붕괴로 직결된다.
수정안
복사 전에 오버플로 안전하게 경계를 검증하고, 커버리지를 구간으로 추적한다.
static constexpr uint32_t kMaxMessageSize = 1u << 20; // 1MB
static constexpr size_t kMaxPending = 1024;
bool onFragment(const Fragment& f, std::vector<uint8_t>& out) {
if (f.totalSize == 0 || f.totalSize > kMaxMessageSize) return false;
// 오버플로 안전 경계 검사: offset + len <= totalSize
if (f.offset > f.totalSize) return false;
if (f.payload.size() > (size_t)(f.totalSize - f.offset)) return false;
auto it = pending_.find(f.msgId);
if (it == pending_.end()) {
if (pending_.size() >= kMaxPending) return false; // 상태 폭증 방지
it = pending_.emplace(f.msgId, Pending(f.totalSize)).first;
}
Pending& p = it->second;
if (p.buffer.size() != f.totalSize) return false; // 조각 간 total 일치
// 새로 덮인 바이트만 진행에 반영(겹침은 기여 X)
uint32_t newly = p.coverage.mark(f.offset, (uint32_t)f.payload.size());
std::memcpy(p.buffer.data() + f.offset, f.payload.data(), f.payload.size());
p.filled += newly;
if (p.filled == p.buffer.size() && p.coverage.complete()) {
out = std::move(p.buffer);
pending_.erase(it);
return true;
}
return false;
}
coverage 는 채워진 구간을 비트맵 또는 정렬 구간 리스트로 관리해 겹침·구멍을 정확히 판정한다. 미완성 msgId 에는 수신 시각을 달아 타임아웃 청소기로 폐기한다.
더 나은 설계
- 경계 검사는 항상 오버플로 안전하게:
offset + len <= total을offset + len을 먼저 더해 비교하면 그 덧셈 자체가 오버플로할 수 있다.len <= total - offset처럼 뺄셈 형태로, 그리고offset <= total을 선행 검사해 뺄셈이 언더플로하지 않게 한다. - span/gsl::span 로 접근: 원시 포인터+오프셋 대신 경계 있는 뷰로 대상 구간을 잘라 쓰면 실수로 인한 OOB를 구조적으로 막는다.
- 자원 상한 3종: 메시지 크기, 동시 재조립 수, 미완성 수명. 상태 보유형 파서의 공통 방어선.
면접 포인트
- C#에서는 배열 경계 검사가 예외(가용성 문제)로 끝나지만, C++에서는 같은 결함이 힙 오버플로(무결성·기밀성 붕괴, RCE)로 격상된다. 신뢰 경계를 넘는 memcpy 앞에는 반드시 경계 검증이 있어야 한다.
- 겹침을 허용하는 재조립에서 "바이트 카운트 == 전체 크기"는 완성 증명이 아니다. 커버리지를 구간으로 추적해야 구멍 없는 완성을 보장한다.