● CWE-22 · CVE-2007-4559

Обход каталогов в 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.
Ограничения ресурсов
Фильтры не ограничивают размер. Небольшой сжатый архив может развернуться в миллионы элементов или многие гигабайты, поэтому считайте элементы и общий размер до извлечения.

Пошаговый сценарий атаки

Шаг 1

Приложение принимает загруженный архив

Импорт данных, установщик плагинов или обработчик артефактов CI получает от пользователя .tar.gz.

Шаг 2

Имя элемента указывает за пределы папки

Сохранённый путь одной из записей поднимается выше папки назначения или является абсолютным. Тестовый архив с элементом ../outside.txt показывает этот случай.

Шаг 3

extractall пишет по вычисленному пути

Файл оказывается там, куда указывает имя, а сама папка назначения может браться из переданного пользователем job_id.

Шаг 4

Файлы приложения подменяются

Перезаписанные конфигурация, шаблоны или скрипты выполняются при следующей загрузке приложением — так обход каталогов превращается в выполнение кода.

Исходный код: Уязвимый 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

Чек-лист по защите системы для инженеров

Источники

← Весь каталог по безопасности Все руководства по уязвимостям →