О чём эта статья и как её читать
Всё описанное выполнялось в изолированной лаборатории на вымышленном приложении, собранном специально для статьи. Совпадения имён с реальными продуктами случайны. Материал образовательный — для AppSec-специалистов, .NET-разработчиков и всех, кому интересно, как «сохранить объект в файл» превращается в удалённое выполнение кода. Не применяйте это к системам, на которые у вас нет письменного разрешения.
Есть распространённый страх: анализ защищённости — это что-то тёмное, для избранных, с ассемблером и магией. На деле большая часть работы — это чтение кода по понятным правилам. Разберём реальный по механике баг (небезопасная десериализация в .NET → выполнение произвольной команды) так, будто читаем букварь: сначала буквы, потом слоги, потом целые слова.
К концу статьи вы будете уметь: декомпилировать .NET-приложение, находить в нём точки сериализации и десериализации, читать свойства и сеттеры, отличать безобидный класс от «gadget» и по одной строчке понимать, есть ли уязвимость. Всё воспроизводится на приложенном стенде.
Азбука 1. Что такое сериализация и десериализация
Внутри программы данные живут как объекты — структуры в оперативной памяти со ссылками и адресами. Такой объект нельзя просто так записать в файл или отправить по сети. Его сначала превращают в плоский текст (или поток байтов), а на другой стороне собирают обратно:
- Сериализация — процесс превращения объекта (данных в памяти) в текст/байты, пригодные, чтобы сохранить в файл или передать по сети. Здесь — в XML.
- Десериализация — обратный процесс: из этого текста/байтов заново собирается объект в памяти.
Объект в памяти Текст в файле (XML)
┌────────────────────┐ сериализация ┌──────────────────────────┐
│ TextItem │ ───────────────▶ │ <TextItem> │
│ text = "отчёт" │ (объект → текст) │ <text>отчёт</text> │
│ │ ◀─────────────── │ </TextItem> │
└────────────────────┘ десериализация └──────────────────────────┘
(текст → объект)Десериализация — это не пассивное чтение. Чтобы собрать объект обратно, программа его создаёт и заполняет свойства, а заполнение свойства — это исполнение кода. Именно здесь и прячется опасность.
В .NET за это отвечают несколько сериализаторов. Самый одиозный — BinaryFormatter.
Пару слов про BinaryFormatter. Он опасен сам по себе, без всяких условий: имя типа зашито прямо в данных, и при загрузке он может воссоздать почти любой объект и попутно выполнить чужой код. Поэтому под него давно готовы публичные gadget-цепочки, а рабочую нагрузку из них собирает генератор ysoserial.net — достаточно подсунуть жертве специально собранный файл:

Именно из-за этого он объявлен устаревшим и с .NET 9 удалён из рантайма. Ключевое отличие от нашего героя: BinaryFormatter берёт тип из данных всегда, а XmlSerializer — только если разработчик сам отдал выбор типа наружу (Type.GetType(...)). То есть XmlSerializer по умолчанию безопасен, а уязвимость появляется только по вине кода приложения.
Мы смотрим именно на XmlSerializer — он считается «хорошим» и живёт в тысячах приложений. Покажем, при каком условии «хороший» превращается в дыру.
Азбука 2. Объект, свойство, геттер, сеттер
Ещё немного «букв» — без них не собрать «слова». Четыре термина на одном примере:
- Класс (тип)чертёж, описание, из чего состоит вещь.
TextItem— это класс. - Объектконкретная вещь, сделанная по чертежу.
new TextItem()— это объект. - Полеячейка внутри объекта, где значение реально лежит.
- Свойство«ручка» снаружи объекта, через которую кладут или достают значение.
Свойство — это не просто ячейка. Это пара маленьких функций, которые срабатывают в момент обращения к нему:
объект.text → это ЧТЕНИЕ → выполняется get { ... } (геттер)
объект.text = "привет" → это ЗАПИСЬ → выполняется set { ... } (сеттер), value = "привет"отвечает на «дай значение» — срабатывает, когда свойство читают.
обрабатывает «на, запиши значение» — срабатывает, когда в свойство пишут; присваиваемое значение лежит в переменной value.
Смотрим на код класса. Строки пронумерованы — на них будем ссылаться:

