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.
Attacker-controlled
Archive entry name
A 7z archive names its own entries. The name is chosen by whoever built the archive.
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`.
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.
Dangerous operation
os.remove(extracted)
The joined path is passed straight to os.remove, so the deletion follows wherever the join resolved to.
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:
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
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
Patch analysis
safejoin() gains a containment check, and the SevenZip path constructions that previously used os.path.join are changed to call it.
src/pyload/core/utils/fs.py
@@ -381,6 +381,16 @@ def which(filename):
| Line before | Line after | Change | Source |
|---|---|---|---|
| Unchanged line | return filename | ||
| Unchanged line | | ||
| Unchanged line | | ||
| Added line | def 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 line | def safepath(value): | ||
| Unchanged line | """ | ||
| Unchanged line | Remove invalid characters and truncate the path if needed. |
@@ -411,9 +421,19 @@ def safepath(value):
| Line before | Line after | Change | Source |
|---|---|---|---|
| Unchanged line | | ||
| Unchanged line | def 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 line | def safename(value): |
src/pyload/plugins/extractors/SevenZip.py
@@ -4,6 +4,7 @@ import subprocess
| Line before | Line after | Change | Source |
|---|---|---|---|
| Unchanged line | | ||
| Unchanged line | from pyload import PKGDIR | ||
| Unchanged line | from pyload.core.utils.convert import to_str | ||
| Added line | from pyload.core.utils.fs import safejoin | ||
| Unchanged line | from pyload.plugins.base.extractor import ArchiveError, BaseExtractor, CRCError, PasswordError | ||
| Unchanged line | from pyload.plugins.helpers import renice | ||
| Unchanged line | |
@@ -11,7 +12,7 @@ from pyload.plugins.helpers import renice
| Line before | Line after | Change | Source |
|---|---|---|---|
| Unchanged line | class 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):
| Line before | Line after | Change | Source |
|---|---|---|---|
| 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):
| Line before | Line after | Change | Source |
|---|---|---|---|
| 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):
| Line before | Line after | Change | Source |
|---|---|---|---|
| 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):
| Line before | Line after | Change | Source |
|---|---|---|---|
| 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
| Date | Event | Description | Reference |
|---|---|---|---|
| fixed | Upstream fix committed, introducing realpath/commonpath containment via safejoin() and applying it to the affected SevenZip paths. | REF-003 | |
| advisory-published | GHSA-7g4m-8hx2-4qh3 published in the GitHub Advisory Database. | REF-001 | |
| disclosed | CVE-2026-32808 record published by the CVE Program. | REF-002 | |
| updated | CVE-2026-32808 record updated. | REF-002 | |
| release-published | pyload-ng 0.5.0b3.dev97 published to the Python Package Index. | REF-004 | |
| research-published | This TheSmartShadow research record published. | - |
References
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