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

Именно из-за этого он объявлен устаревшим и с .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 исполнится? Ровно в момент записи в свойство. Проследим:

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

Теги <root> и <item> — постоянный скелет: загрузчик ждет именно их (SelectNodes("root/item")), переименовывать нельзя — подсвечивать тут нечего. Крутим мы только цветное. И эти имена — не стандарт: их задает код конкретного приложения, в другом они могут быть любыми.
В 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. Слева — дерево сборки. Раскрываем узлы: сборка → пространство имен → классы → методы. Это как оглавление книги: видно все, что внутри.

Что искать в дереве первым делом:
· классы — из чего состоит приложение (Program, модельные классы, вспомогательные);
· метод Main — точка входа, отсюда начинается логика;
· знакомые типы — XmlSerializer, Process, File подсвечивают интересные места.
Никакой магии: декомпиляция превращает непонятный бинарник обратно в читаемый текст. Дальше — обычное чтение кода.
Шаг 2. Где сериализация, а где десериализация
Прежде чем искать баг, поймем, кто здесь кто. Правило простое:
Ищем вызов | Что это значит | В каком приложении |
new XmlSerializer(...) | создается сериализатор под какой-то тип | и там, и там |
.Serialize(...) | объект → текст (сериализация) | которое сохраняет / отправляет |
.Deserialize(...) | текст → объект (десериализация) | которое загружает / принимает |
В дереве слева открываем загрузчик: XmlObjectLoader → Program → Load — справа его код, и в нем вызов Deserialize (текст → объект). Чтобы убедиться, что вызывающий один, правой кнопкой на Deserialize → Analyze (Ctrl+Shift+R) → раздел Used By покажет единственного: Program.Load. Уязвимости живут на стороне десериализации — там, где приложение принимает данные извне.

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

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

Строка 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


Строка [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 вживую: загрузка файла исполнила код сеттера. Пока безобидно — но принцип работает.

Шаг 5. Находим уязвимость: тип берется из файла
Возвращаемся к методу Load в XmlObjectLoader. Читаем построчно:

Красным — два ключевых места, помеченных в коде (1) и (2), а (3) их исполняет:
1. (1) — имя типа из файла: typeName берется из атрибута type. Кто владеет файлом — владеет этой строкой.
2. (2) — резолв типа: Type.GetType(typeName) резолвит любой тип по имени. Без белого списка, без проверки. Сюда пройдет любой класс из любой загруженной сборки — свой или из стандартной библиотеки.NET.
3. (3) — исполнение: Deserialize заполняет свойства созданного объекта, вызывая сеттеры — здесь и «выстреливает» подставленный тип.
Сравните безопасный и опасный варианты — тип зашит в коде против типа из файла:

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

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

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] — типом управляем мы.](https://habrastorage.org/r/w1560/getpro/habr/upload_files/99e/3c7/0e5/99e3c70e56275c8b473c70d357f51645.png)
Шаг 7. Что такое gadget: наглядно gadget против не-gadget
Gadget (гаджет, «деталь») — это класс, который сам по себе легитимен и лежит в приложении для своих нужд, но при десериализации дает атакующему что-то опасное. Аналогия: злоумышленник не приносит свое оружие — берет то, что уже валяется на месте, и применяет не по назначению.
Все решает один вопрос: что стоит внутри сеттера (или конструктора)? Сравним два класса из нашего стенда — красным подсвечен побочный эффект сеттера.
Класс А — TextItem (НЕ gadget):

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

Разница только в красной части сеттера. И вот к чему она приводит, если атакующий подставит такой класс в 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().](https://habrastorage.org/r/w1560/getpro/habr/upload_files/9dd/46d/395/9dd46d39503eac1b0c18a85e7cadab67.png)
Зеленым виден путь данных атакующего — как gadget «вызывается»: value (наша строка из тега <command>) оседает в поле _command, оттуда попадает в parts[0], а он идет в FileName процесса — и красный p.Start() его запускает. Всю цепочку заводит XmlSerializer: он заполняет свойство нашим значением, а класс сам доносит его до запуска — без единого действия жертвы, кроме загрузки файла.
Почему это идеальный gadget — по нашим же требованиям:
· публичный класс, конструктор без аргументов — ✔ XmlSerializer его создаст;
· свойство command (синее) — ✔ заполнится из тега <command>;
· его сеттер зовет Run(), а тот — Process.Start — ✔ опасный сток;
· значение команды приходит целиком из XML (через value в сеттере) — ✔ под контролем атакующего.
Схема срабатывания — та же, что у TextItem, только на конце не печать, а запуск процесса:


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

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 — что угодно, с правами процесса приложения.

Пройденный путь целиком — от файла до выполнения кода:
правим 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(строка_из_файла).
Было:

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

Здесь 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.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.