Вся суть — в различии строк 7 и 8. Строка 7 ожидаемая: сеттер кладёт value в поле. А красная строка 8 делает что-то ещё — печатает в консоль. Это называется побочный эффект: сеттер не обязан только хранить, в него можно вписать любой код. Сегодня печать — завтра запуск программы.
Когда строка 8 исполнится? Ровно в момент записи в свойство. Проследим:

# Вывод в консоль: set text = привет
Единственная строка печати появилась из-за присваивания во второй строке — сработал сеттер. Это тот самый «спусковой крючок», который дальше нажмёт за нас десериализатор.
Когда XmlSerializer восстанавливает объект из куска <text>привет</text>, он под капотом делает ровно объект.text = "привет" — то есть вызывает сеттер. Если внутри стоит запуск программы, он выполнится просто при загрузке файла. Запомните этот механизм — на нём держится всё дальнейшее.
Азбука 3. Как тип, объект и свойство выглядят в XML
Соединим Азбуку 1 и 2 на одном фрагменте файла. Серым (без подсветки) — фиксированный каркас, цветным — динамические части:

Теги <root> и <item> — постоянный скелет: загрузчик ждёт именно их (SelectNodes("root/item")), переименовывать нельзя. Крутим мы только цветное. И эти имена — не стандарт: их задаёт код конкретного приложения, в другом они могут быть любыми.
В XML спрятаны три имени, и все берутся из класса. Полная карта соответствий:

- (1) атрибут typeполное имя класса — какой тип создать
- (2) тег-объектто же имя класса — обязано совпадать с (1)
- (3) тег-свойствосовпадает с именем свойства класса; значение (зелёное) уйдёт в сеттер
- (1) и (2) — это один класс, и они обязаны совпадать. Поставите
type="NoteItem", но тег оставите<TextItem>— получите ошибку<TextItem> was not expected(увидим в Шаге 6). - (3) — тег совпадает с именем свойства. Незнакомый тег XmlSerializer молча игнорирует (сеттер не сработает), поэтому имена свойств берём из целевого класса.
Держите картинку в голове: чтобы натравить загрузчик на нужный класс, меняем имя класса в обоих жёлтых местах, а внутрь кладём синие теги его свойств с зелёными значениями. Именно так <text> у TextItem превратится в <command> у gadget’а CommandItem в Шаге 8.
Инструменты и стенд
- dnSpyдекомпилятор и отладчик .NET. Открывает .exe/.dll и показывает читаемый C#, даже если исходников нет. Главный инструмент разбора.
- .NET SDKсобрать и запустить стенд:
dotnet build,dotnet <app>.dll. - Текстовый редакторчтобы поправить XML-файл руками.
- Команда ОС
touch,idи другие — «полезная нагрузка» для доказательства.
Стенд — два консольных приложения: XmlObjectSerializer (сохраняет объект в файл) и XmlObjectLoader (загружает его обратно). Именно их мы и будем «вскрывать», как если бы исходников у нас не было.
8 шагов: от файла до RCE
Переход от чтения кода к рабочему эксплойту — это последовательность понятных шагов, а не «магия». Каждый воспроизводится на приложенном стенде.
- 01Шаг 1 из 8
Декомпиляция: открываем приложение в dnSpy
На руках — только собранные файлы (
XmlObjectSerializer.dll,XmlObjectLoader.dll), как будто мы забрали их с целевой машины. Исходников нет. Но .NET компилируется не в «голый» машинный код, а в промежуточный IL, из которого декомпилятор восстанавливает почти исходный C#.Открываем
XmlObjectLoader.dllв dnSpy: File → Open. Слева — дерево сборки. Раскрываем узлы: сборка → пространство имён → классы → методы. Это как оглавление книги: видно всё, что внутри.
dnSpy: слева дерево сборки XmlObjectLoader, справа — декомпилированный C#. Исходников не было — код восстановлен из IL. - классы — из чего состоит приложение (Program, модельные классы, вспомогательные);
- метод Main — точка входа, отсюда начинается логика;
- знакомые типы — XmlSerializer, Process, File подсвечивают интересные места.
Никакой магии: декомпиляция превращает непонятный бинарник обратно в читаемый текст. Дальше — обычное чтение кода.
- 02Шаг 2 из 8
Где сериализация, а где десериализация
Прежде чем искать баг, поймём, кто здесь кто. Правило простое:
Ищем вызов Что это значит В каком приложении new XmlSerializer(...) создаётся сериализатор под какой-то тип и там, и там .Serialize(...) объект → текст (сериализация) которое сохраняет / отправляет .Deserialize(...) текст → объект (десериализация) которое загружает / принимает В дереве слева открываем загрузчик:
XmlObjectLoader → Program → Load— справа его код, и в нём вызовDeserialize(текст → объект). Чтобы убедиться, что вызывающий один: правой кнопкой наDeserialize→ Analyze (Ctrl+Shift+R) → раздел Used By покажет единственного:Program.Load. Уязвимости живут на стороне десериализации — там, где приложение принимает данные извне.
dnSpy Analyze → Used By: единственный вызывающий Deserialize — метод Load загрузчика (точка входа данных). Симметрично, поиск по
Serializeпривёл бы вXmlObjectSerializer— приложение, которое создаёт файл. С него и начнём: так понятнее формат, который потом будем ломать. - 03Шаг 3 из 8
Читаем сериализатор: откуда берётся формат файла
Открываем
XmlObjectSerializer. Логика в двух методах —MainиExport:
Main: приложение сохраняет два разных класса (жёлтые). Уже видно важное: приложение умеет сохранять два разных класса — TextItem и NoteItem. Значит, в файл может лечь объект разного типа — и загрузчику надо будет как-то понять, какой именно. Смотрим, как пишется файл:

