4. 게임플레이 패킷 검증 (길이/시퀀스/페이로드)
난이도 상해설 — 게임플레이 패킷 검증 (길이/시퀀스/페이로드)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
신뢰할 수 없는 클라 입력을 다루는 게임플레이 핸들러인데,
길이 미검증 BitConverter 읽기(A), 길이 필드 신뢰로 인한 음수 길이/오파싱(C),
payload 크기 미검증 읽기(D), 범위 검증 없는 배열 인덱싱(E)으로
IndexOutOfRange/ArgumentOutOfRange 크래시와 임의 슬롯 변조가 가능하고,
시퀀스 검사가 재전송 공격을 막지 못한다(B: < 로직 결함). 결과는 서버 워커 크래시,
아이템 복제/수량 위조 같은 경제 사고다. C++ 트윈과 달리 C#은 배열 경계 검사가 있어
RCE 급 메모리 손상 대신 예외(DoS) 와 도메인 무결성 붕괴로 나타나지만, 결함의
계보(길이/인덱스/시퀀스 무검증)는 동일하다. 클라 입력은 전부 적대적이라는 전제에서
모든 필드를 산술 안전하게 검증해야 한다.
문제점
(A) 길이 미검증 BitConverter 읽기 + 엔디안 가정 — 보안/안정성
- 증상:
recvLen이 8바이트(헤더)보다 작아도BitConverter.ToUInt32(recvBuf, 4)같은 호출이 그대로 실행되어ArgumentOutOfRangeException으로 핸들러 워커가 크래시. 또BitConverter는 호스트 엔디안으로 변환하므로(IsLittleEndian), 와이어가 little-endian 으로 합의됐는데 빅엔디안 머신에서 돌면 정수가 뒤집힌다. - 재현조건: 공격자가 2~3바이트짜리 truncated 패킷을 보냄.
- 근본원인: 파싱 전
recvLen >= HeaderSize미확인 + 엔디안을 호스트에 의존.
(C) totalLen 신뢰 → 음수 payloadLen / 오파싱 — 보안 (심각)
- 증상:
payloadLen = totalLen - HeaderSize.totalLen이 8 미만이면int뺄셈이 음수가 되어 이후BitConverter.ToUInt32(buf, offset)가 버퍼 끝을 넘거나, 음수 길이를 다른 검사에 흘려 오파싱. C++의size_t언더플로(거대값)와 달리 C#은 음수가 되지만, 결국 후속 읽기에서 예외/오동작으로 이어진다. - 재현조건: 변조 클라가
totalLen = 0헤더를 보냄. - 근본원인:
totalLen이 실제recvLen과 일치하는지, 최소 헤더 크기 이상인지 검증 안 함. 와이어의 길이 필드를 신뢰해 산술에 바로 사용.
(D) payload 크기 미검증 읽기 — 보안
- 증상:
BitConverter.ToUInt32(buf, offset)(4B) +ToUInt16(buf, offset+4)(2B) = 6B를 읽는데payloadLen >= 6확인이 없다. payload가 6B 미만이면ArgumentOutOfRangeException. - 재현조건:
totalLen = 8(payload 0바이트)인 C_UseItem 패킷. - 근본원인: "패킷ID에 기대한 payload 크기"와 실제 수신 크기를 대조하지 않음.
(E) 배열 인덱스 범위 미검증 → 슬롯 변조/예외 — 보안 (심각)
- 증상:
slots[slot] -= count에서slot이 0..63 범위인지 검사 없음.itemSlot = 0x40000000같은 값이면IndexOutOfRangeException으로 크래시(DoS). (C++ 트윈은 경계 검사가 없어 임의 메모리 쓰기/RCE 였지만, C#은 런타임 경계 검사로 예외가 난다 — 메모리 손상은 막히되 가용성은 깨진다.) 또한count만큼 빼는데 보유량 검사가 없어 음수 수량(아이템 마이너스 보유)도 가능 → 경제 버그. - 재현조건: 임의
itemSlot을 담아 전송. - 근본원인: 신뢰 불가 입력을 배열 인덱스로 직접 사용. 도메인 검증(슬롯 존재/보유 수량) 부재.
(B) 시퀀스 재전송 방어 결함 — 보안/정확성
- 증상:
if (seq < s.lastSeq) return; s.lastSeq = seq;seq == lastSeq(정확히 같은 패킷 재전송)는<가 거짓이라 통과 → 중복 처리(아이템 중복 소비/복제).- 공격자가
seq를 매우 큰 값으로 한 번 보내면lastSeq가 점프 → 이후 정상 패킷이 전부<로 막힘(DoS).
- 근본원인: 단조 증가 가정만 있고 중복(==) 차단, 점프 방어가 없다. TCP면 와이어 재전송은 커널이 처리하므로 앱 레벨 seq의 진짜 목적(중복 행동/리플레이 차단)이 모호.
수정안
private const int HeaderSize = 8;
private const uint kMaxSeqGap = 1024;
public void OnPacket(Session s, byte[] recvBuf, int recvLen)
{
// (A) 헤더 크기 보장
if (recvLen < HeaderSize) { s.Disconnect(); return; }
// 엔디안 명시: 와이어가 LE 이므로 LE 로 읽는다(빅엔디안 머신에서도 안전)
ushort totalLen = BinaryPrimitives.ReadUInt16LittleEndian(recvBuf.AsSpan(0));
ushort packetId = BinaryPrimitives.ReadUInt16LittleEndian(recvBuf.AsSpan(2));
uint seq = BinaryPrimitives.ReadUInt32LittleEndian(recvBuf.AsSpan(4));
// (C) totalLen 정합성: 최소 헤더 이상 && 실제 수신과 일치
if (totalLen < HeaderSize || totalLen != recvLen) { s.Disconnect(); return; }
int payloadLen = totalLen - HeaderSize; // 이제 음수 불가
// (B) 시퀀스: 단조 증가 + 중복(==) 차단 + 비정상 점프 제한
if (seq <= s.lastSeq) return; // <= : 중복/과거 모두 무시
if (seq - s.lastSeq > kMaxSeqGap) { s.Disconnect(); return; } // 점프 방어
s.lastSeq = seq;
switch (packetId)
{
case 1001: HandleUseItem(s, recvBuf, HeaderSize, payloadLen); break;
default: s.Disconnect(); break; // 미지 패킷은 끊는다(정책에 따라 무시)
}
}
private void HandleUseItem(Session s, byte[] buf, int offset, int payloadLen)
{
// (D) payload 크기 검증 (uint 4B + ushort 2B = 6B)
if (payloadLen < 6) { s.Disconnect(); return; }
uint slot = BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(offset));
ushort count = BinaryPrimitives.ReadUInt16LittleEndian(buf.AsSpan(offset + 4));
// (E) 도메인 검증: 인덱스 범위 + 수량 유효성 + 보유량
if (slot >= 64 || count == 0) { s.Disconnect(); return; }
if (s.inv.slots[slot] < count) return; // 보유량 부족 → 거부(음수 방지)
s.inv.slots[slot] -= count;
}
더 나은 설계 — 검증/안티치트 패턴
- 길이 검증의 3계층: ① 수신 루프에서
0 < len <= Max, ② 핸들러 진입에서totalLen == recvLen정합성, ③ 패킷별 기대 payload 크기 대조. 각 단계가 다음 단계의 전제를 보장. - 음수/언더플로 산술 차단: 뺄셈 전
if (totalLen < HeaderSize)검사. C#은 음수, C++은 거대값으로 다르게 깨지므로 언어 무관하게 뺄셈 입력을 먼저 검증. - 인덱스·수량은 항상 도메인 검증: 슬롯 범위, 수량 상한, 보유량/쿨다운/소유권. 클라가 보낸 값은 "요청"일 뿐 권위는 서버에 있다. 결과는 서버가 계산해 다시 통지.
- 재전송/리플레이 방어:
- TCP면 와이어 중복은 커널이 막으므로 앱 seq의 목적은 멱등성(같은 행동의 중복 적용 방지). 중요한 상태변경(거래/사용/구매)은 요청 ID 기반 멱등 처리로 한 번만 적용.
- UDP면 슬라이딩 윈도우 + 비트맵으로 윈도 내 중복을 정밀 차단(단순 lastSeq 비교 불가).
- 엔디안 명시:
BitConverter(호스트 의존) 대신BinaryPrimitives.Read*LittleEndian. Span<byte>기반 파서: 복사 없이 안전한 오프셋 읽기. 가능하면 코드 생성/소스 제너레이터로 패킷별 크기·필드 검증을 자동화.
트레이드오프
- 패킷마다 MAC/CRC는 변조 탐지력↑ 대신 CPU/대역폭 비용. 실시간 다수 패킷엔 부담 → 민감 패킷(거래/결제)만 강검증, 위치 동기화 등은 경량 검증 + 서버 권위 시뮬레이션으로 분리.
- 멱등성 키 저장은 메모리/조회 비용이 있어 TTL·창으로 제한.
면접 포인트
- "클라가 보낸 길이 필드를 어디까지 믿나? 안 믿으면 어떻게 검증하나?" → 전혀 안 믿는다. recvLen과 totalLen 교차검증, 음수/언더플로 차단, 패킷별 크기 대조.
- "재전송 공격으로 아이템이 복제됐다. TCP인데 왜 막혔어야 했나?"
→ 와이어 중복이 아니라 '행동의 중복 적용'. seq
<=차단 + 상태변경 멱등 처리. - "C#에서 클라가 보낸 슬롯 번호를 그대로 배열 인덱스로 쓰면? C++과 결과가 어떻게 다른가?"
→ C#은 경계 검사로
IndexOutOfRangeException(DoS), C++은 OOB 쓰기로 메모리 손상/RCE. 둘 다 도메인 범위 검증으로 막아야 함.
내가 놓친 항목 (복습용)
- [ ] (A) recvLen 미검증 BitConverter → ArgumentOutOfRange + 엔디안 호스트 의존
- [ ] (C) totalLen 신뢰 → 음수 payloadLen + recvLen 교차검증 부재
- [ ] (D) payload 크기 미검증 읽기
- [ ] (E) 슬롯 인덱스 OOB(IndexOutOfRange) / 보유량·수량 도메인 검증 부재
- [ ] (B) seq == 중복 통과 + 점프 방어 부재(재전송/DoS)
해설 — 게임플레이 패킷 검증 (길이/시퀀스/페이로드)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
신뢰할 수 없는 클라 입력을 다루는 게임플레이 핸들러인데,
길이 필드 신뢰로 인한 정수 언더플로/오버리드(C), payload 크기 미검증 구조체 캐스팅(D),
범위 검증 없는 배열 인덱싱(E)으로 메모리 손상 및 임의 메모리 변조가 가능하고,
시퀀스 검사가 재전송 공격을 막지 못한다(B: >= 로직 결함). 결과는 서버 크래시,
인접 메모리 변조, 아이템 복제/수량 위조 같은 경제 사고다. 클라 입력은 전부 적대적이라는
전제에서 모든 필드를 산술 안전하게 검증해야 한다.
문제점
(C) totalLen 신뢰 → 정수 언더플로 + 오버리드 — 보안 (심각)
- 증상:
payloadLen = h->totalLen - sizeof(PacketHeader).totalLen이 8 미만이면size_t(부호 없음) 뺄셈이 거대값으로 언더플로 → 이후 어떤 길이 비교도 무력화,payload에서 수 GB를 읽으려다 오버리드/크래시. - 재현조건: 변조 클라가
totalLen = 0헤더를 보냄. - 근본원인:
totalLen이 실제recvLen과 일치하는지, 최소 헤더 크기 이상인지 검증 안 함. 와이어의 길이 필드를 신뢰해 산술에 바로 사용.
(D) payload 크기 미검증 캐스팅 — 보안
- 증상:
reinterpret_cast<const C_UseItem*>(payload)후req->itemSlot(4B)+req->count(2B)=6B를 읽는데,payloadLen >= sizeof(C_UseItem)확인이 없다. payload가 6B 미만이면 오버리드. - 재현조건:
totalLen = 8(payload 0바이트)인 C_UseItem 패킷. - 근본원인: "패킷ID에 기대한 payload 크기"와 실제 수신 크기를 대조하지 않음. (엔디안/정렬 가정도 잠재 문제.)
(E) 배열 인덱스 범위 미검증 → 임의 메모리 변조 — 보안 (심각)
- 증상:
slots[slot] -= count에서slot이 0..63 범위인지 검사 없음.itemSlot = 0x40000000같은 값이면Inventory객체 밖 임의 주소에 쓰기 → 메모리 손상/RCE 발판. - 재현조건: 임의
itemSlot을 담아 전송. - 근본원인: 신뢰 불가 입력을 배열 인덱스로 직접 사용. 도메인 검증(슬롯 존재/보유 수량) 부재.
또한
count만큼 빼는데 보유량 검사가 없어 음수 수량(언더플로)도 가능 → 경제 버그.
(B) 시퀀스 재전송 방어 결함 — 보안/정확성
- 증상:
if (h->seq < s.lastSeq) return; s.lastSeq = h->seq;seq == lastSeq(정확히 같은 패킷 재전송)는<가 거짓이라 통과 → 중복 처리(아이템 중복 소비/복제).- 공격자가
seq를 매우 큰 값으로 한 번 보내면lastSeq가 점프 → 이후 정상 패킷이 전부<로 막힘(DoS).
- 근본원인: 단조 증가 가정만 있고 중복(==) 차단, 점프 방어가 없다. TCP면 재전송은 커널이 처리하므로 앱 레벨 seq의 진짜 목적(중복 행동/리플레이 차단)이 모호.
(A) 헤더 캐스팅 전 길이 미검증 — 보안
- problem3의 (A)와 동일 계열.
recvLen >= sizeof(PacketHeader)확인 없이 캐스팅. (시나리오상 상위에서 한 패킷으로 잘라 넘겼다 가정하나, 핸들러 자체가 방어적이어야 한다.)
수정안
void OnPacket(Session& s, const uint8_t* recvBuf, size_t recvLen)
{
// (A) 헤더 크기 보장
if (recvLen < sizeof(PacketHeader)) { Disconnect(s); return; }
const PacketHeader* h = reinterpret_cast<const PacketHeader*>(recvBuf);
uint16_t totalLen = le16toh(h->totalLen);
// (C) totalLen 정합성: 최소 헤더 이상 && 실제 수신과 일치
if (totalLen < sizeof(PacketHeader) || totalLen != recvLen) { Disconnect(s); return; }
size_t payloadLen = totalLen - sizeof(PacketHeader); // 이제 언더플로 불가
// (B) 시퀀스: 단조 증가 + 중복(==) 차단 + 비정상 점프 제한
uint32_t seq = le32toh(h->seq);
if (seq <= s.lastSeq) return; // <= : 중복/과거 모두 무시
if (seq - s.lastSeq > kMaxSeqGap) { Disconnect(s); return; } // 점프 방어
s.lastSeq = seq;
const uint8_t* payload = recvBuf + sizeof(PacketHeader);
switch (le16toh(h->packetId)) {
case 1001: HandleUseItem(s, payload, payloadLen); break;
default: Disconnect(s); break; // 미지 패킷은 끊는다(정책에 따라 무시)
}
}
void HandleUseItem(Session& s, const uint8_t* payload, size_t payloadLen)
{
// (D) payload 크기 검증
if (payloadLen < sizeof(C_UseItem)) { Disconnect(s); return; }
const C_UseItem* req = reinterpret_cast<const C_UseItem*>(payload);
uint32_t slot = le32toh(req->itemSlot);
uint16_t count = le16toh(req->count);
// (E) 도메인 검증: 인덱스 범위 + 수량 유효성 + 보유량
if (slot >= 64 || count == 0) { Disconnect(s); return; }
if (s.inv.slots[slot] < count) return; // 보유량 부족 → 거부(언더플로 방지)
s.inv.slots[slot] -= count;
}
더 나은 설계 — 검증/안티치트 패턴
- 길이 검증의 3계층: ① 수신 루프에서
0 < len <= Max(problem1), ② 핸들러 진입에서totalLen == recvLen정합성, ③ 패킷별 기대 payload 크기 대조. 각 단계가 다음 단계의 전제를 보장. - 부호 없는 산술 언더플로를 코드 패턴으로 차단: 뺄셈 전에
if (a < b)검사.a - b를 비교에 직접 쓰지 말 것. 정적분석/UBSan으로 보강. - 인덱스·수량은 항상 도메인 검증: 슬롯 범위, 수량 상한, 보유량/쿨다운/소유권. 클라가 보낸 값은 "요청"일 뿐 권위는 서버에 있다. 결과는 서버가 계산해 다시 통지.
- 재전송/리플레이 방어:
- TCP면 와이어 중복은 커널이 막으므로 앱 seq의 목적은 멱등성(같은 행동의 중복 적용 방지). 중요한 상태변경(거래/사용/구매)은 요청 ID 기반 멱등 처리로 한 번만 적용.
- UDP면 슬라이딩 윈도우 + 비트맵으로 윈도 내 중복을 정밀 차단(단순 lastSeq 비교 불가).
- 인증/세션 키로 MAC(메시지 인증 코드)을 붙여 변조 자체를 탐지하면 가장 견고.
- 미지 packetId 정책 명문화: 끊을지/무시할지/로깅할지. 무한 미지 패킷은 레이트리밋.
트레이드오프
- 패킷마다 MAC/CRC는 변조 탐지력↑ 대신 CPU/대역폭 비용. 실시간 다수 패킷엔 부담 → 민감 패킷(거래/결제)만 강검증, 위치 동기화 등은 경량 검증 + 서버 권위 시뮬레이션으로 분리.
- UDP 윈도우 비트맵은 메모리/복잡도 증가. 윈도 크기는 RTT·재정렬 정도로 튜닝.
면접 포인트
- "클라가 보낸 길이 필드를 어디까지 믿나? 안 믿으면 어떻게 검증하나?" → 전혀 안 믿는다. recvLen과 totalLen 교차검증, 언더플로 차단, 패킷별 크기 대조.
- "재전송 공격으로 아이템이 복제됐다. TCP인데 왜 막혔어야 했나?"
→ 와이어 중복이 아니라 '행동의 중복 적용'. seq
<=차단 + 상태변경 멱등 처리. - "클라가 보낸 슬롯 번호를 그대로 배열 인덱스로 썼다. 무슨 일이 일어날 수 있나?" → OOB 쓰기로 임의 메모리 변조/RCE. 항상 도메인 범위 검증.
내가 놓친 항목 (복습용)
- [ ] (C) totalLen 언더플로(size_t 부호 없음) + recvLen 교차검증 부재
- [ ] (D) payload 크기 미검증 캐스팅
- [ ] (E) 슬롯 인덱스 OOB / 보유량·수량 도메인 검증 부재
- [ ] (B) seq == 중복 통과 + 점프 방어 부재(재전송/DoS)
- [ ] (A) 헤더 길이 미검증 / 엔디안 가정 / 미지 packetId 정책