---
title: "MessagePack-CSharp 의 UntrustedData 는 해시 비교자와 압축 해제 한도만 바꾼다"
description: "기본값 TrustedData 에서는 240 KB 요청이 1초를 먹고 4 KB 압축 블록이 100 MB 를 할당시킵니다. UntrustedData 는 이 둘만 막고, 깊이 한도 500 은 기본값에서도 이미 켜져 있습니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-security/
---
```csharp
var options = MessagePackSerializer.DefaultOptions
    .WithSecurity(MessagePackSecurity.UntrustedData)
    .WithCompression(MessagePackCompression.Lz4BlockArray);

var request = MessagePackSerializer.Deserialize<Request>(bytes, options);
```

클라이언트가 보낸 바이트를 서버가 읽는 코드라면 한 번쯤 쓰는 두 줄입니다. 그런데 `UntrustedData` 로 바꾸면 정확히 무엇이 달라지는지는 이름만으로 알 수 없고, 안 바꾸면 어떤 입력에 서버가 멈추는지도 드러나지 않습니다.

`UntrustedData` 는 **해시 비교자와 압축 해제 한도, 두 줄만** 바꿉니다. 깊은 중첩을 막는 깊이 한도는 기본값에서도 켜져 있고, 포매터가 협조해야 동작합니다. 기준 판본은 MessagePack-CSharp v3.1.9(커밋 `aa16e71`)이고, 실측은 Windows 11, .NET SDK 10.0.401, net10.0 Release 입니다.

## 두 프리셋은 두 줄이 다르다

```csharp
// MessagePack/MessagePackSecurity.cs
public static readonly MessagePackSecurity TrustedData = new MessagePackSecurity()
{
    HashCollisionResistant = false,
    MaximumObjectGraphDepth = 500,
    MaximumDecompressedSize = int.MaxValue,
};

public static readonly MessagePackSecurity UntrustedData = new MessagePackSecurity
{
    HashCollisionResistant = true,
    MaximumObjectGraphDepth = 500,
    MaximumDecompressedSize = 64 * 1024 * 1024,
};
```

기본 옵션의 `Security` 는 `TrustedData` 입니다. README 도 `UntrustedData` 가 흔한 공격 몇 가지를 막을 뿐 완전한 해결책이 아니고, 변조 방지(서명이나 MAC)는 제공하지 않는다고 적습니다.

## 압축 폭탄은 선언된 길이를 믿고 먼저 할당하는 데서 온다

압축은 직렬화 허브에서 평문을 다 쓴 뒤 한 번 걸리고, 결과를 이 라이브러리 고유의 모양으로 쌉니다. `Lz4Block` 은 ext 99 안에 원본 길이(int32)와 LZ4 블록을, `Lz4BlockArray` 는 배열 첫 원소 ext 98 에 청크별 원본 길이를 두고 청크마다 bin 을 씁니다. 읽는 쪽은 적힌 원본 길이만큼 버퍼를 먼저 잡습니다. 3,953 B 짜리 Block 데이터의 길이 필드를 100,000,000 으로 고쳐 넣었습니다.

