31. 프레임 무결성 검증(체크섬/CRC) 부재
난이도 상해설 — 프레임 무결성 검증(체크섬/CRC) 부재
난이도: 상
요약
TryReadFrame 은 길이 프리픽스(frameLen)와 msgType, payload 를 그대로 신뢰해 파싱한다. 프레임에 무결성 검증(체크섬/CRC)이 전혀 없어서, 전송 중 손상되거나 조작된 바이트도 "유효한 프레임"으로 통과된다. 특히 (A) 에서 frameLen 자체가 손상되면 실제 프레임 경계와 다른 위치를 "다음 프레임의 시작"으로 해석하게 되어, 이후 스트림 전체의 프레임 동기화가 깨진다(디싱크). 한 번 깨지면 이후 모든 바이트가 무의미한 msgType/payload 로 오파싱되며 스스로 복구되지 않는다.
문제점
(A) 길이/타입/페이로드 무결성 검증 부재 — 손상 프레임을 유효로 처리
- 증상: 전송 중 몇 바이트가 뒤집히거나 중간자가 값을 바꿔도 파싱이 그대로 진행되어, 잘못된
msgType으로 핸들러가 호출되거나 payload 필드가 오해석된 값(예: 자리수가 바뀐 골드량, 잘못된 아이템 ID)으로 처리된다. - 재현 조건: 정상 프레임 스트림 중 임의의 바이트 하나가 손상(전송 계층 오류, 프록시 버그, 또는 의도적 변조)됨.
- 근본 원인: 프레임에 발신자가 계산한 체크섬/CRC 가 없고, 수신 측도 그것을 검증하는 단계가 없다. "TCP 가 체크섬을 갖고 있으니 안전하다"는 가정은 틀렸다 — TCP 체크섬은 약한 오류 검출(16비트)이며 조작에는 전혀 대응하지 못하고, 애플리케이션 레벨 조작(프록시/미들박스/공격자)은 TCP 계층 밖의 문제다.
(A)→(B) 길이 필드 손상 시 프레임 동기화 영구 붕괴
- 증상:
frameLen이 손상되어 실제 값보다 크거나 작아지면,consumed = 4 + frameLen만큼 버퍼를 당기는 지점이 실제 다음 프레임 시작과 어긋난다. 이후 모든TryReadFrame호출이 완전히 엉뚱한 바이트를 길이/타입으로 해석하며 복구되지 않는다(음수/거대frameLen이면Array.Copy인자가 잘못되어 예외/버퍼 오염까지 발생 가능). - 재현 조건: 길이 필드(첫 4바이트)가 포함된 손상.
- 근본 원인: 길이 필드 자체의 신뢰성을 검증할 방법(체크섬)이 없어, 손상된 길이를 "이번 프레임의 진짜 경계"로 그대로 사용한다. 한 번 어긋나면 스트림 전체가 재동기화될 방법이 없다.
수정안 (정확한 코드)
프레임에 CRC32(또는 다른 체크섬)를 추가하고, 검증 실패 시 프레임을 버리되 바이트 단위로 다음 유효 프레임 헤더를 재탐색하는 재동기화 절차를 둔다.
using System;
using System.IO.Hashing; // Crc32 (System.IO.Hashing 패키지) — 또는 자체 CRC32 구현 사용 가능
class FrameReader
{
private byte[] _buf = new byte[64 * 1024];
private int _len;
public void OnBytesReceived(byte[] data, int count)
{
Array.Copy(data, 0, _buf, _len, count);
_len += count;
}
// 프레임 포맷: [4B frameLen][2B msgType][payload][4B crc32(msgType+payload)]
public bool TryReadFrame(out ushort msgType, out ArraySegment<byte> payload)
{
msgType = 0;
payload = default;
while (true)
{
if (_len < 6) return false;
int frameLen = BitConverter.ToInt32(_buf, 0);
// 길이 필드 자체의 상식적 상한/하한 검증 (조작·손상 방어)
const int MaxFrameLen = 32 * 1024;
if (frameLen < 6 || frameLen > MaxFrameLen)
{
// 손상된 길이 — 이 프레임을 신뢰할 수 없으므로 헤더 시작을 1바이트 밀어
// 다음 유효한 프레임 경계를 재탐색(resync)한다.
ResyncOneByte();
continue;
}
int total = 4 + frameLen; // frameLen 안에 crc32(4B) 포함
if (_len < total) return false; // 아직 다 안 옴
ushort type = BitConverter.ToUInt16(_buf, 4);
int payloadLen = frameLen - 2 - 4; // msgType(2) + crc(4) 제외
if (payloadLen < 0)
{
ResyncOneByte();
continue;
}
uint expectedCrc = BitConverter.ToUInt32(_buf, 4 + 2 + payloadLen);
uint actualCrc = Crc32.HashToUInt32(new ReadOnlySpan<byte>(_buf, 4, 2 + payloadLen));
if (actualCrc != expectedCrc)
{
// 무결성 검증 실패 — 프레임 폐기 후 재동기화(다음 프레임 탐색)
ResyncOneByte();
continue;
}
msgType = type;
payload = new ArraySegment<byte>(_buf, 6, payloadLen);
Array.Copy(_buf, total, _buf, 0, _len - total);
_len -= total;
return true;
}
}
// 손상 감지 시 헤더 후보 위치를 1바이트씩 밀며 재동기화를 시도한다.
private void ResyncOneByte()
{
Array.Copy(_buf, 1, _buf, 0, _len - 1);
_len -= 1;
}
}
핵심: ① CRC 로 손상/조작을 검증해 실패 시 프레임을 버린다. ② 길이 필드 자체에 상한/하한을 둬 명백히 조작된 값으로 인한 버퍼 오염을 막는다. ③ 검증 실패 시 스트림을 끊는 대신 1바이트씩 밀며 재동기화해, 이후 정상 프레임은 계속 처리될 수 있게 한다(완전한 재동기화 보장은 아니지만 스트림 전체 마비는 막는다).
더 나은 설계 (+ 트레이드오프)
- 매직 넘버 + CRC 조합: 프레임 시작에 고정 매직 바이트(예:
0xAB 0xCD)를 두면 재동기화 탐색이 "CRC 통과"뿐 아니라 "매직 일치"까지 요구해 오탐(우연히 CRC 가 맞는 손상 프레임)을 크게 줄인다. 대신 프레임당 2바이트 오버헤드. - 전송 계층에서 TLS 사용: TLS 는 AEAD 로 무결성+기밀성을 함께 보장하므로 애플리케이션 레벨 CRC 없이도 변조를 탐지(실패 시 연결 종료)할 수 있다. 다만 손상 시 "프레임 단위 폐기+재동기화"가 아니라 "연결 종료"가 기본 동작이라 스트림 복구 전략이 달라진다.
- 끊고 재연결(fail-fast): 무결성 검증 실패를 심각한 이상 신호로 보고 연결을 즉시 끊는 전략도 있다(특히 조작이 의심되는 경우). 재동기화보다 안전하지만 일시적 전송 오류에도 재연결 비용이 발생.
- 트레이드오프: CRC 는 계산 비용이 적고 우발적 손상에 강하지만 암호학적 위조에는 취약(공격자가 CRC 도 함께 위조 가능) — 진짜 위협 모델이 "악의적 변조"라면 HMAC/TLS 가 필요하다.
면접 포인트
- TCP 체크섬은 우발적 오류의 약한 검출일 뿐, 애플리케이션 레벨 손상/조작을 막지 못한다 — 프레임 자체에 무결성 검증이 필요한 이유.
- 길이 프리픽스 프레이밍에서 길이 필드가 손상되면 스트림 전체 동기화가 깨진다는 것과, 재동기화(resync) 전략의 필요성.
- CRC(무결성) vs HMAC/TLS(무결성+인증) 의 위협 모델 차이 — 우발적 손상만 막을지, 의도적 위조까지 막을지.
해설 — 프레임 무결성 검증(체크섬/CRC) 부재 (C++)
난이도: 상
요약
tryReadFrame 은 frameLen, msgType, payload 를 검증 없이 그대로 memcpy 해 사용한다. 프레임에 무결성 검증(CRC)이 없어, 전송 중 손상되거나 조작된 바이트도 유효한 프레임으로 통과한다. (A) 에서 frameLen 이 손상되면 (B) 의 consumed/memmove 계산이 실제 프레임 경계와 어긋나 이후 스트림 전체의 프레임 동기화가 붕괴한다. C++ 에서는 여기에 더해, 검증되지 않은 frameLen 이 부호 있는 정수(int32_t)라서 음수이거나 매우 큰 값일 때 len_ - consumed 언더플로(부호 없는 size_t 로 변환되며 거대한 값이 됨)와 memmove 범위 초과로 메모리 손상(UB) 까지 이어질 수 있다.
문제점
(A) 무결성 검증 부재 — 손상/조작 프레임을 유효로 처리
- 증상: 임의의 바이트 손상만으로도
msgType/payload 가 다른 값으로 오해석되어 게임 로직이 잘못된 명령을 실행한다. - 재현 조건: 전송 중 프레임의 아무 바이트나 손상.
- 근본 원인: 발신자가 계산한 체크섬이 없고 수신 측도 검증 단계가 없다. TCP 체크섬(16비트)은 우발적 오류의 약한 검출일 뿐 조작에는 무력하다.
(A)→(B) 길이 필드 손상 시 프레임 동기화 붕괴 + 정수 언더플로/OOB
- 증상:
frameLen이 조작되어 음수이거나 실제보다 훨씬 크면,static_cast<size_t>(4 + frameLen)에서 부호 확장/언더플로가 일어나consumed가 거대한 값이 되고,len_ - consumed도 부호 없는 언더플로로 거대한 값이 되어memmove(buf_.data(), buf_.data() + consumed, len_ - consumed)가 버퍼 범위를 벗어난 메모리를 읽고 쓰는 UB(크래시 또는 메모리 손상) 를 유발할 수 있다. - 재현 조건: 길이 필드가 음수 또는 버퍼 크기를 초과하는 값으로 손상/조작됨.
- 근본 원인: 길이 필드의 신뢰성을 검증할 무결성 체크가 없고, 상식적 상한/하한 검사도 없어 손상된 값을 그대로 포인터 산술에 사용한다.
수정안 (정확한 코드)
프레임에 CRC32 를 추가해 검증하고, 길이 필드에 상한/하한을 두며, 검증 실패 시 1바이트씩 밀어 재동기화한다.
#include <cstdint>
#include <cstring>
#include <vector>
// 간단한 CRC32 구현 (실무에서는 zlib/boost 등 검증된 구현 사용 권장)
uint32_t crc32(const uint8_t* data, size_t len) {
uint32_t crc = 0xFFFFFFFFu;
for (size_t i = 0; i < len; ++i) {
crc ^= data[i];
for (int j = 0; j < 8; ++j)
crc = (crc >> 1) ^ (0xEDB88320u & (~(crc & 1) + 1));
}
return ~crc;
}
class FrameReader {
std::vector<uint8_t> buf_ = std::vector<uint8_t>(64 * 1024);
size_t len_ = 0;
static constexpr int32_t kMinFrameLen = 6; // msgType(2)+crc(4) 최소
static constexpr int32_t kMaxFrameLen = 32 * 1024;
public:
void onBytesReceived(const uint8_t* data, size_t count) {
std::memcpy(buf_.data() + len_, data, count);
len_ += count;
}
// 프레임 포맷: [4B frameLen][2B msgType][payload][4B crc32(msgType+payload)]
bool tryReadFrame(uint16_t& msgType, const uint8_t*& payload, size_t& payloadLen) {
for (;;) {
if (len_ < 6) return false;
int32_t frameLen;
std::memcpy(&frameLen, buf_.data(), 4);
// 길이 필드 자체의 상한/하한 검증 — 조작/손상된 값이 포인터 산술로
// 흘러가는 것을 원천 차단
if (frameLen < kMinFrameLen || frameLen > kMaxFrameLen) {
resyncOneByte();
continue;
}
size_t total = 4 + static_cast<size_t>(frameLen);
if (len_ < total) return false; // 아직 다 안 옴
size_t plen = static_cast<size_t>(frameLen) - 2 - 4; // msgType+crc 제외
uint16_t type;
std::memcpy(&type, buf_.data() + 4, 2);
uint32_t expectedCrc;
std::memcpy(&expectedCrc, buf_.data() + 4 + 2 + plen, 4);
uint32_t actualCrc = crc32(buf_.data() + 4, 2 + plen);
if (actualCrc != expectedCrc) {
// 무결성 검증 실패 — 프레임 폐기 후 재동기화
resyncOneByte();
continue;
}
msgType = type;
payload = buf_.data() + 6;
payloadLen = plen;
std::memmove(buf_.data(), buf_.data() + total, len_ - total);
len_ -= total;
return true;
}
}
private:
// 손상 감지 시 헤더 후보 위치를 1바이트씩 밀며 재동기화를 시도한다.
void resyncOneByte() {
if (len_ == 0) return;
std::memmove(buf_.data(), buf_.data() + 1, len_ - 1);
len_ -= 1;
}
};
핵심: ① frameLen 을 포인터 산술에 쓰기 전 명시적 상한/하한 검증으로 언더플로/OOB 를 원천 차단. ② CRC32 로 손상/조작을 검증. ③ 검증 실패 시 스트림을 끊지 않고 1바이트씩 밀며 재동기화해 이후 정상 프레임 처리를 지속.
더 나은 설계 (+ 트레이드오프)
- 매직 넘버 + CRC 조합: 프레임 헤더에 고정 매직 바이트를 추가하면 재동기화 탐색 시 오탐(우연한 CRC 일치)을 크게 줄인다. 오버헤드는 프레임당 몇 바이트.
- TLS 사용: AEAD 로 무결성+기밀성을 함께 보장, 검증 실패 시 연결 종료가 기본 동작이라 애플리케이션 레벨 재동기화 로직 자체가 불필요해진다. 다만 손상 시 "프레임 폐기 후 계속"이 아니라 "연결 종료"이므로 전략이 다르다.
- 부호 없는 길이 타입 + 명시적 상한 상수 사용:
int32_t대신 애초에 검증된 범위의 값만 신뢰하는 타입/래퍼로 파싱하면 실수로 UB 로 이어지는 경로를 줄일 수 있다. - 트레이드오프: CRC 는 계산이 저렴하지만 위조에는 무력(공격자가 CRC 도 함께 조작 가능) — 진짜 위협이 "의도적 변조"라면 HMAC/TLS 급 인증이 필요하다.
면접 포인트
- 신뢰할 수 없는 길이 필드를 포인터 산술/컨테이너 크기 계산에 바로 사용하면 부호 있는/없는 정수 변환 과정에서 언더플로·OOB(UB)로 이어진다 — 반드시 명시적 상한/하한 검증 후 사용.
- TCP 체크섬은 우발적 오류의 약한 검출일 뿐 애플리케이션 레벨 손상/조작을 막지 못한다 — 프레임 자체 무결성 검증이 필요한 이유.
- 길이 프리픽스 프레이밍에서 길이 필드 손상은 스트림 전체 동기화 붕괴로 이어지므로, 검증 실패 시 폐기+재동기화 전략이 필요하다.