What Zip Slip is and why it matters

Zip Slip happens when archive entries contain path components like ../, absolute paths, or platform-specific tricks that escape the extraction root. For example, a malicious archive might contain:

  • ../../etc/cron.d/payload
  • /root/.ssh/authorized_keys
  • C:\Windows\System32\drivers\etc\hosts
  • subdir/../../app/config.toml

If your code joins these paths directly to an output directory and writes the files, the archive can overwrite arbitrary locations.

This is especially dangerous in:

  • update systems
  • plugin installers
  • document importers
  • backup restore tools
  • CI/CD pipelines that unpack artifacts

The core defense is simple: treat archive entry names as untrusted input and verify that the resolved destination path remains under the extraction root.


A vulnerable extraction pattern

A common mistake is to trust the entry name and write it directly:

use std::fs::{self, File};
use std::io::{self, copy};
use std::path::Path;
use zip::ZipArchive;

fn extract_zip_vulnerable(zip_path: &Path, out_dir: &Path) -> io::Result<()> {
    let file = File::open(zip_path)?;
    let mut archive = ZipArchive::new(file)?;

    for i in 0..archive.len() {
        let mut entry = archive.by_index(i)?;
        let out_path = out_dir.join(entry.name());

        if entry.is_dir() {
            fs::create_dir_all(&out_path)?;
        } else {
            if let Some(parent) = out_path.parent() {
                fs::create_dir_all(parent)?;
            }
            let mut outfile = File::create(&out_path)?;
            copy(&mut entry, &mut outfile)?;
        }
    }

    Ok(())
}

The problem is out_dir.join(entry.name()) does not sanitize the name. If entry.name() contains traversal segments or an absolute path, the resulting path can point outside out_dir.


Safe extraction strategy

A secure extractor should do all of the following:

  1. Reject absolute paths
  2. Reject traversal components such as ..
  3. Normalize and verify the final path
  4. Create directories safely
  5. Avoid following symlinks when possible
  6. Limit file sizes and entry counts to reduce resource abuse

The zip crate provides a useful helper: sanitized_name(). It removes dangerous path components and returns a safer relative path. That said, you should still verify the final path is inside the extraction root.

Safer extraction example

use std::fs::{self, File};
use std::io::{self, copy};
use std::path::{Path, PathBuf};
use zip::ZipArchive;

fn extract_zip_safely(zip_path: &Path, out_dir: &Path) -> io::Result<()> {
    let file = File::open(zip_path)?;
    let mut archive = ZipArchive::new(file)?;

    let out_dir = fs::canonicalize(out_dir)?;

    for i in 0..archive.len() {
        let mut entry = archive.by_index(i)?;

        // Skip suspicious or unsupported entries early.
        let relative = match entry.enclosed_name() {
            Some(path) => path.to_owned(),
            None => continue,
        };

        let out_path = out_dir.join(&relative);

        // Ensure the parent directory exists.
        if entry.is_dir() {
            fs::create_dir_all(&out_path)?;
            continue;
        }

        if let Some(parent) = out_path.parent() {
            fs::create_dir_all(parent)?;
        }

        // Canonicalize the parent directory if it already exists, then verify containment.
        let parent = out_path
            .parent()
            .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "missing parent"))?;

        let parent_canon = fs::canonicalize(parent)?;
        if !parent_canon.starts_with(&out_dir) {
            continue;
        }

        let mut outfile = File::create(&out_path)?;
        copy(&mut entry, &mut outfile)?;
    }

    Ok(())
}

Why this is better

  • entry.enclosed_name() returns None for paths that escape the archive root.
  • canonicalize(out_dir) gives a stable absolute base path.
  • Checking starts_with(&out_dir) helps confirm the destination remains inside the extraction directory.

This pattern is not perfect by itself, but it is much safer than joining raw entry names.


Understanding Rust path validation tools

Rust’s standard library gives you several path APIs, but they serve different purposes. The table below summarizes the most relevant ones for archive extraction.

APIPurposeSecurity value
Path::joinConcatenates pathsNone by itself; can preserve traversal
Path::componentsIterates path segmentsUseful for manual validation
Path::canonicalizeResolves symlinks and ..Good for containment checks
starts_withChecks path prefixUseful after canonicalization
zip::read::ZipFile::enclosed_nameReturns safe relative path if possibleStrong first-line defense
zip::read::ZipFile::sanitized_nameProduces a cleaned pathHelpful, but still verify output

A key point: path cleaning is not the same as path authorization. Even if a path looks normalized, you should still confirm it resolves under the intended directory.


Handling symlinks and special files

Zip Slip is not only about .. segments. An attacker may also abuse symlinks inside the archive:

  1. Extract a symlink into the destination.
  2. Later entries write through that symlink to escape the root.

