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

13 мин.00

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

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

EPSS-оценка CVE-2020-15148 — insecure deserialization в Yii Framework 2 — 0.7754. Top 1% по вероятности эксплуатации среди всех CVE в базе. Gadget chain через пять классов фреймворка, от BatchQueryResult.__destruct() до eval(), воспроизводится одной командой phpggc. На CTF задачи с десериализацией стабильно живут в web-категории — от банальной подмены свойств в PHP-cookie до сборки pickle payload через ручные опкоды. Ниже — практический разбор insecure deserialization эксплуатации в PHP и Python: те техники, которые решают задачи на соревнованиях и встречаются на реальных пентестах.

PHP-сериализация — формат и точки входа в десериализацию объектов

PHP превращает объекты в текстовые строки через serialize() и восстанавливает обратно через unserialize(). Формат человекочитаемый — каждый элемент маркирован буквой типа и длиной значения: Подробнее — в нашем обзоре создание ctf заданий.

  • O:ДЛИНА:"ИмяКласса":КОЛИЧЕСТВО_СВОЙСТВ:{свойства} — объект
  • s:ДЛИНА:"значение" — строка с явным указанием количества символов
  • i:число — целое число, d:число — float
  • b:0 или b:1 — boolean (false/true)
  • a:КОЛИЧЕСТВО:{элементы} — ассоциативный массив

Конкретный пример: строка O:4:"User":2:{s:8:"username";s:6:"carlos";s:7:"isAdmin";b:0;} описывает объект класса User с двумя свойствами. Длина в s:6:"carlos" обязана точно совпадать с реальным числом символов — ошибка на один байт, и unserialize() вернёт false, payload молча не сработает. На CTF это самая частая причина «сломанного» эксплойта. Каждый второй участник забывает пересчитать длину строки после правки значения. Каждый. Второй.

Сериализованные данные встречаются в cookie (обычно base64-encoded), POST-параметрах, скрытых полях форм, хранилищах сессий и кэше. По классификации OWASP — A08:2021 (Software and Data Integrity Failures). CWE-502 (Deserialization of Untrusted Data) фиксирует корневую причину: продукт десериализует недоверенные данные без проверки валидности результата. Платформы из каталога MITRE CWE, подверженные CWE-502: Java, Ruby, PHP, Python, JavaScript.

В матрице MITRE ATT&CK эксплуатация десериализации маппится на T1190 (Exploit Public-Facing Application, тактика Initial Access): crafted payload попадает на публичный эндпоинт и выполняет код на сервере без учётных данных. За пределами CTF — это вектор initial access с последующей загрузкой инструментов (T1105, Ingress Tool Transfer) и горизонтальным перемещением по инфраструктуре. Через контролируемый вход в unserialize() атакующий получает RCE без credentials. Не нужна SQL-инъекция, не нужна загрузка файлов. Php unserialize уязвимость возникает, когда пользовательские данные попадают в функцию без фильтрации. PHP восстановит объект любого класса, доступного в контексте выполнения — не того, который приложение ожидает. Отсюда альтернативное название — object injection.

Магические методы PHP — эксплуатация __wakeup и __destruct

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

__wakeup() срабатывает сразу после десериализации. Если внутри — eval(), system() или file_put_contents() с параметрами из свойств объекта, атакующий контролирует аргументы через подмену значений в сериализованной строке. Payload генерируется элементарно: создаёшь экземпляр класса с нужным значением свойства, сериализуешь через serialize(), отправляешь.

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

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

Классический CTF-паттерн — класс с деструктором, записывающим данные в файл. Объект Logger со свойствами $logFile = "shell.php" и $logData = "<?php system($_GET['c']);?>" при десериализации создаётся молча, а при завершении скрипта __destruct() вызывает file_put_contents() — веб-шелл оказывается на диске. Для генерации payload через PHP CLI хватает однострочника:

php -r "class Logger{public \$logFile='shell.php';public \$logData='<?php system(\$_GET[\"c\"]);?>';} echo serialize(new Logger);"

На CTF регулярно встречается bypass __wakeup() для PHP версий до 7.4 (CVE-2016-7124). Трюк простой: указать в сериализованной строке количество свойств больше реального — O:4:"Test":2:{s:3:"cmd";s:2:"id";} вместо O:4:"Test":1:{...}. PHP пропустит вызов __wakeup(), но __destruct() сработает штатно. Полезно, когда __wakeup() содержит проверку, блокирующую эксплуатацию — обнуляет свойства или вызывает die(). Приём работает до PHP 8.0, что обычно явно указано в условии задачи.

