ESPNBarnwell on five important Week 3 losses and their aftermathThe Jerusalem PostIranians stagger under soaring cost of living after seven months of war with the USESPN DeportesAutoridades de Manchester City rebaten veredicto de Premier LeagueBollywood HungamaGondhal team meets Maharashtra CM Devendra Fadnavis, appeals for financial support ahead of Oscar 2027 journeyDaily MaverickStudent protests and French public sector strike heap pressure on MacronRTP DesportoSalvador diz que Sporting de Braga "exige mais" do que o que foi apresentadoPunchMeet Nigerian dancer attempting 168-hour Guinness World RecordBillboardElton John Opens London’s Apple Music Hall With Intimate, Hit-Packed PerformanceVariety‘GMA’ Anchor and Christopher Reeve’s Son Will Reveals Testicular Cancer Diagnosis: ‘I Was Raised to Share One’s Struggles’Egypt IndependentBritain’s PM is on a ‘Burnham bounce.’ When will he fall to earth?Antara NewsJakarta rises to 140th worldwide in Global Cities Index 2026Il Fatto Quotidiano“Mostrava i genitali e si masturbava di fronte a una 18enne”: giudice sospeso dal Csm. Lui: “Solo frasi volgari, mi scuso”
The Daily Newsstand · Free, Always
Tuesday, September 29, 2026

RCE из одного XML‑тега: разбираем небезопасную десериализацию XmlSerializer с нуля

Translate

Дисклеймер. Все описанное выполнялось в изолированной лаборатории на вымышленном приложении, которое я собрал специально для статьи. Совпадения имен с реальными продуктами случайны. Материал образовательный — для 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.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.