MemoryPack 의 리더는 조작한 패킷 하나로 서버 프로세스를 끝낼 수 있다
게임 서버는 클라이언트가 보낸 패킷을 MemoryPackSerializer.Deserialize 로 풉니다. 클라이언트는 사용자 손에 있으니, 누군가 바이트를 조작해 보낼 수 있습니다.
MemoryPack 의 리더(받은 바이트를 객체로 되돌리는 쪽)는 받은 바이트가 정상이라고 믿고 읽습니다. 입력을 제한하는 옵션도 없습니다. 그래서 라이브러리 안쪽을 모른 채 그대로 쓰면, 조작한 패킷 하나로 서버 프로세스가 꺼질 수 있습니다. 이 글은 어떤 조작이 무슨 문제를 일으키는지, 각각을 어떻게 막는지를 정리합니다. 기준 판본은 MemoryPack 1.21.4, .NET 10 입니다.
한눈에 보는 문제와 예방책
| 문제 | 서버에 생기는 일 | try/catch 로 막히나 |
예방책 |
|---|---|---|---|
| 1. 문자열 길이를 음수로 조작 | 프로세스 종료 (Windows, Linux) | 안 막힌다 | 문자열 멤버에 [SafeString] |
| 2. 자기 자신을 품는 타입을 깊게 중첩 | 프로세스 종료 (Windows, Linux, macOS) | 안 막힌다 | 자기 참조 타입을 빼거나 [NodeDepthLimit] |
| 3. 배열·리스트 길이를 크게 | 입력의 4~8배 메모리를 잡은 뒤 예외 | 막힌다 | 수신 크기 상한 |
| 4. 배열·리스트 길이를 음수로 | .NET 기본 예외 | catch (Exception) 이면 막힌다 |
catch (Exception) 으로 받는다 |
1, 2 는 예외가 아니라 운영체제가 프로세스를 끝내는 것이라 catch 가 실행되지 않습니다. 프로세스가 꺼지면 접속해 있던 모든 사용자가 함께 끊깁니다. 그래서 이 둘은 리더에 닿기 전에 막아야 합니다.
가장 확실한 예방책은 따로 있습니다. 클라이언트 → 서버 패킷은 MemoryPack 으로 읽지 않고, 입력을 검사하는 형식(MessagePack, Protobuf 등)으로 받는 것입니다. 아래 예방책은 그럴 수 없을 때 MemoryPack 을 안전하게 쓰는 방법입니다.
문제 1. 문자열 길이를 음수로 적으면 프로세스가 꺼진다
무슨 일이 생기나
MemoryPack 은 문자열 앞에 “본문이 몇 바이트인가” 를 적습니다. 리더는 그만큼이 남아 있는지 확인한 뒤 읽습니다. 그래서 길이를 그냥 크게 적은 패킷은 이 확인에 걸려 예외로 끝납니다.
문제는 길이를 음수로 적을 때입니다. 리더는 적힌 값을 비트 반전해 본문 바이트 수를 얻고, 거기에 4 를 더해 “이만큼 남았나” 를 확인합니다. 이 더하기에 넘침 검사가 없어서, 반전한 값이 21억이면 4 를 더한 값이 음수로 감깁니다.
| 확인에 쓰는 숫자 | 확인 결과 | 실제로 읽는 길이 | |
|---|---|---|---|
정상 문자열 "ABCD" |
8 | 남은 8 B 이하라 통과 | 4 B |
| 본문 길이를 1,000,000 으로 크게 적은 문자열 | 1,000,004 | 남은 바이트보다 커서 예외 (Sequence reached end) |
읽지 않는다 |
| 길이를 음수로 조작한 문자열 | -2,147,483,645 | 음수라 통과 | 2,147,483,647 B |
확인은 음수로 통과하고, 읽기는 21억 바이트로 합니다. 버퍼에는 본문 몇 바이트뿐이니, 리더는 그 뒤의 메모리를 계속 읽어 나갑니다.
왜 try/catch 로 못 막나
버퍼 뒤를 읽어 나가다가 매핑이 없는 페이지에 닿으면 CPU 가 페이지 폴트를 내고, 운영체제는 이것을 잘못된 접근으로 판정해 프로세스에 알립니다(Windows 접근 위반 0xC0000005, Linux 와 macOS 는 SIGSEGV). .NET 런타임은 이것을 잡을 수 있는 예외로 바꾸지 않고 프로세스를 끝냅니다. 메모리를 이미 잘못 읽은 상태로 계속 도는 것이 더 위험하기 때문입니다. 페이지와 매핑은 가상 메모리와 페이지 폴트 에서 다뤘습니다.
| 환경 | 결과 | catch 에 잡혔나 |
|---|---|---|
| Windows | 프로세스 종료 (0xC0000005) |
안 잡힘 |
| Linux | 프로세스 종료 (Fatal error.) |
안 잡힘 |
| macOS | OutOfMemoryException |
잡힘 |
macOS 에서는 매핑이 없는 페이지에 닿기 전에, 21억 바이트 문자열을 만들 메모리가 없다는 예외가 먼저 났습니다. 버퍼 밖을 읽으려는 동작은 같으므로 안전하다고 볼 수 없습니다.
문자열 멤버는 거의 모든 패킷에 있습니다. 문자열이 하나라도 있는 패킷 타입이면 이 문제에 해당하고, 패킷이 작아서 크기 상한으로도 막을 수 없습니다.
예방책: 문자열 멤버에 [SafeString] 을 붙인다
MemoryPack 은 멤버마다 읽고 쓰는 방법을 바꿔 끼울 수 있습니다. 문자열 멤버에 길이를 먼저 검사하는 포매터를 붙입니다.
[MemoryPackable]
public partial class ChatPacket
{
public int RoomId { get; set; }
[SafeString] public string? Message { get; set; }
}
// 문자열 헤더를 먼저 엿보고, 적힌 바이트 수가 남은 바이트보다 크면 읽지 않는다
public sealed class SafeStringFormatter : MemoryPackFormatter<string>
{
public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref string? value)
{
writer.WriteString(value);
}
public override void Deserialize(ref MemoryPackReader reader, scoped ref string? value)
{
// UTF-8 문자열의 헤더는 바이트 수를 비트 반전한 값이라 -1 보다 작다
if (reader.TryPeekCollectionHeader(out int header) && header < -1)
{
long utf8Length = ~header; // long 으로 받아 넘치지 않게 한다
long need = 4 + 4 + utf8Length; // 헤더 4 B + 글자 수 4 B + 본문
if (need > reader.Remaining)
throw new MemoryPackSerializationException($"문자열 헤더가 남은 바이트보다 크다: {utf8Length:N0} B");
}
value = reader.ReadString();
}
}
public sealed class SafeStringAttribute : MemoryPackCustomFormatterAttribute<SafeStringFormatter, string>
{
static readonly SafeStringFormatter formatter = new();
public override SafeStringFormatter GetFormatter() => formatter;
}
리더가 int 로 하던 계산을 long 으로 합니다. 21억에 8 을 더해도 넘치지 않으니 “남은 바이트보다 크다” 는 확인이 제대로 걸리고, 버퍼 밖을 읽기 전에 예외를 던집니다. 예외가 되면 받는 코드의 catch 가 받습니다.
| 입력 | Windows 막기 전 | Linux 막기 전 | 막은 뒤 (둘 다 같음) |
|---|---|---|---|
| 정상 패킷 | 정상으로 읽음 (17 B) | 정상으로 읽음 (17 B) | 정상으로 읽음 (17 B). 바이트 모양이 그대로다 |
| 길이를 음수로 조작한 문자열 | 죽는다 (0xC0000005) |
죽는다 (Fatal error.) |
산다. MemoryPackSerializationException 이 잡힘 |
한계. 생성된 코드는 특성을 붙인 멤버를 읽을 때만 이 포매터를 부릅니다. 특성이 빠진 문자열 멤버, List<string> 안의 문자열, 타입 없이 바로 부르는 Deserialize<string> 은 그대로 뚫립니다.
문제 2. 자기 자신을 품는 타입을 깊게 중첩하면 프로세스가 꺼진다
무슨 일이 생기나
트리나 연결 리스트처럼 자기 자신을 멤버로 품는 타입이 있습니다.
[MemoryPackable]
public partial class Node
{
public Node? Next { get; set; }
}
MemoryPack 은 쓸 때는 깊이 한도(1,000)가 있지만, 읽을 때는 한도가 없습니다. 객체 하나는 머리 1 B 라서, 공격자는 “객체 안에 객체” 를 수만 겹 적은 바이트를 수십 KB 로 만들 수 있습니다. 리더는 Next 를 읽을 때마다 읽기 함수를 한 번 더 부르고, 호출마다 스레드의 스택이 쌓입니다. 안쪽 호출이 끝나지 않으니 스택은 쌓이기만 하다가 끝에 닿습니다.
| 입력 | Windows | Linux | macOS |
|---|---|---|---|
| 깊이 10,000 (10 KB) | 종료 (0xC00000FD) |
정상으로 읽음 | 종료 (Stack overflow.) |
| 깊이 100,000 (100 KB) | 종료 (0xC00000FD) |
종료 (Stack overflow.) |
종료 (Stack overflow.) |
몇 KB 에서 죽는지는 스레드의 스택 크기에 따라 다르고, 입력을 늘리면 결국 죽습니다.
왜 try/catch 로 못 막나
운영체제는 스택 끝에 접근이 막힌 가드 페이지를 둡니다. 스택이 이 페이지를 건드리면 문제 1 과 같이 페이지 폴트가 나고 잘못된 접근으로 판정됩니다. 예외를 던지고 catch 를 실행하는 것 자체가 스택을 써야 하는데 그 스택이 이미 없으므로, .NET 은 스택 넘침을 예외로 만들지 않고 프로세스를 끝냅니다.
다른 직렬화기는 같은 입력에 죽지 않습니다. 읽으면서 깊이를 세다가 한도를 넘으면 스택이 넘치기 전에 스스로 예외를 던지기 때문입니다.
| 직렬화기 | 깊이 100,000 입력 | 프로세스 |
|---|---|---|
| MemoryPack 1.21.4 | 한도 없음. 스택이 넘친다 | 죽는다 |
| System.Text.Json | JsonException: 깊이 64 를 넘음 |
산다 |
| Newtonsoft.Json 13.0.3 | JsonReaderException: MaxDepth 64 를 넘음 |
산다 |
| MessagePack 3.1.9 | MessagePackSerializationException (기본 한도 500) |
산다 |
| Google.Protobuf 3.28.3 | InvalidProtocolBufferException: 중첩이 너무 깊음 |
산다 |
예방책 1: 클라이언트가 보내는 패킷에서 자기 참조 타입을 뺀다
가장 확실한 방법입니다. 재귀할 멤버가 없으면 깊이도 없습니다. 트리는 각 노드가 부모 번호를 든 평평한 배열로 바꿔 보냅니다.
예방책 2: 뺄 수 없으면 [NodeDepthLimit] 을 붙인다
다른 직렬화기가 안에서 하는 깊이 세기를 포매터로 대신 합니다.
[MemoryPackable]
public partial class Node
{
[NodeDepthLimit] public Node? Next { get; set; }
}
// 읽는 깊이를 세다가 한도를 넘으면 스택이 넘치기 전에 예외를 던진다
public sealed class NodeDepthLimitFormatter : MemoryPackFormatter<GuardedNode>
{
const int MaxDepth = 64;
[ThreadStatic] static int depth;
public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref GuardedNode? value)
{
writer.WriteValue(value);
}
public override void Deserialize(ref MemoryPackReader reader, scoped ref GuardedNode? value)
{
if (depth >= MaxDepth)
throw new MemoryPackSerializationException($"중첩 깊이가 {MaxDepth} 을 넘었다");
depth++;
try
{
reader.ReadValue(ref value);
}
finally
{
depth--;
}
}
}
public sealed class NodeDepthLimitAttribute : MemoryPackCustomFormatterAttribute<NodeDepthLimitFormatter, GuardedNode>
{
static readonly NodeDepthLimitFormatter formatter = new();
public override NodeDepthLimitFormatter GetFormatter() => formatter;
}
| 입력 | Windows 막기 전 | Linux 막기 전 | 막은 뒤 (둘 다 같음) |
|---|---|---|---|
| 깊이 10,000 | 죽는다 (0xC00000FD) |
산다 | 산다. 깊이 64 에서 예외가 잡힘 |
| 깊이 100,000 | 죽는다 (0xC00000FD) |
죽는다 (Stack overflow.) |
산다. 깊이 64 에서 예외가 잡힘 |
테스트 코드에서는 이 포매터를 GuardedNode 타입에 맞춰 두었습니다. 실제로 쓸 때는 자기 타입 이름으로 바꿉니다. A → B → A 처럼 두 타입이 서로 품는 경우에는 그 경로의 멤버마다 붙여야 합니다.
문제 3. 배열·리스트 길이를 크게 적으면 메모리를 먼저 잡는다
무슨 일이 생기나
프로세스는 살지만 메모리를 낭비합니다. 배열과 리스트 앞의 4 B 길이는 “원소가 N 개면 적어도 N 바이트는 남았겠지” 만 확인합니다. 원소 하나가 몇 바이트인지는 보지 않습니다. 그래서 길이 100,000 에 본문 100,000 B 를 붙인 입력이 확인을 통과하고, 타입에 따라 메모리를 먼저 잡습니다.
| 타입 | 리더가 하는 순서 | 할당 | 결과 |
|---|---|---|---|
int[] |
본문 400,000 B 가 있는지 먼저 확인 | 1,816 B | Sequence reached end |
List<int> |
100,000 칸 리스트를 먼저 만든다 | 401,990 B | Sequence reached end |
string[] |
원소 크기를 모르니 100,000 칸 배열을 먼저 만든다 | 801,336 B | Sequence reached end |
결국 예외로 끝나지만, 그 전에 입력의 4~8배를 잡습니다. 리스트가 겹쳐 있으면 겹마다 더해집니다.
| 입력 (모두 100 KB 남짓) | 할당 | 결과 |
|---|---|---|
List<int> 길이 25,000 (정상 데이터) |
100,160 B | 정상 |
List<List<int>> 길이 25,000 두 겹 |
302,048 B | Sequence reached end |
List<List<List<int>>> 길이 25,000 세 겹 |
502,104 B | Sequence reached end |
다차원 배열은 각 차원의 길이를 믿고 먼저 만듭니다. 2000×2000 을 적은 13 B 를 int[,] 로 읽게 하자 16,008,624 B 를 잡은 뒤에야 실패했습니다.
예방책: 메시지 종류마다 수신 크기 상한을 둔다
길이를 크게 적은 패킷은 결국 예외로 끝나니, 할당을 줄이는 것이 목표입니다. 메시지 종류마다 수신 크기 상한을 정하고, 넘는 패킷은 Deserialize 전에 버립니다. 클라이언트가 보내는 패킷 타입에는 겹친 컬렉션과 다차원 배열을 넣지 않습니다.
문제 4. 음수 길이는 MemoryPack 예외가 아니다
무슨 일이 생기나
음수 길이는 확인을 그냥 지나갑니다. -1 만 null 로 다루고, -5 는 “남은 바이트 < -5” 가 거짓이라 통과해 .NET 의 배열 생성 코드가 막습니다. int[] 는 OverflowException, List<int> 는 ArgumentOutOfRangeException 이 났습니다. MemoryPack 의 예외가 아니니 catch (MemoryPackSerializationException) 으로는 잡히지 않습니다.
예방책: catch (Exception) 으로 받는다
역직렬화 실패를 잘못된 입력으로 다루는 자리에서는 예외 타입을 가리지 않고 받습니다. MemoryPack 예외만 잡은 수신 루프는 잘못된 패킷 하나에 루프 전체가 끝납니다.
예방책을 모은 받는 코드
문제 3, 4 의 예방책은 받는 코드 한 곳에 모읍니다. 문제 1, 2 는 패킷 타입의 멤버에 붙인 포매터가 예외로 바꿔 주므로, 이 catch 가 함께 받습니다.
public static class PacketReceiver
{
const int MaxPacketSize = 4 * 1024; // 메시지 종류마다 정한 상한
public static bool TryRead<T>(ReadOnlySpan<byte> packet, out T? message)
{
message = default;
// 1. 크기 제한: 길이 조작으로 큰 할당을 부르는 입력을 줄인다
if (packet.Length > MaxPacketSize)
return false;
try
{
message = MemoryPackSerializer.Deserialize<T>(packet);
return message is not null;
}
catch (Exception) // 2. MemoryPack 예외만이 아니라 .NET 기본 예외도 받는다
{
return false; // 잘못된 패킷은 버리거나 연결을 끊는다
}
}
}
| 문제 | 어디서 막나 |
|---|---|
| 1. 문자열 길이를 음수로 조작 | 문자열 멤버의 [SafeString] 이 예외로 바꾸고, 이 catch 가 받는다 |
| 2. 깊은 중첩 | 자기 참조 타입을 빼거나, [NodeDepthLimit] 이 예외로 바꾸고 이 catch 가 받는다 |
| 3. 큰 길이로 큰 할당 | 크기 상한으로 줄어든다 |
| 4. 음수 길이, 잘린 패킷 | 이 catch (Exception) 이 받는다 |
받는 코드와 포매터를 함께 써도 빠뜨린 멤버는 뚫립니다. 그래서 형식을 고를 수 있다면 방향에 따라 나누는 쪽이 먼저입니다.
| 방향 | 형식 |
|---|---|
| 서버 → 클라이언트, 서버 ↔ 서버, 저장 파일 | MemoryPack (보내는 쪽을 믿을 수 있다) |
| 클라이언트 → 서버 | 입력을 검사하는 형식 (MessagePack, Protobuf 등) |
직접 확인하는 테스트 코드
프로세스가 꺼지는 경우가 있어서, 경우마다 같은 실행 파일을 자식 프로세스로 띄우고 종료 코드를 봅니다. 종료 코드가 0 이면 산 것이고, 아니면 죽은 것입니다. MemoryPack 을 참조한 콘솔 프로젝트에 두 파일로 넣으면 Windows, Linux, macOS 에서 그대로 실행됩니다.
Program.cs
// 자식 프로세스로 다시 불리면 그 경우 하나만 실행한다
if (ReaderTest.IsChild(args))
{
ReaderTest.RunChild(args[1]);
return;
}
ReaderTest.Run();
ReaderTest.cs
// 리더 실험: 조작한 바이트를 Deserialize 했을 때 프로세스가 사는지 죽는지
// 프로세스가 꺼지는 경우가 있어서, 경우마다 같은 실행 파일을 자식 프로세스로 띄워 종료 코드를 본다.
using System.Diagnostics;
using System.Text;
using MemoryPack;
public static class ReaderTest
{
const string ChildFlag = "reader-child";
// (이름, 자식에게 넘길 경우 이름)
static readonly (string Name, string Case)[] Cases =
{
("0. 정상 패킷", "normal"),
("1. 중간에서 자른 패킷", "cut"),
("2. 배열 길이를 int.MaxValue 로", "huge"),
("3. 리스트 길이를 100,000 으로 (본문은 100,000 B 뿐)", "large"),
("4. 배열 길이를 -5 로", "negative"),
("5. 문자열 길이 자리를 int.MinValue 로", "string"),
("6. 자기 자신을 품는 타입, 깊이 10,000", "depth10000"),
("7. 자기 자신을 품는 타입, 깊이 100,000", "depth100000"),
("8. [막은 뒤] 정상 패킷", "guard-normal"),
("9. [막은 뒤] 문자열 길이 자리를 int.MinValue 로", "guard-string"),
("10. [막은 뒤] 깊이 10,000", "guard-depth10000"),
("11. [막은 뒤] 깊이 100,000", "guard-depth100000"),
};
public static bool IsChild(string[] args) => args.Length >= 2 && args[0] == ChildFlag;
// 부모: 경우마다 자식 프로세스를 띄우고 결과를 모은다
public static void Run()
{
var log = new StringBuilder();
void Log(string s) { Console.WriteLine(s); log.AppendLine(s); }
Log("==== 조작한 바이트를 Deserialize 하면 (try/catch 로 감싼 상태) ====");
Log($"OS: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}");
Log($"런타임: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}");
foreach (var (name, c) in Cases)
{
var (exitCode, stdout, stderr) = RunChildProcess(c);
string verdict = exitCode == 0 ? "산다" : "죽는다";
Log("");
Log($"[{name}]");
Log($" 프로세스: {verdict} (종료 코드 {exitCode}, 0x{exitCode:X8})");
if (stdout.Length > 0) Log($" 자식 출력: {stdout}");
if (stderr.Length > 0) Log($" 자식 오류: {stderr}");
}
File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "../../../reader-result.txt"), log.ToString());
}
static (int ExitCode, string Stdout, string Stderr) RunChildProcess(string c)
{
string exe = Environment.ProcessPath!;
var psi = new ProcessStartInfo(exe)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
};
// dotnet 으로 실행 중이면 dll 경로를 먼저 넘긴다
if (Path.GetFileNameWithoutExtension(exe) == "dotnet")
psi.ArgumentList.Add(typeof(ReaderTest).Assembly.Location);
psi.ArgumentList.Add(ChildFlag);
psi.ArgumentList.Add(c);
using var p = Process.Start(psi)!;
var stdout = p.StandardOutput.ReadToEndAsync();
var stderr = p.StandardError.ReadToEndAsync();
p.WaitForExit();
// 오류 출력은 첫 줄만 (스택 넘침은 수만 줄이 나온다)
string firstError = stderr.Result.Split('\n').FirstOrDefault(l => l.Trim().Length > 0)?.Trim() ?? "";
return (p.ExitCode, stdout.Result.Trim(), firstError);
}
// 자식: 조작한 바이트 하나를 try/catch 안에서 Deserialize 한다
public static void RunChild(string c)
{
try
{
switch (c)
{
case "normal":
{
byte[] input = MemoryPackSerializer.Serialize(new ReaderPerson { Age = 40, Name = "hoge" });
var p = MemoryPackSerializer.Deserialize<ReaderPerson>(input)!;
Console.WriteLine($"정상으로 읽음: Age={p.Age}, Name={p.Name} ({input.Length} B)");
break;
}
case "cut":
{
byte[] full = MemoryPackSerializer.Serialize(new ReaderPerson { Age = 40, Name = "hoge" });
byte[] input = full.AsSpan(0, 10).ToArray(); // 17 B 중 앞 10 B 만
MemoryPackSerializer.Deserialize<ReaderPerson>(input);
Console.WriteLine("정상으로 읽음");
break;
}
case "huge":
{
// 길이 0x7FFFFFFF, 본문 없음
byte[] input = { 0xFF, 0xFF, 0xFF, 0x7F };
MemoryPackSerializer.Deserialize<int[]>(input);
Console.WriteLine("정상으로 읽음");
break;
}
case "large":
{
// 길이 100,000 + 본문 100,000 B. int 100,000개면 400,000 B 가 있어야 한다
var input = new byte[4 + 100_000];
BitConverter.TryWriteBytes(input, 100_000);
long before = GC.GetAllocatedBytesForCurrentThread();
try
{
MemoryPackSerializer.Deserialize<List<int>>(input);
}
finally
{
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Console.Write($"입력 {input.Length:N0} B 를 읽는 동안 {allocated:N0} B 할당. ");
}
Console.WriteLine("정상으로 읽음");
break;
}
case "negative":
{
byte[] input = { 0xFB, 0xFF, 0xFF, 0xFF }; // -5
MemoryPackSerializer.Deserialize<int[]>(input);
Console.WriteLine("정상으로 읽음");
break;
}
case "string":
{
// 문자열 머리 4 B = int.MinValue, 그 뒤 8 B
byte[] input = { 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x41, 0x42, 0x43, 0x44 };
MemoryPackSerializer.Deserialize<string>(input);
Console.WriteLine("정상으로 읽음");
break;
}
case "depth10000":
case "depth100000":
{
int depth = c == "depth10000" ? 10_000 : 100_000;
var input = new byte[depth + 1];
Array.Fill(input, (byte)0x01, 0, depth); // 01 = 멤버 1개인 ReaderNode
input[depth] = 0xFF; // FF = null
// 서버의 패킷 처리처럼 스레드 풀 스레드에서 읽는다
Task.Run(() => MemoryPackSerializer.Deserialize<ReaderNode>(input)).GetAwaiter().GetResult();
Console.WriteLine($"정상으로 읽음 (입력 {input.Length:N0} B)");
break;
}
case "guard-normal":
{
byte[] input = MemoryPackSerializer.Serialize(new GuardedPerson { Age = 40, Name = "hoge" });
var p = MemoryPackSerializer.Deserialize<GuardedPerson>(input)!;
Console.WriteLine($"정상으로 읽음: Age={p.Age}, Name={p.Name} ({input.Length} B)");
break;
}
case "guard-string":
{
// 멤버 수 02, Age 4 B, 그 뒤가 5번과 같은 문자열 12 B
byte[] input =
{
0x02,
0x28, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x41, 0x42, 0x43, 0x44,
};
MemoryPackSerializer.Deserialize<GuardedPerson>(input);
Console.WriteLine("정상으로 읽음");
break;
}
case "guard-depth10000":
case "guard-depth100000":
{
int depth = c == "guard-depth10000" ? 10_000 : 100_000;
var input = new byte[depth + 1];
Array.Fill(input, (byte)0x01, 0, depth);
input[depth] = 0xFF;
Task.Run(() => MemoryPackSerializer.Deserialize<GuardedNode>(input)).GetAwaiter().GetResult();
Console.WriteLine($"정상으로 읽음 (입력 {input.Length:N0} B)");
break;
}
}
}
catch (Exception e)
{
Console.WriteLine($"예외로 끝남, catch 로 잡힘: {e.GetType().Name}: {e.Message}");
}
}
}
[MemoryPackable]
public partial class ReaderPerson
{
public int Age { get; set; }
public string? Name { get; set; }
}
[MemoryPackable]
public partial class ReaderNode
{
public ReaderNode? Next { get; set; }
}
// ---- 막는 방법: 멤버에 붙이는 커스텀 포매터 ----
[MemoryPackable]
public partial class GuardedPerson
{
public int Age { get; set; }
[SafeString] public string? Name { get; set; }
}
[MemoryPackable]
public partial class GuardedNode
{
[NodeDepthLimit] public GuardedNode? Next { get; set; }
}
// 문자열 헤더를 먼저 엿보고, 적힌 바이트 수가 남은 바이트보다 크면 읽지 않는다
public sealed class SafeStringFormatter : MemoryPackFormatter<string>
{
public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref string? value)
{
writer.WriteString(value);
}
public override void Deserialize(ref MemoryPackReader reader, scoped ref string? value)
{
// UTF-8 문자열의 헤더는 바이트 수를 비트 반전한 값이라 -1 보다 작다
if (reader.TryPeekCollectionHeader(out int header) && header < -1)
{
long utf8Length = ~header; // long 으로 받아 넘치지 않게 한다
long need = 4 + 4 + utf8Length; // 헤더 4 B + 글자 수 4 B + 본문
if (need > reader.Remaining)
throw new MemoryPackSerializationException($"문자열 헤더가 남은 바이트보다 크다: {utf8Length:N0} B");
}
value = reader.ReadString();
}
}
public sealed class SafeStringAttribute : MemoryPackCustomFormatterAttribute<SafeStringFormatter, string>
{
static readonly SafeStringFormatter formatter = new();
public override SafeStringFormatter GetFormatter() => formatter;
}
// 읽는 깊이를 세다가 한도를 넘으면 스택이 넘치기 전에 예외를 던진다
public sealed class NodeDepthLimitFormatter : MemoryPackFormatter<GuardedNode>
{
const int MaxDepth = 64;
[ThreadStatic] static int depth;
public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref GuardedNode? value)
{
writer.WriteValue(value);
}
public override void Deserialize(ref MemoryPackReader reader, scoped ref GuardedNode? value)
{
if (depth >= MaxDepth)
throw new MemoryPackSerializationException($"중첩 깊이가 {MaxDepth} 을 넘었다");
depth++;
try
{
reader.ReadValue(ref value);
}
finally
{
depth--;
}
}
}
public sealed class NodeDepthLimitAttribute : MemoryPackCustomFormatterAttribute<NodeDepthLimitFormatter, GuardedNode>
{
static readonly NodeDepthLimitFormatter formatter = new();
public override NodeDepthLimitFormatter GetFormatter() => formatter;
}
Windows, Linux, macOS 의 .NET 10 에서 돌린 결과입니다.
| 경우 | Windows | Linux | macOS |
|---|---|---|---|
| 0. 정상 패킷 | 산다 | 산다 | 산다 |
| 1. 중간에서 자른 패킷 | 산다. Sequence reached end 가 잡힘 |
같음 | 같음 |
2. 배열 길이를 int.MaxValue 로 |
산다. Length header size is larger than buffer size 가 잡힘 |
같음 | 같음 |
| 3. 리스트 길이를 100,000 으로 | 산다. 441,344 B 할당 후 예외가 잡힘 | 산다. 417,888 B 할당 | 산다. 444,184 B 할당 |
| 4. 배열 길이를 -5 로 | 산다. OverflowException 이 잡힘 |
같음 | 같음 |
5. 문자열 길이 자리를 int.MinValue 로 |
죽는다 (0xC0000005, Fatal error.) |
죽는다 (종료 코드 134, Fatal error.) |
산다. OutOfMemoryException 이 잡힘 |
| 6. 깊이 10,000 | 죽는다 (0xC00000FD) |
산다. 정상으로 읽음 | 죽는다 (Stack overflow.) |
| 7. 깊이 100,000 | 죽는다 (0xC00000FD) |
죽는다 (Stack overflow.) |
죽는다 (Stack overflow.) |
| 8. [막은 뒤] 정상 패킷 | 산다. 정상으로 읽음 | 산다. 정상으로 읽음 | 안 돌림 |
| 9. [막은 뒤] 문자열 길이를 음수로 조작 | 산다. MemoryPackSerializationException 이 잡힘 |
같음 | 안 돌림 |
| 10. [막은 뒤] 깊이 10,000 | 산다. 깊이 64 에서 예외가 잡힘 | 같음 | 안 돌림 |
| 11. [막은 뒤] 깊이 100,000 | 산다. 깊이 64 에서 예외가 잡힘 | 같음 | 안 돌림 |
0~4번은 세 환경 모두 예외로 끝나 catch 가 받았습니다. 5번은 Windows 와 Linux 에서 죽고 macOS 에서만 예외로 끝났습니다. 깊은 중첩은 세 환경 모두 죽었고, 죽기 시작하는 깊이만 달랐습니다. 3번의 할당량은 자식 프로세스의 첫 호출을 잰 값이라 처음 한 번의 준비 비용이 섞여 있습니다.
8번부터 11번까지는 [SafeString], [NodeDepthLimit] 을 붙인 타입에 5, 6, 7번과 같은 입력을 넣은 것입니다. 막기 전에 프로세스가 꺼지던 입력이 모두 예외로 바뀌어 catch 에 잡혔고, 프로세스는 살았습니다.
주의할 점
1. 포매터 특성은 빠뜨린 멤버를 지켜 주지 않는다
안 지키면 생기는 문제. 클라이언트가 보내는 패킷 타입 중 문자열 멤버 하나에 [SafeString] 이 빠지면, 그 멤버 하나로 프로세스가 꺼집니다. List<string> 안의 문자열에는 특성이 닿지 않습니다.
이렇게 쓴다. 클라이언트 → 서버 패킷 타입을 한 곳에 모아 두고, 새 문자열 멤버를 추가할 때마다 특성을 확인합니다. 문자열 컬렉션은 클라이언트 패킷에 넣지 않습니다.
2. MemoryPackSerializationException 만 잡지 않는다
안 지키면 생기는 문제. 음수 길이는 OverflowException 이나 ArgumentOutOfRangeException 으로 나옵니다. MemoryPack 예외만 잡은 수신 루프는 잘못된 패킷 하나에 루프 전체가 끝납니다.
이렇게 쓴다. 역직렬화 실패를 잘못된 입력으로 다루는 자리에서는 catch (Exception) 으로 받고, 예외 타입을 로그에 남긴 뒤 패킷을 버리거나 연결을 끊습니다.
3. try/catch 와 크기 상한만으로 안전하다고 보지 않는다
안 지키면 생기는 문제. 문제 1 의 패킷은 작아서 크기 상한을 지나고, 프로세스 종료라 catch 도 실행되지 않습니다. 받는 코드를 감쌌다고 안심하면 이 둘이 그대로 남습니다.
이렇게 쓴다. 프로세스를 끝내는 두 문제는 리더에 닿기 전에 막습니다. 형식을 나누거나, 문자열 멤버와 자기 참조 멤버에 포매터를 붙입니다.
정리
MemoryPack 의 리더는 받은 바이트를 믿고 읽기 때문에, 클라이언트가 보낸 바이트는 개발자가 막아야 합니다.
- 문자열 길이를 음수로 조작하면 확인을 통과해 버퍼 밖을 읽고, 프로세스가 꺼집니다. 문자열 멤버에
[SafeString]을 붙여 막습니다. - 자기 참조 타입을 깊게 중첩하면 스택이 넘쳐 프로세스가 꺼집니다. 자기 참조 타입을 빼거나
[NodeDepthLimit]을 붙여 막습니다. - 큰 길이와 음수 길이는 예외로 끝나니, 수신 크기 상한과
catch (Exception)으로 받습니다. - 가장 확실한 방법은 클라이언트 → 서버 패킷에 MemoryPack 을 쓰지 않는 것입니다.