Export: в файл пишется полное имя типа (жёлтое). Строка 4 — сердце формата. В файл записывается полное имя типа: не просто
TextItem, аXmlObjectSerializer.TextItem, XmlObjectSerializer, Version=1.0.0.0, .... Зачем? Чтобы загрузчик прочитал это имя и понял, какой класс восстанавливать. Запомним: тип объекта хранится прямо в файле, рядом с данными.Запускаем и смотрим результат:
$ dotnet XmlObjectSerializer.dll "confidential report" 1 [TextItem] set text = confidential report [+] saved: .../store.xml

store.xml: класс (жёлтый), свойство (синий), значение (зелёное). 
Сериализация TextItem: сеттер печатает строку, объект и его тип уходят в store.xml. Строка
[TextItem] set text = ...в выводе — это сеттер уже сработал. Пока честно, это наш сериализатор. Но тот же сеттер сработает и при загрузке. - 04Шаг 4 из 8
Как искать сеттеры и читать модельные классы
Формат завязан на модельные классы. Открываем в dnSpy класс TextItem. Как быстро находить сеттеры: у свойства в dnSpy есть узлы
get_textиset_text— это геттер и сеттер; либо просто ищем в коде блокset { ... }. Всё, что стоит внутри set, исполнится при десериализации.
Модели: класс (жёлтый), свойство (синий), значение (зелёное), красным — побочный эффект. Два класса, у каждого свойство
textс сеттером, печатающим свою метку. Побочный эффект безобидный, но он даёт видимый индикатор: по строке в консоли мы точно знаем, чей сеттер отработал, а значит — объект какого класса создан.Загрузим штатный
store.xmlдесериализатором:$ dotnet XmlObjectLoader.dll store.xml [TextItem] set text = confidential report
Строку напечатал не загрузчик — он лишь вызвал
Deserialize. Печать пришла из сеттера TextItem. Вот тот самый принцип из Азбуки 2 вживую: загрузка файла исполнила код сеттера. Пока безобидно — но принцип работает.
Штатная загрузка: строку печатает сеттер класса TextItem, а не загрузчик. Код исполнился при десериализации. - 05Шаг 5 из 8
Находим уязвимость: тип берётся из файла
Возвращаемся к методу
Loadв XmlObjectLoader. Читаем построчно:
Уязвимый Load: красным — тип из файла (стр. 8) и Type.GetType без белого списка (стр. 9). - Имя типа из файла:
typeNameберётся из атрибутаtype. Кто владеет файлом — владеет этой строкой. - Резолв типа:
Type.GetType(typeName)резолвит любой тип по имени. Без белого списка, без проверки. Сюда пройдёт любой класс из любой загруженной сборки. - Исполнение:
Deserializeзаполняет свойства созданного объекта, вызывая сеттеры — здесь и «выстреливает» подставленный тип.
Сравните безопасный и опасный варианты — тип зашит в коде против типа из файла:

