8. 대용량 메시지 프래그먼트/재조립
난이도 상해설 — 대용량 메시지 프래그먼트/재조립
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
MTU 초과 메시지를 [msgId][fragIndex][fragCount] 로 쪼개 재조립하는 코드다.
"수신 카운트 == fragCount 면 완성"이라는 골격은 맞지만, 신뢰할 수 없는 프래그먼트
헤더를 검증 없이 메모리 할당/오프셋 계산에 사용해서 다수의 보안·정확성 결함이 있다.
핵심 결함은 (1) 가변 payloadLen 으로 고정 오프셋(fragIndex × payloadLen) 계산 →
재조립 깨짐/예외, (2) fragCount·fragIndex 무검증 → 거대 할당·IndexOutOfRange,
(3) 재조립 상태의 수명/메모리 상한 부재 → DoS, (4) 길이/카운트 일관성 미검증이다.
C++ 트윈은 OOB write 로 힙 손상/RCE 였으나, C#은 배열 경계 검사로 ArgumentException/
IndexOutOfRangeException(DoS) + OutOfMemoryException 으로 나타난다 — 메모리 손상은
막히되 가용성·정확성은 동일하게 붕괴한다.
문제점
(C)+(D) 가변 payloadLen 으로 오프셋/전체 크기 추정 — 정확성/메모리 안전
- 증상: 마지막 프래그먼트는 보통 payload 가 짧다. 그런데
new byte[fragCount * payloadLen]와offset = fragIndex * payloadLen은 모든 프래그먼트가 동일 크기라고 가정. 첫 프래그먼트가 마지막(짧은) 것이면 버퍼를 과소 할당 → 이후 큰 프래그먼트가Array.Copy로ArgumentException(대상 범위 초과). 반대로 첫 게 가득 찬 것이면 마지막 프래그먼트가 엉뚱한 오프셋에 써져 메시지가 깨진다. - 재현조건: 프래그먼트가 순서대로 안 옴(신뢰 UDP 라도 재전송으로 역순 가능),
또는 변조 클라가 fragIndex 를 큰 값으로 보냄 →
offset이 버퍼 밖 →ArgumentException/IndexOutOfRange. (C++은 OOB write 였지만 C#은 경계 검사로 예외 → 워커 크래시 DoS.) - 근본원인: "프래그먼트 크기가 균일"하다는 검증되지 않은 가정 + 인덱스 경계 미검사.
(B)+(C) fragCount 무검증 → 거대 할당 DoS — 보안
- 증상: 첫 프래그먼트의
fragCount와payloadLen을 곱해 바로new byte[...]. 변조 클라가fragCount=65535, payload 가 큰 프래그먼트를 보내면 수 GB 할당 시도 →OutOfMemoryException.new bool[65535]도 함께. 한 패킷으로 메모리 고갈. 곱셈fragCount * payloadLen자체가 int 오버플로로 음수/작은 값이 되어 추가 오동작 가능. - 근본원인: 신뢰 못 할 카운트를 상한 검사 없이 할당 파라미터로 사용.
(A) 길이/헤더 일관성 미검증 — 메모리 안전
- 증상:
len < HeaderSize면payloadLen = len - HeaderSize가 음수가 되어new byte[fragCount * payloadLen]/Array.Copy(..., payloadLen)에서 예외. 또BitConverter.ToUInt16(buf, 6)자체가 짧은 버퍼에서ArgumentOutOfRangeException. (C++의 size_t 언더플로(거대값)와 달리 C#은 음수지만 결국 예외로 폭발.) - 근본원인: 입력 최소 길이/뺄셈 가드 부재 + 호스트 엔디안
BitConverter의존.
(B)+(E) 재조립 상태 수명·메모리 상한 부재 — DoS/정확성
- 증상: 완성되지 않는 메시지(유실/공격)는
_pending에 영원히 남는다. 공격자가 서로 다른 msgId 로 "절반만" 보내면 딕셔너리가 무한 증식 → OOM. 타임아웃/개수 상한/세션별 쿼터가 전혀 없다. - 추가: msgId 가 세션 키 없이 전역 Dictionary 다. 다른 클라가 같은 msgId 를 보내면 서로의 재조립 버퍼를 간섭/오염(cross-session corruption)할 수 있다.
- 근본원인: 부분 상태의 lifecycle 관리·격리·쿼터 부재.
(C)+(D) fragCount 일관성 미검증 — 정확성
- 한 메시지의 프래그먼트마다
fragCount가 다르게 와도 검증하지 않는다. 첫 값만 믿는다. 변조 시got크기와 어긋나st.got[fragIndex]가IndexOutOfRange.
수정안
헤더·인덱스·카운트 전면 검증 + 명시적 길이 헤더
// 와이어 헤더에 totalSize/fragOffset/fragLen 을 추가하는 게 정석 (균일 크기 가정 제거)
// [uint msgId][uint totalSize][ushort fragIndex][ushort fragCount][ushort fragOffset][ushort fragLen]
private const int HeaderSize2 = 16;
private const uint kMaxMessageBytes = 1 << 20; // 1MB 상한 (정책)
private const ushort kMaxFragCount = 1024;
private const int kMaxPendingPerSession = 8; // 동시 재조립 상한
public void OnFragment(Session sess, byte[] buf, int len)
{
if (len < HeaderSize2) return; // (A) 최소 길이
var sp = buf.AsSpan(0, len);
uint msgId = BinaryPrimitives.ReadUInt32LittleEndian(sp); // 엔디안 명시
uint totalSize = BinaryPrimitives.ReadUInt32LittleEndian(sp.Slice(4));
ushort fragIndex = BinaryPrimitives.ReadUInt16LittleEndian(sp.Slice(8));
ushort fragCount = BinaryPrimitives.ReadUInt16LittleEndian(sp.Slice(10));
ushort fragOffset = BinaryPrimitives.ReadUInt16LittleEndian(sp.Slice(12));
ushort fragLen = BinaryPrimitives.ReadUInt16LittleEndian(sp.Slice(14));
int avail = len - HeaderSize2;
// (B)(C) 카운트/사이즈 상한 검증
if (fragCount == 0 || fragCount > kMaxFragCount) return;
if (totalSize == 0 || totalSize > kMaxMessageBytes) return;
if (fragIndex >= fragCount) return;
if (fragLen > avail) return;
// (D) 오프셋 경계 검증 (long 으로 오버플로 방지)
if ((long)fragOffset + fragLen > totalSize) return;
// 세션별 격리 + 쿼터
var pend = sess.pending; // Dictionary<uint, State>
if (!pend.TryGetValue(msgId, out var st))
{
if (pend.Count >= kMaxPendingPerSession) return; // 동시 재조립 제한
st = new State {
totalSize = totalSize, fragCount = fragCount,
buffer = new byte[totalSize], got = new bool[fragCount],
bytesReceived = 0, deadline = Now() + kReassemblyTimeout // 타임아웃
};
pend[msgId] = st;
}
// (C 일관성) 후속 프래그먼트 메타가 첫 것과 다르면 폐기
if (st.totalSize != totalSize || st.fragCount != fragCount) { pend.Remove(msgId); return; }
if (st.got[fragIndex]) return; // 중복: 무시(idempotent)
Array.Copy(buf, HeaderSize2, st.buffer, fragOffset, fragLen); // 검증된 오프셋
st.got[fragIndex] = true;
st.bytesReceived += fragLen;
if (st.bytesReceived == st.totalSize) // 카운트가 아니라 실제 바이트로
{
OnMessageComplete(msgId, st.buffer, st.buffer.Length);
pend.Remove(msgId);
}
}
완성 판정을 "received == fragCount" 가 아니라 "채워진 바이트 == totalSize"(또는 모든 got 비트 set)로 하면 균일-크기 가정에서 완전히 자유로워진다.
주기적 청소(타임아웃)
별도 타이머에서 deadline 지난 pending 항목을 제거 → 미완성 메시지 누수 방지.
더 나은 설계
- 헤더에 totalSize/offset/len 을 명시 → "모든 프래그먼트 크기가 같다"는 위험한 암묵 가정을 제거. QUIC/IP 프래그먼트도 offset 기반.
- 세션 단위 격리 + 쿼터: 재조립 상태는 세션에 귀속. 동시 재조립 개수, 총 메모리, 메시지 최대 크기에 상한. 초과 시 가장 오래된 것 폐기 또는 거부.
- 타임아웃/가비지 수집: 부분 상태는 TTL 을 두고 청소. 신뢰 계층의 RTO 와 맞춤.
- 가능하면 애플리케이션 프래그먼트를 피한다: 큰 스냅샷은 델타/스트리밍으로 쪼개고, 전송 계층(QUIC 스트림)이 프래그먼트를 처리하게 위임하면 이 클래스의 버그가 사라진다.
- 할당 산술은 checked/long:
fragCount * payloadLen같은 곱셈은 int 오버플로를 피하도록long으로 계산하거나checked블록으로 즉시 검출.
트레이드오프
- 헤더에 offset/len/totalSize 를 넣으면 프래그먼트당 몇 바이트 오버헤드가 늘지만, 안전성과 비균일 프래그먼트 지원을 얻는다.
- 세션별 쿼터는 정상적인 대용량 전송을 제한할 수 있어, 상한을 콘텐츠(맵 크기)에 맞춰 넉넉히 잡되 절대 무한대는 두지 않는다.
면접 포인트
- "프래그먼트 재조립에서 가장 흔한 버그는? C#과 C++에서 결과가 어떻게 다른가?"
→ fragIndex/offset 무검증, fragCount × size 거대 할당, len 음수/언더플로.
C++은 OOB write(RCE), C#은
ArgumentException/OutOfMemoryException(DoS). IP 프래그먼트 공격(teardrop, ping of death) 계보. - "완성 판정을 fragCount 카운트로 하면 뭐가 문제인가?" → 균일 크기 가정·중복 카운팅·메타 불일치에 취약. 실제 채워진 바이트/비트맵으로 판정.
- "미완성 재조립 상태를 어떻게 관리하나?" → 세션 격리 + 동시 개수/메모리 쿼터 + TTL 타임아웃 GC. 없으면 half-open 프래그먼트로 OOM DoS.
내가 놓친 항목 (복습용)
- [ ] (C)(D) 가변 payloadLen 으로 균일 오프셋 계산 → 재조립 깨짐 / ArgumentException
- [ ] (B) fragCount/totalSize 무검증 → 거대 할당 OOM DoS + int 오버플로
- [ ] (A) len < header 시 음수 payloadLen → 할당/Copy 예외 + 엔디안 의존
- [ ] (B)(E) 재조립 상태 TTL/쿼터/세션격리 부재 → OOM, cross-session 오염
- [ ] (C) 프래그먼트 간 fragCount/totalSize 일관성 미검증 → got IndexOutOfRange
해설 — 대용량 메시지 프래그먼트/재조립
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
MTU 초과 메시지를 [msgId][fragIndex][fragCount] 로 쪼개 재조립하는 코드다.
"수신 카운트 == fragCount 면 완성"이라는 골격은 맞지만, 신뢰할 수 없는 프래그먼트
헤더를 검증 없이 메모리 할당/오프셋 계산에 사용해서 다수의 보안·정확성 결함이 있다.
핵심 결함은 (1) 가변 payloadLen 으로 고정 오프셋(fragIndex × payloadLen) 계산 →
재조립 깨짐/오버플로우, (2) fragCount·fragIndex 무검증 → 거대 할당·OOB write,
(3) 재조립 상태의 수명/메모리 상한 부재 → DoS, (4) 길이/카운트 일관성 미검증이다.
문제점
(C)+(D) 가변 payloadLen 으로 오프셋/전체 크기 추정 — 정확성/메모리 안전
- 증상: 마지막 프래그먼트는 보통 payload 가 짧다. 그런데
st.buffer.resize(h->fragCount * payloadLen)와offset = fragIndex * payloadLen은 모든 프래그먼트가 동일 크기라고 가정. 첫 프래그먼트가 마지막(짧은) 것이면 버퍼를 과소 할당 → 이후 큰 프래그먼트가memcpy로 힙 오버플로우. 반대로 첫 게 가득 찬 것이면 마지막 프래그먼트가 엉뚱한 오프셋에 써져 메시지가 깨진다. - 재현조건: 프래그먼트가 순서대로 안 옴(신뢰 UDP 라도 재전송으로 역순 가능),
또는 변조 클라가 fragIndex 를 큰 값으로 보냄 →
offset이 버퍼 밖 → OOB write(RCE 급). - 근본원인: "프래그먼트 크기가 균일"하다는 검증되지 않은 가정 + 인덱스 경계 미검사.
(B)+(C) fragCount 무검증 → 거대 할당 DoS — 보안
- 증상: 첫 프래그먼트의
h->fragCount와payloadLen을 곱해 바로resize. 변조 클라가fragCount=65535, payload 가 큰 프래그먼트를 보내면 수 GB 할당 시도.got.resize(65535)도 함께. 한 패킷으로 메모리 고갈. - 근본원인: 신뢰 못 할 카운트를 상한 검사 없이 할당 파라미터로 사용.
(A) 길이/헤더 일관성 미검증 — 메모리 안전
- 증상:
len < sizeof(FragHeader)면payloadLen = len - sizeof(...)가 거대한 양수로 언더플로우(size_t). 이후 memcpy 가 폭발. 또reinterpret_cast로 헤더를 읽기 전 최소 길이 보장이 없다. - 근본원인: 입력 최소 길이/부호 없는 뺄셈 가드 부재.
(B)+(E) 재조립 상태 수명·메모리 상한 부재 — DoS/정확성
- 증상: 완성되지 않는 메시지(유실/공격)는
_pending에 영원히 남는다. 공격자가 서로 다른 msgId 로 "절반만" 보내면 맵이 무한 증식 → OOM. 타임아웃/개수 상한/세션별 쿼터가 전혀 없다. - 추가: msgId 가 세션 키 없이 전역 map 이다. 다른 클라가 같은 msgId 를 보내면 서로의 재조립 버퍼를 간섭/오염(cross-session corruption)할 수 있다.
- 근본원인: 부분 상태의 lifecycle 관리·격리·쿼터 부재.
(C)+(D) fragCount 일관성 미검증 — 정확성
- 한 메시지의 프래그먼트마다
fragCount가 다르게 와도 검증하지 않는다. 첫 값만 믿는다. 변조 시got크기와 어긋나st.got[fragIndex]가 OOB.
수정안
헤더·인덱스·카운트 전면 검증 + 명시적 길이 헤더
// 와이어 헤더에 totalSize 를 추가하는 게 정석 (균일 크기 가정 제거)
struct FragHeader {
uint32_t msgId;
uint32_t totalSize; // 완성 메시지 전체 바이트 (첫/모든 프래그먼트에 동일)
uint16_t fragIndex;
uint16_t fragCount;
uint16_t fragOffset; // 이 프래그먼트가 들어갈 시작 오프셋 (또는 균일크기 명세)
uint16_t fragLen; // 이 프래그먼트 payload 길이
};
static constexpr uint32_t kMaxMessageBytes = 1 << 20; // 1MB 상한 (정책)
static constexpr uint16_t kMaxFragCount = 1024;
static constexpr size_t kMaxPendingPerSession = 8; // 동시 재조립 상한
void OnFragment(Session& sess, const uint8_t* buf, size_t len) {
if (len < sizeof(FragHeader)) return; // (A) 최소 길이
FragHeader h; std::memcpy(&h, buf, sizeof(h)); // 정렬/엔디안 안전하게
const uint8_t* payload = buf + sizeof(FragHeader);
size_t avail = len - sizeof(FragHeader);
// (B)(C) 카운트/사이즈 상한 검증
if (h.fragCount == 0 || h.fragCount > kMaxFragCount) return;
if (h.totalSize == 0 || h.totalSize > kMaxMessageBytes) return;
if (h.fragIndex >= h.fragCount) return;
if (h.fragLen > avail) return;
// (D) 오프셋 경계 검증
if (uint64_t(h.fragOffset) + h.fragLen > h.totalSize) return;
// 세션별 격리 + 쿼터
auto& pend = sess.pending; // map<msgId, State>
auto it = pend.find(h.msgId);
if (it == pend.end()) {
if (pend.size() >= kMaxPendingPerSession) return; // 동시 재조립 제한
State st;
st.totalSize = h.totalSize;
st.fragCount = h.fragCount;
st.buffer.assign(h.totalSize, 0);
st.got.assign(h.fragCount, false);
st.bytesReceived = 0;
st.deadline = Now() + kReassemblyTimeout; // 타임아웃
it = pend.emplace(h.msgId, std::move(st)).first;
}
State& st = it->second;
// (C 일관성) 후속 프래그먼트의 메타가 첫 것과 다르면 폐기
if (st.totalSize != h.totalSize || st.fragCount != h.fragCount) {
pend.erase(it); return;
}
if (st.got[h.fragIndex]) return; // 중복: 무시(idempotent)
std::memcpy(st.buffer.data() + h.fragOffset, payload, h.fragLen); // 검증된 오프셋
st.got[h.fragIndex] = true;
st.bytesReceived += h.fragLen;
if (st.bytesReceived == st.totalSize) { // 카운트가 아니라 실제 바이트로
OnMessageComplete(h.msgId, st.buffer.data(), st.buffer.size());
pend.erase(it);
}
}
완성 판정을 "received == fragCount" 가 아니라 "채워진 바이트 == totalSize"(또는 모든 got 비트 set)로 하면 균일-크기 가정에서 완전히 자유로워진다.
주기적 청소(타임아웃)
별도 타이머에서 deadline 지난 _pending 항목을 제거 → 미완성 메시지 누수 방지.
더 나은 설계
- 헤더에 totalSize/offset/len 을 명시 → "모든 프래그먼트 크기가 같다"는 위험한 암묵 가정을 제거. QUIC/IP 프래그먼트도 offset 기반.
- 세션 단위 격리 + 쿼터: 재조립 상태는 세션에 귀속. 동시 재조립 개수, 총 메모리, 메시지 최대 크기에 상한. 초과 시 가장 오래된 것 폐기 또는 거부.
- 타임아웃/가비지 수집: 부분 상태는 TTL 을 두고 청소. 신뢰 계층의 RTO 와 맞춤.
- 가능하면 애플리케이션 프래그먼트를 피한다: 큰 스냅샷은 델타/스트리밍으로 쪼개고, 전송 계층(QUIC 스트림)이 프래그먼트를 처리하게 위임하면 이 클래스의 버그가 사라진다.
트레이드오프
- 헤더에 offset/len/totalSize 를 넣으면 프래그먼트당 몇 바이트 오버헤드가 늘지만, 안전성과 비균일 프래그먼트 지원을 얻는다.
- 세션별 쿼터는 정상적인 대용량 전송을 제한할 수 있어, 상한을 콘텐츠(맵 크기)에 맞춰 넉넉히 잡되 절대 무한대는 두지 않는다.
면접 포인트
- "프래그먼트 재조립에서 가장 흔한 메모리 안전 버그는?" → fragIndex/offset 무검증 OOB write, fragCount × size 거대 할당, len 언더플로우. IP 프래그먼트 공격(teardrop, ping of death)과 같은 계보.
- "완성 판정을 fragCount 카운트로 하면 뭐가 문제인가?" → 균일 크기 가정·중복 카운팅·메타 불일치에 취약. 실제 채워진 바이트/비트맵으로 판정.
- "미완성 재조립 상태를 어떻게 관리하나?" → 세션 격리 + 동시 개수/메모리 쿼터 + TTL 타임아웃 GC. 없으면 half-open 프래그먼트로 OOM DoS.
내가 놓친 항목 (복습용)
- [ ] (C)(D) 가변 payloadLen 으로 균일 오프셋 계산 → 재조립 깨짐 / 힙 OOB write
- [ ] (B) fragCount/totalSize 무검증 → 거대 할당 DoS
- [ ] (A) len < header 시 size_t 언더플로우 → memcpy 폭발
- [ ] (B)(E) 재조립 상태 TTL/쿼터/세션격리 부재 → OOM, cross-session 오염
- [ ] (C) 프래그먼트 간 fragCount/totalSize 일관성 미검증 → got OOB