Introduction

I spend some of my evenings browsing ZDI’s Advisory Page I saw two very interesting bugs (CVE-2025-11001, CVE-2025-11002) reported by Ryota Shiga from GMO Flatt Security Inc. The description shows that it is a path traversal in 7-Zip, yet the CVSS seems quite low for a potential initial access bug.

I’d like to mention there are 2 bugs disclosed by ZDI affecting this release with the same description and reporter, most likely the other report exploits a symlink bug with UNC paths, as this is also mentioned in the diff.

This post describes a vulnerability in 7-Zip’s module responsible for converting Linux symlinks to Windows ones (as well as other types of symlinks but this blog will focus on the Linux -> Windows side).

Initial assessment

When diffing between 7-Zip 24.09 vs 25.00 We can see that there are a few bugs fixed in this release. This patchs adds a considerable rework of the symlink support in zip extraction code in CPP/7zip/UI/Common/ArchiveExtractCallback.cpp. My eye instantly darted to the patch of IsSafePath.

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

+void CLinkLevelsInfo::Parse(const UString &path, bool isWSL)
 {
-  IsAbsolute = NName::IsAbsolutePath(path);
-
+  IsAbsolute = isWSL ?
+      IS_PATH_SEPAR(path[0]) :
+      NName::IsAbsolutePath(path);
   LowLevel = 0;
   FinalLevel = 0;
 }

The bug looks like a case of processing Linux or WSL-style symlinks in zip. I initially thought of a year-old discussion between Bill Demarkapi and Yarden Shafir on LX symlinks https://x.com/BillDemirkapi/status/1750226136938725819 but this turned out to be the wrong idea.

Analysis

The main extraction point starts with CArchiveExtractCallback::GetStream() which calls ReadLink which makes this bug annoying to triage because ReadLink is not involved in parsing of actual symlinks but rather seems to try to get properties such as kpidHardLink which are supported in other types of archives.

GetStream calls CArchiveExtractCallback::GetExtractStream which identifies a symlink by first checking if it’s a small file (< 4k) and then performing a full file check.

  if (_curSize_Defined && _curSize > 0 && _curSize < (1 << 12))
  {
    if (_fi.IsLinuxSymLink())
    {
      is_SymLink_in_Data = true;
      _is_SymLink_in_Data_Linux = true;
    }
    else if (_fi.IsReparse())
    {
      is_SymLink_in_Data = true;
      _is_SymLink_in_Data_Linux = false;
    }
  }

After a bunch of additional processing we hop into CArchiveExtractCallback::CloseReparseAndFile which is where the fun starts. The method attempts to parse the link and get an idea on where it is trying to point.

// Definition
bool CLinkInfo::Parse(const Byte *data, size_t dataSize, bool isLinuxData);

/* some code */

  bool repraseMode = false;
  bool needSetReparse = false;
  CLinkInfo linkInfo;
  
  if (_bufPtrSeqOutStream)
  {
    repraseMode = true;
    reparseSize = _bufPtrSeqOutStream_Spec->GetPos();
    if (_curSize_Defined && reparseSize == _outMemBuf.Size())
    {
      // _is_SymLink_in_Data_Linux == true 
      needSetReparse = linkInfo.Parse(_outMemBuf, reparseSize, _is_SymLink_in_Data_Linux);
      if (!needSetReparse)
        res = SendMessageError_with_LastError("Incorrect reparse stream", us2fs(_item.Path));
    }
  }

The parser sets 2 crucial attributes

  • Link path (destination path of the symlink)
  • isRelative (states if the symlink is relative)

The First issue

What happens when a Linux symlink has a Windows-style C:\ path?

The link path is set to the full C:\ path, yet it’s labeled relative because the parser follows the Linux-style check for absolute paths in the parser.