Безопасно (typeof, зелёное) против опасно (Type.GetType, красное). Критерий уязвимостиЕсли класс (тип) для десериализации строится из данных, которые контролирует атакующий, и нет белого списка — это дыра. Осталось воспользоваться.

Уязвимая строка в dnSpy: Type.GetType(typeName) — тип берётся из файла и создаётся без проверки. - Имя типа из файла:
- 06Шаг 6 из 8
Подмена класса: тип решаем мы
Раз тип берётся из атрибута
type(Азбука 3), подставим туда другой известный класс — NoteItem. Правимstore.xmlруками. Имя класса сидит в двух местах — атрибут type и имя тега-объекта — и менять надо оба:
Подмена класса: красным — было (TextItem), зелёным — стало (NoteItem). $ dotnet XmlObjectLoader.dll swap.xml [NoteItem] set text = confidential report
Мы не трогали и не пересобирали приложение. Отредактировали XML — и загрузчик создал другой класс. Метка
[NoteItem]вместо[TextItem]доказывает: типом управляем мы. Это репетиция настоящей атаки — осталось найти класс поинтереснее печати.Почему обязательно менять оба места? Оставим атрибут
type="NoteItem", но тег вернём в<TextItem>— и десериализация упадёт:Unhandled exception. System.InvalidOperationException: <TextItem xmlns=''> was not expected.

cat swap.xml: класс NoteItem стоит в двух местах — атрибут type и тег <NoteItem>; запуск загрузчика печатает [NoteItem] вместо [TextItem] — типом управляем мы. - 07Шаг 7 из 8
Что такое gadget: наглядно gadget против не-gadget
Gadget (гаджет, «деталь») — это класс, который сам по себе легитимен и лежит в приложении для своих нужд, но при десериализации даёт атакующему что-то опасное. Аналогия: злоумышленник не приносит своё оружие — берёт то, что уже валяется на месте, и применяет не по назначению.
Всё решает один вопрос: что стоит внутри сеттера (или конструктора)? Сравним два класса из нашего стенда:
класс А · не-gadgetTextItem сеттер только печатает — безобидный побочный эффект.
класс Б · gadgetCommandItem сеттер зовёт Run() → запуск процесса.

Не-gadget: сеттер только печатает (красным — безобидный побочный эффект). 
Gadget: сеттер зовёт Run() (красным) → запуск процесса. Класс Что делает сеттер Опасный сток? Что получает атакующий TextItem сохраняет + печатает строку нет ничего полезного — просто печать NoteItem сохраняет + печатает строку нет то же самое — печать CommandItem сохраняет + Process.Start да → RCE выполнение произвольной команды ОС Правило, короче некудаgadget = класс, у которого в сеттере/конструкторе есть опасный сток (запуск процесса, запись файла, загрузка кода, сеть...). Не-gadget = сеттер только хранит значение — подставлять его бессмысленно.
«Опасный сток» (sink) — это вызов, который делает что-то за пределами простого хранения данных. По таким вызовам gadget и ищут — грепом в dnSpy (Search → имя вызова) по всей сборке, глядя, не стоит ли он внутри сеттера/конструктора:

Опасные стоки (красные) — что грепать внутри сеттеров/конструкторов. Поиск по
Processв нашей сборке приводит в CommandItem — вот он целиком:
Gadget CommandItem: зелёным — путь данных (value → parts[0]), красным — sink p.Start(). Зелёным виден путь данных атакующего:
value(наша строка из тега<command>) оседает в поле_command, оттуда попадает вparts[0], а он идёт вFileNameпроцесса — и красныйp.Start()его запускает. Всю цепочку заводит XmlSerializer: он заполняет свойство нашим значением, а класс сам доносит его до запуска — без единого действия жертвы, кроме загрузки файла.- публичный класс, конструктор без аргументов — ✔ XmlSerializer его создаст;
- свойство command (синее) — ✔ заполнится из тега <command>;
- его сеттер зовёт Run(), а тот — Process.Start — ✔ опасный сток;
- значение команды приходит целиком из XML (через value в сеттере) — ✔ под контролем атакующего.

