Skip to content

ZIP Extraction Allows Arbitrary File Write via Pre-existing Dangling Symlink #1027

Description

@cryptcrack

Summary

mz_zip_reader_entry_save_file() uses stat()-based existence checks before creating extracted files. Because stat() follows symlinks, dangling symlinks are incorrectly treated as nonexistent, bypassing the overwrite check. The subsequent file creation follows the symlink, allowing writes to an attacker-chosen location outside the extraction directory. This issue is independent of #1025 and remains exploitable even when an explicit -d/destination_dir is specified.

Root Cause

  1. mz_os_file_exists() (mz_os_posix.c) calls stat(), which resolves symlinks. A dangling symlink causes stat() to fail with ENOENT, so the function treats the path as nonexistent even though the symlink itself exists.

  2. mz_zip_reader_entry_save_file() (mz_zip_rw.c) uses this existence check to gate its overwrite/removal handling and subsequently calls mz_stream_os_open(path, MZ_OPEN_MODE_CREATE | MZ_OPEN_MODE_WRITE) on the same path.

  3. mz_stream_os_open() ultimately calls fopen()/open() without an O_NOFOLLOW-style safeguard, so if path is a symlink, the open follows it and operates on the symlink target rather than the symlink itself.

Impact: A ZIP entry with a completely benign, non-traversing filename can still create or overwrite a file at an attacker-chosen location outside the intended extraction directory, provided a dangling symlink with the same name has been pre-planted at the destination. This issue remains exploitable even when an explicit -d/destination_dir is specified.

Reproduction

Pre-plant a dangling symlink at the destination path corresponding to the ZIP entry, then extract a normal single-entry ZIP archive (the entry name matches the symlink name, contains no path traversal, and is not a symlink entry) with an explicit -d/destination_dir.

PoC file: evil_1027.zip
Command:

minizip -x -o -d /tmp/poc ./evil.zip

Log

$ mkdir -p /tmp/dest /tmp/outside
$ ln -s /tmp/outside/pwned.txt /tmp/dest/link_name
$ stat -c '%A %N' /tmp/dest/link_name
lrwxrwxrwx '/tmp/dest/link_name' -> '/tmp/outside/pwned.txt'
$ ./minizip -x -o -d /tmp/dest /tmp/evil.zip
minizip-ng 4.2.2 - https://github.com/zlib-ng/minizip-ng
---------------------------------------------------
-x -o -d /tmp/dest /tmp/evil.zip 
Archive /tmp/evil.zip
Extracting link_name

$ echo "--- dest ---"
ls -la /tmp/dest
--- dest ---
total 8
drwxr-xr-x 2 root root 4096 Jul 25 14:50 .
drwxrwxrwt 1 root root 4096 Jul 25 14:50 ..
lrwxrwxrwx 1 root root   22 Jul 25 14:50 link_name -> /tmp/outside/pwned.txt

echo "--- outside ---"
ls -la /tmp/outside
cat /tmp/outside/pwned.txt
--- outside ---
total 12
drwxr-xr-x 2 root root 4096 Jul 25 14:51 .
drwxrwxrwt 1 root root 4096 Jul 25 14:50 ..
-rw-r--r-- 1 root root    6 Nov 30  1979 pwned.txt
PWNED

Ref

GHSA-qcc4-p59m-p54m

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions