Автор статьи: Юрий Гришаев — специалист по анализу защищённости веб‑приложений и ПО под Windows. Тимлид в АБП2Б. Сертифицированный специалист OSCP+, входит в Топ75 лучших хакеров на Standoff 365 по найденным уязвимостям.

Дисклеймер. Все описанное выполнялось в изолированной лаборатории на вымышленном приложении, которое я собрал специально для статьи. Совпадения имен с реальными продуктами случайны. Материал образовательный — для AppSec,.NET‑разработчиков и всех, кому интересно, как «сохранить объект в файл» превращается в удаленное выполнение кода. Не применяйте это к системам, на которые у вас нет письменного разрешения.

Есть распространенный страх: анализ защищенности — это что‑то темное, для избранных, с ассемблером и магией. На деле большая часть работы — это чтение кода по понятным правилам. Сейчас разберем реальный по механике баг (небезопасная десериализация в.NET → выполнение произвольной команды) так, будто читаем букварь: сначала буквы, потом слоги, потом целые слова.

К концу статьи вы будете уметь: декомпилировать.NET‑приложение, находить в нем точки сериализации и десериализации, читать свойства и сеттеры, отличать безобидный класс от «gadget» и по одной строчке понимать, есть ли уязвимость. Все воспроизводится на приложенном стенде.

Про цвет. Листинги с подсветкой даны картинками. Цвета: желтый — имя класса/тип, синий — имя свойства, зеленый — значение (данные), красный — опасное место (sink, уязвимая строка, побочный эффект). В «было/стало» красный — что убираем, зеленый — что ставим.

Азбука 1. Что такое сериализация и десериализация

Внутри программы данные живут как объекты — структуры в оперативной памяти со ссылками и адресами. Такой объект нельзя просто так записать в файл или отправить по сети. Его сначала превращают в плоский текст (или поток байтов), а на другой стороне собирают обратно:

  • Сериализация — процесс превращения объекта (данных в памяти) в текст/байты, пригодные, чтобы сохранить в файл или передать по сети. Здесь — в XML.

  • Десериализация — обратный процесс: из этого текста/байтов заново собирается объект в памяти.

Объект в памяти Текст в файле (XML)
┌────────────────────┐ сериализация ┌──────────────────────────┐
│ TextItem │ ───────────────▶ │ <TextItem> │
│ text = “отчет” │ (объект → текст) │ <text>отчет</text> │
│ │ ◀─────────────── │ </TextItem> │
└────────────────────┘ десериализация └──────────────────────────┘
(текст → объект)

Ключевая мысль, вокруг которой крутится вся статья: десериализация — это не пассивное чтение. Чтобы собрать объект обратно, программа его создает и заполняет свойства, а заполнение свойства — это исполнение кода (мы к этому придем в Азбуке 2). Именно здесь и прячется опасность.

В.NET за это отвечают несколько сериализаторов. Самый одиозный — BinaryFormatter.

Пару слов про BinaryFormatter. Он опасен сам по себе, без всяких условий: имя типа зашито прямо в данных, и при загрузке он может воссоздать почти любой объект и попутно выполнить чужой код. Поэтому под него давно готовы публичные gadget‑цепочки, а рабочую нагрузку из них собирает генератор ysoserial.net — достаточно подсунуть жертве специально собранный файл:

BinaryFormatter: Deserialize(stream) — sink, RCE «из коробки».
BinaryFormatter: Deserialize(stream) — sink, RCE “из коробки”.

Именно из‑за этого он объявлен устаревшим и с.NET 9 удален из рантайма. Ключевое отличие от нашего героя: BinaryFormatter берет тип из данных всегда, а XmlSerializer — только если разработчик сам отдал выбор типа наружу (Type.GetType(...)). То есть XmlSerializer по умолчанию безопасен, а уязвимость появляется только по вине кода приложения — при каком именно условии, мы разберем ниже.

Мы смотрим именно на XmlSerializer — он считается «хорошим» и живет в тысячах приложений. Покажем, при каком условии «хороший» превращается в дыру.

Азбука 2. Объект, свойство, геттер, сеттер

Еще немного «букв» — без них не собрать «слова». Четыре термина на одном примере:

  • Класс (тип) — чертеж, описание, из чего состоит вещь. TextItem — это класс.

  • Объект — конкретная вещь, сделанная по чертежу. new TextItem() — это объект.

  • Поле — ячейка внутри объекта, где значение реально лежит.

  • Свойство — «ручка» снаружи объекта, через которую кладут или достают значение.

Свойство — это не просто ячейка. Это пара маленьких функций, которые срабатывают в момент обращения к нему:

объект.text            → это ЧТЕНИЕ  → выполняется get { ... }   (геттер)
объект.text = "привет" → это ЗАПИСЬ  → выполняется set { ... }   (сеттер), value = "привет"

  • геттер (get) отвечает на «дай значение» — срабатывает, когда свойство читают;

  • сеттер (set) обрабатывает «на, запиши значение» — срабатывает, когда в свойство пишут; присваиваемое значение лежит в переменной value.

Смотрим на код класса. Строки пронумерованы — на них будем ссылаться:

Анатомия свойства: поле, геттер, сеттер; красным — побочный эффект в сеттере
Анатомия свойства: поле, геттер, сеттер; красным — побочный эффект в сеттере

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

Когда строка 8 исполнится? Ровно в момент записи в свойство. Проследим:

Когда срабатывает сеттер: запись (стр. 2) зовет сеттер, чтение (стр. 3) — геттер.
Когда срабатывает сеттер: запись (стр. 2) зовет сеттер, чтение (стр. 3) — геттер.

Вывод в консоль:

set text = привет

Единственная строка печати появилась из‑за присваивания во второй строке — сработал сеттер. Это тот самый «спусковой крючок», который дальше нажмет за нас десериализатор.

Мост к десериализации. Когда XmlSerializer восстанавливает объект из куска <text>привет</text>, он под капотом делает ровно объект.text = "привет" — то есть вызывает сеттер (строки 7–8). Если в строке 8 стоит запуск программы, он выполнится просто при загрузке файла. Запомните этот простой механизм — на нем держится все дальнейшее.

Азбука 3. Как тип, объект и свойство выглядят в XML

Соединим Азбуку 1 и 2 на одном фрагменте файла. Серым (без подсветки) — фиксированный каркас, цветным — динамические части:

Тип (желтый), объект, свойство (синий), значение (зеленый). <root>/<item> — каркас.
Тип (желтый), объект, свойство (синий), значение (зеленый). <root>/<item> — каркас.

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

В XML спрятаны три имени, и все берутся из класса. Полная карта соответствий:

Карта соответствий: цвет ↔ что это в XML.
Карта соответствий: цвет ↔ что это в XML.

Две тонкости, которых нет в таблице:

  • (1) и (2) — это один класс, и они обязаны совпадать. Поставите type=“NoteItem”, но тег оставите <TextItem> — получите ошибку <TextItem> was not expected (увидим в Шаге 6).

  • (3) — тег совпадает с именем свойства. Незнакомый тег XmlSerializer молча игнорирует (сеттер не сработает), поэтому имена свойств берем из целевого класса.

Держите картинку в голове: чтобы натравить загрузчик на нужный класс, меняем имя класса в обоих желтых местах (1) и (2), а внутрь кладем синие теги его свойств с зелеными значениями. Именно так <text> у TextItem превратится в <command> у gadget'а CommandItem в Шаге 8.

Инструменты

  • dnSpy — декомпилятор и отладчик.NET. Открывает.exe/.dll и показывает читаемый C#, даже если исходников нет. Наш главный инструмент.

  • NET SDK — собрать и запустить стенд (dotnet build, dotnet <app>.dll).

  • Любой текстовый редактор — чтобы поправить XML‑файл руками.

  • Любая команда ОС (touch, id, …) — “полезная нагрузка” для доказательства.

Стенд — два консольных приложения: XmlObjectSerializer (сохраняет объект в файл) и XmlObjectLoader (загружает его обратно). Именно их мы и будем «вскрывать», как если бы исходников у нас не было.

Шаг 1. Декомпиляция: открываем приложение в dnSpy

На руках — только собранные файлы (XmlObjectSerializer.dll, XmlObjectLoader.dll), как будто мы забрали их с целевой машины. Исходников нет. Но.NET компилируется не в «голый» машинный код, а в промежуточный IL, из которого декомпилятор восстанавливает почти исходный C#.

Открываем XmlObjectLoader.dll в dnSpy: File → Open. Слева — дерево сборки. Раскрываем узлы: сборка → пространство имен → классы → методы. Это как оглавление книги: видно все, что внутри.

dnSpy: слева дерево сборки XmlObjectLoader, справа — декомпилированный C#. Исходников не было — код восстановлен из IL.
dnSpy: слева дерево сборки XmlObjectLoader, справа — декомпилированный C#. Исходников не было — код восстановлен из IL.

