Статья · Практика AppSec

XmlSerializer в .NET: как «безопасный» класс превращается в RCE

Разбираем на живом стенде, как из чтения кода получается удалённое выполнение команд: декомпиляция в dnSpy, поиск точки Deserialize, класс-«gadget» и эксплуатация — шаг за шагом, без ассемблера и нулевых дней.

  • 18 минут чтения
  • 8 шагов эксплуатации
00

О чём эта статья и как её читать

Дисклеймер

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

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

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

Цвет в листингах жёлтый — имя класса / тип синий — имя свойства зелёный — значение (данные) красный — опасное место (sink, уязвимая строка)
01

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

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

  • Сериализация — процесс превращения объекта (данных в памяти) в текст/байты, пригодные, чтобы сохранить в файл или передать по сети. Здесь — в XML.
  • Десериализация — обратный процесс: из этого текста/байтов заново собирается объект в памяти.
Объект в памяти                    Текст в файле (XML)
┌────────────────────┐  сериализация   ┌──────────────────────────┐
│ TextItem            │ ───────────────▶ │ <TextItem>                │
│ text = "отчёт"      │ (объект → текст)  │   <text>отчёт</text>      │
│                     │ ◀─────────────── │ </TextItem>                │
└────────────────────┘  десериализация  └──────────────────────────┘
                        (текст → объект)
Ключевая мысль

Десериализация — это не пассивное чтение. Чтобы собрать объект обратно, программа его создаёт и заполняет свойства, а заполнение свойства — это исполнение кода. Именно здесь и прячется опасность.

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

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

BinaryFormatter.Deserialize — опасный sink
BinaryFormatter: Deserialize(stream) — sink, RCE «из коробки».

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

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

02

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

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

  • Класс (тип)чертёж, описание, из чего состоит вещь. TextItem — это класс.
  • Объектконкретная вещь, сделанная по чертежу. new TextItem() — это объект.
  • Полеячейка внутри объекта, где значение реально лежит.
  • Свойство«ручка» снаружи объекта, через которую кладут или достают значение.

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

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

отвечает на «дай значение» — срабатывает, когда свойство читают.

setСеттер

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

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

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

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

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

Вызов сеттера и геттера построчно
Когда срабатывает сеттер: запись (стр. 2) зовёт сеттер, чтение (стр. 3) — геттер.
# Вывод в консоль: set text = привет

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

Мост к десериализации

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

03

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

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

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

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

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

Карта соответствий цвета и XML
Карта соответствий: цвет ↔ что это в XML.
  • (1) атрибут typeполное имя класса — какой тип создать
  • (2) тег-объектто же имя класса — обязано совпадать с (1)
  • (3) тег-свойствосовпадает с именем свойства класса; значение (зелёное) уйдёт в сеттер
  • (1) и (2) — это один класс, и они обязаны совпадать. Поставите type="NoteItem", но тег оставите <TextItem> — получите ошибку <TextItem> was not expected (увидим в Шаге 6).
  • (3) — тег совпадает с именем свойства. Незнакомый тег XmlSerializer молча игнорирует (сеттер не сработает), поэтому имена свойств берём из целевого класса.

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

04

Инструменты и стенд

  • dnSpyдекомпилятор и отладчик .NET. Открывает .exe/.dll и показывает читаемый C#, даже если исходников нет. Главный инструмент разбора.
  • .NET SDKсобрать и запустить стенд: dotnet build, dotnet <app>.dll.
  • Текстовый редакторчтобы поправить XML-файл руками.
  • Команда ОСtouch, id и другие — «полезная нагрузка» для доказательства.

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

05

8 шагов: от файла до RCE

