TSS-R-2026-001research record identifier

pyLoad: Arbitrary File Deletion via Path Traversal during Encrypted 7z Password Verification

Public record of CVE-2026-32808, a path traversal in pyLoad's encrypted 7z password verification that allowed a file outside the extraction directory to be deleted. Fixed in pyload-ng 0.5.0b3.dev97.

Record facts

The machine identity of this record, as published.

Research type
vulnerability
Publication status
published
Finding status
fixed
Identifiers
CVE-2026-32808GHSA-7g4m-8hx2-4qh3EUVD-2026-13435
Severity
highCVSS 3.1 8.1
Weakness
CWE-22
Product
pyload-ngpyLoad
Component
SevenZip.verify()
Affected
>= 0.4.9-6262-g2fa0b11d3, < 0.5.0b3.dev97
Fixed in
0.5.0b3.dev97
Published
Updated
Current revision
1.0
Research domains
application-security

Mechanism

Where untrusted input enters, what carries it, and the operation that turns it into impact.

  1. Attacker-controlled

    Archive entry name

    A 7z archive names its own entries. The name is chosen by whoever built the archive.

  2. Carried by

    7z listing output

    SevenZip._find_smallest_file() reads the entry list from the 7z process and returns the smallest entry's name as `smallest`.

  3. Failed assumption

    Path construction

    verify() joins the extraction destination with that name using os.path.join, which resolves a leading traversal segment instead of rejecting it.

  4. Dangerous operation

    os.remove(extracted)

    The joined path is passed straight to os.remove, so the deletion follows wherever the join resolved to.

  5. Consequence

    File deleted outside the destination

    A file outside the intended extraction directory is removed. CVSS records no confidentiality impact and high integrity and availability impact.

Summary

pyLoad’s 7z extractor performed a password-verification step that derived a filesystem path from an archive entry name and then deleted that path. The entry name came from the output of a 7z listing and was attacker-controlled, so an archive could cause a file outside the intended extraction directory to be removed.

The issue is tracked as CVE-2026-32808 and GHSA-7g4m-8hx2-4qh3, classified as CWE-22 (improper limitation of a pathname to a restricted directory), and scored CVSS 3.1 8.1 (High) with vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H.

Affected versions

Per the CVE record, the affected range is >= 0.4.9-6262-g2fa0b11d3, < 0.5.0b3.dev97, and the issue is fixed in 0.5.0b3.dev97.

Root cause

SevenZip.verify() constructed a path by joining the extraction destination with an entry name taken from the archive listing, and passed the result to os.remove(). Because the entry name was attacker-controlled and could contain traversal segments, the joined path was not guaranteed to remain inside the destination directory. Nothing in the verification step re-checked containment before the deletion occurred.

The fix

Upstream commit 5f4f0fa5fed3520e57f2cb5a1053e3654c082eea introduced a safejoin() helper that resolves the candidate path and its base with realpath, compares them with commonpath, and rejects any result that falls outside the base. The relevant SevenZip path constructions, including the one in verify(), were changed to use safejoin(), so a traversing entry name is rejected instead of being resolved into a deletable path.

Impact

The CVSS vector records no confidentiality impact and high integrity and availability impact, which matches a primitive that deletes a file rather than reading or modifying its contents. It also records that user interaction is required: the affected code path runs when a user has pyLoad process an archive supplied by an attacker.

This record does not include proof-of-concept material. The public advisory remains the reference for the reported technical detail.

Credit

The issue was publicly credited on the GitHub Security Advisory to the reporters thesmartshadow and execiq. Structured researcher attribution for this record is carried in its metadata; the strings above are the credits as published externally.

Code evidence

Read from pyload/pyload at the commits below. Line numbers are the upstream file's own, so every excerpt can be checked against the same lines at source.

SevenZip.verify() before the fix

The password-verification branch. `smallest` is an entry name taken from the archive listing; it reaches os.path.join and then os.remove without any containment check.


              
              
                          smallest = self._find_smallest_file(password=password)[0]
            
              
              
                          if smallest is None:
            
              
              
                              raise ArchiveError("Cannot find smallest file")
            
              
              
              
            
              
              
                          try:
            
              
              
                              extracted = os.path.join(self.dest, smallest if self.fullpath else os.path.basename(smallest))
            
              
              
                              try:
            
              
              
                                  os.remove(extracted)
            
              
              
                              except OSError as exc:
            
              
              
                                  pass
            
              
              
                              self.extract(password=password, file=smallest)
            
              
              
              
            
              
              
                              #: Extraction was successful so exclude the file from further extraction
            
              
              
                              if smallest not in self.excludefiles:
            
              
              
                                  self.excludefiles.append(smallest)
            
              
              
              
            
              
              
                          except (PasswordError, CRCError, ArchiveError) as exc:
            
              
              
                              try:
            
              
              
                                  os.remove(extracted)
            
              
              
                              except OSError as exc:
            
  • Line 136vulnerableAttacker-controlled entry name joined to the destination with no containment check.
  • Line 138impactThe joined path is deleted.
  • Line 149impactDeleted again on the error path.