<x-flow>
<script type="application/json">
{
  "label": "Lz4Block 을 해제하는 순서 (TryDecompress 를 줄인 코드)",
  "lines": [
    "int uncompressedLength = reader.ReadInt32();   // 헤더에 적힌 원본 길이",
    "ThrowIfInvalidLz4BlockLength(uncompressedLength, options.Security.MaximumDecompressedSize);",
    "var span = writer.GetSpan(uncompressedLength);   // 원본 크기 버퍼를 먼저 잡는다",
    "int decoded = LZ4Codec.Decode(압축 블록, span);",
    "if (소비한 입력 길이 != 블록 길이) throw \"LZ4 block is corrupted, or invalid length has been given.\";"
  ],
  "cases": [
    {
      "label": "TrustedData (기본값)",
      "state": "입력 3,953 B · 선언한 원본 길이 100,000,000 · 한도 int.MaxValue",
      "path": [
        { "line": 0, "why": "공격자가 적은 1억을 그대로 읽습니다." },
        { "line": 1, "verdict": "통과", "why": "1억은 int.MaxValue 보다 작습니다." },
        { "line": 2, "why": "1억 바이트를 할당합니다.", "state": "할당 = 100,001,536 B" },
        { "line": 3, "why": "디코더는 입출력 경계를 매 단계 검사하므로 메모리를 넘어 쓰지는 않습니다." },
        { "line": 4, "verdict": "true", "out": "LZ4 block is corrupted, or invalid length has been given. (할당은 이미 끝남)" }
      ]
    },
    {
      "label": "UntrustedData",
      "state": "입력 3,953 B · 선언한 원본 길이 100,000,000 · 한도 64 MB",
      "path": [
        { "line": 0 },
        { "line": 1, "verdict": "거부", "why": "1억은 64 MB(67,108,864)보다 큽니다.", "state": "할당 = 1,096 B", "out": "LZ4 block declares a decompressed length that exceeds the configured maximum." }
      ],
      "skip": [2, 3, 4]
    }
  ],
  "note": "두 경우 모두 결국 예외입니다. 차이는 예외 전에 100 MB 를 잡았느냐입니다."
}
</script>
</x-flow>

기본값에서는 **4 KB 입력 하나가 100 MB 할당을 시킵니다.** 한도가 `int.MaxValue` 이니 최대 2 GB 까지 갑니다. BlockArray 는 청크별 원본 길이를 한도에서 하나씩 빼 나가 합계에 한도를 겁니다.

두 모드의 차이도 여기서 봐 둡니다. BlockArray 의 청크는 32 KB 로 자른 조각이 아니라 평문을 담은 임시 버퍼의 **세그먼트**라서, 청크 수는 포매터가 버퍼를 어떻게 요청했느냐로 정해집니다.

| 페이로드 | 모드 | 출력 | 역직렬화 할당/op |
| --- | --- | --- | --- |
| `string[100000]` (평문 889,005 B, 세그먼트 29개) | Lz4Block | **7,565 B** | 5,689,056 B |
|  | Lz4BlockArray | 114,797 B | 4,800,044 B |
| `byte[1,000,000]` (세그먼트 1개) | Lz4Block | 3,946 B | |
|  | Lz4BlockArray | 3,951 B | |

잘게 쓰는 그래프에서 BlockArray 는 역직렬화 때 원본 크기의 연속 버퍼를 피했지만, 청크마다 LZ4 사전이 비어서 시작해 출력이 Block 의 15배였습니다. 큰 배열 하나는 청크가 하나라 LOH 를 피하지 못하고, `byte[]` 를 돌려받는 입구에서는 직렬화 할당이 Block 보다 1 MB 가량 많았습니다(재사용 `IBufferWriter` 로 보내면 두 모드 모두 1,000,032 B 로 같았습니다). 압축 옵션을 켠 작은 값은 압축하지 않아도 직렬화가 0.24 µs 에서 0.87 µs 로 느려졌습니다. 모드는 실제 메시지로 재 보고 고릅니다.

## 해시 플러딩은 비교자를 SipHash 로 바꿔 막는다

해시 컬렉션을 만드는 포매터는 모두 보안 설정에서 비교자를 받습니다.