Схема: тег <command> → сеттер → Run() → Process.Start (красным). 
Класс CommandItem в dnSpy: сеттер command через Run() вызывает Process.Start — готовый gadget. - 08Шаг 8 из 8
Эксплуатация: gadget → RCE
Собираем боевой файл по карте из Азбуки 3. Имя класса меняем в обоих жёлтых местах на CommandItem. А тег-свойство теперь не
<text>, а <command> — потому что у CommandItem свойство называетсяcommand. Внутрь кладём команду. Для наглядного и воспроизводимого доказательства пусть gadget создаёт файл-маркер/tmp/pwned_by_deser.
Payload: класс (жёлтый), свойство command (синее), команда (зелёная). $ rm -f /tmp/pwned_by_deser $ dotnet XmlObjectLoader.dll rce.xml && ls -la /tmp/pwned_by_deser [CommandItem] started: /usr/bin/touch /tmp/pwned_by_deser -rw-rw-r-- 1 kali kali 0 ... /tmp/pwned_by_deser
Файл появился — значит
<command>из XML долетел доProcess.Startи выполнился. Это и есть RCE: содержимое команды полностью под контролем того, кто пишет файл. В боевом сценарии вместоtouchбыла бы загрузка и запуск stager’а,powershell -enc ..., reverse shell — что угодно, с правами процесса приложения.
Десериализация rce.xml создаёт /tmp/pwned_by_deser: <command> из XML долетел до Process.Start. # Пройденный путь целиком: правим type в XML → Type.GetType() создаёт CommandItem → → Deserialize заполняет command → сеттер зовёт Process.Start → RCE
Что это даёт
Через один XML-файл — выполнение произвольной команды в контексте приложения:
RCE «из коробки», если в загруженных сборках есть удобный gadget (как CommandItem). Свои «командные» классы, обёртки над Process, «плагины» — первые кандидаты.
Даже без явного gadget опасны любые сеттеры/конструкторы с побочными эффектами: запись файлов, сетевые запросы, LoadXml/XmlResolver (XXE, SSRF).
Поверхность шире одного класса: Type.GetType видит все загруженные сборки, включая стандартную библиотеку .NET. Правда, использовать чужой тип как gadget мешает сама модель XmlSerializer — нужен публичный конструктор без аргументов и опасный сеттер/конструктор.
По CVSS такое стабильно High/Critical — вектор зависит от того, откуда приходит файл (локально, по сети, из общей папки) и нужна ли аутентификация, чтобы подсунуть его приложению.
А если своего гаджета нет?
Раз Type.GetType создаёт любой тип, хочется взять готовый gadget прямо из .NET. Но найти чужой класс и заставить его сработать — разные задачи. Найти легко: подойдёт любой тип из загруженных сборок. А сработает он лишь так, как умеет XmlSerializer: тот создаёт объект пустым конструктором и заполняет публичные свойства — чужие методы он не вызывает.
Опасное действие должно стоять прямо в сеттере или конструкторе — с важной разницей: конструктор (всегда без аргументов) срабатывает при создании объекта, до заполнения свойств, поэтому наших данных из XML в нём ещё нет — годится он лишь для фиксированного эффекта; а сеттер получает подконтрольное value из файла, поэтому для «запусти вот эту команду» нужен именно он. Готовых таких классов в стандартной библиотеке почти нет.
Поэтому реальный источник gadget — обычно сами классы приложения и его библиотек (как CommandItem), а не стандартная библиотека. У других сериализаторов (BinaryFormatter, Json.NET с TypeNameHandling) модель богаче — там живут универсальные гаджеты и цепочки; каталог по форматтерам — ysoserial.net.
Как чинить
Корень — загрузчик доверяет имени типа из недоверенного источника. Лечится тем, что тип не должен приходить из данных.
- 1
Не берите тип из входных данных
Фиксируйте его в коде:
new XmlSerializer(typeof(TextItem)). Если типов несколько — сопоставляйте по белому списку заранее известных классов, а не черезType.GetType(строка_из_файла). - 2
Проверяйте источник и целостность файла
Данные из общей папки, сети или от пользователя — недоверенные по умолчанию. Подпись/HMAC на файле отсекает подмену.
- 3
Сеттеры и конструкторы модельных классов — без побочных эффектов
Никаких Process.Start, записи файлов, сетевых вызовов в свойствах, которые заполняет сериализатор.
- 4
Наименьшие привилегии
Процессу приложения незачем запускать дочерние процессы или писать за пределы своей папки — ограничьте это на уровне ОС.
- 5
Статический анализ в CI
Паттерн «Type.GetType/Activator.CreateInstance от переменной, идущей из ввода» ловится Semgrep/CodeQL на ревью, а не на пентесте.