Type juggling — php unserialize уязвимость через подмену типов

Вектор, который русскоязычные источники почти не раскрывают. Согласно исследованию PortSwigger Web Security Academy, PHP с оператором нестрогого сравнения (==) открывает отдельный путь эксплуатации — не RCE, но обход аутентификации.

PHP при сравнении == приводит типы автоматически. На PHP 7.x выражение 0 == "Example string" вернёт true, потому что строка без ведущих цифр приводится к integer 0. Выражение 5 == "5 of something" тоже вернёт true — PHP берёт число с начала строки и отбрасывает остаток. Это поведение превращается в уязвимость при десериализации:

$login = unserialize($_COOKIE);
if ($login['password'] == $password) {
    // доступ разрешён
}

Если атакующий через десериализацию подменит значение password на integer 0 вместо ожидаемой строки, условие 0 == "s3cur3p@ss" вернёт true. Десериализация сохраняет тип: integer остаётся integer, а не конвертируется в строку, как при получении из GET/POST-параметра. Payload для обхода — a:1:{s:8:"password";i:0;}. Одна буква i вместо s, ноль вместо хэша пароля. Всё.

В PHP 8.0+ сравнение 0 == "Example string" возвращает false. Но 5 == "5 of something" по-прежнему даёт true. На CTF задачи с type juggling явно указывают PHP 7.x в условии или в заголовке X-Powered-By. Видите нестрогое сравнение и unserialize() в одном скрипте — проверяйте подмену типа раньше, чем начнёте строить gadget chain.

Gadget chain PHP — POP chain и phpggc на практике

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

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

phpggc использование для известных фреймворков

Для автоматизации поиска POP chains во фреймворках есть phpggc — библиотека готовых gadget chains. Просмотр всех доступных цепочек: phpggc -l. Фильтрация по фреймворку: phpggc -l Laravel. Генерация payload: phpggc Laravel/RCE1 system id создаёт сериализованную строку, выполняющую system('id') при десериализации в Laravel-приложении.

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

CVE-2020-15148 — ручное построение gadget chain в Yii2

Как выглядит ручное построение, демонстрирует CVE-2020-15148 — уязвимость в Yii Framework 2 до версии 2.0.38 (CVSS 8.9 HIGH, вектор CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:H/A:H, CWE-502). По данным EPSS — 0.7754, Top 1% по вероятности эксплуатации. Nuclei-шаблон для детекции — http/cves/2020/CVE-2020-15148.yaml.

Исследователи из RedTeam Pentesting описали процесс трассировки chain: поиск начинается с grep по __destruct в исходниках фреймворка. В Yii 2.0.37 единственный результат — класс yii\db\BatchQueryResult с деструктором, вызывающим $this->reset(). Метод reset() вызывает $this->_dataReader->close(). Подставив в _dataReader объект yii\web\DbSession, получаем вызов composeFields(), который использует call_user_func($this->writeCallback, $this). Финальный шаг — направить writeCallback на метод generateDependencyData() класса yii\caching\ExpressionDependency, содержащий eval("return {$this->expression};").

Итоговая цепочка: BatchQueryResult.__destruct() → reset() → DbSession.close() → composeFields() → call_user_func() → ExpressionDependency.generateDependencyData() → eval(). Пять классов, ни один не содержит прямого вызова system() — но в совокупности дают RCE. Уязвимость исправлена в версии 2.0.38 пакета yiisoft/yii2.

Контрастный пример — CVE-2023-30534 в Cacti v1.2.24 (CVSS 4.3 MEDIUM, CWE-502). Два экземпляра небезопасной десериализации есть, gadget chain через phpseclib присутствует в vendor-директории — но необходимые гаджеты не подключены, эксплуатация невозможна. CISA оценила как Track* (PoC есть, автоматизация — нет, technical impact — partial). Десериализация без доступных гаджетов — уязвимость, но не эксплуатируемая. Gadget chain — ключевое условие pop chain exploitation в PHP. Без него уязвимость — просто запись в CVE-базе.

PHAR-десериализация без вызова unserialize()

PHAR (PHP Archive) хранит метаданные в сериализованном виде. При обращении к файлу через stream wrapper phar:// PHP автоматически десериализует метаданные — без явного вызова unserialize() в коде. Поверхность атаки расширяется на все функции с поддержкой stream wrappers: file_exists(), is_dir(), file_get_contents(), fopen(), filesize().

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