<x-flow>
<script type="application/json">
{
  "label": "Dictionary 포매터가 비교자를 고르는 길 (소스를 줄여 옮김)",
  "lines": [
    "// DictionaryFormatter<TKey, TValue>",
    "protected override Dictionary<TKey, TValue> Create(int count, MessagePackSerializerOptions options)",
    "    => new Dictionary<TKey, TValue>(count, options.Security.GetEqualityComparer<TKey>());",
    "",
    "// MessagePackSecurity",
    "public IEqualityComparer<T> GetEqualityComparer<T>()",
    "    => this.HashCollisionResistant ? GetHashCollisionResistantEqualityComparer<T>() : EqualityComparer<T>.Default;",
    "protected virtual IEqualityComparer<T> GetHashCollisionResistantEqualityComparer<T>()",
    "    if (HashResistantCache<T>.EqualityComparer is { } result) return result;   // 목록에 있는 타입",
    "    if (typeof(T) == typeof(object)) return (IEqualityComparer<T>)this.objectFallbackEqualityComparer;",
    "    throw new TypeAccessException($\"No hash-resistant equality comparer available for type: {typeof(T)}\");",
    "",
    "// CollisionResistantHasherUnmanaged<T>",
    "public override int GetHashCode(T value) => SecureHash(value);   // SipHash-2-4",
    "public bool Equals(T? x, T? y) => EqualityComparer<T?>.Default.Equals(x, y);"
  ],
  "cases": [
    {
      "label": "TrustedData · Dictionary<int, V>",
      "state": "HashCollisionResistant = false",
      "path": [
        { "line": 2, "why": "맵 헤더에서 개수를 읽은 뒤 사전을 만들며 비교자를 보안 설정에 묻습니다.", "frames": ["Deserialize", "DictionaryFormatter", "Create"] },
        { "line": 6, "verdict": "false", "why": "기본 비교자를 그대로 씁니다. int 의 해시는 값 그대로라, 버킷 수의 배수만 골라 보내면 모든 키가 한 버킷에 쌓입니다.", "frames": ["Deserialize", "DictionaryFormatter", "Create", "GetEqualityComparer"], "out": "EqualityComparer<int>.Default. 공격 입력 40,000 키에 1,095.7 ms" }
      ],
      "skip": [8, 9, 10, 13, 14]
    },
    {
      "label": "UntrustedData · Dictionary<int, V>",
      "state": "HashCollisionResistant = true",
      "path": [
        { "line": 2, "frames": ["Deserialize", "DictionaryFormatter", "Create"] },
        { "line": 6, "verdict": "true", "frames": ["Deserialize", "DictionaryFormatter", "Create", "GetEqualityComparer"] },
        { "line": 8, "verdict": "int 는 목록에 있음", "why": "정해 둔 목록에서 int 용 CollisionResistantHasherUnmanaged<int>.Instance 가 나옵니다.", "frames": ["Deserialize", "DictionaryFormatter", "Create", "GetEqualityComparer", "GetHashCollisionResistantEqualityComparer"] },
        { "line": 13, "why": "키를 넣을 때마다 프로세스마다 무작위로 뽑은 키로 SipHash 합니다. 공격자는 결과를 모르니 한 버킷에 몰 수 없습니다. Equals 는 기본 그대로입니다.", "frames": ["Deserialize", "DictionaryFormatter", "Dictionary.Add", "GetHashCode"], "out": "같은 공격 입력 40,000 키에 2.19 ms" }
      ],
      "skip": [9, 10]
    },
    {
      "label": "UntrustedData · Dictionary<Version, int>",
      "state": "HashCollisionResistant = true",
      "path": [
        { "line": 2, "frames": ["Deserialize", "DictionaryFormatter", "Create"] },
        { "line": 6, "verdict": "true", "frames": ["Deserialize", "DictionaryFormatter", "Create", "GetEqualityComparer"] },
        { "line": 8, "verdict": "목록에 없음", "frames": ["Deserialize", "DictionaryFormatter", "Create", "GetEqualityComparer", "GetHashCollisionResistantEqualityComparer"] },
        { "line": 9, "verdict": "false" },
        { "line": 10, "out": "TypeAccessException. TrustedData 에서는 정상" }
      ],
      "skip": [13, 14]
    }
  ],
  "note": "바뀌는 것은 해시뿐입니다. 해시 컬렉션을 만드는 다른 포매터도 같은 GetEqualityComparer 를 거칩니다."
}
</script>
</x-flow>

`Dictionary<int,...>(n)` 의 실제 버킷 수 B 를 읽고 키를 0, B, 2B ... 로 채운 map 을 만들어 넣었습니다.

