23. 배치 메시지 디코딩과 길이/개수 검증 (OOB / 메모리 증폭 / 부분 프레임)
난이도 중해설 — 배치 메시지 디코딩과 길이/개수 검증 (OOB / 메모리 증폭 / 부분 프레임)
난이도: 중상
요약
디코더는 와이어에서 읽은 count(A)·len(C)·읽기 오프셋을 남은 바이트와 한 번도 대조하지 않는다. ReadU16(F)도 인덱스 경계를 확인하지 않는다. 따라서 (1) 큰 count 나 (2) 남은 바이트보다 큰 len 이 오면 b[o]/Array.Copy 가 버퍼 밖을 읽어 IndexOutOfRangeException/ArgumentException 으로 죽거나(요청 한 건으로 처리 스레드 다운 = DoS), (3) 공격자 제어 len 으로 매 항목 new byte[len] 을 선할당해 메모리를 증폭시킨다. 또 (4) 프레임이 덜 도착한 부분 수신을 완전한 프레임으로 오인해 마지막 항목을 잘린 채 복사한다.
문제점
1. (입력 검증) count 신뢰 — (A)
- 증상:
count=0xFFFF인데 실제 항목은 2개뿐이면, 3번째 반복부터 버퍼 밖을 읽어 예외/크래시. - 근본원인: 선언된 개수를 실제 데이터 양과 대조하지 않음.
2. (입력 검증) len 신뢰 — (C)(D)(E)
- 증상:
off + len > buf.Length인데Array.Copy(buf, off, payload, 0, len)가 범위를 벗어나 던진다. 버퍼 오버리드(인접 데이터 노출)나 크래시. - 근본원인: 길이 프리픽스를 "남은 바이트"와 비교하지 않음.
3. (메모리 증폭/DoS) 선할당 — (D)
- 항목마다
new byte[len]을 검증 없이 만든다. 작은 입력으로 다수의 큰 할당을 유도(할당 폭증).
4. (경계 검사 부재) ReadU16 — (F)
b[o],b[o+1]접근 전에o+2 <= buf.Length를 확인하지 않는다. 모든 헤더 읽기가 OOB 후보.
5. (프레이밍) 부분 프레임/잔여 바이트 미처리
- buf 가 한 프레임 전체라는 보장이 없다. 덜 오면 잘린 항목을 읽고, 더 길면(여러 프레임이 붙어 옴) 남는 바이트를 조용히 무시한다. 끝에서
off == buf.Length인지 확인하지 않음.
수정안
모든 읽기 전에 남은 바이트를 검사하고, count·len·총량에 상한을 두며, 끝에서 정확히 소비됐는지 확인한다. 불완전하면 "더 받아야 함"으로, 위반이면 "프레임 거부"로 분리한다.
const int MaxCount = 1024;
const int MaxLen = 16 * 1024;
public bool TryDecode(ReadOnlySpan<byte> buf, out List<Msg> result)
{
result = null;
int off = 0;
if (!TryU16(buf, ref off, out ushort count)) return false; // 헤더도 다 안 옴
if (count > MaxCount) throw new ProtocolException("count"); // 명백한 위반은 연결 차단
var list = new List<Msg>(Math.Min(count, 64)); // 신뢰 가능한 만큼만 예약
for (int i = 0; i < count; i++)
{
if (!TryU16(buf, ref off, out ushort type)) return false;
if (!TryU16(buf, ref off, out ushort len)) return false;
if (len > MaxLen) throw new ProtocolException("len");
if (off + len > buf.Length) return false; // 남은 바이트 부족 → 부분 프레임
var payload = buf.Slice(off, len).ToArray();
off += len;
list.Add(new Msg { Type = type, Payload = payload });
}
if (off != buf.Length) throw new ProtocolException("trailing"); // 정확히 소비됐는지
result = list;
return true;
}
static bool TryU16(ReadOnlySpan<byte> b, ref int o, out ushort v)
{
if (o + 2 > b.Length) { v = 0; return false; }
v = (ushort)(b[o] | (b[o + 1] << 8));
o += 2;
return true;
}
- 부분 프레임(
return false) 과 프로토콜 위반(throw→ 연결 종료) 을 구분한다. 전자는 더 받으면 되고, 후자는 신뢰 불가 상대다. - 외곽에 프레임 길이 프리픽스(
[u32 frameLen][batch...])를 둬 한 프레임 경계를 먼저 확정하면 "부분 vs 완전"이 명확해진다.
더 나은 설계
- "선언된 길이는 절대 믿지 말고 항상 남은 바이트로 검증"을 디코더의 불변식으로. 모든 읽기는
TryRead류로 경계를 반환. - count·len·총 페이로드 합에 상한, 선할당은 상한 이하에서만. 큰 페이로드는 별도 청크 프로토콜로.
- 길이 프리픽스 프레이밍 + 누적 버퍼로 부분 수신을 명시적으로 다룬다(이 저장소 protocol/problem1, problem20 과 연계).
면접 포인트
- 신뢰 불가 입력의 count/length 는 반드시 남은 바이트와 대조 — OOB·DoS의 단골 원인.
- "부분 수신(더 받기)"과 "프로토콜 위반(끊기)"은 다른 처리 — 섞으면 정상 트래픽을 끊거나 공격을 흘려보낸다.
- 끝에서 정확히 소비 검증으로 trailing/under-read 를 잡는다.
해설 — 배치 메시지 디코딩과 길이/개수 검증 (버퍼 오버리드 / 메모리 증폭)
난이도: 중상
요약
decode 는 와이어의 count(A)·len(D)을 신뢰하고, 전달받은 size 를 전혀 사용하지 않는다. readU16(G)·memcpy(F)는 data 의 경계를 확인하지 않으므로, 큰 count 나 남은 바이트보다 큰 len 이 오면 버퍼 밖을 읽는다(OOB read — 인접 힙 데이터 유출 또는 크래시, UB). 또 reserve(count)(B)·resize(len)(E)가 공격자 제어 값으로 큰 할당을 유발(메모리 증폭/DoS). 부분 수신(불완전 프레임)도 완전한 것으로 오인한다.
문제점
1. (size 미사용 = 경계 검사 전무) — (G)(F)
readU16는b[o], b[o+1]을size와 무관하게 읽는다.memcpy(..., data+off, len)도 마찬가지. C++ 에서 이는 정의되지 않은 동작으로, 운 좋으면 크래시·나쁘면 인접 메모리 유출.
2. (입력 검증) count/len 신뢰 — (A)(D)
count=0xFFFF,len과대 모두 OOB 로 이어진다. 선언값을 실제 데이터와 대조하지 않음.
3. (메모리 증폭/DoS) — (B)(E)
reserve(count)와resize(len)가 작은 입력으로 큰 할당을 만든다(예: count 65535 × resize). 상한·검증 없음.
4. (프레이밍) 부분/잔여 바이트 미처리
size를 안 쓰니 "프레임이 다 왔는지/남았는지"를 알 수 없다. 끝에서off == size확인도 없다.
수정안
커서(span) 기반으로 모든 읽기 전에 남은 바이트를 검사하고, 상한을 두며, 끝에서 정확히 소비됐는지 확인한다.
struct Reader {
const uint8_t* p; size_t n; size_t off = 0;
bool u16(uint16_t& v) {
if (off + 2 > n) return false;
v = static_cast<uint16_t>(p[off] | (p[off + 1] << 8));
off += 2; return true;
}
bool bytes(uint16_t len, std::vector<uint8_t>& dst) {
if (off + len > n) return false; // 남은 바이트 부족
dst.assign(p + off, p + off + len);
off += len; return true;
}
};
enum class DecodeStatus { Ok, NeedMore, Invalid };
DecodeStatus decode(const uint8_t* data, size_t size, std::vector<Msg>& out) {
static constexpr uint16_t kMaxCount = 1024;
static constexpr uint16_t kMaxLen = 16 * 1024;
Reader r{data, size};
uint16_t count;
if (!r.u16(count)) return DecodeStatus::NeedMore;
if (count > kMaxCount) return DecodeStatus::Invalid; // 명백한 위반 → 연결 차단
out.clear();
out.reserve(count < 64 ? count : 64); // 신뢰 가능한 만큼만
for (uint16_t i = 0; i < count; ++i) {
uint16_t type, len;
if (!r.u16(type) || !r.u16(len)) return DecodeStatus::NeedMore;
if (len > kMaxLen) return DecodeStatus::Invalid;
Msg m; m.type = type;
if (!r.bytes(len, m.payload)) return DecodeStatus::NeedMore;
out.push_back(std::move(m));
}
if (r.off != size) return DecodeStatus::Invalid; // trailing 바이트
return DecodeStatus::Ok;
}
NeedMore(부분 수신, 더 받기) 와Invalid(프로토콜 위반, 연결 종료) 를 상태로 분리한다.- 외곽
[u32 frameLen]프레이밍으로 한 프레임 경계를 먼저 확정하면 부분/완전 구분이 명확.
더 나은 설계
std::span<const uint8_t>기반 Reader 로 모든 디코딩의 경계 검사를 한곳에 모은다("선언 길이는 항상 남은 바이트로 검증").- count·len·총량 상한, 상한 이하에서만 선할당. 큰 데이터는 청크 프로토콜.
- 누적 수신 버퍼 + 길이 프리픽스로 부분 수신을 명시 처리(이 저장소 protocol/problem1, problem20 과 연계).
면접 포인트
- C++ 디코더에서
size(남은 바이트)를 모든 읽기에 적용하지 않으면 곧 OOB/UB. 커서 타입으로 강제하라. - 신뢰 불가 count/len 의 선할당은 메모리 증폭 — 상한 + 검증.
- "더 받기 / 끊기"를 상태로 구분해 정상 부분 수신과 공격을 분리.