Обход каталогов в tarfile.extractall в Python (CVE-2007-4559): как это происходит и как исправить
До появления фильтров извлечения tarfile.extractall() записывал каждый элемент по пути, сохранённому в архиве, поэтому имена с .., абсолютные пути или ссылки могли размещать файлы за пределами целевой папки. Как аргумент filter="data" из PEP 706 это блокирует, почему папка назначения не должна зависеть от ввода пользователя и какие ограничения размера добавить, на примере Python.
Простыми словами (ELI5)
Грузчики распаковывают коробки в вашей новой квартире и ставят каждую вещь туда, куда велит её ярлык. Ярлык «кухня, потом в прихожую, потом в спальню соседа» выполняется буквально, потому что грузчики только читают ярлыки и никогда не проверяют, что адрес всё ещё внутри вашей квартиры. Архив устроен так же: каждый файл несёт собственный путь назначения. Исправление — бригадир, который заранее читает каждый ярлык и отклоняет всё, что вышло бы за пределы квартиры, а также всё, что не является обычной коробкой, например табличку, указывающую в другое место.
Ключевые понятия и термины
- CVE-2007-4559
- Модуль
tarfileв Python не мешал именам элементов выходить за пределы каталога извлечения. Проблема была известна с 2007 года, а в 2022 году Trellix оценила, что около 350 000 проектов с открытым кодом вызывалиextractall()для недоверенных архивов. - Имена элементов с .. или абсолютными путями
- Элемент tar с именем
../outside.txtили/etc/cron.d/jobпосле соединения с папкой назначения указывает за её пределы. Без фильтраextractall()пишет по этому пути с правами процесса. - Элементы-ссылки, символические и жёсткие
- Архив может сначала создать внутри папки назначения ссылку, указывающую наружу, а затем записать через неё обычный файл. Проверка одних лишь имён этого не замечает, поэтому фильтры проверяют и цели ссылок.
- Фильтры извлечения (PEP 706)
filter="data"отклоняет абсолютные имена, выходы через.., ссылки за пределы папки и файлы устройств, а также снимает биты set-user-ID и права на выполнение для остальных. Он доступен начиная с Python 3.12 и в выпусках безопасности 3.8–3.11 и становится поведением по умолчанию в Python 3.14.- Ограничения ресурсов
- Фильтры не ограничивают размер. Небольшой сжатый архив может развернуться в миллионы элементов или многие гигабайты, поэтому считайте элементы и общий размер до извлечения.
Пошаговый сценарий атаки
Приложение принимает загруженный архив
Импорт данных, установщик плагинов или обработчик артефактов CI получает от пользователя .tar.gz.
Имя элемента указывает за пределы папки
Сохранённый путь одной из записей поднимается выше папки назначения или является абсолютным. Тестовый архив с элементом ../outside.txt показывает этот случай.
extractall пишет по вычисленному пути
Файл оказывается там, куда указывает имя, а сама папка назначения может браться из переданного пользователем job_id.
Файлы приложения подменяются
Перезаписанные конфигурация, шаблоны или скрипты выполняются при следующей загрузке приложением — так обход каталогов превращается в выполнение кода.
Исходный код: Уязвимый vs Защищённый вариант
# imports.py: каждый элемент попадает туда, куда указывает его сохранённое имя
import tarfile
from pathlib import Path
UPLOAD_ROOT = Path("/srv/app/imports")
# папку выбирает вызывающий, фильтра и лимитов размера нет
def import_bundle(archive_path: str, job_id: str) -> Path:
dest = UPLOAD_ROOT / job_id
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path) as tar:
tar.extractall(dest)
return dest
# imports.py: фильтр data, папка с именем от сервера и лимиты размера
import tarfile
import uuid
from pathlib import Path
UPLOAD_ROOT = Path("/srv/app/imports")
MAX_MEMBERS = 10_000
MAX_TOTAL_BYTES = 500 * 1024 * 1024
# отклонять архивы со слишком многими элементами или байтами
def check_limits(tar: tarfile.TarFile) -> None:
members = tar.getmembers()
if len(members) > MAX_MEMBERS or sum(m.size for m in members) > MAX_TOTAL_BYTES:
raise ValueError("archive too large")
# filter='data' блокирует .., абсолютные имена, ссылки наружу и файлы устройств
def import_bundle(archive_path: str) -> Path:
dest = UPLOAD_ROOT / uuid.uuid4().hex
dest.mkdir(parents=True)
with tarfile.open(archive_path, mode="r:*") as tar:
check_limits(tar)
tar.extractall(dest, filter="data")
return dest
Чек-лист по защите системы для инженеров
- Передавайте
filter="data"в каждый вызовextractall()иextract()и проваливайте CI, если Bandit B202 или правило Semgrep находят вызов безfilter=. - Извлекайте в новую папку с именем, выбранным сервером, например UUID, а не в путь, собранный из данных запроса.
- До извлечения проверяйте число элементов и общий несжатый размер и отклоняйте архивы, превышающие лимиты.
- Запускайте процесс извлечения от пользователя без права записи в код или конфигурацию приложения.
- Добавьте в CI тест с архивом, содержащим
../outside.txtи элемент с абсолютным путём, и ожидайтеtarfile.FilterError.