19. 압축 패킷 해제 시 압축 폭탄(decompression bomb) 방어 (C#)
난이도 최상해설 — 압축 패킷 해제 시 압축 폭탄(decompression bomb) 방어 (C#)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
DecompressPacket 이 공격자 제어 값과 무한 팽창을 모두 신뢰한다. (A) new byte[originalLen]
— 선언 originalLen(최대 ~4GB)으로 즉시 선할당 → 16바이트 패킷으로 OOM(over-allocation
DoS). 게다가 이 버퍼는 쓰이지도 않는다. (B) inflate.CopyTo(outStream) 는 출력 상한 없이
스트림 끝까지 전부 복사 → 작은 입력이 수 GB 로 팽창하는 압축 폭탄(ratio bomb) 에 그대로
당해 메모리·CPU 고갈. (C) 풀어낸 크기와 선언 originalLen 의 일치 검증도, 압축비 상한도
없다. (D) compressedLen 이 실제 버퍼 경계 안인지 검사하지 않아 MemoryStream(buf, 8, compressedLen) 에서 ArgumentException/OOB 위험. 정답 한 줄: 선언 크기를 신뢰해 선할당하지
말고, "절대 출력 상한 + 압축비 상한" 을 두고 고정 청크로 읽으며 상한 초과 즉시 거부하고,
compressedLen 경계와 produced==originalLen 을 검증한다.
변별: problem9(압축/암호화 협상·다운그레이드 폴백), problem17(비압축 길이 프리픽스 over-allocation/over-read)와 달리, 본 문제는 압축 해제 출력 팽창(ratio bomb)·선언 크기 신뢰 라는 압축 특유의 DoS 가 핵심이다.
문제점
(A) 선언 originalLen 으로 선할당 — over-allocation DoS (보안/리소스) ★간판
- 분류 태그: trust-declared-size / resource-exhaustion.
- 증상:
new byte[originalLen]는 검증 전에originalLen바이트를 할당한다.originalLen=0xFFFFFFFF만 실어 보내면 실제 압축 데이터가 없어도 거대한 할당 시도 →OutOfMemoryException/프로세스 압박. 더구나 이preallocated버퍼는 사용되지도 않는다. - 재현조건: 큰
originalLen패킷 1개. - 근본 원인: "선언 크기" 를 가용·정책 상한과 무관하게 신뢰.
(B) 상한 없는 CopyTo — 압축 폭탄(ratio bomb) DoS (보안/리소스) ★간판
- 분류 태그: decompression-bomb / unbounded-output.
- 증상:
inflate.CopyTo(outStream)는 압축 스트림이 끝날 때까지 출력 제한 없이MemoryStream을 키운다. 고압축비 폭탄은 수 KB 입력이 수 GB 출력으로 팽창 → OOM + CPU. (A) 를 고쳐도 이쪽이 남으면 여전히 취약. - 재현조건: 고압축비 페이로드 1개.
- 근본 원인: 해제 출력에 절대 상한·압축비 상한이 없다.
(C) produced/압축비 미검증 — 정합·정책 (버그/보안)
- 증상: 풀어낸 크기가 선언과 다른지, 압축비가 정책을 넘는지 검사하지 않는다. 손상/변조 데이터를 통과시키고 폭탄 식별 신호도 버린다.
- 근본 원인: 해제 결과 사후 검증/정책 부재.
(D) compressedLen 경계 미검사 — 예외/오버리드 (보안/안정성)
- 증상:
new MemoryStream(buf, 8, (int)compressedLen)에서8 + compressedLen > buf.Length면ArgumentException(미처리 시 크래시).(int)캐스팅은 큰 u32 를 음수로 만들어 또 다른 예외/논리오류.ReadU32들도buf.Length>=8가정. - 근본 원인: 길이 필드를 실제 가용 바이트로 검증하지 않음.
수정안
핵심: ① 선할당 금지, ② 절대 출력 상한 + 압축비 상한, ③ 고정 청크로 읽으며 상한 초과 즉시 거부, ④ 경계·일치 검증.
public static class PacketCompression
{
const int MaxOriginal = 8 * 1024 * 1024; // 절대 출력 상한 (예: 8MB)
const int MaxRatio = 100; // 압축비 상한 (출력/입력)
public static byte[] DecompressPacket(byte[] buf)
{
if (buf.Length < 8) throw new InvalidDataException("short header");
uint compressedLen = ReadU32(buf, 0);
uint originalLen = ReadU32(buf, 4);
// (D) compressedLen 경계 검증 (int 오버플로/음수 방지)
if (compressedLen > (uint)(buf.Length - 8))
throw new InvalidDataException("compressedLen out of range");
// (A)(C) 선언 크기는 상한 검사용일 뿐 — 선할당하지 않는다
if (originalLen > MaxOriginal) throw new InvalidDataException("declared size too large");
// 출력 상한 = min(절대 상한, 선언 크기, 입력 × 압축비 상한)
long cap = Math.Min(MaxOriginal,
Math.Min((long)originalLen, (long)compressedLen * MaxRatio));
using var input = new MemoryStream(buf, 8, (int)compressedLen, writable: false);
using var inflate = new DeflateStream(input, CompressionMode.Decompress);
// 출력 상한보다 1바이트 더 읽어보고, 초과하면 폭탄으로 간주해 거부
var outBuf = new byte[cap + 1];
int total = 0, read;
while ((read = inflate.Read(outBuf, total, outBuf.Length - total)) > 0)
{
total += read;
if (total > cap) throw new InvalidDataException("decompression bomb"); // (B) 즉시 중단
}
// (C) 선언과 실제 일치(정책)
if (originalLen != 0 && total != originalLen)
throw new InvalidDataException("size mismatch");
var result = new byte[total];
Array.Copy(outBuf, result, total);
return result;
}
private static uint ReadU32(byte[] b, int off) =>
(uint)(b[off] | (b[off + 1] << 8) | (b[off + 2] << 16) | (b[off + 3] << 24));
}
포인트
originalLen은 상한 검사용, 그 크기로 선할당하지 않는다(over-alloc 차단).cap = min(절대상한, 선언크기, 입력×압축비상한)→ 작은 입력은 작은 출력만 허용(ratio bomb 차단).cap+1바이트까지 읽어 초과를 검출하면 즉시 거부(연결 차단/패킷 폐기).compressedLen경계와int캐스팅 안전성 확인,produced==originalLen검증.- 더 큰 출력이 정당한 경로(맵 청크 등)는 그 타입의 상한을 따로 둔다.
더 나은 설계 (+트레이드오프)
- 메시지 종류별 상한 테이블: 타입마다 출력 상한·압축비를 선언하고 파서가 강제. 트레이드오프: 명세-코드 동기화.
- 스트리밍 + 누적 한도: 큰 정당 데이터는 청크 단위로 소비하며 누적 상한 enforce(한 번에
ToArray()금지). 트레이드오프: 소비 측 스트리밍 처리 필요. - 신뢰경계별 정책: 외부(클라) 입력은 보수적 상한, 내부(서버-서버 신뢰 채널)는 완화. 트레이드오프: 경로별 분기.
- 자원 쿼터/격리 + 퍼징: 해제에 메모리/시간 쿼터, 악성 입력 퍼징으로 강건성 검증.
면접 포인트 (예상 질문)
new byte[originalLen]가 왜 16바이트 패킷으로 OOM 을 일으키나?(int)compressedLen캐스팅은 왜 위험한가?CopyTo를 고정 청크Read루프 +cap+1검출로 바꾸면 압축 폭탄을 어떻게 막는가? "절대 상한 + 압축비 상한" 을 함께 두는 이유는?- 선언 originalLen 과 실제 produced 가 다를 때 거부해야 하는 이유, 그리고 정당한 대용량 데이터(맵 청크 등)와 폭탄을 어떻게 구분하겠는가?
해설 — 압축 패킷 해제 시 압축 폭탄(decompression bomb) 방어 (C++)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
decompressPacket 이 공격자 제어 값과 무한 팽창을 모두 신뢰한다. (A) std::vector<uint8_t> out(originalLen) — 선언된 originalLen(최대 ~4GB)으로 즉시 선할당 → 16바이트 패킷
하나로 OOM(over-allocation DoS). (B) 해제 루프가 출력 총량 상한 없이 out 을 2배씩 키우며
스트림이 끝날 때까지 푼다 → 1KB 입력이 수 GB 로 팽창하는 압축 폭탄(ratio bomb) 에 그대로
당해 메모리·CPU 고갈. (C) 풀어낸 produced 와 선언 originalLen 의 일치 검증도, 압축비
상한도 없다. (D) compressedLen 이 실제 수신 버퍼(n) 경계 안인지 검사하지 않아 OOB read
가능. 정답 한 줄: 선언 크기를 신뢰해 선할당하지 말고, "절대 출력 상한 + 압축비 상한" 을 두고
스트리밍으로 풀되 상한 초과 즉시 중단·거부하며, compressedLen 경계와 produced==originalLen
을 검증한다.
변별: problem9(압축/암호화 협상·다운그레이드 폴백), problem17(비압축 길이 프리픽스 over-allocation/over-read)와 달리, 본 문제는 압축 해제 출력의 팽창(ratio bomb)·선언 크기 신뢰 라는 압축 특유의 DoS 가 핵심이다.
문제점
(A) 선언 originalLen 으로 선할당 — over-allocation DoS (보안/리소스) ★간판
- 분류 태그: trust-declared-size / resource-exhaustion.
- 증상:
std::vector<uint8_t> out(originalLen)는 검증 전에originalLen바이트를 즉시 0 초기화하며 할당한다.originalLen=0xFFFFFFFF만 실어 보내면 실제 압축 데이터가 없어도 ~4GB 할당 시도 → OOM/스왑/프로세스 강제종료. 16바이트 패킷으로 가능. - 재현조건: 큰
originalLen을 가진 악성 패킷 1개. - 근본 원인: "선언된 크기" 를 가용·정책 상한과 무관하게 신뢰.
(B) 출력 상한 없는 해제 루프 — 압축 폭탄(ratio bomb) DoS (보안/리소스) ★간판
- 분류 태그: decompression-bomb / unbounded-output.
- 증상:
while (!done) { ... out.resize(out.size()*2 + 1024); ... }는 스트림이 끝날 때까지 출력 총량 제한 없이 버퍼를 키운다. 잘 만든 압축 폭탄(반복 패턴)은 수 KB 입력이 수 GB 출력으로 팽창 → 메모리 + CPU(복사/0초기화 반복) 고갈. (A) 를 고쳐도 이쪽이 남으면 여전히 폭탄에 취약. - 재현조건: 고압축비 페이로드 1개(작은 compressedLen, 거대 실제 출력).
- 근본 원인: 해제 출력에 절대 상한·압축비 상한 이 없다.
(C) produced/압축비 미검증 — 정합·정책 (버그/보안)
- 증상: 풀어낸
produced가 선언originalLen과 다른지, 압축비 (produced / compressedLen)가 정책을 넘는지 검사하지 않는다. 변조·손상 데이터를 그대로 통과시키고 폭탄을 식별할 신호도 버린다. - 근본 원인: 해제 결과에 대한 사후 검증/정책 부재.
(D) compressedLen 경계 미검사 — 버퍼 오버리드 (보안)
- 증상:
in = buf + 8이후inf.step(in, compressedLen, ...)에compressedLen을 그대로 넘긴다.8 + compressedLen > n이면 해제기가 수신 버퍼 밖을 읽는다(인접 메모리 유출/크래시).readU32들도n>=8을 가정. - 근본 원인: 길이 필드를 실제 가용 바이트로 검증하지 않음.
수정안
핵심: ① 선할당 금지(상한 내 점진 확장 또는 clamp), ② 절대 출력 상한 + 압축비 상한, ③ 스트리밍 중 상한 초과 즉시 중단·거부, ④ 경계·일치 검증.
#include <cstdint>
#include <vector>
#include <stdexcept>
#include <algorithm>
struct ParseError : std::runtime_error { using std::runtime_error::runtime_error; };
constexpr size_t kMaxOriginal = 8 * 1024 * 1024; // 절대 출력 상한 (예: 8MB)
constexpr size_t kMaxRatio = 100; // 압축비 상한 (출력/입력)
std::vector<uint8_t> decompressPacket(const uint8_t* buf, size_t n) {
if (n < 8) throw ParseError("short header");
PacketReader r(buf, n);
uint32_t compressedLen = r.readU32();
uint32_t originalLen = r.readU32();
// (D) compressedLen 경계 검증
if (compressedLen > n - 8) throw ParseError("compressedLen out of range");
const uint8_t* in = buf + 8;
// (A)(C) 선언 크기는 "상한 검사" 에만 쓰고, 선할당하지 않는다
if (originalLen > kMaxOriginal) throw ParseError("declared size too large");
// 출력 상한 = min(선언 크기, 압축비 상한, 절대 상한)
size_t cap = std::min<size_t>(kMaxOriginal,
std::min<size_t>(originalLen,
static_cast<size_t>(compressedLen) * kMaxRatio));
std::vector<uint8_t> out;
out.reserve(std::min<size_t>(cap, 64 * 1024)); // 작게 시작, 상한 내 점진 확장
Inflater inf;
size_t produced = 0;
bool done = false;
while (!done) {
if (produced == out.size()) {
if (out.size() >= cap) throw ParseError("decompression bomb"); // (B) 상한 초과 즉시 중단
out.resize(std::min(cap, out.size() * 2 + 1024));
}
size_t wrote = inf.step(in, compressedLen,
out.data() + produced, out.size() - produced, done);
if (wrote == 0 && !done) { // 진행 없음(잘못된 스트림) — 무한루프 방지
throw ParseError("inflate stalled");
}
produced += wrote;
if (produced > cap) throw ParseError("decompression bomb"); // 이중 방어
}
// (C) 정책: 선언과 실제 일치 요구(혹은 정책상 허용 범위)
if (originalLen != 0 && produced != originalLen)
throw ParseError("size mismatch");
out.resize(produced);
return out;
}
포인트
originalLen은 상한 검사용일 뿐, 그 크기로 선할당하지 않는다(over-alloc 차단).cap = min(절대상한, 선언크기, 입력×압축비상한)→ 작은 입력은 작은 출력만 허용(ratio bomb 차단). 상한 초과 시 즉시 throw → 연결 차단/패킷 폐기.wrote==0 && !done진행 없음 검출로 무한루프 방지(악성 스트림).compressedLen경계 검사로 OOB read 차단.produced==originalLen일치 검증.
더 나은 설계 (+트레이드오프)
- 메시지 종류별 상한 테이블: 채팅 히스토리·맵 청크 등 타입마다 합리적 출력 상한·압축비를 선언하고 파서가 강제. 트레이드오프: 명세-코드 동기화.
- 검증된 라이브러리 + 스트리밍 한도: zlib
inflate의avail_out을 고정 청크로 돌리며 누적 출력 상한을 enforce(한 번에 다 풀지 않음). 트레이드오프: 구현 주의(상태 관리). - 압축 자체를 신뢰경계 밖에선 제한: 외부(클라) 입력은 압축 허용 크기/비율을 보수적으로, 내부(서버-서버 신뢰 채널)는 완화. 트레이드오프: 경로별 정책 분기.
- 격리 + 자원 쿼터: 해제를 메모리/시간 쿼터가 걸린 작업으로 처리(cgroup/타임아웃)해 최악에도 프로세스 전체가 죽지 않게. 퍼징(libFuzzer)+ASAN 으로 파서 강건성 검증.
면접 포인트 (예상 질문)
std::vector<uint8_t> out(originalLen)한 줄이 왜 16바이트 패킷으로 OOM 을 일으키나? "선언 크기" 를 어떻게 다뤄야 하나?- (A) 의 선할당을 고쳐도 (B) 의 무한 팽창이 왜 여전히 압축 폭탄에 취약한가? "절대 상한 + 압축비 상한" 을 어떻게 함께 enforce 하는가?
- 스트리밍 inflate 에서
avail_out을 청크로 제한하며 누적 상한을 거는 방법, 그리고wrote==0진행 없음(stall) 을 검출해야 하는 이유는?