Что искать в дереве первым делом:

  • классы — из чего состоит приложение (Program, модельные классы, вспомогательные);

  • метод Main — точка входа, отсюда начинается логика;

  • знакомые типы — XmlSerializer, Process, File подсвечивают интересные места.

Никакой магии: декомпиляция превращает непонятный бинарник обратно в читаемый текст. Дальше — обычное чтение кода.

Шаг 2. Где сериализация, а где десериализация

Прежде чем искать баг, поймем, кто здесь кто. Правило простое:

Ищем вызов

Что это значит

В каком приложении

new XmlSerializer(...)

создается сериализатор под какой‑то тип

и там, и там

Serialize(...)

объект → текст (сериализация)

которое сохраняет / отправляет

Deserialize(...)

текст → объект (десериализация)

которое загружает / принимает

В дереве слева открываем загрузчик: XmlObjectLoader → Program → Load — справа его код, и в нем вызов Deserialize (текст → объект). Чтобы убедиться, что вызывающий один, правой кнопкой на Deserialize → Analyze (Ctrl+Shift+R) → раздел Used By покажет единственного: Program.Load. Уязвимости живут на стороне десериализации — там, где приложение принимает данные извне.

dnSpy Analyze → Used By: единственный вызывающий Deserialize — метод Load загрузчика (точка входа данных).
dnSpy Analyze → Used By: единственный вызывающий Deserialize — метод Load загрузчика (точка входа данных).

Симметрично, поиск по Serialize привел бы в XmlObjectSerializer — приложение, которое создает файл. С него и начнем: так понятнее формат, который потом будем ломать.

Шаг 3. Читаем сериализатор: откуда берется формат файла

Открываем XmlObjectSerializer. Логика в двух методах — Main и Export:

Main: приложение сохраняет два разных класса (желтые).
Main: приложение сохраняет два разных класса (желтые).

Уже видно важное: в строках 6–7 приложение умеет сохранять два разных класса — TextItem и NoteItem (желтые). Значит, в файл может лечь объект разного типа — и загрузчику надо будет как‑то понять, какой именно. Смотрим, как пишется файл:

Export: в файл пишется полное имя типа (желтое).
Export: в файл пишется полное имя типа (желтое).

Строка 4 — сердце формата. В файл записывается полное имя типа (желтое): не просто TextItem, а XmlObjectSerializer.TextItem, XmlObjectSerializer, Version=1.0.0.0, …. Зачем? Чтобы загрузчик прочитал это имя и понял, какой класс восстанавливать. Запомним: тип объекта хранится прямо в файле, рядом с данными — как в Азбуке 3.

Запускаем и смотрим результат:

dotnet XmlObjectSerializer.dll "confidential report" 1

[TextItem] set text = confidential report
[+] saved: .../store.xml

store.xml: класс (желтый), свойство (синий), значение (зеленый).
store.xml: класс (желтый), свойство (синий), значение (зеленый).
Сериализация TextItem: сеттер печатает строку, объект и его тип уходят в store.xml.
Сериализация TextItem: сеттер печатает строку, объект и его тип уходят в store.xml.

Строка [TextItem] set text =... в выводе — это сеттер уже сработал (на item.text = text в Main). Пока честно, это наш сериализатор. Но тот же сеттер сработает и при загрузке.

Шаг 4. Как искать сеттеры и читать модельные классы

Формат завязан на модельные классы. Открываем в dnSpy класс TextItem. Как быстро находить сеттеры: у свойства в dnSpy есть узлы get_text и set_text — это геттер и сеттер; либо просто ищем в коде блок set { … }. Все, что стоит внутри set (красное), исполнится при десериализации.

Модели: класс (желтый), свойство (синий), значение (зеленый), красным — побочный эффект.
Модели: класс (желтый), свойство (синий), значение (зеленый), красным — побочный эффект.

Два класса (желтые), у каждого свойство text (синее) с сеттером, печатающим свою метку (красные строки 7 и 17). Побочный эффект безобидный, но он дает видимый индикатор: по строке в консоли мы точно знаем, чей сеттер отработал, а значит — объект какого класса создан.

Загрузим штатный store.xml десериализатором:

dotnet XmlObjectLoader.dll store.xml

[TextItem] set text = confidential report