На CTF такие задачи уровня hard. Нужны два условия одновременно: контроль над путём в функции с поддержкой stream wrappers плюс возможность загрузить файл на сервер. Встречается реже прямого unserialize(), но именно PHAR-вектор ломает предположение «мы не используем unserialize, значит безопасны».

Python pickle эксплуатация — reduce и путь к RCE

Python pickle — бинарный протокол сериализации из стандартной библиотеки. pickle.dumps() превращает объект в байтовую строку, pickle.loads() восстанавливает. Ключевое отличие от PHP: для python pickle RCE не нужны gadget chains и не нужен доступ к исходникам приложения. Вообще.

Метод __reduce__ определяет, как объект восстанавливается при десериализации. Он возвращает кортеж (callable, args) — и pickle.loads() вызовет callable(*args). Подставив os.system в качестве callable, получаем выполнение произвольного кода:

import pickle, base64

class Exploit:
    def __reduce__(self):
        import os
        return (os.system, ('cat /flag.txt',))

payload = base64.b64encode(pickle.dumps(Exploit()))
print(payload.decode())

Результат pickle.loads(base64.b64decode(payload)) выполнит os.system('cat /flag.txt') на сервере. Дополнительных классов на стороне приложения не требуется. В PHP для RCE нужен подходящий gadget chain с sink-функцией — в Python любой вызов pickle.loads() на недоверенных данных уже даёт полный RCE. Этим pickle python эксплуатация принципиально проще: барьер входа ниже, а результат тот же.

