Zip Slip: Archive Extraction Path Traversal in Java, Python, Node.js, and Go

Zip Slip is a class of path traversal vulnerability in archive extraction code that allows a maliciously crafted archive to write files outside the intended extraction directory. This guide covers how the attack works, the vulnerable patterns in Java, Python, Node.js, and Go, and safe extraction implementations for each.

Zip Slip is the name given to a class of path traversal vulnerability that occurs during archive extraction. The core issue: most archive formats (ZIP, TAR, JAR, WAR, etc.) allow entries to contain relative paths including ../ sequences. If extraction code writes archive entries without validating the resulting destination path, an attacker who controls the archive content can write files to arbitrary locations on the filesystem — overwriting executables, planting configuration files, or dropping web shells.

The vulnerability was formally documented and named by the Snyk security team in 2018 after they found it in thousands of open source libraries across multiple languages. Despite that disclosure, variants still appear regularly in production code, in dependencies, and in new projects where developers write extraction logic from scratch without checking whether their implementation is safe.

This guide covers how the attack works, the specific vulnerable patterns in Java, Python, Node.js, and Go, and safe implementations for each language.

How Zip Slip Works

Standard archive extraction loops over archive entries and writes each to a local path. A naive implementation:

for each entry in archive:
    output_path = destination_dir + "/" + entry.name
    write(output_path, entry.content)

A malicious archive contains entries with names like:

../../../tmp/evil.sh
../../etc/cron.d/backdoor
../../../../home/user/.ssh/authorized_keys

The extracted path resolves outside the intended destination directory. On a web server, ../../webroot/shell.php writes a PHP web shell. On a Linux system with a writable /etc/cron.d, the attacker gets code execution on the next cron cycle.

The attack requires the ability to deliver a crafted archive to the extraction code. That’s a realistic condition in:

  • File upload endpoints that accept ZIP or TAR archives
  • CI/CD systems that download and extract build artifacts
  • Package managers that extract dependency archives
  • Applications that process user-submitted archives (backup restoration, import/export features)
  • Software update mechanisms

Java

Java’s ZipInputStream is the standard extraction API, and its entry names are accepted verbatim by default. The vulnerable pattern is almost exactly what the JDK tutorials show:

// VULNERABLE — do not use
public void extractZip(File zipFile, File destDir) throws IOException {
    try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile))) {
        ZipEntry entry;
        while ((entry = zis.getNextEntry()) != null) {
            File outputFile = new File(destDir, entry.getName()); // UNSAFE: entry name not validated
            if (entry.isDirectory()) {
                outputFile.mkdirs();
            } else {
                outputFile.getParentFile().mkdirs();
                try (FileOutputStream fos = new FileOutputStream(outputFile)) {
                    byte[] buffer = new byte[4096];
                    int len;
                    while ((len = zis.read(buffer)) > 0) {
                        fos.write(buffer, 0, len);
                    }
                }
            }
            zis.closeEntry();
        }
    }
}

The problem is new File(destDir, entry.getName()). If entry.getName() is ../../etc/passwd, the resulting path escapes destDir.

Safe Java implementation:

public void extractZipSafe(File zipFile, File destDir) throws IOException {
    String canonicalDest = destDir.getCanonicalPath() + File.separator;

    try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile))) {
        ZipEntry entry;
        while ((entry = zis.getNextEntry()) != null) {
            File outputFile = new File(destDir, entry.getName());
            String canonicalOutput = outputFile.getCanonicalPath();

            // Verify the canonical path starts with the destination directory
            if (!canonicalOutput.startsWith(canonicalDest)) {
                throw new SecurityException("Zip Slip attempt detected: " + entry.getName());
            }

            if (entry.isDirectory()) {
                outputFile.mkdirs();
            } else {
                outputFile.getParentFile().mkdirs();
                try (FileOutputStream fos = new FileOutputStream(outputFile)) {
                    byte[] buffer = new byte[4096];
                    int len;
                    while ((len = zis.read(buffer)) > 0) {
                        fos.write(buffer, 0, len);
                    }
                }
            }
            zis.closeEntry();
        }
    }
}