| 키 개수 | 공격 입력, Trusted | 공격 입력, Untrusted | 보통 키, Trusted |
| --- | --- | --- | --- |
| 10,000 | 68.5 ms | 0.74 ms | 0.27 ms |
| 20,000 | 273.6 ms | 0.90 ms | 0.49 ms |
| 40,000 | **1,095.7 ms** | **2.19 ms** | 0.64 ms |

키를 두 배로 늘리면 Trusted 는 네 배가 됩니다. **약 240 KB 요청 하나가 코어 하나를 1초 묶습니다.** 대가도 있습니다. 보통 `int` 키 10만 개는 1.97 ms 에서 7.97 ms 로 약 4배 느려졌고, `string` 키는 1.0~1.3배였습니다(1회 관측). 정수 해시는 원래 값 그대로라 공짜였기 때문입니다.

## 깊이 한도 500 은 기본값에서도 켜져 있다

재귀하는 포매터는 중첩 한 층에 들어갈 때 `options.Security.DepthStep(ref reader)` 로 리더의 깊이 카운터를 올리고, `finally` 에서 내립니다. `Depth >= 500` 이면 `InsufficientExecutionStackException` 입니다. 한도를 3 으로 줄여 그리면 이렇습니다.

<x-flow>
<script type="application/json">
{
  "label": "object[] 포매터가 중첩 배열을 읽을 때 (MaximumObjectGraphDepth = 3 으로 줄인 그림)",
  "lines": [
    "var len = reader.ReadArrayHeader();",
    "options.Security.DepthStep(ref reader);   // Depth >= 3 이면 던진다, 아니면 Depth++",
    "try { for (i < len) array[i] = 이 포매터를 다시 부른다(ref reader); }",
    "finally { reader.Depth--; }"
  ],
  "cases": [
    {
      "label": "91 91 91 91 C0 (네 겹)",
      "state": "Depth = 0",
      "path": [
        { "line": 1, "state": "Depth = 1", "frames": ["배열 1층"] },
        { "line": 2, "why": "원소를 읽으러 다시 들어갑니다." },
        { "line": 1, "state": "Depth = 2", "frames": ["배열 1층", "배열 2층"] },
        { "line": 2 },
        { "line": 1, "state": "Depth = 3", "frames": ["배열 1층", "배열 2층", "배열 3층"] },
        { "line": 2 },
        { "line": 0, "why": "넷째 91 을 읽었습니다." },
        { "line": 1, "verdict": "Depth >= 3", "why": "스택이 넘치기 전에 잡을 수 있는 예외로 끊습니다.", "out": "InsufficientExecutionStackException" }
      ]
    }
  ],
  "note": "실제 한도는 500 입니다. 500 층까지 받고 501 층부터 거부합니다. 세 겹이면 통과합니다."
}
</script>
</x-flow>

`0x91` 을 쌓은 입력에서 500 층은 두 프리셋 모두 통과, 501 층은 두 프리셋 모두 거부됐습니다. 한도를 `int.MaxValue` 로 풀자 3,000 층에서 `0xC00000FD Stack overflow.` 로 **프로세스가 죽었습니다.** .NET 의 스택 오버플로는 `catch` 로 잡히지 않으니 3 KB 요청 하나로 서버가 내려갑니다. 500 은 그 지점에 한참 못 미쳐 잡을 수 있는 예외로 끊으려고 고른 값입니다.

이 한도는 **포매터가 `DepthStep` 을 불러야** 동작합니다. `class Node { Node? Child }` 에 `DepthStep` 없이 자기 자신을 재귀로 부르는 포매터를 붙이자 600 겹이 그대로 통과했고, 1 MB 스택 스레드에서 10만 겹은 `Stack overflow.` 로 프로세스가 종료 코드 134 로 끝났습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release). 반면 `reader.Skip()` 은 재귀가 아닌 카운터 반복이라 10만 층도 통과했고, 거짓 원소 개수는 `ReadArrayHeader` 가 설정과 무관하게 늘 막습니다.

## 주의할 점

