В понедельник мы публиковали таск про реальную уязыимость в MLflow. Сегодня разбираем, что там произошло, почему это опасно и как это починили (три месяца чинили, кстати).
Вот уязвимый код ещё раз:
def extract_archive_to_dir(archive_path, dest_dir):
os.makedirs(dest_dir, exist_ok=True)
with tarfile.open(archive_path, "r") as tar:
tar.extractall(path=dest_dir)
А вот ссылка на репорт: https://huntr.com/bounties/09856f77-f968-446f-a930-657d126efe4e
Проблема кроется в последней строке:
tarfile.extractall() распаковывает архив, доверяя путям файлов внутри него. А эти пути — просто строки, которые записал тот, кто создавал архив, т.е. потенциальный злоумышленник.Ничто не мешает ему положить в архив файл с именем
../../config/settings.py или /etc/cron.d/backdoor. Python при распаковке честно создаст его именно там, где сказано.Это атака Tar Slip — та же механика, что и Zip Slip, только для
.tar.gz. И она в целом классическая: сама документация Python помечает extractall() как небезопасный метод начиная с 3.12. Но код был написан раньше, и никто не обратил внимания. OpenSource, hah?Давайте теперь поговорим про патч. Разработчики MLflow написали отдельную функцию
check_tarfile_security(), которая проверяет каждый файл в архиве до распаковки. Она блокирует три сценария:1. Абсолютный путь
/etc/cron.d/mlflow-backdoor
Файл с абсолютным путём записывается именно туда, игнорируя
dest_dir полностью.2. Path traversal через `..`
../../.env
../../app/config.py
Классика — выход за пределы целевой директории через
.. в имени файла.3. Traversal через симлинк
Это самый хитрый вектор. В архив кладётся симлинк
escape -> .., а потом файл escape/pwned.txt. При наивной проверке путь выглядит безобидно — никаких .. в имени. Но физически файл окажется за пределами директории назначения, потому что симлинк выводит наружу.Именно поэтому в патче два прохода по членам архива: сначала собирается множество всех симлинков, потом проверяется, не ведёт ли путь файла через один из них.
И вот как выглядит исправление
def check_tarfile_security(archive_path):
with tarfile.open(archive_path, "r") as tar:
symlink_set = set()
# Первый проход: собираем все симлинки
for m in tar.getmembers():
path = posixpath.normpath(m.name)
if m.issym():
symlink_set.add(path)
else:
# Блокируем абсолютные пути
if path.startswith("/"):
raise MlflowException(f"Absolute path is not allowed: {path}")
# Блокируем path traversal
if path.split("/")[0] == "..":
raise MlflowException(f"Escaped path is not allowed: {path}")
# Второй проход: проверяем пути через симлинки
for m in tar.getmembers():
if not m.issym():
path_parts = posixpath.normpath(m.name).split("/")
for prefix_len in range(1, len(path_parts) + 1):
prefix = "/".join(path_parts[:prefix_len])
if prefix in symlink_set:
raise MlflowException(
f"Path goes through a symlink, not allowed: {m.name}"
)
И одна строка в
extract_archive_to_dir:
def extract_archive_to_dir(archive_path, dest_dir):
check_tarfile_security(archive_path) # <- вот и весь фикс снаружи
os.makedirs(dest_dir, exist_ok=True)
with tarfile.open(archive_path, "r") as tar:
tar.extractall(path=dest_dir)
В контексте MLflow жертва часто даже не знает, что запускает чужой артефакт — модели регулярно обновляются и загружаются автоматически. А сам MLflow нередко работает с широкими правами, потому что ему нужен доступ к S3, базам и GPU-инфраструктуре.
Встречали ли Вы когда-нибудь похожие места в своём коде, где архивы распаковываются без проверки путей? А может, даже писали что-то подобное?🫣