На CTF pickle-задачи чаще всего маскируются под cookie (base64-encoded), WebSocket-сообщения или POST-body в API на Flask/Django. Сигнатура: байты \x80\x04\x95 в начале (protocol 4) или \x80\x05\x95 (protocol 5). Protocol 0 — текстовый, начинается с (dp0 или cos\n.

Для отладки — встроенный дизассемблер pickletools.dis(), показывающий каждый опкод:

import pickle, pickletools

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

pickletools.dis(pickle.dumps(Exploit()))

Вывод покажет последовательность: GLOBAL 'posix system', SHORT_BINUNICODE 'id', TUPLE1, REDUCE, STOP. Каждый опкод — однобайтовая инструкция виртуальной машины pickle. Понимание этой последовательности критично для обхода фильтров.

Pickle opcodes — ручная сборка и обход фильтров

На CTF средней и высокой сложности появляется RestrictedUnpickler — кастомный класс, наследующий pickle.Unpickler и переопределяющий метод find_class(). Типичная фильтрация блокирует импорт os, subprocess, builtins — стандартных модулей для RCE. Стандартный __reduce__ payload с os.system не пройдёт. Тут начинается ручная работа с опкодами.

Структура pickle opcodes

Pickle — стековая виртуальная машина. Ключевые опкоды: c (GLOBAL — импорт модуля и атрибута), ( (MARK — метка стека), t (TUPLE — собрать кортеж из элементов до MARK), R (REDUCE — вызвать callable с аргументами), . (STOP — завершение). Минимальный payload для os.system('id') в protocol 0:

b"cos\nsystem\n(S'id'\ntR."

Восемнадцать байт — и RCE.

Разбор по опкодам: c импортирует os.system, ( ставит MARK, S'id' кладёт строку на стек, t собирает кортеж ('id',), R вызывает system('id'), . завершает выполнение. Если pickletools.dis() показывает ошибку — опкод или его аргумент сформированы неправильно. Пересобирайте.

Обход RestrictedUnpickler

Конкретные способы обхода зависят от реализации фильтра. Несколько приёмов, которые я видел на CTF:

Если фильтр проверяет имя модуля по точному совпадению с os, но не блокирует posix или nt — вызываем system через альтернативное имя модуля. На Linux os.system внутренне проксирует через posix. Фильтр смотрит на строку «os» — а мы подсовываем «posix». Мимо.

Если разрешён builtins.getattr, payload собирается через цепочку: получить ссылку на __import__ через getattr, импортировать нужный модуль, вызвать функцию. Это обходит фильтр на GLOBAL для конкретного модуля.

Если builtins.eval не заблокирован — вся фильтрация GLOBAL для отдельных модулей бессмысленна, потому что eval('__import__("os").system("id")') обходит любые ограничения на уровне опкодов. Один незаблокированный eval — и вся защита рассыпается.

Для продвинутых задач: opcode i (INST) и o (OBJ) — альтернативные способы создания объектов, которые некоторые фильтры не учитывают. Опкод b (BUILD) вызывает __setstate__, что может стать отдельным вектором, если приложение определяет этот метод.

Главный инструмент при работе с ограниченными unpickler — pickletools.dis() на каждом этапе сборки payload. Payload отвергается — дизассемблируйте, найдите опкод, который блокирует фильтр, замените на альтернативный. Методично.

Web CTF десериализация — алгоритм от задачи до флага

Пошаговый workflow для ctf writeup десериализации, который работает для большинства задач:

Шаг 1 — определить формат. PHP-сериализация начинается с O:, a:, s:. Pickle protocol 0 — с cos\n или (dp0. Pickle protocol 2+ — с \x80\x02 (или \x80\x04, \x80\x05). Java — с ac ed в hex или rO0 в base64. Base64-decode всех cookie и скрытых параметров.

Шаг 2 — определить язык и фреймворк. Заголовки ответа (X-Powered-By: PHP/7.4, Server: Werkzeug), структура URL (/index.php vs /api/v1/), файлы конфигурации (composer.json или requirements.txt через LFI / source disclosure).

Шаг 3 — построить payload. Для PHP: grep -rn '__destruct\|__wakeup\|__toString' по исходникам, если доступны. Фреймворк известен — phpggc -l <framework>. Для Python без фильтров — __reduce__ payload с os.system. С фильтрами — анализ find_class() и подбор обходного пути.

Шаг 4 — доставить. Кодировка зависит от канала: base64 для cookie, URL-encode для GET, raw bytes для POST. Отправить через Burp Suite Repeater, проверить ответ или side-effect (появление файла, DNS-запрос на контролируемый домен).

Шаг 5 — отладка. PHP: пересчитать длины строк и количество свойств — unserialize() молча возвращает false при ошибке. Pickle: pickletools.dis() покажет битый опкод с точной позицией.

PHP vs Python — сравнение подходов

Характеристика PHP unserialize Python pickle
Формат данных Текстовый, читаемый Бинарный (опкоды)
Путь к RCE Magic methods + gadget chain reduce напрямую
Нужен исходный код Да (для построения chain) Нет
Основной инструмент phpggc Ручная сборка + pickletools
Типичная защита allowed_classes RestrictedUnpickler
Сложность базовой эксплуатации Средняя-высокая Низкая (без фильтров)
Сложность при наличии фильтров Высокая Средняя-высокая

На каждом втором web-CTF, где я участвовал за последний год, минимум одна задача завязана на десериализацию — PHP или Python. Разница между solve за 15 минут и зависанием на два часа — в понимании того, что происходит на уровне байтов, а не в наличии готового скрипта. Участники, которые копируют payload из чужого writeup, ломаются на первом нестандартном фильтре: RestrictedUnpickler с whitelist модулей, allowed_classes в unserialize(), HMAC на cookie.

Мой подход — каждый pickle payload собирать хотя бы раз через опкоды вручную, а каждый PHP chain трассировать от __destruct до sink-функции по исходникам. Неудобно. Медленнее. Но когда на соревновании попадается кастомный фреймворк без chain в phpggc или RestrictedUnpickler с нетривиальным whitelist, навык ручной сборки — единственное, что работает. Готовые инструменты закрывают стандартные случаи; нестандартные выигрывают те, кто понимает механику.

CVE-2020-15148 с пятью классами в цепочке — пример, который стоит разобрать целиком: не для заучивания конкретного chain, а для тренировки навыка трассировки от magic method до eval. На WAPT эту цепочку от базовой десериализации до POP chains проходят в лабораторных условиях с ментором — для тех, кому одних writeup'ов недостаточно.

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

Поделиться

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

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

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

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

LFI уязвимость эксплуатация: от passwd до RCE

13 мин.

1

LFI уязвимость эксплуатация: от passwd до RCE

Полный гайд по LFI: path traversal, php://filter, log poisoning до RCE. Разбор CVE-2024-40348 (CVSS 8.2), ffuf-фаззинг и пейлоады для CTF-задач.

12 НОЯБРЬ, 2026

Реверс-инжиниринг crackme в Ghidra: пишем кейген

12 мин.

6

Реверс-инжиниринг crackme в Ghidra: пишем кейген

Разбираем crackme в Ghidra: находим проверку пароля через XREF, восстанавливаем XOR-алгоритм, пишем кейген на Python за 5 строк. Практический гайд.

11 НОЯБРЬ, 2026

TryHackMe vs HackTheBox vs HackerLab для новичков

15 мин.

5

TryHackMe vs HackTheBox vs HackerLab для новичков

Сравнение TryHackMe, Hack The Box и HackerLab: цены, формат обучения, доступность из РФ, ограничения каждой платформы. Пошаговый план выбора для новичка.

11 НОЯБРЬ, 2026