Переход от чтения кода к рабочему эксплойту — это последовательность понятных шагов, а не «магия». Каждый воспроизводится на приложенном стенде.

  1. 01
    Шаг 1 из 8

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

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

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

    dnSpy: дерево сборки и декомпилированный код
    dnSpy: слева дерево сборки XmlObjectLoader, справа — декомпилированный C#. Исходников не было — код восстановлен из IL.
    • классы — из чего состоит приложение (Program, модельные классы, вспомогательные);
    • метод Main — точка входа, отсюда начинается логика;
    • знакомые типы — XmlSerializer, Process, File подсвечивают интересные места.

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

  2. 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
    dnSpy Analyze → Used By: единственный вызывающий Deserialize — метод Load загрузчика (точка входа данных).

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

  3. 03
    Шаг 3 из 8

    Читаем сериализатор: откуда берётся формат файла

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

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

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

    Export: запись полного имени типа в файл
    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 с классом, свойством и значением
    store.xml: класс (жёлтый), свойство (синий), значение (зелёное).
    Сериализация TextItem
    Сериализация TextItem: сеттер печатает строку, объект и его тип уходят в store.xml.

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

  4. 04
    Шаг 4 из 8

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

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

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

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

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

    $ dotnet XmlObjectLoader.dll store.xml [TextItem] set text = confidential report

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

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

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

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

    Уязвимый метод Load
    Уязвимый Load: красным — тип из файла (стр. 8) и Type.GetType без белого списка (стр. 9).
    • Имя типа из файла: typeName берётся из атрибута type. Кто владеет файлом — владеет этой строкой.
    • Резолв типа: Type.GetType(typeName) резолвит любой тип по имени. Без белого списка, без проверки. Сюда пройдёт любой класс из любой загруженной сборки.
    • Исполнение: Deserialize заполняет свойства созданного объекта, вызывая сеттеры — здесь и «выстреливает» подставленный тип.

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

    typeof против Type.GetType
    Безопасно (typeof, зелёное) против опасно (Type.GetType, красное).
    Критерий уязвимости

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

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

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

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

    Подмена класса в XML
    Подмена класса: красным — было (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 и вывод загрузчика
    cat swap.xml: класс NoteItem стоит в двух местах — атрибут type и тег <NoteItem>; запуск загрузчика печатает [NoteItem] вместо [TextItem] — типом управляем мы.
  7. 07
    Шаг 7 из 8

    Что такое gadget: наглядно gadget против не-gadget

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

    Всё решает один вопрос: что стоит внутри сеттера (или конструктора)? Сравним два класса из нашего стенда:

    класс А · не-gadgetTextItem

    сеттер только печатает — безобидный побочный эффект.

    класс Б · gadgetCommandItem

    сеттер зовёт Run() → запуск процесса.

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

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

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

    Список опасных стоков для поиска
    Опасные стоки (красные) — что грепать внутри сеттеров/конструкторов.

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

    Класс 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 в сеттере) — ✔ под контролем атакующего.
    Схема срабатывания gadget CommandItem
    Схема: тег <command> → сеттер → Run() → Process.Start (красным).
    CommandItem в dnSpy целиком
    Класс CommandItem в dnSpy: сеттер command через Run() вызывает Process.Start — готовый gadget.
  8. 08
    Шаг 8 из 8

    Эксплуатация: gadget → RCE

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

    Payload rce.xml
    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
06

Что это даёт

Через один 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.

07

Как чинить

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

  • 1

    Не берите тип из входных данных

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

  • 2

    Проверяйте источник и целостность файла

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

  • 3

    Сеттеры и конструкторы модельных классов — без побочных эффектов

    Никаких Process.Start, записи файлов, сетевых вызовов в свойствах, которые заполняет сериализатор.

  • 4

    Наименьшие привилегии

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

  • 5

    Статический анализ в CI

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

Было: Type.GetType из файла
Было: Type.GetType(...) из файла (красным).
Стало: белый список и тип из него
Стало: белый список Allowed и тип t из него (зелёным).

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

08

Итог

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

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

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

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

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

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

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

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

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

09

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

Весь разбор выше сводится к нескольким поискам по коду. Чтобы грепать было по чему, в 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) — кандидаты в gadgetrg -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:

то же самое без ripgrepgrep -rnE 'new[[:space:]]+XmlSerializer[[:space:]]*\([[:space:]]*(Type\.GetType|Activator|[A-Za-z_]+\.GetType)' --include='*.cs' .

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

  • (1) найти Deserialize — это вход. Рядом посмотреть, откуда берётся тип.
  • (3)/(4) — тип строится из строки/переменной (не typeof(...)) и без белого списка → уязвимость.
  • (7) найти стоки, (8) — сузить до тех, что срабатывают из сеттера. Класс со стоком в сеттере/конструкторе + публичным свойством = gadget.
Нюанс к пункту (8)

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