PYSEC-2026-2890
Vulnerability from pysec - Published: 2026-07-13 15:02 - Updated: 2026-07-13 16:05Summary
The extractall() function in src/poetry/utils/helpers.py:410-426 extracts sdist tarballs without path traversal protection on Python versions where tarfile.data_filter is unavailable. Considering only Python versions which are still supported by Poetry, these are 3.10.0 - 3.10.12 and 3.11.0 - 3.11.4.
Impact
Arbitrary file write (path traversal) from untrusted sdist content.
In practice, the impact is low because an attacker who exploits this vulnerability can as well include arbitrary code in a setup.py, which will be executed when the sdist is built after tar extraction. In other words, a malicious sdist can write arbitrary files by design. However, since it is unexpected and not by design that the file write already happens during tar extraction, this is still considered a vulnerability.
On Python 3.11.2 (Debian Bookworm default, directly tested), a crafted sdist with ../../ tar member paths writes files outside the intended extraction directory. The traversal occurs during metadata resolution (poetry add --lock), before the build backend is run.
Affected Environments:
- Python 3.10.0 through 3.10.12 (inclusive): tarfile.data_filter absent or broken
- Python 3.11.0 through 3.11.4 (inclusive): tarfile.data_filter absent or broken
- Debian Bookworm: Python 3.11.2 (default)
- Ubuntu 22.04 LTS: Python 3.10.6 (default)
Patches
Versions 2.3.4 and newer of Poetry ensure that paths are inside the target directory.
Root Cause
File: src/poetry/utils/helpers.py, lines 410-426:
def extractall(source: Path, dest: Path, zip: bool) -> None:
"""Extract all members from either a zip or tar archive."""
if zip:
with zipfile.ZipFile(source) as archive:
archive.extractall(dest)
else:
broken_tarfile_filter = {(3, 9, 17), (3, 10, 12), (3, 11, 4)}
with tarfile.open(source) as archive:
if (
hasattr(tarfile, "data_filter")
and sys.version_info[:3] not in broken_tarfile_filter
):
archive.extractall(dest, filter="data")
else:
archive.extractall(dest) # <-- NO FILTER: path traversal
On Python versions without a working tarfile.data_filter, the else branch at line 426 calls tarfile.extractall() without any filter or path validation. This enables three attack vectors:
- Direct path traversal: Tar members with
../../path components write files outside the extraction directory. - Symlink traversal: A symlink member pointing outside dest, followed by a file written through that symlink, escapes the boundary.
- Hardlink attacks: Hardlink members can read arbitrary files (same inode) or overwrite targets outside dest.
Call Sites
This function is called from two locations:
-
src/poetry/installation/chef.py:104(_prepare_sdist): Duringpoetry install/poetry addwhen building a package from sdist. Only triggered when the executor is enabled (actual installation). -
src/poetry/inspection/info.py:322(_from_sdist_file): During dependency resolution (poetry lock/poetry add). This path is reached when the sdist'sPKG-INFOlacksRequires-Distmetadata, forcing Poetry to extract the archive (and afterwards build the package).
Suggested Fix
Apply path validation in the else branch, covering direct traversal, symlinks, and hardlinks:
def extractall(source: Path, dest: Path, zip: bool) -> None:
"""Extract all members from either a zip or tar archive."""
if zip:
with zipfile.ZipFile(source) as archive:
archive.extractall(dest)
else:
broken_tarfile_filter = {(3, 9, 17), (3, 10, 12), (3, 11, 4)}
with tarfile.open(source) as archive:
if (
hasattr(tarfile, "data_filter")
and sys.version_info[:3] not in broken_tarfile_filter
):
archive.extractall(dest, filter="data")
else:
# Validate all member paths before extraction
dest_resolved = dest.resolve()
safe_members = []
for member in archive.getmembers():
member_path = (dest_resolved / member.name).resolve()
if not member_path.is_relative_to(dest_resolved):
raise ValueError(
f"Refusing to extract {member.name}: "
f"would write outside {dest}"
)
if member.issym():
link_target = (member_path.parent / member.linkname).resolve()
if not link_target.is_relative_to(dest_resolved):
raise ValueError(
f"Refusing symlink {member.name}: "
f"target {member.linkname} outside {dest}"
)
elif member.islnk():
link_target = (dest_resolved / member.linkname).resolve()
if not link_target.is_relative_to(dest_resolved):
raise ValueError(
f"Refusing hardlink {member.name}: "
f"target {member.linkname} outside {dest}"
)
safe_members.append(member)
archive.extractall(dest, members=safe_members)
| Name | purl | poetry | pkg:pypi/poetry |
|---|
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "poetry",
"purl": "pkg:pypi/poetry"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.3.4"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"0.1.0",
"0.10.0",
"0.10.0a0",
"0.10.0a1",
"0.10.0a2",
"0.10.0a3",
"0.10.1",
"0.10.2",
"0.10.3",
"0.11.0",
"0.11.0a0",
"0.11.0a1",
"0.11.0a2",
"0.11.0a3",
"0.11.0a4",
"0.11.1",
"0.11.2",
"0.11.3",
"0.11.4",
"0.11.5",
"0.12.0",
"0.12.0a0",
"0.12.0a1",
"0.12.0a2",
"0.12.0a3",
"0.12.0a4",
"0.12.0a5",
"0.12.1",
"0.12.10",
"0.12.11",
"0.12.12",
"0.12.13",
"0.12.14",
"0.12.15",
"0.12.16",
"0.12.17",
"0.12.2",
"0.12.3",
"0.12.4",
"0.12.5",
"0.12.6",
"0.12.7",
"0.12.8",
"0.12.9",
"0.2.0",
"0.3.0",
"0.4.0",
"0.4.0.post1",
"0.4.1",
"0.4.2",
"0.5.0",
"0.5.0b1",
"0.5.0b2",
"0.6.0",
"0.6.1",
"0.6.2",
"0.6.3",
"0.6.3b1",
"0.6.3b2",
"0.6.3b3",
"0.6.3b4",
"0.6.3b5",
"0.6.3b6",
"0.6.3b7",
"0.6.4",
"0.6.4b1",
"0.6.5",
"0.7.0",
"0.7.0b1",
"0.7.0b2",
"0.7.0b3",
"0.7.0b4",
"0.7.1",
"0.8.0",
"0.8.0a0",
"0.8.0a1",
"0.8.0a2",
"0.8.0a3",
"0.8.0a4",
"0.8.1",
"0.8.1a0",
"0.8.2",
"0.8.3",
"0.8.4",
"0.8.5",
"0.8.5a0",
"0.8.6",
"0.9.0",
"0.9.0a0",
"0.9.0a1",
"0.9.0a2",
"0.9.0a3",
"0.9.1",
"1.0.0",
"1.0.0a0",
"1.0.0a1",
"1.0.0a2",
"1.0.0a3",
"1.0.0a4",
"1.0.0a5",
"1.0.0b1",
"1.0.0b2",
"1.0.0b3",
"1.0.0b4",
"1.0.0b5",
"1.0.0b6",
"1.0.0b7",
"1.0.0b8",
"1.0.0b9",
"1.0.1",
"1.0.10",
"1.0.2",
"1.0.3",
"1.0.4",
"1.0.5",
"1.0.6",
"1.0.7",
"1.0.8",
"1.0.9",
"1.1.0",
"1.1.0a1",
"1.1.0a2",
"1.1.0a3",
"1.1.0b1",
"1.1.0b2",
"1.1.0b3",
"1.1.0b4",
"1.1.0rc1",
"1.1.1",
"1.1.10",
"1.1.11",
"1.1.12",
"1.1.13",
"1.1.14",
"1.1.15",
"1.1.2",
"1.1.3",
"1.1.4",
"1.1.5",
"1.1.6",
"1.1.7",
"1.1.8",
"1.1.9",
"1.2.0",
"1.2.0a1",
"1.2.0a2",
"1.2.0b1",
"1.2.0b2",
"1.2.0b3",
"1.2.0rc1",
"1.2.0rc2",
"1.2.1",
"1.2.2",
"1.3.0",
"1.3.1",
"1.3.2",
"1.4.0",
"1.4.1",
"1.4.2",
"1.5.0",
"1.5.1",
"1.6.0",
"1.6.1",
"1.7.0",
"1.7.1",
"1.8.0",
"1.8.1",
"1.8.2",
"1.8.3",
"1.8.4",
"1.8.5",
"2.0.0",
"2.0.1",
"2.1.0",
"2.1.1",
"2.1.2",
"2.1.3",
"2.1.4",
"2.2.0",
"2.2.1",
"2.3.0",
"2.3.1",
"2.3.2",
"2.3.3"
]
}
],
"aliases": [
"CVE-2026-41140",
"GHSA-73h3-mf4w-8647"
],
"details": "### Summary\n\nThe `extractall()` function in `src/poetry/utils/helpers.py:410-426` extracts sdist tarballs without path traversal protection on Python versions where `tarfile.data_filter` is unavailable. Considering only Python versions which are still supported by Poetry, these are 3.10.0 - 3.10.12 and 3.11.0 - 3.11.4.\n\n### Impact\n\nArbitrary file write (path traversal) from untrusted sdist content.\n\n**In practice, the impact is low** because an attacker who exploits this vulnerability can as well include arbitrary code in a `setup.py`, which will be executed when the sdist is built after tar extraction. In other words, a malicious sdist can write arbitrary files by design. However, since it is unexpected and not by design that the file write already happens during tar extraction, this is still considered a vulnerability.\n\nOn Python 3.11.2 (Debian Bookworm default, directly tested), a crafted sdist with `../../` tar member paths writes files outside the intended extraction directory. The traversal occurs during metadata resolution (`poetry add --lock`), before the build backend is run.\n\nAffected Environments: \n- **Python 3.10.0 through 3.10.12** (inclusive): `tarfile.data_filter` absent or broken\n- **Python 3.11.0 through 3.11.4** (inclusive): `tarfile.data_filter` absent or broken\n- **Debian Bookworm**: Python 3.11.2 (default)\n- **Ubuntu 22.04 LTS**: Python 3.10.6 (default)\n\n### Patches\n\nVersions 2.3.4 and newer of Poetry ensure that paths are inside the target directory.\n\n### Root Cause\n\nFile: `src/poetry/utils/helpers.py`, lines 410-426:\n\n```python\ndef extractall(source: Path, dest: Path, zip: bool) -\u003e None:\n \"\"\"Extract all members from either a zip or tar archive.\"\"\"\n if zip:\n with zipfile.ZipFile(source) as archive:\n archive.extractall(dest)\n else:\n broken_tarfile_filter = {(3, 9, 17), (3, 10, 12), (3, 11, 4)}\n with tarfile.open(source) as archive:\n if (\n hasattr(tarfile, \"data_filter\")\n and sys.version_info[:3] not in broken_tarfile_filter\n ):\n archive.extractall(dest, filter=\"data\")\n else:\n archive.extractall(dest) # \u003c-- NO FILTER: path traversal\n```\n\nOn Python versions without a working `tarfile.data_filter`, the `else` branch at line 426 calls `tarfile.extractall()` without any filter or path validation. This enables three attack vectors:\n\n1. **Direct path traversal**: Tar members with `../../` path components write files outside the extraction directory.\n2. **Symlink traversal**: A symlink member pointing outside dest, followed by a file written through that symlink, escapes the boundary.\n3. **Hardlink attacks**: Hardlink members can read arbitrary files (same inode) or overwrite targets outside dest.\n\n#### Call Sites\n\nThis function is called from two locations:\n\n1. **`src/poetry/installation/chef.py:104`** (`_prepare_sdist`): During `poetry install` / `poetry add` when building a package from sdist. Only triggered when the executor is enabled (actual installation).\n\n2. **`src/poetry/inspection/info.py:322`** (`_from_sdist_file`): During dependency resolution (`poetry lock` / `poetry add`). This path is reached when the sdist\u0027s `PKG-INFO` lacks `Requires-Dist` metadata, forcing Poetry to extract the archive (and afterwards build the package).\n\n### Suggested Fix\n\nApply path validation in the `else` branch, covering direct traversal, symlinks, and hardlinks:\n\n```python\ndef extractall(source: Path, dest: Path, zip: bool) -\u003e None:\n \"\"\"Extract all members from either a zip or tar archive.\"\"\"\n if zip:\n with zipfile.ZipFile(source) as archive:\n archive.extractall(dest)\n else:\n broken_tarfile_filter = {(3, 9, 17), (3, 10, 12), (3, 11, 4)}\n with tarfile.open(source) as archive:\n if (\n hasattr(tarfile, \"data_filter\")\n and sys.version_info[:3] not in broken_tarfile_filter\n ):\n archive.extractall(dest, filter=\"data\")\n else:\n # Validate all member paths before extraction\n dest_resolved = dest.resolve()\n safe_members = []\n for member in archive.getmembers():\n member_path = (dest_resolved / member.name).resolve()\n if not member_path.is_relative_to(dest_resolved):\n raise ValueError(\n f\"Refusing to extract {member.name}: \"\n f\"would write outside {dest}\"\n )\n if member.issym():\n link_target = (member_path.parent / member.linkname).resolve()\n if not link_target.is_relative_to(dest_resolved):\n raise ValueError(\n f\"Refusing symlink {member.name}: \"\n f\"target {member.linkname} outside {dest}\"\n )\n elif member.islnk():\n link_target = (dest_resolved / member.linkname).resolve()\n if not link_target.is_relative_to(dest_resolved):\n raise ValueError(\n f\"Refusing hardlink {member.name}: \"\n f\"target {member.linkname} outside {dest}\"\n )\n safe_members.append(member)\n archive.extractall(dest, members=safe_members)\n```",
"id": "PYSEC-2026-2890",
"modified": "2026-07-13T16:05:36.936329Z",
"published": "2026-07-13T15:02:52.466566Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/python-poetry/poetry/security/advisories/GHSA-73h3-mf4w-8647"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-41140"
},
{
"type": "PACKAGE",
"url": "https://github.com/python-poetry/poetry"
},
{
"type": "WEB",
"url": "https://github.com/python-poetry/poetry/releases/tag/2.3.4"
},
{
"type": "PACKAGE",
"url": "https://pypi.org/project/poetry"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-73h3-mf4w-8647"
}
],
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:U",
"type": "CVSS_V4"
}
],
"summary": "Poetry has Path Traversal in tar extraction on Python 3.10.0 - 3.10.12 and 3.11.0 - 3.11.4"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.