If your use case does not require symlinks, the safest approach is to reject them entirely. The zip crate exposes file attributes, but support differs by platform and archive metadata. If you do allow symlinks, you need extra checks to ensure they cannot redirect writes outside the extraction directory.

Practical guidance

  • Prefer extracting only regular files and directories.
  • Skip device files, FIFOs, and sockets.
  • Treat symlink extraction as an advanced feature with dedicated validation.
  • Re-check containment before every write, not just once at the start.

For many applications, the simplest secure policy is: accept only regular files and directories.


Enforcing size and count limits

A malicious archive can also be used for denial of service. Even if paths are safe, the archive may contain:

  • millions of entries
  • extremely large files
  • highly compressed data that expands dramatically

To reduce risk, enforce limits before extraction.

Example: basic resource limits

use std::fs::{self, File};
use std::io::{self, copy};
use std::path::Path;
use zip::ZipArchive;

const MAX_ENTRIES: usize = 10_000;
const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; // 50 MiB

fn extract_with_limits(zip_path: &Path, out_dir: &Path) -> io::Result<()> {
    let file = File::open(zip_path)?;
    let mut archive = ZipArchive::new(file)?;

    if archive.len() > MAX_ENTRIES {
        return Err(io::Error::new(io::ErrorKind::InvalidData, "too many entries"));
    }

    fs::create_dir_all(out_dir)?;

    for i in 0..archive.len() {
        let mut entry = archive.by_index(i)?;

        if entry.size() > MAX_FILE_SIZE {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "file too large"));
        }

        let relative = match entry.enclosed_name() {
            Some(path) => path.to_owned(),
            None => continue,
        };

        let out_path = out_dir.join(relative);

        if entry.is_dir() {
            fs::create_dir_all(&out_path)?;
            continue;
        }

        if let Some(parent) = out_path.parent() {
            fs::create_dir_all(parent)?;
        }

        let mut outfile = File::create(&out_path)?;
        copy(&mut entry, &mut outfile)?;
    }

    Ok(())
}

These limits should be tuned to your application. A desktop app importing user documents may allow larger files than a web service unpacking uploads.


Choosing a validation policy

Different applications need different tradeoffs. The following matrix can help you decide.

PolicySecurityCompatibilityRecommended for
Reject any path with .. or absolute componentsHighMediumMost server-side extractors
Use enclosed_name() and skip invalid entriesHighHighGeneral-purpose archive importers
Allow symlinks with extra checksMediumMediumSpecialized tooling
Trust archive names directlyVery lowHighNever

In practice, the best default is: skip invalid entries, extract only regular files and directories, and enforce size limits.


Testing for Zip Slip regressions

Security bugs often return when code changes. Add tests that prove your extractor rejects dangerous archive names.

Example test cases

  • ../outside.txt
  • subdir/../../escape.txt
  • /absolute/path.txt
  • C:\absolute\path.txt
  • nested/ok.txt

You can also create a malicious ZIP in a test and assert that no files appear outside the extraction directory.

#[test]
fn rejects_traversal_entries() {
    // Pseudocode: build a zip containing "../escape.txt"
    // Run extractor
    // Assert that "escape.txt" was not created outside the target directory
}

For stronger assurance, use temporary directories and compare the set of created files against an allowlist. This helps catch regressions in path handling, symlink behavior, and directory creation.


Operational best practices

Secure extraction is not only about code. Deployment and runtime choices matter too.

Recommended practices

  • Extract into a dedicated temporary directory first.
  • Move validated files into their final location only after extraction succeeds.
  • Run extraction with the least privilege possible.
  • Avoid extracting archives received from untrusted sources unless necessary.
  • Log rejected entries for incident analysis, but do not echo raw paths into user-facing messages without escaping.
  • Keep archive libraries updated, especially if they handle multiple formats.

If your application processes archives uploaded by users, consider isolating extraction in a separate worker process or container. That way, even if a bug slips through, the blast radius is smaller.


A secure mental model

When reviewing archive extraction code, ask these questions:

  1. Does every entry name come from an untrusted source?
  2. Is the final destination path verified to stay inside the extraction root?
  3. Are symlinks and special files rejected or carefully controlled?
  4. Are file sizes and entry counts bounded?
  5. Are tests covering traversal and absolute-path payloads?

If the answer to any of these is “no,” the extractor is likely unsafe.


Conclusion

Zip Slip is a classic example of why file handling needs explicit security checks. In Rust, the safest approach is to treat archive paths as hostile input, use enclosed_name() or equivalent validation, verify containment after path resolution, and reject anything that does not belong in the extraction root.

With a small amount of extra code, you can turn archive extraction from a common vulnerability source into a predictable, testable part of your system.

Learn more with useful resources