Строку напечатал не загрузчик — он лишь вызвал Deserialize. Печать пришла из сеттера TextItem. Вот тот самый принцип из Азбуки 2 вживую: загрузка файла исполнила код сеттера. Пока безобидно — но принцип работает.

Штатная загрузка: строку печатает сеттер класса TextItem, а не загрузчик. Код исполнился при десериализации.
Штатная загрузка: строку печатает сеттер класса TextItem, а не загрузчик. Код исполнился при десериализации.

Шаг 5. Находим уязвимость: тип берется из файла

Возвращаемся к методу Load в XmlObjectLoader. Читаем построчно:

Уязвимый Load: красным — тип из файла (стр. 8) и Type.GetType без белого списка (стр. 9).
Уязвимый Load: красным — тип из файла (стр. 8) и Type.GetType без белого списка (стр. 9).

Красным — два ключевых места, помеченных в коде (1) и (2), а (3) их исполняет:

  1. (1) — имя типа из файла: typeName берется из атрибута type. Кто владеет файлом — владеет этой строкой.

  2. (2) — резолв типа: Type.GetType(typeName) резолвит любой тип по имени. Без белого списка, без проверки. Сюда пройдет любой класс из любой загруженной сборки — свой или из стандартной библиотеки.NET.

  3. (3) — исполнение: Deserialize заполняет свойства созданного объекта, вызывая сеттеры — здесь и «выстреливает» подставленный тип.

Сравните безопасный и опасный варианты — тип зашит в коде против типа из файла:

Безопасно (typeof, зеленый) против опасно (Type.GetType, красный).
Безопасно (typeof, зеленый) против опасно (Type.GetType, красный).

Вот критерий уязвимости, который можно прикладывать как линейку: если класс (тип) для десериализации строится из данных, которые контролирует атакующий, и нет белого списка — это дыра. Осталось воспользоваться.

Уязвимая строка в dnSpy: Type.GetType(typeName) — тип берется из файла и создается без проверки.
Уязвимая строка в dnSpy: Type.GetType(typeName) — тип берется из файла и создается без проверки.

Шаг 6. Подмена класса: тип решаем мы

Раз тип берется из атрибута type (Азбука 3), подставим туда другой известный класс — NoteItem. Правим store.xml руками. Как мы уже знаем, имя класса сидит в двух местах — атрибут type и имя тега‑объекта — и менять надо оба. Ниже красным — что было, зеленым — что стало:

Подмена класса: красным — было (TextItem), зеленым — стало (NoteItem).
Подмена класса: красным — было (TextItem), зеленым — стало (NoteItem).

dotnet XmlObjectLoader.dll swap.xml

[NoteItem] set text = confidential report

Мы не трогали и не пересобирали приложение. Отредактировали XML — и загрузчик создал другой класс. Метка [NoteItem] вместо [TextItem] доказывает: типом управляем мы. Это репетиция настоящей атаки — осталось найти класс поинтереснее печати.

Почему обязательно менять оба места (как обещал в Азбуке 3)? Оставим атрибут type=“NoteItem”, но тег вернем в <TextItem> — и десериализация упадет: XmlSerializer для NoteItem ждет корневой тег <NoteItem>, а видит чужой:

Unhandled exception. System.InvalidOperationException: <TextItem xmlns=''> was not expected.

Вот почему имя класса в файле продублировано — и почему при подмене его правят в обеих точках.

cat swap.xml: класс NoteItem стоит в двух местах — атрибут type и тег <NoteItem>; запуск загрузчика печатает [NoteItem] вместо [TextItem] — типом управляем мы.
cat swap.xml: класс NoteItem стоит в двух местах — атрибут type и тег <NoteItem>; запуск загрузчика печатает [NoteItem] вместо [TextItem] — типом управляем мы.

Шаг 7. Что такое gadget: наглядно gadget против не‑gadget

Gadget (гаджет, «деталь») — это класс, который сам по себе легитимен и лежит в приложении для своих нужд, но при десериализации дает атакующему что‑то опасное. Аналогия: злоумышленник не приносит свое оружие — берет то, что уже валяется на месте, и применяет не по назначению.

Все решает один вопрос: что стоит внутри сеттера (или конструктора)? Сравним два класса из нашего стенда — красным подсвечен побочный эффект сеттера.

Класс А — TextItem (НЕ gadget):

Не-gadget: сеттер только печатает (красным — безобидный побочный эффект).
Не‑gadget: сеттер только печатает (красным — безобидный побочный эффект).

Класс Б — CommandItem (gadget):

