1. TCP 길이 프리픽스 패킷 수신 루프
난이도 하내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — TCP 길이 프리픽스 패킷 수신 루프
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
TCP 스트림에서 [2B 길이][payload] 패킷을 잘라내는 전형적인 수신 루프다.
파싱 골격(부분/복수 수신 루프, 잔여 바이트 당기기)은 맞지만,
누적 버퍼 오버플로우, 길이 필드 무검증, 큰 패킷 영구 정지(stuck), 핫패스 복사
네 가지가 실무에서 문제를 일으킨다. 특히 (A)는 외부 입력으로 트리거되는 버퍼 오버런이다.
문제점
(A) 누적 버퍼 오버플로우 — 보안/정확성
- 증상:
Array.Copy(data, 0, _buffer, _writePos, len)에서_writePos + len > 4096이면ArgumentException(혹은 네이티브 버퍼였다면 오버런). - 재현조건: 한 패킷이 4KB에 가깝거나, 짧은 패킷이 연달아 들어와 처리 전에 누적이 4096을 넘을 때. 공격자가 큰 길이의 패킷을 흘려보내면 의도적으로 유발 가능.
- 근본원인: 쓰기 전에
_buffer의 남은 공간(free space)을 검사하지 않는다. 고정 4096 버퍼인데 경계 체크가 없다.
(C)+(B) 길이 필드 무검증 — 보안
- 증상: payload 길이를 그대로 신뢰한다.
payloadLen이 4094보다 크면if (_writePos - readPos < 2 + payloadLen)가 영원히 참 → 패킷이 절대 완성되지 않고, 누적 버퍼는 (A) 때문에 곧 터지거나, 안 터져도 세션이 무한 정지(헤드 오브 라인 블로킹). - 재현조건: 변조 클라이언트가
payloadLen = 60000헤더만 보냄. - 근본원인: 신뢰할 수 없는 길이 필드를 상한(MaxPayload)과 대조하지 않음. "길이 필드는 절대 신뢰하지 말라"는 원칙 위반.
(큰 패킷 stuck) 버퍼보다 큰 정상 패킷 처리 불가 — 정확성
- 설령 검증을 넣어도, 단일 고정 버퍼(4096)는 버퍼보다 큰 정상 패킷(예: 인벤토리 풀 동기화 5KB)을 영원히 못 받는다. 최대 패킷 크기 정책과 버퍼 크기가 명시적으로 묶여 있어야 한다.
(D) 잔여 바이트 memmove — 성능
- 매 콜백마다 남은 바이트를 버퍼 앞으로
Array.Copy로 당긴다. 부분 수신이 잦은 고RPS 환경에서 반복 복사 비용이 누적된다. (정확성 문제는 아니지만 핫패스 낭비.)
수정안
(A)+(B)+(C) 경계 검사 + 길이 상한 검증
private const int MaxPayload = 8192; // 프로토콜이 허용하는 최대 payload
private readonly byte[] _buffer = new byte[2 + MaxPayload];
public bool OnReceive(byte[] data, int len) // false 반환 시 세션 강제 종료
{
// (A) 남은 공간 검사: 쓸 자리가 없으면 비정상
if (_writePos + len > _buffer.Length)
return false; // 누적 버퍼 초과 → 끊는다
Array.Copy(data, 0, _buffer, _writePos, len);
_writePos += len;
int readPos = 0;
while (true)
{
if (_writePos - readPos < 2) break;
ushort payloadLen = (ushort)(_buffer[readPos] | (_buffer[readPos + 1] << 8));
// (B)+(C) 길이 상한 검증 — 신뢰하지 않는다
if (payloadLen > MaxPayload)
return false; // 변조/버그 → 끊는다
if (_writePos - readPos < 2 + payloadLen) break; // 아직 부분 수신
OnPacket(_buffer, readPos + 2, payloadLen);
readPos += 2 + payloadLen;
}
int remain = _writePos - readPos;
if (remain > 0 && readPos > 0)
Array.Copy(_buffer, readPos, _buffer, 0, remain);
_writePos = remain;
return true;
}
(D) 링 버퍼 / 이중 인덱스로 memmove 제거
readPos/writePos를 유지하는 링 버퍼나, "버퍼 거의 다 찼을 때만 당기는"
지연 압축(compact-on-threshold)으로 매 콜백 복사를 없앤다.
더 나은 설계
- 길이 검증 패턴: 헤더를 읽는 즉시
0 < len <= Max범위 검사. 통과 못 하면 파싱을 멈추고 세션을 끊는다(에러 응답조차 신뢰 못 하는 입력엔 사치). 로그/메트릭 적재. - 버퍼 크기 = 헤더 + Max payload로 못박아, "버퍼보다 큰 패킷" 모순을 구조적으로 제거.
Span<byte>/Memory<byte>+ArrayPool: 핫패스에서 슬라이스만 넘기고 복사 최소화.System.IO.Pipelines는 이 패턴(부분/복수 수신, 백프레셔)을 라이브러리 차원에서 해결한다.- 헤더에 길이 외 식별 필드: 길이만으로는 desync 복구가 어렵다 → 매직/타입을 함께 두면 스트림이 어긋났을 때 조기에 탐지 가능.
트레이드오프
- 링 버퍼는 빠르지만 래핑(wrap-around) 구간 처리 코드가 늘어 버그 가능성↑. 대부분은 compact-on-threshold면 충분하다.
- 패킷마다 매직/CRC를 붙이면 대역폭이 늘지만 desync/변조 탐지력이 올라간다.
면접 포인트
- "TCP에서 메시지 경계가 없다는 게 무슨 뜻이고, 길이 프리픽스 외에 경계를 잡는 방법은?" → 구분자(delimiter) 방식 vs 길이 프리픽스 vs 고정 길이. 게임은 보통 길이 프리픽스.
- "길이 필드를 신뢰하면 왜 위험한가? 어떤 공격이 가능한가?" → 거대한 길이로 메모리 고갈/세션 정지, (네이티브라면) 버퍼 오버런으로 RCE까지.
- "한 콜백에 패킷 1.5개가 왔다. 0.5개는 어떻게 보관하나?" → 누적 버퍼에 잔여 바이트 유지, 다음 수신과 합쳐 다시 파싱.
내가 놓친 항목 (복습용)
- [ ] (A) 누적 버퍼 free space 미검사 → 오버런
- [ ] (B)(C) payload 길이 상한 미검증 → 무한 정지/메모리 고갈
- [ ] 버퍼보다 큰 정상 패킷 처리 불가(정책-버퍼 크기 결합)
- [ ] (D) 매 콜백 memmove 비용
해설 · C++
해설 — TCP 길이 프리픽스 패킷 수신 루프
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
TCP 스트림에서 [2B 길이][payload] 패킷을 잘라내는 전형적인 수신 루프다.
파싱 골격(부분/복수 수신 루프, 잔여 바이트 당기기)은 맞지만,
누적 버퍼 오버플로우, 길이 필드 무검증, 엔디안/정렬 가정(reinterpret_cast)
세 가지가 실무에서 문제를 일으킨다. 특히 (A)는 외부 입력으로 트리거되는 버퍼 오버런이고,
(B)는 C++ 특유의 미정의 동작(엔디안 + 정렬 위반)이다.
문제점
(A) 누적 버퍼 오버플로우 — 보안/정확성
- 증상:
std::memcpy(_buffer + _writePos, data, len)에서_writePos + len > 4096이면 스택/멤버 배열 오버런(버퍼 오버플로우 → 인접 메모리 손상, RCE 발판). C#의Array.Copy와 달리memcpy는 경계를 검사하지 않아 조용히 넘어 쓴다. - 재현조건: 한 패킷이 4KB에 가깝거나, 짧은 패킷이 연달아 들어와 처리 전에 누적이 4096을 넘을 때. 공격자가 큰 길이의 패킷을 흘려보내면 의도적으로 유발 가능.
- 근본원인: 쓰기 전에
_buffer의 남은 공간(free space)을 검사하지 않는다. 고정 4096 버퍼인데 경계 체크가 없다.
(B) 엔디안 + 정렬 가정 (reinterpret_cast) — 호환성/UB (핵심)
- 증상:
*reinterpret_cast<const uint16_t*>(_buffer + readPos)는 두 가지 문제가 있다.- 엔디안: 호스트 바이트 순서로 읽는다. 와이어가 빅엔디안(네트워크 바이트오더)인데 x86은 little-endian이라 바이트가 뒤집힌 길이값을 얻는다(예: 0x0010 → 0x1000). 길이 오독 → (C)에서 영원히 미완성 처리 또는 (A)로 오버런.
- 정렬(alignment):
readPos가 홀수면uint16_t*가 비정렬 주소를 가리킨다. x86은 봐주지만 ARM(strict-alignment)에서는 SIGBUS/크래시, 그리고 strict aliasing UB.
- 재현조건: 빅엔디안/이기종 클라, 또는 패킷이 연달아 와 readPos가 홀수 오프셋이 될 때.
- 근본원인: 와이어의 바이트 순서를 호스트 표현과 동일시하고, 임의 정렬 버퍼를 정렬된 타입 포인터로 캐스팅. 멀티바이트 필드는 바이트 단위로 조립해야 한다.
(C)+(B) 길이 필드 무검증 — 보안
- 증상: payload 길이를 그대로 신뢰한다.
payloadLen이 4094보다 크면if (_writePos - readPos < 2 + payloadLen)가 영원히 참 → 패킷이 절대 완성되지 않고, 누적 버퍼는 (A) 때문에 곧 터지거나, 안 터져도 세션이 무한 정지(헤드 오브 라인 블로킹). - 재현조건: 변조 클라이언트가
payloadLen = 60000헤더만 보냄(혹은 (B)의 엔디안 오독으로 우연히 거대값). - 근본원인: 신뢰할 수 없는 길이 필드를 상한(MaxPayload)과 대조하지 않음. "길이 필드는 절대 신뢰하지 말라"는 원칙 위반.
(큰 패킷 stuck) 버퍼보다 큰 정상 패킷 처리 불가 — 정확성
- 설령 검증을 넣어도, 단일 고정 버퍼(4096)는 버퍼보다 큰 정상 패킷(예: 인벤토리 풀 동기화 5KB)을 영원히 못 받는다. 최대 패킷 크기 정책과 버퍼 크기가 명시적으로 묶여 있어야 한다.
(D) 잔여 바이트 memmove — 성능/정확성
- 매 콜백마다 남은 바이트를 버퍼 앞으로
memcpy로 당긴다. 부분 수신이 잦은 고RPS 환경에서 반복 복사 비용이 누적된다. 또 겹치는 영역(_buffer→_buffer)을memcpy로 옮기면 readPos가 작고 remain이 클 때 src/dst가 겹쳐 UB다 →memmove사용해야 한다.
수정안
(A)+(B)+(C) 경계 검사 + 바이트 단위 조립 + 길이 상한
static constexpr int kMaxPayload = 8192; // 프로토콜 허용 최대 payload
// _buffer 크기 = 2 + kMaxPayload 로 못박는다
bool OnReceive(const uint8_t* data, int len) // false 반환 시 세션 강제 종료
{
// (A) 남은 공간 검사: 쓸 자리가 없으면 비정상
if (len < 0 || _writePos + len > (int)sizeof(_buffer))
return false; // 누적 버퍼 초과 → 끊는다
std::memcpy(_buffer + _writePos, data, len);
_writePos += len;
int readPos = 0;
while (true)
{
if (_writePos - readPos < 2) break;
// (B) 바이트 단위로 빅엔디안 조립 — 엔디안/정렬 무관
uint16_t payloadLen =
(uint16_t)((_buffer[readPos] << 8) | _buffer[readPos + 1]);
// (B)+(C) 길이 상한 검증 — 신뢰하지 않는다
if (payloadLen > kMaxPayload)
return false; // 변조/버그 → 끊는다
if (_writePos - readPos < 2 + (int)payloadLen) break; // 아직 부분 수신
OnPacket(_buffer + readPos + 2, payloadLen);
readPos += 2 + payloadLen;
}
int remain = _writePos - readPos;
if (remain > 0 && readPos > 0)
std::memmove(_buffer, _buffer + readPos, remain); // (D) 겹침 안전
_writePos = remain;
return true;
}
- 멀티바이트 필드는
<< 8 | ...로 명시 조립(또는ntohs).reinterpret_cast로 직접 역참조하지 않아 엔디안/정렬에서 자유롭다.
(D) 링 버퍼 / 지연 압축으로 memmove 제거
readPos/writePos를 유지하는 링 버퍼나, "버퍼 거의 다 찼을 때만 당기는"
지연 압축(compact-on-threshold)으로 매 콜백 복사를 없앤다.
더 나은 설계
- 엔디안 정책을 한 곳에:
ntohs/htons또는Get/PutU16BE헬퍼 한 쌍을 전 팀이 공유. 와이어 포맷에 "빅엔디안"을 명세로 못박는다(struct memcpy/캐스팅 금지). - 길이 검증 패턴: 헤더를 읽는 즉시
0 < len <= Max범위 검사. 통과 못 하면 파싱을 멈추고 세션을 끊는다. 로그/메트릭 적재. - 버퍼 크기 = 헤더 + Max payload로 못박아, "버퍼보다 큰 패킷" 모순을 구조적으로 제거.
- 헤더에 길이 외 식별 필드: 길이만으로는 desync 복구가 어렵다 → 매직/타입을 함께 두면 스트림이 어긋났을 때 조기에 탐지 가능.
트레이드오프
- 링 버퍼는 빠르지만 래핑(wrap-around) 구간 처리 코드가 늘어 버그 가능성↑. 대부분은 compact-on-threshold면 충분하다.
- 바이트 단위 조립은 캐스팅보다 약간 느리나, 실제 병목은 네트워크라 이식성/정확성 이득이 압도적.
면접 포인트
- "TCP에서 메시지 경계가 없다는 게 무슨 뜻이고, 길이 프리픽스 외에 경계를 잡는 방법은?" → 구분자(delimiter) 방식 vs 길이 프리픽스 vs 고정 길이. 게임은 보통 길이 프리픽스.
- "
*(uint16_t*)buf로 길이를 읽으면 무슨 문제가 있나?" → 호스트 엔디안 의존(빅엔디안 와이어와 불일치) + 비정렬 접근 시 ARM SIGBUS/strict aliasing UB. 바이트 단위 조립이나ntohs로 우회. - "한 콜백에 패킷 1.5개가 왔다. 0.5개는 어떻게 보관하나?" → 누적 버퍼에 잔여 바이트 유지, 다음 수신과 합쳐 다시 파싱. memmove로 압축(겹침 안전).
내가 놓친 항목 (복습용)
- [ ] (A) 누적 버퍼 free space 미검사 → memcpy 오버런
- [ ] (B) reinterpret_cast 엔디안 + 정렬 가정(빅엔디안 와이어/ARM UB)
- [ ] (C) payload 길이 상한 미검증 → 무한 정지/메모리 고갈
- [ ] 버퍼보다 큰 정상 패킷 처리 불가(정책-버퍼 크기 결합)
- [ ] (D) 겹치는 영역 memcpy(→memmove) + 매 콜백 복사 비용