Archive extraction path traversal (Zip Slip)

Archive extraction path traversal (Zip Slip)

Description

Zip Slip occurs when an archive entry name is used to create a file without checking that the destination stays inside the extraction directory. Names containing ../, ..\ or absolute paths can direct writes outside that directory. A malicious archive may create or overwrite files wherever the application has write permission. Symbolic links can also redirect writes. The impact depends on file permissions and how the written files are later used.

Potential impact

  • File creation or overwrite: Files may be written to unintended locations accessible to the process.
  • Configuration tampering: Changed application or server settings may alter behavior or disrupt service.
  • Code execution: Writing a script into an executable web location, or replacing a program that is later run, may allow code execution.
  • Information disclosure or privilege escalation: Tampering with files later used by a privileged process may expose data or extend access.

Remediation

  • Normalize with base.resolve(entryName).normalize() and verify containment under base with the path-component check Path.startsWith(base).
  • Reject absolute paths and parent-directory traversal such as ../ or ..\.
  • Allow only the expected file and directory structure.
  • Validate parent paths before calling Files.createDirectories.
  • Extract into a new private directory and skip link entries. If nested paths are needed, use directory-relative operations to verify each parent component without following symbolic links.
  • Use CREATE_NEW and NOFOLLOW_LINKS to avoid replacing existing files or following links.
  • Run extraction with only the necessary permissions.

Examples

Before

java
import java.io.*;
import java.util.zip.*;
public class UnsafeUnzipper {
    public void extract(InputStream zipStream, File destDir) throws IOException {
        ZipInputStream zis = new ZipInputStream(zipStream);
        ZipEntry entry;
        while ((entry = zis.getNextEntry()) != null) {
            // Combine the raw entry name with the destination.
            File outFile = new File(destDir, entry.getName());
            if (entry.isDirectory()) {
                outFile.mkdirs();
                continue;
            }
            outFile.getParentFile().mkdirs();
            try (OutputStream os = new FileOutputStream(outFile)) {
                byte[] buf = new byte[8192];
                int len;
                while ((len = zis.read(buf)) != -1) {
                    os.write(buf, 0, len);
                }
            }
        }
    }
}

After

This example requires Java 11 or later. destDir must be controlled by the application and protected against changes by other users. Apply separate limits to expanded size and entry count.

java
import java.io.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
public class SafeUnzipper {
    public void extract(InputStream zipStream, File destDir) throws IOException {
        // Create a private directory for this extraction under application control.
        Path serviceRoot = destDir.toPath().toRealPath();
        Path base = Files.createTempDirectory(serviceRoot, "extract-")
                .toRealPath();
        try (ZipInputStream zis = new ZipInputStream(zipStream)) {
            ZipEntry entry;
            byte[] buf = new byte[8192];
            while ((entry = zis.getNextEntry()) != null) {
                String name = entry.getName();
                Path entryPath = Path.of(name);
                // Accept only plain filenames, without parent directories that could be links.
                if (entry.isDirectory() || name.isBlank() || entryPath.isAbsolute()
                        || name.indexOf('/') >= 0 || name.indexOf('\\') >= 0
                        || name.equals(".") || name.equals("..")) {
                    throw new IOException("Invalid zip entry name: " + name);
                }

                Path target = base.resolve(entryPath).normalize();
                if (!target.startsWith(base) || !target.getParent().equals(base)) {
                    throw new IOException("Zip Slip detected: " + name);
                }

                Set<OpenOption> options = Set.of(
                        StandardOpenOption.CREATE_NEW,
                        StandardOpenOption.WRITE,
                        LinkOption.NOFOLLOW_LINKS);
                try (SeekableByteChannel channel = Files.newByteChannel(target, options);
                     OutputStream os = Channels.newOutputStream(channel)) {
                    int len;
                    while ((len = zis.read(buf)) != -1) {
                        os.write(buf, 0, len);
                    }
                }
            }
        }
    }
}

Explanation:

  • Before: Concatenates entry.getName() with the destination. A name such as ../../etc/passwd can escape the intended directory and create or overwrite a sensitive file if the process has permission.
  • After: Creates a private directory for each extraction and accepts only plain filenames without subdirectories. Component-based containment checks and CREATE_NEW with NOFOLLOW_LINKS avoid traversing symbolic-link parents or truncating existing files or links. Supporting directory trees requires separate, directory-relative creation and validation of every parent.

References