---
title: "MemoryPack 의 리더는 조작한 패킷 하나로 서버 프로세스를 끝낼 수 있다"
description: "서버가 클라이언트 패킷을 MemoryPack 으로 바로 풀 때 생기는 네 가지 문제와 예방책입니다. 그중 둘은 try/catch 로 잡을 수 없이 프로세스를 끝내므로, 멤버 포매터나 형식 분리로 리더에 닿기 전에 막아야 합니다."
author: "유니얼"
pubDate: 2026-10-01
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-memorypack-reader/
---
게임 서버는 클라이언트가 보낸 패킷을 `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 런타임은 이것을 잡을 수 있는 예외로 바꾸지 않고 프로세스를 끝냅니다. 메모리를 이미 잘못 읽은 상태로 계속 도는 것이 더 위험하기 때문입니다. 페이지와 매핑은 [가상 메모리와 페이지 폴트](/posts/os-virtual-memory-page-fault/) 에서 다뤘습니다.

| 환경 | 결과 | `catch` 에 잡혔나 |
| --- | --- | --- |
| Windows | 프로세스 종료 (`0xC0000005`) | 안 잡힘 |
| Linux | 프로세스 종료 (`Fatal error.`) | 안 잡힘 |
| macOS | `OutOfMemoryException` | 잡힘 |

macOS 에서는 매핑이 없는 페이지에 닿기 전에, 21억 바이트 문자열을 만들 메모리가 없다는 예외가 먼저 났습니다. 버퍼 밖을 읽으려는 동작은 같으므로 안전하다고 볼 수 없습니다.

문자열 멤버는 거의 모든 패킷에 있습니다. 문자열이 하나라도 있는 패킷 타입이면 이 문제에 해당하고, 패킷이 작아서 크기 상한으로도 막을 수 없습니다.

### 예방책: 문자열 멤버에 [SafeString] 을 붙인다

MemoryPack 은 멤버마다 읽고 쓰는 방법을 바꿔 끼울 수 있습니다. 문자열 멤버에 길이를 먼저 검사하는 포매터를 붙입니다.

```csharp
[MemoryPackable]
public partial class ChatPacket
{
    public int RoomId { get; set; }
    [SafeString] public string? Message { get; set; }
}
```

```csharp
// 문자열 헤더를 먼저 엿보고, 적힌 바이트 수가 남은 바이트보다 크면 읽지 않는다
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. 자기 자신을 품는 타입을 깊게 중첩하면 프로세스가 꺼진다

### 무슨 일이 생기나

트리나 연결 리스트처럼 자기 자신을 멤버로 품는 타입이 있습니다.

```csharp
[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] 을 붙인다

다른 직렬화기가 안에서 하는 깊이 세기를 포매터로 대신 합니다.

```csharp
[MemoryPackable]
public partial class Node
{
    [NodeDepthLimit] public Node? Next { get; set; }
}
```

```csharp
// 읽는 깊이를 세다가 한도를 넘으면 스택이 넘치기 전에 예외를 던진다
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` 가 함께 받습니다.

```csharp
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`

```csharp
// 자식 프로세스로 다시 불리면 그 경우 하나만 실행한다
if (ReaderTest.IsChild(args))
{
    ReaderTest.RunChild(args[1]);
    return;
}

ReaderTest.Run();
```

`ReaderTest.cs`

```csharp
// 리더 실험: 조작한 바이트를 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 을 쓰지 않는 것입니다.