safejoin() before the fix

The helper the extractors already used. It normalised characters but never checked that the result stayed inside the base directory.


              
              
              def safejoin(*args):
            
              
              
                  """
            
              
              
                  os.path.join + safepath.
            
              
              
                  """
            
              
              
                  return safepath(os.path.join(*args))
            
  • Line 416vulnerableJoins and sanitises characters, but performs no containment check.

is_within_directory() introduced by the fix

The containment test the fix adds. realpath resolves symlinks and traversal sequences, then commonpath must still equal the base.


              
              
              def is_within_directory(base_dir, target_dir):
            
              
              
                  """
            
              
              
                  Check if target_dir is within base_dir
            
              
              
                  """
            
              
              
                  # Use realpath for normalization to handle symlinks and traversal sequences
            
              
              
                  real_base = os.path.realpath(base_dir)
            
              
              
                  real_target = os.path.realpath(target_dir)
            
              
              
                  return os.path.commonpath([real_base, real_target]) == real_base
            
  • Line 389fixrealpath normalises symlinks and traversal sequences before comparison.
  • Line 391fixcommonpath must equal the base, or the target lies outside it.

safejoin() after the fix

The same helper the vulnerable code already called, now refusing to return a path that escapes its base. Every SevenZip call site was switched to it, so the traversing entry name raises instead of resolving to a deletable path.


              
              
              def safejoin(*args):
            
              
              
                  """
            
              
              
                  os.path.join + safepath, with path traversal protection.
            
              
              
                  Assumes the first argument is the base directory.
            
              
              
                  """
            
              
              
                  if len(args) < 1:
            
              
              
                      raise ValueError("At least one argument required (base directory)")
            
              
              
              
            
              
              
                  base = args[0]
            
              
              
                  safe_joined = safepath(os.path.join(*args))
            
              
              
              
            
              
              
                  if not is_within_directory(base, safe_joined):
            
              
              
                      raise ValueError("Path traversal attempt detected")
            
              
              
              
            
              
              
                  return safe_joined
            
  • Line 433fixThe containment check the vulnerable version had no equivalent of.
  • Line 434fixA traversing path now raises instead of being returned.

Patch analysis

safejoin() gains a containment check, and the SevenZip path constructions that previously used os.path.join are changed to call it.

5f4f0fa5fed3View commit 5f4f0fa5fed3520e57f2cb5a1053e3654c082eea on the upstream repository+28−7across 2 files

src/pyload/core/utils/fs.py

@@ -381,6 +381,16 @@ def which(filename):

