Знакомимся с форматами
Прежде чем сравнивать, давайте разберемся с каждым форматом по отдельности. Для всех примеров будем использовать одну и ту же модель данных:
public class UserProfile
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime BirthDate { get; set; }
public UserRole Role { get; set; }
}
public enum UserRole
{
User,
Admin,
Moderator
}
JSON
JSON (JavaScript Object Notation) — пожалуй, самый популярный текстовый формат сериализации на сегодня. Человекочитаемый, компактнее XML, поддерживается буквально везде.
В .NET у нас два основных сериализатора: встроенный System.Text.Json и сторонний Newtonsoft.Json (он же Json.NET). Давайте разберемся, когда какой выбирать:
System.Text.Json встроен в .NET, начиная с .NET Core 3.0. Работает быстрее, аллоцирует меньше памяти, и его активно развивает команда Microsoft. Для новых проектов я рекомендую начинать именно с него.
Newtonsoft.Json — ветеран .NET-экосистемы, появился задолго до System.Text.Json. Поддерживает больше сценариев из коробки: полиморфную сериализацию, кастомные конвертеры с более гибким API, работу с dynamic. Если нужна максимальная гибкость или вы работаете с легаси-кодом, этот сериализатор по-прежнему актуален.
Попробуем сериализовать наш объект в файл с помощью System.Text.Json:
using System.Text.Json;
var user = new UserProfile
{
Id = 1,
Name = "Дмитрий",
Email = "dmitry@example.com",
BirthDate = new DateTime(1990, 5, 15),
Role = UserRole.Admin
};
var options = new JsonSerializerOptions
{
WriteIndented = true, // для человекочитаемого вывода
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping // чтобы кириллица не экранировалась
};
// Сериализация в файл
await using var stream = File.Create("user.json");
await JsonSerializer.SerializeAsync(stream, user, options);
На выходе получим:
{
"Id": 1,
"Name": "Дмитрий",
"Email": "dmitry@example.com",
"BirthDate": "1990-05-15T00:00:00",
"Role": 1
}
XML
XML (eXtensible Markup Language) — формат, который многие считают устаревшим, но он до сих пор живет и здравствует. Конфигурации .csproj, SOAP-сервисы, интеграции с легаси-системами — XML повсюду.
В .NET есть два основных XML-сериализатора:
XmlSerializer — классический вариант, работает с публичными свойствами и требует конструктор без параметров. Простой, предсказуемый, знакомый всем.
DataContractSerializer — более гибкий, работает с атрибутами [DataContract] / [DataMember], поддерживает приватные поля и более сложные графы объектов. Используется в WCF.
Для нашего примера возьмем XmlSerializer: он проще и для файловой сериализации подходит отлично:
using System.Xml.Serialization;
var serializer = new XmlSerializer(typeof(UserProfile));
// Сериализация в файл
using var writer = new StreamWriter("user.xml");
serializer.Serialize(writer, user);
// Десериализация из файла
using var reader = new StreamReader("user.xml");
var deserialized = (UserProfile)serializer.Deserialize(reader)!;
Результат:
<?xml version="1.0" encoding="utf-8"?>
<UserProfile xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Id>1</Id>
<Name>Дмитрий</Name>
<Email>dmitry@example.com</Email>
<BirthDate>1990-05-15T00:00:00</BirthDate>
<Role>Admin</Role>
</UserProfile>
Видно, что XML значительно многословнее JSON: пространства имен, закрывающие теги, объявление XML. Для одного объекта разница небольшая, но когда у вас коллекция из тысяч элементов, работать с этим становится сложнее.
YAML
YAML — формат, который любят за читаемость. Docker Compose, Kubernetes-манифесты, конфиги GitHub Actions, настройки Jekyll — YAML повсюду в мире DevOps.
В .NET для работы с YAML есть библиотека YamlDotNet. Подключаем через NuGet:
dotnet add package YamlDotNet
Пример сериализации:
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
var serializer = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
var yaml = serializer.Serialize(user);
await File.WriteAllTextAsync("user.yaml", yaml);
Результат:
Выглядит аккуратно, правда? Но у YAML есть несколько неприятных сюрпризов, о которых стоит знать:
Norway Problem. В YAML 1.1 строка NO интерпретируется как bool false. Представьте, что у вас есть коды стран, и код Норвегии (NO) вдруг превращается в false. Та же история с YES, ON, OFF. В YAML 1.2 это исправили, но далеко не все парсеры используют новую версию.
Неявная типизация. Строка 1.0 может стать числом float, строка 2024-01-01 — датой. Это особенно опасно при обмене данными между разными языками: Python-парсер может интерпретировать значение иначе, чем C#-парсер.
YAML отлично подходит для конфигов, которые люди читают и редактируют руками. Для сериализации данных в файлы лучше выбрать что-то другое.
BinaryFormatter
BinaryFormatter — встроенный бинарный сериализатор, который был в .NET с самого начала. Работал просто: помечаешь класс атрибутом [Serializable], вызываешь Serialize/Deserialize, и он через рефлексию обходит весь граф объектов, включая приватные поля.
Звучит удобно. Так почему же Microsoft его убил?
Проблема в том, как BinaryFormatter работает под капотом. Сериализованные данные содержат полную информацию о типах: имя сборки, имя класса, все поля. При десериализации BinaryFormatter создает объекты указанных типов и заполняет их поля. Если атакующий подсунет вредоносный payload с нужными типами, при десериализации выполнится произвольный код. Через эту уязвимость ломали Microsoft Exchange Server, SharePoint, Azure DevOps.
Microsoft долго боролся с последствиями, но в итоге решил, что формат невозможно сделать безопасным. Вот хронология:
- .NET 5 — атрибут
[Obsolete]с предупреждением SYSLIB0011. - .NET 7 — предупреждение стало ошибкой компиляции.
- .NET 8 —
NotSupportedExceptionпо умолчанию при вызове. - .NET 9 — полное удаление реализации из поставки.
Показывать пример кода с BinaryFormatter я не буду: незачем учить тому, что нельзя использовать. Вместо этого запомним: если вы встретили BinaryFormatter в легаси-коде — это повод для миграции на System.Text.Json, MessagePack или Protobuf.
Полезные ссылки:
- SYSLIB0011 — BinaryFormatter obsolete (Microsoft Learn)
- BinaryFormatter removed from .NET 9 (.NET Blog)
- Announcement: BinaryFormatter is being removed in .NET 9 (dotnet/runtime #98245)
MessagePack
MessagePack — бинарный формат сериализации, который часто описывают как «бинарный JSON». Он schema-less (не требует описания схемы), компактный и быстрый.
Немного истории. MessagePack создал японский разработчик Садаюки Фурухаси (Sadayuki Furuhashi) в 2008 году. Что интересно: изначально формат разрабатывался не для сетевого взаимодействия, а для распределенной файловой системы. Позже Фурухаси создал Fluentd — популярный коллектор логов, и MessagePack стал его основой.
В .NET для работы с MessagePack есть библиотека MessagePack-CSharp. Подключаем:
dotnet add package MessagePack
Модель нужно пометить атрибутами:
using MessagePack;
[MessagePackObject]
public class UserProfile
{
[Key(0)] public int Id { get; set; }
[Key(1)] public string Name { get; set; } = string.Empty;
[Key(2)] public string Email { get; set; } = string.Empty;
[Key(3)] public DateTime BirthDate { get; set; }
[Key(4)] public UserRole Role { get; set; }
}
Атрибут [Key(N)] задает порядковый номер поля. Именно по этим номерам (а не по именам свойств) MessagePack находит данные при десериализации. Это одна из причин компактности: в бинарном представлении хранятся числовые индексы, а не строковые имена полей.
// Сериализация в файл
var bytes = MessagePackSerializer.Serialize(user);
await File.WriteAllBytesAsync("user.msgpack", bytes);
// Десериализация из файла
var data = await File.ReadAllBytesAsync("user.msgpack");
var deserialized = MessagePackSerializer.Deserialize<UserProfile>(data);
Библиотека также поддерживает встроенное LZ4-сжатие — можно сжимать данные прямо при сериализации без дополнительных шагов:
var options = MessagePackSerializerOptions.Standard.WithCompression(
MessagePackCompression.Lz4BlockArray);
var compressed = MessagePackSerializer.Serialize(user, options);
Как мы видим, файл в формате MessagePack гораздо меньше, чем в остальных форматах:
Где MessagePack используют для сохранения в файлы на практике:
- Игровые сейвы в Unity. Unity официально рекомендует MessagePack как формат для сохранений.
- Кеш на диск (Pinterest). Сжатие 64-битных ID до 5 байт.
Protocol Buffers (Protobuf)
Protocol Buffers (Protobuf) — бинарный формат сериализации от Google. В отличие от MessagePack, Protobuf — schema-first: структура данных описывается заранее, и сериализатор строго ей следует.
Разработка Protobuf внутри Google началась в 2001 году (Proto1). В 2008-м формат стал open source (Proto2), а в 2016-м вышла третья версия (Proto3). Сегодня Protobuf — основа gRPC и множества внутренних систем Google.
Для .NET самый популярный вариант — библиотека protobuf-net. Она позволяет работать с Protobuf через атрибуты, без .proto-файлов:
dotnet add package protobuf-net
using ProtoBuf;
[ProtoContract]
public class UserProfile
{
[ProtoMember(1)] public int Id { get; set; }
[ProtoMember(2)] public string Name { get; set; } = string.Empty;
[ProtoMember(3)] public string Email { get; set; } = string.Empty;
[ProtoMember(4)] public DateTime BirthDate { get; set; }
[ProtoMember(5)] public UserRole Role { get; set; }
}
// Сериализация в файл
using var file = File.Create("user.proto.bin");
Serializer.Serialize(file, user);
// Десериализация из файла
using var input = File.OpenRead("user.proto.bin");
var deserialized = Serializer.Deserialize<UserProfile>(input);
Одна из главных сильных сторон Protobuf — версионирование контрактов. Вы можете добавлять новые поля (с новыми номерами тегов), и старые данные продолжат корректно десериализоваться. Старые клиенты просто проигнорируют неизвестные теги. Для файлов, которые будут жить долго (архивы, конфиги, сейвы), это критически важно.
Где Protobuf используют для сохранения в файлы:
- Архивное хранение в Google — Protobuf используется не только для gRPC, но и для хранения данных на диске (документация Google так и говорит: Archival storage of data).
- TFRecord в TensorFlow — формат хранения ML-датасетов (изображения + метки) внутри Protobuf.
- Игровые сейвы —
protobuf-unity.
Полезные ссылки:
- Protocol Buffers Overview (protobuf.dev)
- History of Protocol Buffers (protobuf.dev)
- protobuf-net (GitHub)
Кто быстрее?
С теорией разобрались, теперь давайте замерим. Я написал бенчмарк на BenchmarkDotNet под .NET 8 (LTS) с двумя сценариями:
- Маленький DTO — один объект
UserProfile(строки, число, DateTime, enum). - Коллекция —
List<UserProfile>; из 10 000 таких объектов.
Что измеряем:
- Скорость сериализации.
- Скорость десериализации.
- Размер выходного файла.
- Аллокации (через
[MemoryDiagnoser]).
Участники:
System.Text.JsonNewtonsoft.JsonXmlSerializerYamlDotNetMessagePack-CSharpprotobuf-net
Результаты: коллекция из 10 000 объектов
Что бросается в глаза
Бинарные форматы (MessagePack, Protobuf) ожидаемо лидируют — и по скорости, и по размеру файла.
Newtonsoft.Json предсказуемо медленнее System.Text.Json и аллоцирует значительно больше.
YamlDotNet — аутсайдер по производительности. Аллокации в десятки раз больше, чем у остальных.
Уязвимости при десериализации
А теперь поговорим о безопасности. Каждый раз, когда вы десериализуете данные из внешнего источника (файла, HTTP-запроса, очереди сообщений), вы потенциально выполняете чужие инструкции.
JSON/Newtonsoft: TypeNameHandling
Начнем с самого распространенного: Newtonsoft.Json и его настройка TypeNameHandling.
Если вы включите TypeNameHandling.All (или Auto, Objects), Newtonsoft начнет записывать в JSON специальное поле $type с полным именем .NET-типа. При десериализации библиотека создаст именно этот тип. Казалось бы, удобно для полиморфизма. Но проблема в том, что атакующий может указать в $type любой тип из загруженных сборок.
Вот как выглядит вредоносный payload:
{
"$type": "System.IO.FileInfo, System.IO.FileSystem",
"fileName": "rce-test.txt"
}
Это упрощенный пример. На практике через инструмент ysoserial.net можно собрать цепочку объектов (gadget chain), которая при десериализации выполнит произвольную команду на сервере. Атакующий подставляет тип с сеттером, который вызывает Process.Start(), и готово — RCE (Remote Code Execution).
Реальные случаи: CVE-2026-26208 в ADB Explorer. Приложение читает App.txt с небезопасной настройкой, и можно подменить этот файл для выполнения произвольного кода.
Как защититься:
- Не используйте
TypeNameHandling.All/Auto/Objects. Никогда. - Если полиморфизм нужен, настройте
SerializationBinder, который разрешает только конкретные типы. - Или перейдите на
System.Text.Json: он не поддерживает$typeпо дизайну и безопасен в этом отношении.
XML: XXE (XML External Entities)
У XML есть своя классическая уязвимость — XXE (XML External Entities). Она не связана с десериализацией как таковой, но напрямую касается обработки XML-данных.
XML поддерживает конструкцию <!DOCTYPE> с определением внешних сущностей (<!ENTITY>). Парсер может подставить содержимое внешнего ресурса прямо в документ. Атакующий отправляет XML такого вида:
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<UserProfile>
<Name>&xxe;</Name>
</UserProfile>
Если парсер обрабатывает DTD, он прочитает файл /etc/passwd (или любой другой файл на сервере) и подставит его содержимое в поле Name. Это позволяет читать произвольные файлы, что может быть недопустимо.
Как защититься:
В .NET нужно правильно настроить XmlReaderSettings:
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit, // запрещаем обработку DTD
XmlResolver = null // отключаем резолвинг внешних ресурсов
};
using var reader = XmlReader.Create(stream, settings);
var serializer = new XmlSerializer(typeof(UserProfile));
var result = (UserProfile)serializer.Deserialize(reader)!;
Начиная с .NET Core / .NET 5+, XmlReader по умолчанию не обрабатывает DTD, но явно указать DtdProcessing.Prohibit — хорошая практика. Если вы работаете с .NET Framework, там дефолты менее безопасные и настройка обязательна.
BinaryFormatter: десериализация = исполнение кода
Мы уже говорили, что Microsoft убил BinaryFormatter, и вот конкретная причина.
При десериализации BinaryFormatter восстанавливает граф объектов из бинарных данных. Payload содержит полные имена типов, и десериализатор создает экземпляры этих типов, вызывая конструкторы и заполняя поля. Атакующий собирает цепочку объектов, в которой создание и инициализация определенных типов приводят к выполнению произвольного кода.
Инструмент ysoserial.net содержит десятки готовых gadget chains для .NET. Достаточно скормить вредоносный payload приложению, которое вызывает BinaryFormatter.Deserialize, — и всё, RCE. Один из примеров — CVE-2019-1306 в Azure DevOps.
Проблема в том, что payload управляет тем, какие объекты создаются. В JSON-сериализаторах (без TypeNameHandling) десериализатор создает только тот тип, который вы указали в коде. В BinaryFormatter тип определяется данными.
После многих лет борьбы с последствиями Microsoft признал, что BinaryFormatter невозможно сделать безопасным, и принял решение об удалении.
YAML: десериализация произвольных типов
YAML поддерживает теги типов — специальные маркеры, которые указывают парсеру, какой объект создать:
user: !tag:MyApp.MaliciousType
command: "rm -rf /"
Если десериализатор резолвит произвольные типы по тегам из данных, атакующий может подсунуть вредоносный объект. C этим связана уязвимость CVE-2018-1000210, в которой через теги можно было выполнить RCE.
Как защититься:
- Используйте
DeserializerBuilderбез регистрации произвольных тегов. - Десериализуйте в конкретные, заранее известные типы.
- Не используйте «динамическую» десериализацию для внешних данных.
MessagePack/Protobuf: почему безопаснее по дизайну
Бинарные форматы MessagePack и Protobuf значительно безопаснее при десериализации. Причина: в них нет механизма полиморфной десериализации — данные не содержат информации о типах .NET, а десериализатор создает только те объекты, которые описаны в вашем коде.
Protobuf особенно строг: schema-first подход означает, что сериализатор и десериализатор работают только с полями, описанными в контракте. Неизвестные поля просто пропускаются.
В MessagePack-CSharp существует режим Typeless. Он делает ровно то, за что мы ругали BinaryFormatter: записывает имена типов в сериализованные данные и создает их при десериализации. Используете Typeless с внешними данными — получаете потенциальную RCE.
Общая оценка
Какой формат выбрать
Заключение
Что можно вынести из этой статьи:
- JSON — отличный дефолт для человекочитаемых данных.
System.Text.Jsonзакрывает большинство задач и делает это быстро. - XML жив, но для новых проектов выбирайте его, только если того требует интеграция.
- YAML хорош для конфигов, но для сериализации данных в файлы у него слишком много подводных камней и слабая производительность.
- BinaryFormatter мертв. Если встретите в легаси, мигрируйте.
- MessagePack и Protobuf выбирайте, если важны скорость, компактность и безопасность. MessagePack проще начать (schema-less), Protobuf надежнее для долгоживущих данных (версионирование).
И главное — помните о безопасности. Десериализация из недоверенных источников требует осторожности с любым форматом. Не включайте TypeNameHandling в Newtonsoft, не забывайте о DtdProcessing.Prohibit в XML и держитесь подальше от Typeless-режимов.
Спасибо за прочтение статьи! Кроме того, я веду телеграм-канал Flexible Coding, где рассказываю о своем опыте в программировании.