Gadget: сеттер зовет Run() (красным) → запуск процесса.
Gadget: сеттер зовет Run() (красным) → запуск процесса.

Разница только в красной части сеттера. И вот к чему она приводит, если атакующий подставит такой класс в type:

Класс

Что делает сеттер

Опасный сток?

Что получает атакующий, подставив класс

TextItem

сохраняет + печатает строку

нет

ничего полезного — просто печать в консоль

NoteItem

сохраняет + печатает строку

нет

то же самое — печать

CommandItem

сохраняет + Process.Start

да → RCE

выполнение произвольной команды ОС

Правило, короче некуда:

gadget = класс, у которого в сеттере/конструкторе есть опасный сток (запуск процесса, запись файла, загрузка кода, сеть…). не-gadget = сеттер только хранит значение (ну или печатает) — подставлять его бессмысленно.

“Опасный сток” (sink) — это вызов, который делает что‑то за пределами простого хранения данных. По таким вызовам (красные) gadget и ищут — грепом в dnSpy (Search → имя вызова) по всей сборке, глядя, не стоит ли он внутри сеттера/конструктора:

Опасные стоки (красные) — что грепать внутри сеттеров/конструкторов
Опасные стоки (красные) — что грепать внутри сеттеров/конструкторов

Поиск по Process в нашей сборке приводит в CommandItem — вот он целиком:

Gadget CommandItem: зеленым — путь данных (value → parts[0]), красным — sink p.Start().
Gadget CommandItem: зеленым — путь данных (value → parts[0]), красным — sink p.Start().

Зеленым виден путь данных атакующего — как gadget “вызывается”: value (наша строка из тега <command>) оседает в поле _command, оттуда попадает в parts[0], а он идет в FileName процесса — и красный p.Start() его запускает. Всю цепочку заводит XmlSerializer: он заполняет свойство нашим значением, а класс сам доносит его до запуска — без единого действия жертвы, кроме загрузки файла.

Почему это идеальный gadget — по нашим же требованиям:

  • публичный класс, конструктор без аргументов — ✔ XmlSerializer его создаст;

  • свойство command (синее) — ✔ заполнится из тега <command>;

  • его сеттер зовет Run(), а тот — Process.Start — ✔ опасный сток;

  • значение команды приходит целиком из XML (через value в сеттере) — ✔ под контролем атакующего.

Схема срабатывания — та же, что у TextItem, только на конце не печать, а запуск процесса:

Схема: тег <command> → сеттер → Run() → Process.Start (красным).
Схема: тег <command> → сеттер → Run() → Process.Start (красным).
Класс CommandItem в dnSpy: сеттер command через Run() вызывает Process.Start — готовый gadget.
Класс CommandItem в dnSpy: сеттер command через Run() вызывает Process.Start — готовый gadget.

Шаг 8. Эксплуатация: gadget → RCE

Собираем боевой файл по карте из Азбуки 3. Имя класса меняем в обоих желтых местах на CommandItem. А тег‑свойство теперь не <text>, а <command> (синий) — потому что у CommandItem свойство называется command (правило (3): тег = имя свойства). Внутрь (зеленое) кладем команду. Для наглядного и воспроизводимого доказательства пусть gadget создает файл‑маркер /tmp/pwned_by_deser.

Payload: класс (желтый), свойство command (синий), команда (зеленая).
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.
Десериализация 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 из файла, поэтому для «запусти вот эту команду» нужен именно он (наш CommandItem бьет через сеттер). Готовых таких классов в стандартной библиотеке почти нет.

Поэтому реальный источник gadget — обычно сами классы приложения и его библиотек (как CommandItem), а не стандартная библиотека. У других сериализаторов (BinaryFormatter, Json.NET с TypeNameHandling) модель богаче — там живут универсальные гаджеты и цепочки; каталог по форматтерам — ysoserial.net.

Как чинить

Корень — загрузчик доверяет имени типа из недоверенного источника. Лечится тем, что тип не должен приходить из данных.

4. Не берите тип из входных данных. Фиксируйте его в коде: new XmlSerializer(typeof(TextItem)). Если типов несколько — сопоставляйте по белому списку заранее известных классов, а не через Type.GetType(строка_из_файла).

Было:

Было: Type.GetType(...) из файла (красным).
Было: Type.GetType(...) из файла (красным).

Стало — белый список и полученный из него тип:

Стало: белый список Allowed и тип t из него (зеленым).
Стало: белый список Allowed и тип t из него (зеленым).

