46. 널 종료 문자열 필드 파싱의 프레임 경계 미준수 (오배열·프레임 오염, C#)
난이도 중해설 — 널 종료 문자열 필드 파싱의 프레임 경계 미준수 (오배열·프레임 오염, C#)
난이도: 중
요약
Array.IndexOf(buffer, 0, offset)가 프레임 끝(offset+frameLen)을 상한으로 두지 않고 세션 수신 링버퍼 전체에서 널 바이트를 찾는다(A). 계정명에 널 종료가 없으면 스캔이 프레임을 넘어 다음 프레임의 바이트에서 0을 만나고, 그 위치를 문자열 끝으로 삼는다. 그러면 계정명이 다음 메시지 내용을 포함해 부풀고, 이어지는 버전 읽기(B)는 완전히 다른 프레임의 바이트를 읽어 파싱이 어긋난다. 링버퍼에 0이 전혀 없으면 IndexOf가 -1을 반환해 nameLen이 음수가 되고 GetString이 ArgumentOutOfRangeException으로 세션을 죽인다.
문제점
- 분류태그: 경계 검증 부재 / 신뢰 경계 침범(untrusted length) / 프레임 경계 오정렬
- 증상: 특정 클라(널 종료 누락)에서 계정명이 비정상적으로 길거나 옆 유저의 데이터가 섞여 보이고, 버전 판정이 틀리며, 최악에는 예외로 세션 강제 종료(DoS).
- 재현조건: 계정명 필드에
\0을 넣지 않고 프레임을 채워 전송. 뒤에 다른 프레임이 쌓여 있으면 정보 유출/오배달, 없으면 예외. - 근본원인: 널 스캔의 상한을 프레임 길이로 제한하지 않음. "널 종료가 반드시 있다"는 신뢰할 수 없는 가정.
수정안
스캔 범위를 프레임 안으로 한정하고, 종료 바이트가 없으면 잘못된 프레임으로 간주해 거부한다.
public static (string account, ushort version) Parse(byte[] buffer, int offset, int frameLen)
{
int end = offset + frameLen;
int nul = Array.IndexOf(buffer, (byte)0, offset, frameLen); // 범위 한정
if (nul < 0) throw new ProtocolException("account not null-terminated");
int nameLen = nul - offset;
if (nameLen > MaxAccountLen) throw new ProtocolException("account too long");
int p = nul + 1;
if (p + 2 > end) throw new ProtocolException("truncated version");
string account = Encoding.UTF8.GetString(buffer, offset, nameLen);
ushort version = (ushort)(buffer[p] | (buffer[p + 1] << 8));
return (account, version);
}
Array.IndexOf(buffer, value, startIndex, count) 오버로드로 count를 프레임 길이로 제한하는 것이 핵심이고, 이어지는 모든 읽기도 end를 상한으로 검증한다.
더 나은 설계
- 바이너리 프로토콜에서 **널 종료 문자열을 피하고 길이 프리픽스(length-prefixed)**를 쓰는 것이 정석이다. 길이를 먼저 읽고
len이 남은 프레임 안인지 검증하면 스캔 자체가 필요 없다. 트레이드오프: 스키마 변경. - 파서를 "남은 바이트 수를 들고 다니는
SpanReader"로 감싸 모든 읽기가 자동으로 경계 검사되게 한다. 트레이드오프: 약간의 추상화 비용.
면접 포인트
- 널 종료 문자열이 바이너리 프로토콜에서 위험한 이유(경계·인젝션·오배열)와 길이 프리픽스의 장점.
- 신뢰할 수 없는 입력을 파싱할 때 "종료 바이트/길이가 항상 유효하다"는 가정이 만드는 취약점 계열(OOB read, 정보 유출, DoS).
해설 — 널 종료 문자열 필드 파싱의 프레임 경계 미준수 (OOB read/UB, C++)
난이도: 중
요약
std::strlen(data)는 프레임 길이 frame_len을 전혀 참조하지 않고 첫 \0이 나올 때까지 메모리를 스캔한다(A). 계정명에 널 종료가 없으면 strlen이 프레임 경계를 넘어 계속 읽어 **버퍼 밖 접근 = 정의되지 않은 동작(UB)**이 되고, 운이 나쁘면 크래시, 좋아도 옆 프레임/힙 데이터를 계정명으로 읽어들인다. 이어지는 버전 읽기 p[0]|p[1]도 프레임 밖을 참조할 수 있다(B).
문제점
- 분류태그: 버퍼 오버리드(OOB read) / UB / 신뢰 경계 침범
- 증상: 널 종료 누락 패킷에서 크래시 또는 인접 메모리 유출(힙 정보 노출), 버전 오판.
- 재현조건: 계정명 필드에
\0없이 프레임을 채워 전송. - 근본원인:
strlen은 상한이 없다. 프레임 길이를 무시하고 종료 바이트를 신뢰.
수정안
strnlen으로 스캔 상한을 프레임 길이로 제한하고, 종료 바이트·후속 필드 길이를 명시 검증한다.
#include <stdexcept>
LoginInfo Parse(const char* data, size_t frame_len) {
size_t name_len = ::strnlen(data, frame_len); // 상한 지정
if (name_len == frame_len) // 널 종료 없음
throw std::runtime_error("account not null-terminated");
if (name_len > kMaxAccountLen)
throw std::runtime_error("account too long");
size_t rest = name_len + 1; // '\0' 소비
if (rest + 2 > frame_len)
throw std::runtime_error("truncated version");
LoginInfo info;
info.account.assign(data, name_len);
const unsigned char* p =
reinterpret_cast<const unsigned char*>(data + rest);
info.version = static_cast<uint16_t>(p[0] | (p[1] << 8));
return info;
}
핵심은 strlen → strnlen(data, frame_len) 교체와, 남은 바이트가 후속 필드를 담을 만큼 있는지 검증하는 것이다.
더 나은 설계
- 바이너리 프로토콜에서는 널 종료 대신 길이 프리픽스 문자열을 사용한다.
read_u16 len → len<=remaining 검증 → assign. 스캔이 사라져 UB 여지가 없다. 트레이드오프: 스키마 변경. const char* + size_t를 매번 들고 다니는 대신 경계 검사를 내장한ByteReader{ptr,end}로 감싸 모든 읽기가 안전하도록 한다.
면접 포인트
strlenvsstrnlen의 차이와, 네트워크 입력에strlen/strcpy류를 쓰면 안 되는 이유.- 데이터 레이스가 아니어도 발생하는 UB(경계 밖 읽기)와 그로 인한 정보 유출(힙 인접 데이터 노출)의 보안적 함의.