Unified diff for src/pyload/core/utils/fs.py, hunk @@ -381,6 +381,16 @@ def which(filename):
Line beforeLine afterChangeSource
Unchanged line return filename
Unchanged line
Unchanged line
Added linedef is_within_directory(base_dir, target_dir):
Added line """
Added line Check if target_dir is within base_dir
Added line """
Added line # Use realpath for normalization to handle symlinks and traversal sequences
Added line real_base = os.path.realpath(base_dir)
Added line real_target = os.path.realpath(target_dir)
Added line return os.path.commonpath([real_base, real_target]) == real_base
Added line
Added line
Unchanged linedef safepath(value):
Unchanged line """
Unchanged line Remove invalid characters and truncate the path if needed.

@@ -411,9 +421,19 @@ def safepath(value):

Unified diff for src/pyload/core/utils/fs.py, hunk @@ -411,9 +421,19 @@ def safepath(value):
Line beforeLine afterChangeSource
Unchanged line
Unchanged linedef safejoin(*args):
Unchanged line """
Removed line os.path.join + safepath.
Added line os.path.join + safepath, with path traversal protection.
Added line Assumes the first argument is the base directory.
Unchanged line """
Removed line return safepath(os.path.join(*args))
Added line if len(args) < 1:
Added line raise ValueError("At least one argument required (base directory)")
Added line
Added line base = args[0]
Added line safe_joined = safepath(os.path.join(*args))
Added line
Added line if not is_within_directory(base, safe_joined):
Added line raise ValueError("Path traversal attempt detected")
Added line
Added line return safe_joined
Unchanged line
Unchanged line
Unchanged linedef safename(value):

src/pyload/plugins/extractors/SevenZip.py

@@ -4,6 +4,7 @@ import subprocess

Unified diff for src/pyload/plugins/extractors/SevenZip.py, hunk @@ -4,6 +4,7 @@ import subprocess
Line beforeLine afterChangeSource
Unchanged line
Unchanged linefrom pyload import PKGDIR
Unchanged linefrom pyload.core.utils.convert import to_str
Added linefrom pyload.core.utils.fs import safejoin
Unchanged linefrom pyload.plugins.base.extractor import ArchiveError, BaseExtractor, CRCError, PasswordError
Unchanged linefrom pyload.plugins.helpers import renice
Unchanged line

@@ -11,7 +12,7 @@ from pyload.plugins.helpers import renice

Unified diff for src/pyload/plugins/extractors/SevenZip.py, hunk @@ -11,7 +12,7 @@ from pyload.plugins.helpers import renice
Line beforeLine afterChangeSource
Unchanged lineclass SevenZip(BaseExtractor):
Unchanged line __name__ = "SevenZip"
Unchanged line __type__ = "extractor"
Removed line __version__ = "0.39"
Added line __version__ = "0.40"
Unchanged line __status__ = "testing"
Unchanged line
Unchanged line __description__ = """7-Zip extractor plugin"""

@@ -84,7 +85,7 @@ class SevenZip(BaseExtractor):

Unified diff for src/pyload/plugins/extractors/SevenZip.py, hunk @@ -84,7 +85,7 @@ class SevenZip(BaseExtractor):
Line beforeLine afterChangeSource
Unchanged line def find(cls):
Unchanged line try:
Unchanged line if os.name == "nt":
Removed line cls.CMD = os.path.join(PKGDIR, "lib", "7z.exe")
Added line cls.CMD = safejoin(PKGDIR, "lib", "7z.exe")
Unchanged line
Unchanged line p = subprocess.Popen(
Unchanged line [cls.CMD], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8"

@@ -133,7 +134,7 @@ class SevenZip(BaseExtractor):

Unified diff for src/pyload/plugins/extractors/SevenZip.py, hunk @@ -133,7 +134,7 @@ class SevenZip(BaseExtractor):
Line beforeLine afterChangeSource
Unchanged line raise ArchiveError("Cannot find smallest file")
Unchanged line
Unchanged line try:
Removed line extracted = os.path.join(self.dest, smallest if self.fullpath else os.path.basename(smallest))
Added line extracted = safejoin(self.dest, smallest if self.fullpath else os.path.basename(smallest))
Unchanged line try:
Unchanged line os.remove(extracted)
Unchanged line except OSError as exc:

@@ -198,7 +199,7 @@ class SevenZip(BaseExtractor):

Unified diff for src/pyload/plugins/extractors/SevenZip.py, hunk @@ -198,7 +199,7 @@ class SevenZip(BaseExtractor):
Line beforeLine afterChangeSource
Unchanged line
Unchanged line #: eventually multi-part files
Unchanged line files.extend(
Removed line os.path.join(dir, os.path.basename(_f))
Added line safejoin(dir, os.path.basename(_f))
Unchanged line for _f in filter(self.ismultipart, os.listdir(dir))
Unchanged line if self._RE_PART.sub("", name) == self._RE_PART.sub("", _f)
Unchanged line )

@@ -296,7 +297,7 @@ class SevenZip(BaseExtractor):

Unified diff for src/pyload/plugins/extractors/SevenZip.py, hunk @@ -296,7 +297,7 @@ class SevenZip(BaseExtractor):
Line beforeLine afterChangeSource
Unchanged line
Unchanged line if not self.fullpath:
Unchanged line f = os.path.basename(f)
Removed line f = os.path.join(self.dest, f)
Added line f = safejoin(self.dest, f)
Unchanged line files.add(f)
Unchanged line
Unchanged line self.smallest = smallest

Timeline

DateEventDescriptionReference
fixedUpstream fix committed, introducing realpath/commonpath containment via safejoin() and applying it to the affected SevenZip paths.REF-003
advisory-publishedGHSA-7g4m-8hx2-4qh3 published in the GitHub Advisory Database.REF-001
disclosedCVE-2026-32808 record published by the CVE Program.REF-002
updatedCVE-2026-32808 record updated.REF-002
release-publishedpyload-ng 0.5.0b3.dev97 published to the Python Package Index.REF-004
research-publishedThis TheSmartShadow research record published.-

References

  1. Arbitrary File Deletion via Path Traversal during Encrypted 7z Password Verification (pyLoad)

  2. CVE-2026-32808 record (CVE Program cvelistV5)

  3. fix GHSA-7g4m-8hx2-4qh3 security advisory

  4. pyload-ng 0.5.0b3.dev97 on the Python Package Index

Published credit

Who this research is credited to, and the advisory that published the credit. Canonical specification §21.4 keeps the two apart: one is a claim made here, the other is what someone else wrote.

Source evidence

Arbitrary File Deletion via Path Traversal during Encrypted 7z Password Verification (pyLoad)

Reference
REF-001
Type
ghsa
Publisher
GitHub Advisory Database
Accessed

Attribution

Credited researchers

  • Ali Firas (TheSmartShadow)
  • Ali Alakbar (ExeC_IQ)

As the advisory published it

thesmartshadowexeciq