Здесь t — локальная переменная, в которую TryGetValue кладет одобренный Type из словаря Allowed. Даже если в файле напишут CommandItem, его нет в списке → срабатывает throw, и до Deserialize дело не доходит. Тип больше не приходит из данных — вот и вся починка.

5. Проверяйте источник и целостность файла. Данные из общей папки, сети или от пользователя — недоверенные по умолчанию. Подпись/HMAC на файле отсекает подмену.

6. Сеттеры и конструкторы модельных классов — без побочных эффектов. Никаких Process.Start, записи файлов, сетевых вызовов в свойствах, которые заполняет сериализатор.

7. Наименьшие привилегии. Процессу приложения незачем запускать дочерние процессы или писать за пределы своей папки — ограничьте это на уровне ОС.

8. Статический анализ в CI. Паттерн «Type.GetType/Activator.CreateInstance от переменной, идущей из ввода» ловится Semgrep/CodeQL на ревью, а не на пентесте.

Итог

Разложим все по полкам еще раз — это и есть та самая «азбука»:

9. Сериализация — объект в текст, десериализация — текст в объект; и десериализация исполняет код.

10. Свойство — «ручка» объекта; сеттер — код, срабатывающий при записи; десериализатор дергает сеттер на каждый тег.

11. Декомпиляция (dnSpy) возвращает читаемый C# из бинарника — дальше просто чтение.

12. Ищем Deserialize — это точка входа данных; смотрим, откуда берется тип.

13. Тип из файла + Type.GetType без белого списка = уязвимость (линейка приложена).

14. Gadget — легитимный класс с опасным стоком в сеттере; ищется грепом по Process.Start/File.*/…; не‑gadget просто хранит значение.

15. Подставляем gadget в type — получаем RCE.

Ни ассемблера, ни нулевого дня — только чтение кода по шагам. XmlSerializer называют «безопасным», и это правда — ровно до строки Type.GetType(строка_из_файла). Цена вопроса — typeof(...) вместо нее.

Приложение. Найти это грепом

Весь разбор выше сводится к нескольким поискам по коду. Чтобы грепать было по чему, в dnSpy жмем правый клик по сборке → Export to Project (получаем дерево.cs); без GUI — ilspycmd XmlObjectLoader.dll > loader.cs. Дальше — ripgrep:

# 1) ДЕсериализация — точка входа данных (отсюда начинается баг)
rg -n '\.Deserialize\s*\(' --glob '*.cs'

# 2) Сериализация — понять формат файла
rg -n '\.Serialize\s*\(|new\s+XmlSerializer\s*\(' --glob '*.cs'

# 3) ГЛАВНАЯ: сериализатор строится от РАНТАЙМ-типа (тип из данных = уязвимость)
rg -nP 'new\s+XmlSerializer\s*\(\s*(Type\.GetType|Activator|\w+\.GetType)' --glob '*.cs'

# 4) Резолв типа по строке (сердце type-confusion)
rg -n 'Type\.GetType\s*\(|Activator\.CreateInstance|Assembly\.Load' --glob '*.cs'

# 5) Классы
rg -n '\bclass\s+\w+' --glob '*.cs'

# 6) Сеттеры (ловит 'set {', 'set' на отдельной строке и IL-имена set_Xxx)
rg -nP '^\s*set\b|\bset\s*\{|\bset_\w+\s*\(' --glob '*.cs'

# 7) Опасные стоки (sinks) — кандидаты в gadget
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'

# 8) GADGET-ХАНТЕР: сеттер, внутри которого есть сток или обертка (Run/Exec)
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' .

Как это читается по шагам:

16. (1) найти Deserialize — это вход. Рядом посмотреть, откуда берется тип.

17. (3)/(4) — тип строится из строки/переменной (не typeof(...)) и без белого списка → уязвимость.

18. (7) найти стоки, (8) — сузить до тех, что срабатывают из сеттера. Класс со стоком в сеттере/конструкторе + публичным свойством = gadget.

Нюанс к (8). В нашем CommandItem Process.Start лежит не прямо в сеттере, а в методе Run(), который сеттер вызывает. Паттерн «сток внутри set{}» такой gadget пропустил бы — поэтому добавлены обертки Run(/Exec…(. Универсально: сначала ищем стоки (7), затем для каждого смотрим, не дергается ли он (прямо или через хелпер) из сеттера/конструктора класса, доступного XmlSerializer.

Комментарии (0)