From: Louis Sanchez via Fulldisclosure <fulldisclosure () seclists org>
Date: Thu, 10 Sep 2026 22:20:54 -0400
Posting this as an update rather than a first disclosure. The advisory
went public on 2026-08-04 with no vendor fix. Penpot has shipped two
releases since then, 2.17.1 and 2.17.2 -- the latter 14 days ago, on
2026-08-27 -- and I re-checked the code this morning: the missing
permission check is still missing in both, and in every release before
them. It was fixed on develop the day after this advisory went public.
That fix has never shipped. Anyone running a released version of
self-hosted Penpot should know that, so here is the whole thing in one
message.
SUMMARY
=======
Penpot's file-import RPC command takes an optional caller-supplied
file-id, meaning "import into this existing file instead of creating a
new one." The handler checks that the caller may edit the project they
named -- a project they own, since they supplied it. It never checks
that the file-id they supplied belongs to them.
So any authenticated user can point that parameter at any file on the
instance and overwrite it. The same operation also re-parents the file
into the attacker's project, which is the part that matters: the
attacker does not merely destroy the victim's design, they end up owning
it. On a default install with open self-registration, the attacker needs
no prior relationship to the target at all.
AFFECTED
========
Product: Penpot (open-source design and prototyping platform, Kaleidos)
Affected: 1.20 through 2.17.2, self-hosted Community Edition and
Penpot Cloud. The in-place file-id parameter was introduced in
1.20 -- the handler's own ::doc/changes metadata records
"1.20 Add file-id param for in-place import". The CVE record
was updated 2026-08-27 to state the range as 1.20 through
2.17.1, the same day 2.17.2 shipped; 2.17.2 is also affected
and is not yet reflected in the record.
Fixed in: nothing released. Fixed on develop, unreleased, since
2026-08-05, and present in the 2.18.0 release-candidate line.
Never backported to a 2.17.x release.
CVE-2026-17613, assigned by CERT/CC as CNA, record state PUBLISHED.
CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-862
(Missing Authorization).
STILL UNPATCHED IN EVERY RELEASE -- FIXED ON DEVELOP, NOT SHIPPED
=================================================================
The published advisory last verified the flaw at develop HEAD on
2026-07-27. Penpot has released 2.17.1 and 2.17.2 since then
(2026-08-17 and 2026-08-27), and 2.17.0 shortly before it (2026-07-22).
None of the three fixes it. Verified today, 2026-09-10:
backend/src/app/rpc/commands/binfile.clj @ tag 2.17.2
line 78 (files/check-read-permissions! pool profile-id file-id)
-- export path, correct check, present
line 153 (projects/check-edition-permissions! pool profile-id project-id)
-- import path, checks only the caller's own project
line 160 (uuid? file-id)
line 161 (assoc ::bfc/file-id file-id)
-- import path, the supplied file-id bound unchecked
same file @ develop 37dab75e (2026-09-10T18:29:44Z)
line 88 (files/check-read-permissions! cfg profile-id file-id)
-- export path, correct check, present
line 166 (projects/check-edition-permissions! pool profile-id project-id)
-- import path; file-id is no longer a parameter here at
all, so there is nothing left to check
The fix exists. It just has not shipped. The day after this advisory
went public, commit 9242556d landed on develop and removed file-id from
import-binfile outright -- gone from the schema, from the handler's
destructuring, and from the audit props. Commit message: ":bug: Close
import-binfile schema and remove file-id parameter (#10994)". The same
change is in the current 2.18.0 release-candidate line (2.18.0-RC5). It
has never been backported to a 2.17.x release.
The 2.17.2 release notes list three bug fixes, one of them a different
security fix (an SVG-exporter command-injection bug, per the vendor's
own changelog) -- so the vendor does put security fixes in this line
when it chooses to. This one is not among them. The code above is
byte-identical to 2.17.0 (2026-07-22) and 2.17.1 (2026-08-17) -- the
same three call sites, the same line numbers, across the last three
releases. I am not going to guess why -- I am saying that today, 105
days after the report, nobody running a released version of Penpot has
the fix that has sat on develop, unreleased, for 36 days.
ROOT CAUSE
==========
What makes this one worth reading is that the correct check is in the
same source file, about seventy lines away, on the other direction of
the same feature.
export-binfile validates read permission on its file-id before handing
anything back:
(sv/defmethod ::export-binfile
...
[{:keys [::db/pool] :as cfg}
{:keys [::rpc/profile-id file-id] :as params}]
(files/check-read-permissions! pool profile-id file-id) ; <-- yes
(sse/response (partial export-binfile cfg params)))
import-binfile validates the caller's project and then binds the
caller's file-id straight through:
(sv/defmethod ::import-binfile
...
[{:keys [::db/pool] :as cfg}
{:keys [::rpc/profile-id project-id version file-id upload-id]
:as params}]
(projects/check-edition-permissions! pool profile-id project-id)
(let [...
cfg (cond-> cfg
(uuid? file-id)
(assoc ::bfc/file-id file-id))] ; <-- no check
...))
The permission function exists. It is already imported. It is used
correctly a few dozen lines up. The import path just does not call it.
Downstream, the in-place import fetches the target file by id alone with
no ownership predicate on the query, enables overwrite mode, writes the
attacker's content into the victim's file row, and sets that row's
project to the attacker's project. The final update is keyed only on the
attacker-supplied file id.
EXPLOITATION
============
Prerequisite: any authenticated account with a project of its own. On a
default install, self-register.
POST /api/rpc/command/import-binfile
Cookie: <attacker session>
Content-Type: multipart/form-data
project-id = <a project the attacker owns> ; checked
file-id = <the victim's file> ; NOT checked
file = <a valid single-file .penpot archive>
The obvious objection is that file ids are random UUIDs, so how do you
aim it. In practice they leak through entirely normal use, and two of
the three routes require no mistake by the victim:
1. Public share links. Clicking Share produces a URL with the file id
in it in plaintext. People send those to clients and contractors
all day. Whoever holds the link holds the targeting data.
2. Shared-library enumeration. Any user who has ever linked a shared
design-system library can read that library's file id from an
ordinary library-listing call. No error, no audit trail. This is
the route that reaches an entire organisation's design system
rather than one file.
3. Workspace and viewer URLs embed the file id, so it turns up in
screenshots, support tickets, Slack pastes and screen shares.
On attack complexity, since it came up during coordination: AC:H is
meant for conditions the attacker cannot arrange unilaterally. Receiving
a share link and listing shared libraries are ordinary actions any
account can take on its own. Complexity stays Low.
IMPACT
======
- Data destruction. The victim's file contents are replaced with the
attacker's.
- Persistent takeover. Penpot resolves file permissions by walking
file -> project -> team. Re-parenting rewrites that lineage, so the
attacker becomes the legitimate owner in the eyes of every other
endpoint, free to open, edit, export, duplicate and re-share the
victim's design. The file vanishes from the victim's project. They
cannot reach it to attempt a restore.
- Library poisoning. If the hijacked file was a shared design library,
every file in every other team that syncs from it starts pulling
attacker-controlled components, colours and typography.
- Cross-team scope. The attacker holds no authorisation on the
victim's team. The operation moves a resource out of that boundary
entirely.
Design files are not low-value data. One file can hold an unreleased
product's whole interface, pre-launch brand and marketing assets, or
mockups carrying real customer names in placeholder text. Agencies
routinely run one instance with a team per client and rely on team
separation to keep those clients confidential from each other. That
assumption does not currently hold.
The original report also bundled a lower-severity issue with the same
root pattern: the realtime WebSocket channel lets a client subscribe to
a file or team topic using a client-supplied id with no permission
check, allowing live exfiltration of collaborative edits and
shared-library changes for any file whose id you know. Same mistake --
trusting a client-supplied identifier without re-checking it. One
disciplined patch closes both.
ON THE SCORE
============
The CVE record carries 7.5 High:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
That is also the number on my own advisory. I publish the assigned score
rather than mine, because the CVE record is what the ecosystem actually
consumes and a mismatched headline number reads as inflation. It is also
the number I disagree with, and I would rather argue that in the open
than quietly ship a different one.
C:N/I:N describes the vulnerable endpoint honestly enough if you read
the operation as "the victim's file is destroyed." But the file is not
destroyed. It is moved, intact, into the attacker's project, where they
can read and export it. That is a confidentiality loss. Its contents are
then attacker-controlled, and if it was a shared library that content
propagates into other teams' files. That is an integrity loss with a
blast radius past the original resource. Scoring it A:H only measures
what the victim stopped having, not what the attacker started having.
S:U has the same problem. The operation carries a resource across a team
boundary the attacker was never authorised for; calling that scope
unchanged treats Penpot's file store as one undifferentiated pool, which
is precisely the assumption the product's team model tells customers is
false.
My pre-assignment score was 9.9. Take that as one researcher's read, not
as a correction to the record -- but if you are triaging this from the
7.5 alone, triage the impact section above instead. Worth more than
either number for that purpose: the CISA ADP Vulnrichment enrichment on
the same record carries an SSVC assessment of exploitation
proof-of-concept, technical impact partial, automatable yes. I wrote the
general version of this argument up here, alongside the same pattern in
CVE-2026-16751:
https://vokecyber.com/blog/when-cvss-scores-the-endpoint-not-the-loss
MITIGATIONS
===========
No fixed release to upgrade to, so, in order of value:
1. Disable open self-registration. Does not fix the bug. Removes the
sign-up-and-attack path and takes the score to 6.5, since the
attacker then needs an account somebody gave them.
2. Treat every account on the instance as having write access to every
file on it, and plan accordingly. If you rely on team separation
for client confidentiality, consider separate instances for
genuinely sensitive work until this is fixed.
3. Audit for prior exploitation. Look for file rows whose owning
project changed with no corresponding user action, and for edits
written by a profile that is not a member of the file's team. An
in-place import does not leave a normal change row behind, so the
signal to chase is a file whose project lineage no longer matches
the accounts that historically edited it.
4. Keep backups outside Penpot. Because the victim loses access to the
file entirely, there is no in-product recovery path. Periodic
export-binfile snapshots stored elsewhere are the practical
fallback.
THE FIX
=======
Two assertions, both cheap:
1. When an in-place import is requested, require edit permission on
the supplied file-id, not just on the caller's project. The
function to call is the one export-binfile already uses.
2. Separately, reject any in-place import that would move the file
into a project other than the one it currently belongs to.
The second matters more than it looks. It kills the re-parenting leg on
its own, so if a future refactor ever loosens the per-file check, the
bug cannot come back as a takeover.
That is not what the develop fix actually does. Commit 9242556d skips
both assertions and deletes file-id from import-binfile instead -- no
in-place import, no target file, nothing left to authorize. Fewer moving
parts, same result: no unchecked caller-supplied file-id, no
re-parenting path. Whether in-place import ever comes back with the
checks above, or stays gone, either one closes this.
ON THE WITHHELD PROOF-OF-CONCEPT
================================
The request shape above is the whole mechanism, and it is enough to
audit your own instance or write your own test. The drop-in script and
the payload recipe are held back until a fixed release ships. I am aware
that is not this list's default preference, and I am not going to
pretend it is a principled universal position -- it is a judgement about
this specific case: no released patch exists, no version to upgrade to,
and the mitigations above are configuration changes rather than fixes.
If the vendor backports the fix to a release, or if it becomes clear
that withholding is protecting nobody, the rest goes out.
TIMELINE
========
2026-05-27 Found during a source review of the Penpot backend,
confirmed against develop HEAD.
2026-05-28 Reported privately via GitHub's advisory process and to
the vendor's published security contact.
2026-06-18 No vendor response after 21 days. Confirmed no silent
patch had landed. Escalated to CERT/CC.
2026-06-29 CERT/CC raised the attack-complexity question; answered
same day.
2026-07-06 CVE-2026-17613 assigned; CERT/CC case VU#241166 opened.
2026-07-27 Re-verified vulnerable at develop HEAD; proof-of-concept
package provided to CERT/CC.
2026-08-04 Public disclosure. No vendor fix.
2026-08-05 Fix merged to develop (commit 9242556d): file-id removed
from import-binfile entirely. Never backported to a
release.
2026-08-17 Penpot 2.17.1 released. Not fixed.
2026-08-27 Penpot 2.17.2 released. Not fixed.
2026-09-10 Re-verified vulnerable at 2.17.2 (latest release) and
fixed at develop HEAD (37dab75e) and in 2.18.0-RC5. This
post.
105 days from report to this post. Seven releases have shipped since the
report -- 2.15.4, 2.16.0, 2.16.1, 2.16.2, 2.17.0, 2.17.1, 2.17.2 -- and
none of them carry the fix, though the fix itself has now existed,
unreleased, on develop for 36 of those days.
A note on identifiers, so nobody wastes time clicking: CERT/CC is the
CNA on this CVE and opened case VU#241166, but no public vulnerability
note was ever published, so that VU number does not resolve. Likewise
the GitHub advisory draft GHSA-8qqw-wm58-45v7 was the private reporting
channel and was never published by the vendor, so it 404s at
github.com/advisories. Both are real case identifiers, neither is a
citable public page. The CVE record is the authoritative reference.
REFERENCES
==========
CVE record
https://www.cve.org/CVERecord?id=CVE-2026-17613
Full advisory
https://vokecyber.com/research/cve-2026-17613-penpot-cross-team-file-takeover
Disclosure narrative
https://vokecyber.com/blog/cve-2026-17613-penpot-cross-team-file-takeover
Vendor
https://penpot.app/
https://github.com/penpot/penpot
If anyone reading this has a working line to the Penpot maintainers, I
would rather have this fixed than be right about it. Happy to answer
questions on-list.
--
Louis Sanchez
Voke Cyber -- https://vokecyber.com
Research index: https://vokecyber.com/research
info () vokecyber com
OSCP, OSWA, CISSP, CCSK
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/
Current thread:
- CVE-2026-17613: Penpot cross-team file takeover via import-binfile (unpatched in 2.17.2) Louis Sanchez via Fulldisclosure (Sep 22)