### 1. 바깥에서 온 바이트는 UntrustedData 로 읽는다

**안 지키면 생기는 문제.** 기본값 `TrustedData` 는 해시 비교자를 바꾸지 않고 압축 해제 한도가 `int.MaxValue` 입니다. 버킷 수의 배수로 고른 정수 키 4만 개가 역직렬화 한 번에 1초를 먹고, 길이 필드만 고친 4 KB LZ4 블록이 100 MB 를 할당시킵니다.

**이렇게 쓴다.** 클라이언트, 파일 업로드, 외부 서비스처럼 내가 만들지 않은 바이트를 읽는 옵션에 `WithSecurity(MessagePackSecurity.UntrustedData)` 를 붙입니다. 옵션은 불변이니 한 번 만들어 필드에 둡니다.

### 2. UntrustedData 로 바꾸기 전에 해시 키 타입을 확인한다

**안 지키면 생기는 문제.** 충돌 저항 비교자는 원시 타입, `string`, `Guid`, `DateTime`, enum 같은 정해진 목록에서만 나옵니다. `Dictionary<Version,int>` 나 사용자 구조체를 키로 쓰는 메시지는 바꾸는 순간 `TypeAccessException` 으로 깨지고, 그 메시지를 실제로 받기 전까지는 드러나지 않습니다.

**이렇게 쓴다.** `Dictionary`, `HashSet` 의 키 타입을 훑어 목록 밖의 것이 있으면 키를 `string` 이나 정수로 바꾸거나, `MessagePackSecurity` 를 상속해 `GetHashCollisionResistantEqualityComparer<T>` 를 재정의합니다. 바꾼 뒤 모든 메시지 타입을 한 번씩 왕복시킵니다.

### 3. 직접 짠 재귀 포매터는 DepthStep 을 부른다

**안 지키면 생기는 문제.** 트리나 연결 리스트를 읽는 커스텀 포매터가 `DepthStep` 없이 재귀하면 그 경로에는 한도가 없고, 깊게 쌓은 입력 하나가 잡을 수 없는 스택 오버플로로 프로세스를 끝냅니다.

**이렇게 쓴다.** `TryReadNil` 다음에 `options.Security.DepthStep(ref reader);` 를 부르고 `try` 로 감싸 `finally` 에서 `reader.Depth--;` 합니다. 원소마다가 아니라 한 층에 한 번입니다. 한도를 늘려야 하더라도 수천 단위로 풀지 않습니다.

### 4. 사용자 ext 코드로 98, 99 를 쓰지 않는다

**안 지키면 생기는 문제.** 읽는 쪽에 압축 옵션이 켜져 있으면 첫 토큰이 ext 99 이거나 배열 첫 원소가 ext 98 인 평문을 압축으로 보고 해제에 들어갑니다. 사용자 포매터가 쓴 `D6 63 01 02 03 04` 는 `LZ4 block is corrupted` 로, `92 D6 62 10 20 30 40 01` 은 `Unexpected msgpack code 32 (positive fixint)` 로 실패했고, 두 모드 어느 쪽을 켜도 같았습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release). 메시지가 사용자 ext 와 상관없어 보여 원인을 찾기 어렵습니다.

**이렇게 쓴다.** 직접 정의하는 ext 번호는 사용자 몫인 0~29 나 120~127 에서 고르고, 쓰는 번호를 한 파일의 상수로 모아 둡니다.

## 정리

`UntrustedData` 는 **해시 비교자와 압축 해제 한도, 두 가지만** 바꿉니다.

- 압축 해제 한도 64 MB 는 선언된 길이를 믿고 먼저 할당하는 압축 폭탄을 1 KB 남짓에서 끊습니다.
- SipHash-2-4 비교자는 정수 키 해시 플러딩을 1초에서 2 ms 로 줄이는 대신, 정수 키를 약 4배 느리게 하고 목록 밖의 키 타입을 거부합니다.
- 깊이 한도 500 은 기본값에서도 켜져 있지만 포매터가 `DepthStep` 을 불러야 동작합니다.