Здесь t — локальная переменная, в которую TryGetValue кладёт одобренный Type из словаря Allowed. Даже если в файле напишут CommandItem, его нет в списке → срабатывает throw, и до Deserialize дело не доходит. Тип больше не приходит из данных — вот и вся починка.
Итог
Разложим всё по полкам ещё раз — это и есть та самая «азбука»:
Сериализация — объект в текст, десериализация — текст в объект; и десериализация исполняет код.
Свойство — «ручка» объекта; сеттер — код, срабатывающий при записи; десериализатор дёргает сеттер на каждый тег.
Декомпиляция (dnSpy) возвращает читаемый C# из бинарника — дальше просто чтение.
Ищем Deserialize — это точка входа данных; смотрим, откуда берётся тип.
Тип из файла + Type.GetType без белого списка = уязвимость (линейка приложена).
Gadget — легитимный класс с опасным стоком в сеттере; ищется грепом по Process.Start/File.*/...; не-gadget просто хранит значение.
Подставляем gadget в type — получаем RCE.
Ни ассемблера, ни нулевого дня — только чтение кода по шагам. XmlSerializer называют «безопасным», и это правда — ровно до строки Type.GetType(строка_из_файла). Цена вопроса — typeof(...) вместо неё.
Приложение. Найти это грепом
Весь разбор выше сводится к нескольким поискам по коду. Чтобы грепать было по чему, в dnSpy жмём правый клик по сборке → Export to Project (получаем дерево .cs); без GUI — ilspycmd XmlObjectLoader.dll > loader.cs. Дальше — ripgrep:
rg -n '\.Deserialize\s*\(' --glob '*.cs'rg -n '\.Serialize\s*\(|new\s+XmlSerializer\s*\(' --glob '*.cs'rg -nP 'new\s+XmlSerializer\s*\(\s*(Type\.GetType|Activator|\w+\.GetType)' --glob '*.cs'rg -n 'Type\.GetType\s*\(|Activator\.CreateInstance|Assembly\.Load' --glob '*.cs'rg -n '\bclass\s+\w+' --glob '*.cs'rg -nP '^\s*set\b|\bset\s*\{|\bset_\w+\s*\(' --glob '*.cs'rg -n 'Process\.Start|new\s+Process\s*\(|\.Start\s*\(|File\.(Write|ReadAll|Delete|Copy|Move|Open)|StreamWriter|Assembly\.Load|Activator\.CreateInstance|WebClient|HttpClient|Socket|XmlResolver|\.LoadXml|Registry|CSharpCodeProvider|MethodInfo|DynamicInvoke' --glob '*.cs'rg -nUP 'set\s*\{(?:[^{}]|\{[^{}]*\})*?(Process\.Start|\.Start\s*\(|File\.\w+|Assembly\.Load|Activator\.|Run\s*\(|Exec\w*\s*\()' --glob '*.cs'Если ripgrep нет — та же «главная» проверка через grep:
grep -rnE 'new[[:space:]]+XmlSerializer[[:space:]]*\([[:space:]]*(Type\.GetType|Activator|[A-Za-z_]+\.GetType)' --include='*.cs' .Как это читается по шагам:
- (1) найти Deserialize — это вход. Рядом посмотреть, откуда берётся тип.
- (3)/(4) — тип строится из строки/переменной (не typeof(...)) и без белого списка → уязвимость.
- (7) найти стоки, (8) — сузить до тех, что срабатывают из сеттера. Класс со стоком в сеттере/конструкторе + публичным свойством = gadget.
В нашем CommandItem Process.Start лежит не прямо в сеттере, а в методе Run(), который сеттер вызывает. Паттерн «сток внутри set{}» такой gadget пропустил бы — поэтому добавлены обёртки Run(/Exec.... Универсально: сначала ищем стоки (7), затем для каждого смотрим, не дёргается ли он (прямо или через хелпер) из сеттера/конструктора класса, доступного XmlSerializer.