MessagePack-CSharp 의 UntrustedData 는 해시 비교자와 압축 해제 한도만 바꾼다
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 입니다.
두 프리셋은 두 줄이 다르다
// 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 으로 고쳐 넣었습니다.
기본값에서는 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 로 바꿔 막는다
해시 컬렉션을 만드는 포매터는 모두 보안 설정에서 비교자를 받습니다.
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 으로 줄여 그리면 이렇습니다.
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 번호는 사용자 몫인 029 나 120127 에서 고르고, 쓰는 번호를 한 파일의 상수로 모아 둡니다.
정리
UntrustedData 는 해시 비교자와 압축 해제 한도, 두 가지만 바꿉니다.
- 압축 해제 한도 64 MB 는 선언된 길이를 믿고 먼저 할당하는 압축 폭탄을 1 KB 남짓에서 끊습니다.
- SipHash-2-4 비교자는 정수 키 해시 플러딩을 1초에서 2 ms 로 줄이는 대신, 정수 키를 약 4배 느리게 하고 목록 밖의 키 타입을 거부합니다.
- 깊이 한도 500 은 기본값에서도 켜져 있지만 포매터가
DepthStep을 불러야 동작합니다.