This will come in handy later.

  #ifdef SUPPORT_LINKS
  if (repraseMode)
  {
    _curSize = reparseSize;
    _curSize_Defined = true;
    
    #ifdef SUPPORT_LINKS
    if (needSetReparse)
    {
      if (!DeleteFileAlways(_diskFilePath))
      {
        RINOK(SendMessageError_with_LastError("can't delete file", _diskFilePath))
      }
      {
        bool linkWasSet = false;
        RINOK(SetFromLinkPath(_diskFilePath, linkInfo, linkWasSet))
        if (linkWasSet)
          _isSymLinkCreated = linkInfo.IsSymLink();
        else
          _needSetAttrib = false;
      }

    }
    #endif
  }
  #endif

SetFromLinkPath is the function which is responsible for creating a symlink with the specified path, however there was a guard rail in place stopping us from creating links to absolute paths.

  if (linkInfo.isRelative)
    relatPath = GetDirPrefixOf(_item.Path);
  relatPath += linkInfo.linkPath;
  
  if (!IsSafePath(relatPath))
  {
    return SendMessageError2(
          0, // errorCode
          "Dangerous link path was ignored",
          us2fs(_item.Path),
          us2fs(linkInfo.linkPath)); // us2fs(relatPath)
  }

7-Zip crafts a relative destination path for the link to point to under the newly extracted zip file. Then it is verified with IsSafePath. In case of a relative link it adds the directory the symlink is in within the zip to the path being checked.

The second issue

In our case isRelative == true because the link was evaluated previously as relative, local path of the symlink inside of the directory gets prepended to the path, allowing us to bypass this check when the symlink is anywhere but the root directory of the zip file.

the check becomes isSafePath("some/directory/in/zip" + "C:\some\other\path") evaluating as true

The third issue

Later on there is a check which is supposed to check the actual link path for validity prior to creating a symlink, however previous to checking it, it checks if a given “item” (our symlink) is a directory, which it is not - effectively bypassing the check.

  if (!_ntOptions.SymLinks_AllowDangerous.Val)
  {
    #ifdef _WIN32
    if (_item.IsDir) // NOPE
    #endif
    if (linkInfo.isRelative)
      {
        CLinkLevelsInfo levelsInfo;
        levelsInfo.Parse(linkInfo.linkPath);
        if (levelsInfo.FinalLevel < 1 || levelsInfo.IsAbsolute)
        {
          return SendMessageError2(
            0, // errorCode
            "Dangerous symbolic link path was ignored",
            us2fs(_item.Path),
            us2fs(linkInfo.linkPath));
        }
      }
  }

After all of those checks, a symlink is created with

  // existPath -> C:\some\other\path (symlink destination)
  // data -> path for symlink to be created 
  // Initializes reparse data for symlink creation
  if (!FillLinkData(data, fs2us(existPath), !linkInfo.isJunction, linkInfo.isWSL))
    return SendMessageError("Cannot fill link data", us2fs(_item.Path));

  /// ...

  // creates symlink
  if (!NFile::NIO::SetReparseData(fullProcessedPath, _item.IsDir, data, (DWORD)data.Size()))
  {
    RINOK(SendMessageError_with_LastError(kCantCreateSymLink, fullProcessedPath))
    return S_OK;
  }

Exploitation

Exploiting this bug is very simple, if we assume that the symlink gets extracted first we can craft a directory structure as below.

data/link -> symlink to C:\Users\YOURUSERNAME\Desktop (or any other location of your choice) data/link -> Directory data/link/calc.exe -> The file you want to write to the target directory

In this case the link is unpacked first, after which calc.exe gets unpacked into the symlink which 7-Zip follows and writes the binary to a directory of your choice

You can find an example exploit on my GitHub https://github.com/pacbypass/CVE-2025-11001

Basic takeaways

  • Fixed version is v25.00
  • Introduced in v21.02
  • This vulnerability can only be exploited from the context of an elevated user / service account or a machine with developer mode enabled.
  • This vulnerability can only be exploited on Windows

Thank you

Thank you for reading as well as a huge thank you to Ryota Shiga for discovering this vulnerability!