The key is getCanonicalPath(), which resolves . and .. sequences and returns the actual absolute path. Checking that the canonical output path starts with the canonical destination directory prevents escape. Note the trailing File.separator on canonicalDest — without it, a destination of /tmp/out would incorrectly allow paths under /tmp/output/.

For TAR archives in Java, use Apache Commons Compress’s TarArchiveInputStream with the same canonical path check applied to each TarArchiveEntry.getName().

Python

Python’s zipfile module and the tarfile module both accept entry names verbatim unless explicitly checked.

# VULNERABLE — standard zipfile extraction
import zipfile

def extract_zip(zip_path: str, dest_dir: str) -> None:
    with zipfile.ZipFile(zip_path, 'r') as zf:
        zf.extractall(dest_dir)  # UNSAFE: no path validation

extractall() is convenient but provides no protection against ../ traversal in entry names. The same applies to extract(member, path) when member contains traversal sequences.

For tarfile, the Python standard library added mitigations in Python 3.12 (tarfile.TarFile.extractall gained a filter parameter), but earlier versions are fully vulnerable.

Safe Python implementation:

import os
import zipfile
from pathlib import Path

def extract_zip_safe(zip_path: str, dest_dir: str) -> None:
    dest = Path(dest_dir).resolve()

    with zipfile.ZipFile(zip_path, 'r') as zf:
        for member in zf.namelist():
            member_path = (dest / member).resolve()

            # Ensure the resolved path is inside dest
            try:
                member_path.relative_to(dest)
            except ValueError:
                raise ValueError(f"Zip Slip attempt: {member!r} escapes destination")

            if member.endswith('/'):
                member_path.mkdir(parents=True, exist_ok=True)
            else:
                member_path.parent.mkdir(parents=True, exist_ok=True)
                with zf.open(member) as src, open(member_path, 'wb') as dst:
                    dst.write(src.read())

Using Path.resolve() normalises the path, then relative_to() raises ValueError if the resolved path is not under the destination. This pattern works correctly on both Unix and Windows.

Safe TAR extraction in Python (3.12+):

import tarfile

def extract_tar_safe(tar_path: str, dest_dir: str) -> None:
    with tarfile.open(tar_path) as tf:
        # Python 3.12+: use the 'data' filter which rejects dangerous paths
        tf.extractall(dest_dir, filter='data')

For Python < 3.12, manually iterate and apply the resolve() + relative_to() check on each member’s name.

Node.js

Node.js has no built-in ZIP extraction — projects use libraries like adm-zip, jszip, unzipper, or node-stream-zip. Several of these have had Zip Slip vulnerabilities in older versions. The naive implementation pattern looks like:

// VULNERABLE — using adm-zip without validation
const AdmZip = require('adm-zip');

function extractZip(zipPath, destDir) {
  const zip = new AdmZip(zipPath);
  zip.extractAllTo(destDir, true); // UNSAFE: no path validation in older versions
}

Safe Node.js implementation using unzipper with explicit validation:

const path = require('path');
const fs = require('fs');
const unzipper = require('unzipper');

async function extractZipSafe(zipPath, destDir) {
  const resolvedDest = path.resolve(destDir);

  await new Promise((resolve, reject) => {
    fs.createReadStream(zipPath)
      .pipe(unzipper.Parse())
      .on('entry', (entry) => {
        const entryPath = entry.path;
        const fullPath = path.resolve(resolvedDest, entryPath);

        // Verify the resolved path stays within destination
        if (!fullPath.startsWith(resolvedDest + path.sep) && fullPath !== resolvedDest) {
          entry.autodrain();
          return reject(new Error(`Zip Slip attempt: ${entryPath}`));
        }

        if (entry.type === 'Directory') {
          fs.mkdirSync(fullPath, { recursive: true });
          entry.autodrain();
        } else {
          fs.mkdirSync(path.dirname(fullPath), { recursive: true });
          entry.pipe(fs.createWriteStream(fullPath))
            .on('error', reject);
        }
      })
      .on('close', resolve)
      .on('error', reject);
  });
}

Note the path.sep check: on Windows, path.sep is \, and a destination check that only looks for / will be bypassed.

If you’re using adm-zip, prefer version 0.5.0 or later, which added traversal protection. But the check above is correct regardless of library version — write explicit validation rather than trusting the library’s internal protection.

Go

Go’s archive/zip package accepts entry names verbatim. The vulnerable standard pattern:

// VULNERABLE
func extractZip(src, dest string) error {
    r, err := zip.OpenReader(src)
    if err != nil {
        return err
    }
    defer r.Close()

    for _, f := range r.File {
        fpath := filepath.Join(dest, f.Name) // UNSAFE: f.Name not validated

        if f.FileInfo().IsDir() {
            os.MkdirAll(fpath, os.ModePerm)
            continue
        }

        os.MkdirAll(filepath.Dir(fpath), os.ModePerm)
        outFile, _ := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
        rc, _ := f.Open()
        io.Copy(outFile, rc)
        rc.Close()
        outFile.Close()
    }
    return nil
}

Safe Go implementation:

import (
    "archive/zip"
    "fmt"
    "io"
    "os"
    "path/filepath"
    "strings"
)

func extractZipSafe(src, dest string) error {
    r, err := zip.OpenReader(src)
    if err != nil {
        return err
    }
    defer r.Close()

    // Resolve the destination to an absolute, clean path
    absDest, err := filepath.Abs(dest)
    if err != nil {
        return err
    }

    for _, f := range r.File {
        fpath := filepath.Join(absDest, f.Name)

        // filepath.Join cleans the path, but verify it's still within dest
        if !strings.HasPrefix(fpath, absDest+string(os.PathSeparator)) {
            return fmt.Errorf("zip slip detected: %q escapes destination", f.Name)
        }

        if f.FileInfo().IsDir() {
            if err := os.MkdirAll(fpath, os.ModePerm); err != nil {
                return err
            }
            continue
        }

        if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
            return err
        }

        outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
        if err != nil {
            return err
        }

        rc, err := f.Open()
        if err != nil {
            outFile.Close()
            return err
        }

        _, err = io.Copy(outFile, rc)
        rc.Close()
        outFile.Close()
        if err != nil {
            return err
        }
    }
    return nil
}

filepath.Join cleans .. sequences out of paths — which is why the strings.HasPrefix check works here, whereas with Java or Python’s string concatenation it wouldn’t. The verification still matters because a path like /tmp/dest/../etc/passwd is cleaned to /etc/passwd by filepath.Join, and strings.HasPrefix("/etc/passwd", "/tmp/dest/") correctly returns false.

TAR-Specific Considerations

TAR archives carry additional risk: the TAR format supports absolute paths (beginning with /) as well as ../ relative paths. An archive entry with name /etc/crontab would write directly to /etc/crontab on extraction with naive code.

The validation rules are the same — resolve the full target path and check it’s within the destination — but also explicitly reject any entry name that begins with /:

# Additional TAR check in Python
for member in tf.getmembers():
    if member.name.startswith('/') or member.name.startswith('..'):
        raise ValueError(f"Unsafe TAR entry: {member.name!r}")

Similarly, TAR archives support symlinks as entries. A symlink entry pointing outside the destination can be used to make subsequent writes land in an attacker-controlled location. After extraction, before operating on the extracted contents, resolve any symlinks to verify they point within the extraction directory.

Testing Your Implementation

Crafting a test ZIP with traversal entries:

import zipfile
import io

# Create a malicious test archive
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
    zf.writestr('../test-traversal-marker.txt', 'zip slip test')
    zf.writestr('../../test-traversal-marker-2.txt', 'zip slip test 2')

with open('/tmp/test-traversal.zip', 'wb') as f:
    f.write(buf.getvalue())

Run this against your extraction code and verify it raises an error rather than writing test-traversal-marker.txt into the parent of your destination directory. Any correct implementation should reject both entries.

The Zip Slip attack has been public for years, has documented CVEs in dozens of libraries, and continues to appear in new code because the vulnerable pattern is the obvious first implementation and the safe version requires one additional check. That check is small — a canonical path comparison before writing — and it completely eliminates the attack surface.