CVE-2026-54823: Verified Repro With Script Download
CVE-2026-54823: Widget Options WordPress plugin contributor remote code execution
CVE-2026-54823 is verified against the affected target. Vulnerability class: RCE. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00230.
What Is CVE-2026-54823?
CVE-2026-54823 is a critical code-injection vulnerability (CWE-94) in the Widget Options WordPress plugin that lets a low-privileged Contributor account execute arbitrary code on the server. Pruva reproduced it (reproduction REPRO-2026-00230).
CVE-2026-54823 Severity & CVSS Score
CVE-2026-54823 is rated critical severity, with a CVSS base score of 9.9 out of 10.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
How to Reproduce CVE-2026-54823
pruva-verify REPRO-2026-00230 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00230/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-54823
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
Display Logic PHP expression supplied in extended_widget_opts_block.class.logic via the block-renderer REST endpoint attributes (no save step)
- POST /wp-json/wp/v2/block-renderer/core/archives with JSON body {attributes:{extended_widget_opts_block:{class:{logic:PAYLOAD}}}, context:'edit'} (context=edit in BODY only, NOT in query string) as an authenticated Contributor via WordPress Application Password
Variant/bypass analysis of the Widget Options 4.2.4 fix for CVE-2026-54823 (Contributor RCE via block-renderer REST -> widgetopts_safe_eval -> eval). Five runtime candidate variants (original payload, stripslashes-differential, ${'z'}() brace-call, array_map concat callable, freeform <!--start_widgetopts--> comment in…
How the agent worked
Root Cause and Exploit Chain for CVE-2026-54823
The Widget Options – Advanced Conditional Visibility WordPress plugin
(MarketingFire) versions up to and including 4.2.3 allow an authenticated
Contributor (and any role with edit_posts) to execute arbitrary PHP/server
code remotely. The plugin's Display Logic feature evaluates user-supplied PHP
expressions through widgetopts_safe_eval() (which calls eval()). In 4.2.3 the
only guard for non-administrators is a preview-context short-circuit
(widgetopts_is_widget_or_post_preview()), which inspects $_GET['context'].
The server-side block-rendering REST endpoint
/wp-json/wp/v2/block-renderer/<block> renders attacker-supplied block
attributes with no save step, so the save-time logic stripper never runs; and
because the endpoint accepts context=edit in the JSON body (not the query
string), the plugin's $_GET['context'] guard is bypassed and the contributor's
Display Logic expression reaches eval(). The accompanying blocklist (regex) and
token allowlist are defeated with a variable-variable call built from string
concatenation: $z='s'.'y'.'s'.'t'.'e'.'m'; ${'z'}('CMD'); return true;, which
hides the word system from the regex and places a } before ( (a token the
4.2.3 validator did not block). This yields arbitrary command execution as the
web server user, including dropping an executable PHP file into the webroot.
- Package/component affected:
widget-optionsplugin, fileincludes/extras.php(widgetopts_safe_eval()/widgetopts_validate_expression()/widgetopts_validate_code_with_tokens()), reached viaincludes/widgets/gutenberg/gutenberg-toolbar.php(blockopts_filter_before_display()on therender_blockfilter) when the Display Logic module is active (widgetopts_tabmodule-logic/widgetopts_settings['logic'] == 'activate'). - Affected versions: Widget Options <= 4.2.3 (vulnerable). Fixed in
4.2.4. (Earlier Contributor+ RCE variants were CVE-2025-22630, fixed in
4.1.1, and CVE-2026-2052, partially patched in 4.2.0; CVE-2026-54823 is the
block-renderer/
}-token bypass that remained through 4.2.3.) - Risk level: Critical. A low-privileged authenticated user (Contributor, who cannot publish posts or manage options) obtains remote code execution as the WordPress/web-server user, leading to full site compromise, data exfiltration, and pivoting to the host.
Impact Parity
- Disclosed/claimed maximum impact: Authenticated (Contributor+) Remote Code Execution — "send a crafted request that executes arbitrary code on the server … writes or executes a PHP file."
- Reproduced impact from this run: Full RCE demonstrated end-to-end through
the real WordPress REST API surface. The contributor's single HTTP request
executed
system('<cmd>')on the server, writing a marker file (/tmp/wo_rce_proof.txtcontainingPRUVA_RCE+idoutput,uid=1000) and dropping an executable PHP filewp-content/wo_rce_proof.php(<?php echo 7*6;) into the webroot, which was then fetched over HTTP and returned the computed value42— proving the contributor both executed arbitrary commands and planted executable PHP in the webroot. The identical request against the fixed 4.2.4 build produced no proof artifacts (blocked). - Parity:
full. - Not demonstrated: N/A — code execution parity is achieved (not merely a crash).
Root Cause
Insufficient eval guard (preview-only).
widgetopts_safe_eval()in 4.2.3 only short-circuits to "show without eval" for non-admins inside a preview context:if ( widgetopts_is_widget_or_post_preview() ) { if ( ! current_user_can('manage_options') ) { return true; } }widgetopts_is_widget_or_post_preview()returns true only forREST_REQUEST && $_GET['context'] === 'edit',is_preview(),?preview=true, the Customizer, or Beaver/Elementor edit mode. On any non-preview render path theeval()runs for every user, including contributors and logged-out visitors.Block-renderer REST endpoint bypasses the save-time stripper. The
/wp-json/wp/v2/block-renderer/<block>route renders request-supplied block attributes server-side without persisting the post, so thewp_insert_post_data/rest_pre_insertlogic strippers (which clearextended_widget_opts[_block].class.logicfor non-admins on save) never execute. The contributor's maliciousclass.logictherefore reaches therender_blockfilter →blockopts_filter_before_display()→widgetopts_safe_eval().$_GET['context']vs REST body context mismatch. The block-renderer endpoint requirescontext=edit. Sendingcontext=editin the JSON body satisfies the REST controller (so it returns 200 and renders) while leaving$_GET['context']unset, so the plugin's preview guard in step 1 evaluates to false and eval proceeds for the contributor.Blocklist + token-allowlist bypass.
widgetopts_validate_expression()applies a regex blocklist (e.g./\b(eval|system|exec|...)\b/i) andwidgetopts_validate_code_with_tokens()enforces a function allowlist, blockingT_STRING/T_VARIABLE/T_CONSTANT_ENCAPSED_STRINGand]or)immediately before(. It does not block}before(. The payload$z = 's'.'y'.'s'.'t'.'e'.'m'; ${'z'}('CMD'); return true;:- builds the function name
systemvia concatenation so the literal word never appears (regex\bsystem\bdoes not match); - calls it through
${'z'}(...), i.e. a variable-variable call where the token before(is}— not blocked in 4.2.3; - contains the substring
return, sowidgetopts_safe_eval()skips itsreturn( ... );wrapper and evaluates the raw multi-statement code.
Net effect:
system('CMD')executes as the contributor.This was verified against the real plugin validator extracted from 4.2.3 (
bundle/repro/validator_harness.php): the payload returns['valid' => true, 'message' => 'Expression is safe.']while directsystem('id')andarray_map('s'.'y'..., ...)are correctly rejected.- builds the function name
Fix (4.2.4): includes/extras.php introduces a closed-default trust flag —
if (!current_user_can('manage_options') && !widgetopts_eval_is_trusted()) return true;
at the top of widgetopts_safe_eval() — so non-admins get no eval unless a
callsite wrapped in widgetopts_safe_eval_trusted() flagging DB-backed,
admin-controlled logic. A render_block_data sha-256 allowlist
(widgetopts_get_post_logic_allowlist()), scoped to the block-renderer route via
rest_pre_dispatch (_widgetopts_in_block_renderer), zeroes any
class.logic not actually stored in the post. A rest_request_before_callbacks
scrub net clears legacy logic shapes on all non-admin REST writes. Finally, the
token validator now also blocks } (catching ${$fn}() / Foo::{'m'}()),
T_FUNCTION, and T_FN before (. (See bundle/artifacts/wo-versions/ext-4.2.4
vs ext-4.2.3 diffs.) No single upstream commit hash was published with the
advisory; the fix ships in plugin version 4.2.4.
Reproduction Steps
- Script:
bundle/repro/reproduction_steps.sh(self-contained; run withPRUVA_ROOT=<bundle> bash bundle/repro/reproduction_steps.sh). - What it does:
- Installs PHP + MariaDB deps via apt, starts a dedicated MariaDB instance on a private socket.
- Downloads WordPress core (7.0), wp-cli, and the plugin zips for 4.2.3 (vulnerable) and 4.2.4 (fixed) from wordpress.org.
- Installs WordPress with
WP_ENVIRONMENT_TYPE=local(enables Application Passwords over HTTP), enables pretty permalinks, and serves it via the PHP built-in server with a router so/wp-json/*routes to WordPress. - Installs the vulnerable plugin 4.2.3, activates the Display Logic module
(
widgetopts_settings['logic'] = 'activate'), creates a Contributor user and an Application Password for it. - As the contributor, sends the exploit:
POST /wp-json/wp/v2/block-renderer/core/archiveswith JSON body{"attributes":{"extended_widget_opts_block":{"class":{"logic":"<PAYLOAD>"}}},"context":"edit"}(no?context=editquery param). The payload writes a marker file and a webroot PHP file viasystem(). - Confirms RCE: fetches the dropped PHP file over HTTP (returns
42) and reads the marker file (containsidoutput). - Repeats the identical request against the fixed 4.2.4 build on a fresh
server (to avoid PHP
require_oncestaleness) and confirms no proof artifacts are produced. - Writes
bundle/repro/runtime_manifest.jsonand exits 0 only if the vulnerable build is exploited AND the fixed build blocks it.
- Expected evidence of reproduction: HTTP 200 on the exploit request; a
dropped
wp-content/wo_rce_proof.phpthat returns42over HTTP; a/tmp/wo_rce_proof.txtcontainingPRUVA_RCE+idoutput; and absence of both on the fixed 4.2.4 control.
Evidence
bundle/repro/runtime_manifest.json— runtime manifest (api_remote, service started, healthcheck passed, target path reached, proof artifacts listed).bundle/repro/validation_verdict.json— structured verdict (confirmed).bundle/repro/wo_rce_proof.php— the PHP file the contributor dropped into the webroot (<?php echo 7*6;).bundle/repro/wo_rce_proof.txt—PRUVA_RCE+idoutput produced bysystem().bundle/logs/exploit_vuln.log— vulnerable-run exploit output (HTTP 200,PHP_PROOF_EXISTS True,TMP_PROOF_EXISTS True,idoutput).bundle/logs/exploit_fixed.log— fixed-run exploit output (HTTP 200,PHP_PROOF_EXISTS False,TMP_PROOF_EXISTS False).bundle/logs/reproduction_steps.log— full phase log.bundle/repro/exploit_request.py— the exploit client (auth + payload + verification).bundle/repro/validator_harness.php— local proof that the payload passes the real 4.2.3widgetopts_validate_expression().bundle/artifacts/wo-versions/ext-4.2.3andext-4.2.4— extracted plugin sources used for the vulnerable/fixed comparison.
Key excerpts (vulnerable run, logs/exploit_vuln.log):
POST http://127.0.0.1:<port>/wp-json/wp/v2/block-renderer/core/archives (context=edit in BODY, none in query string)
HTTP 200
PHP_PROOF_EXISTS True
TMP_PROOF_EXISTS True
TMP_PROOF_CONTENT>>
PRUVA_RCE
uid=1000(vscode) gid=1000(vscode) groups=1000(vscode),969(969)
<<TMP_PROOF_CONTENT
Fixed control (logs/exploit_fixed.log):
HTTP 200
PHP_PROOF_EXISTS False
TMP_PROOF_EXISTS False
Environment: WordPress 7.0, PHP 8.5, MariaDB 11.8, Widget Options 4.2.3 (vulnerable) / 4.2.4 (fixed), PHP built-in server + custom router, contributor authenticated via WordPress Application Password.
Recommendations / Next Steps
- Upgrade immediately to Widget Options >= 4.2.4 (4.2.5 is current).
- Defense in depth: restrict the block-renderer REST endpoint to roles that
actually need server-side block preview; treat any Display Logic/eval feature
as admin-only and never evaluate attacker-supplied expressions on non-admin
render paths. Prefer a non-
evalexpression engine (allowlisted operators only) over a blocklist. - Hardening: disable
system/exec/passthru/shell_exec/proc_openviadisable_functionswhere possible; enforce least privilege for the web server user; use WAF rules to flagextended_widget_opts_blockattributes carrying PHP-like logic in REST payloads. - Testing: add regression tests that (a) a contributor cannot trigger
widgetopts_safe_evalvia the block-renderer endpoint, and (b) the token validator rejects${...}(),Foo::{'...'}, closures, and concatenation-built callables.
Additional Notes
- Idempotency: The script was run twice consecutively, both runs exited 0 with identical results (4.2.3 exploited, 4.2.4 blocked). It cleans up its own MariaDB and PHP-server processes (SIGTERM then SIGKILL) and kills any stale mariadbd bound to its datadir before starting.
- require_once staleness note: The vulnerable and fixed runs are served by
separate fresh PHP built-in server processes; a single long-lived process
would keep 4.2.3's
extras.phploaded viarequire_onceand mask the fixed build's behavior. opcache is off for the CLI SAPI and is not a factor. - Surface match: The claim's
claimed_surfaceisapi_remoteand the reproduction proves the real WordPress REST API path end-to-end (no library harness), sovalidated_surface = api_remoteandclaim_outcome = confirmed. - Auth: Application Passwords are used because regular WordPress passwords are
not accepted over HTTP Basic Auth;
WP_ENVIRONMENT_TYPE=localpermits app passwords over plain HTTP in this isolated reproduction environment. - Bypass minimality: The exploit uses only the contributor's own credentials and a single REST request — no admin interaction, no post publication, no shortcode/block insertion step required by the victim.
Variant Analysis & Alternative Triggers for CVE-2026-54823
No bypass was found. After systematically enumerating the materially
distinct trigger paths that could reach the same widgetopts_safe_eval() -> eval() sink as the parent CVE-2026-54823 (Contributor RCE via the
block-renderer REST endpoint), every candidate variant is blocked on the
fixed 4.2.4 and the current/latest 4.2.5 builds. The fix is a closed-default
trust gate: widgetopts_safe_eval() now returns true (show, no eval) for any
caller that is not an administrator and is not executing inside
widgetopts_safe_eval_trusted(), whose only call sites consume admin-controlled
/ DB-backed input (classic widgets, Elementor, Beaver, SiteOrigin, and the
admin-only widgetopts_snippet CPT). Because a Contributor can never set the
trust flag and can never supply input to a trusted call site, no Contributor
payload of any shape reaches eval() on 4.2.4/4.2.5 — this was verified against
the real extracted plugin functions with a parse-error probe that
distinguishes "gate blocked (eval never reached)" from "eval reached (Throwable
caught)". On the vulnerable 4.2.3, the same probe confirms eval() is
reached for a contributor, and the alternate payloads (original, stripslashes-
differential, ${'z'}() brace-call) execute — i.e. they are alternate triggers
of the same root cause on the vulnerable version, not a bypass of the fix.
The latest release 4.2.5 is byte-identical to 4.2.4 on the entire security
surface (includes/extras.php and includes/widgets/gutenberg/gutenberg-toolbar.php
compare equal; 4.2.5 is only a "tested for WordPress 7.0" compat bump per its
changelog), so the 4.2.4 analysis covers the current release.
Fix Coverage / Assumptions
Invariant the fix relies on: the only way a non-administrator can reach
eval() is through widgetopts_safe_eval_trusted(), and every call site of
that wrapper feeds it input that is provably admin-controlled / DB-backed
(not attacker-supplied over the trust boundary). The closed default is:
function widgetopts_safe_eval($expression) {
if (!current_user_can('manage_options') && !widgetopts_eval_is_trusted()) {
return true; // G1: contributor -> no eval, ever
}
if (widgetopts_is_widget_or_post_preview()) {
if (!current_user_can('manage_options')) { return true; } // G5: preview
}
$validation_result = widgetopts_validate_expression($expression); // G2 + blocklist
...
@eval($expression);
}
Code paths explicitly covered:
- G1 — closed-default trust gate (
includes/extras.php::widgetopts_safe_eval, line ~707-711): any non-admin outside a trusted wrapper is short-circuited before the validator andeval. The trust flag ($GLOBALS['_widgetopts_trust_depth']) is set only bywidgetopts_safe_eval_trusted(). - G2 — token validator hardening (
widgetopts_validate_code_with_tokens, line ~1077): now blocks},T_FUNCTION,T_FN(in addition to],)) immediately before(, defeating${'z'}(...)/Foo::{'m'}(...)/ closure-IIFE shapes; also blocksT_EVAL/T_INCLUDE/T_REQUIRE/T_EXIT/T_GOTOconstructs andT_ENCAPSED_AND_WHITESPACE. - G3 — render_block_data sha256 allowlist (
gutenberg-toolbar.phpline ~429, scoped viarest_pre_dispatchflag_widgetopts_in_block_rendererto the/wp/v2/block-renderer/route): zeroes anyextended_widget_opts[_block].class.logicwhose sha256 is not among the values actually stored in the referenced post'spost_content. - G4 — save/REST stripper (
wp_insert_post_datafilter line ~377 +rest_request_before_callbacksscrubextras.phpline ~679 +widgetopts_strip_logic_from_blocksline ~306): strips legacyextended_widget_opts[_block].class.logicand freeform<!--start_widgetopts ... end_widgetopts-->comment logic for non-admins on every save (classic editor, block editor,wp_insert_post) and every non-admin REST write (GET/HEAD/OPTIONS untouched, but those are covered by G3 on the block-renderer route). - G5 — preview guard: non-admin preview / customizer /
?preview=true-> no eval even when trusted (prevents a contributor from previewing a draft to trigger their own logic).
What the fix does NOT cover (examined, found non-exploitable):
- The freeform
<!--start_widgetopts-->render extractor (blockopts_filter_before_display) readsinnerContent[0]and callswidgetopts_safe_eval_trusted()— which sets the trust flag. This is the most dangerous-looking path because G3 (the allowlist) only inspects parsedattrs, notinnerContent. However it is neutralised by G4 (the save stripper explicitly matches and rewrites freeform comments for non-admins) and by reachability: the block-renderer REST endpoint builds blocks from requestattributesonly and does not populateinnerContent, so the freeform extractor cannot fire through that endpoint; and a contributor cannot publish, so a saved freeform comment only renders on preview (G5) or after an admin publishes it (admin action, not a contributor trigger). post_idmanipulation against G3: a contributor controls$_REQUEST['post_id'], but the allowlist is the set of sha256 hashes of logic stored in that post. Passing requires a sha256 preimage of an admin's stored logic (infeasible) or supplying the admin's exact logic string (which is admin-controlled anyway). An empty/invalidpost_idyields an empty allowlist -> every logic is zeroed.
Variant / Alternate Trigger
Five candidate variants were tested (entry point + data path each):
| # | Variant | Entry point / data path | Result on 4.2.3 (vuln) | Result on 4.2.4 / 4.2.5 (fixed) |
|---|---|---|---|---|
| A | Original CVE payload $z='s'..'m'; ${'z'}('CMD'); return true; |
block-renderer REST, attrs.extended_widget_opts_block.class.logic |
eval reached (RCE) | Blocked at G1 (trust gate) |
| B | stripslashes-differential payload (validator stripslashes() vs raw eval) |
same endpoint | eval reached | Blocked at G1 |
| D | ${'z'}(...) brace variable-call (the 4.2.3 token bypass) |
same endpoint | eval reached (token } not blocked) |
Blocked at G1; also G2 now blocks } before ( |
| E | array_map($concat,['x']) alternate dynamic-call shape |
same endpoint | rejected by validator (] before () |
Blocked at G1 |
| F | freeform <!--start_widgetopts {"class":{"logic":"..."}} end_widgetopts--> comment injection |
saved post innerContent (alternate data path) |
stripped at save (G4) | stripped at save (G4); not reachable via block-renderer (no innerContent); preview blocked (G5) |
Additional paths inspected and ruled out by trust-boundary / capability analysis (not runtime-tested because they are admin-gated by design):
- Snippets API (
WidgetOpts_Snippets_API::execute_snippet->widgetopts_safe_eval_trusted): thewidgetopts_snippetCPT is registered withcapability_type=>'post'overridden bycapabilitiesrequiringmanage_optionsforedit_post/edit_posts/create_posts/delete_post(class-snippets-cpt.phplines 76-86). A Contributor cannot create or edit snippets, so cannot control the trust-flagged code. Admin AJAX endpoints (class-snippets-admin.php) all checkmanage_options. - Classic widgets (
includes/widgets/display.php:586): widget instances live inwidget_<id>options edited by users withedit_theme_options(administrators). Contributor cannot control. - Elementor / Beaver / SiteOrigin render paths: page-builder content is
edited by users with builder edit access (administrators/editors); the legacy
widgetopts_logic/widgetopts_settings_logickeys are also scrubbed by G4 on REST writes.
Exact entry points: POST/GET /wp-json/wp/v2/block-renderer/<block>
(A-E, same as parent), and saved post post_content freeform comments (F).
Code paths involved: includes/extras.php (widgetopts_safe_eval,
widgetopts_safe_eval_trusted, widgetopts_eval_is_trusted,
widgetopts_validate_expression, widgetopts_validate_code_with_tokens,
widgetopts_rest_scrub_legacy_logic); includes/widgets/gutenberg/gutenberg-toolbar.php
(blockopts_filter_before_display freeform extractor + legacy logic path,
widgetopts_strip_logic_from_blocks, render_block_data allowlist,
rest_pre_dispatch/rest_post_dispatch scope flag,
wp_insert_post_data stripper); includes/snippets/class-snippets-cpt.php
(CPT capabilities).
- Package/component affected:
widget-optionsplugin,includes/extras.phpeval surface andincludes/widgets/gutenberg/gutenberg-toolbar.phprender path — as tested on 4.2.3 (vulnerable), 4.2.4 (fixed), 4.2.5 (latest). - Affected versions: <= 4.2.3 vulnerable (parent CVE). 4.2.4 fixed. 4.2.5 (current) identical fix.
- Risk level (this variant analysis): No new exploitable path on fixed/latest.
The alternate triggers (A/B/D) on 4.2.3 are the same critical Contributor RCE
class as the parent (CVSS-class critical), already mitigated by upgrading to
= 4.2.4.
Impact Parity
- Disclosed/claimed maximum impact (parent): Authenticated (Contributor+) Remote Code Execution — arbitrary server command execution + webroot PHP drop.
- Reproduced impact from this variant run: No impact on 4.2.4/4.2.5 —
eval()is never reached for a contributor (G1 gate). On 4.2.3, variants A/B/D reacheval()(alternate triggers of the same RCE), confirming the candidates are real triggers of the underlying bug, just not a bypass. - Parity:
none(no bypass of the fix; the fix fully closes the contributor->eval trust boundary). - Not demonstrated: Any Contributor code execution on 4.2.4 or 4.2.5.
Root Cause
The parent root cause is that widgetopts_safe_eval() exposed PHP eval() of
user-supplied "Display Logic" to low-privilege users, with only a preview-
context guard and an incomplete blocklist/token-validator. The 4.2.4 fix
changes the default from "eval unless preview" to "never eval for non-
admins unless a trusted wrapper vouches that the input is admin-controlled".
That single closed-default inversion (G1) is what removes the trust-boundary
crossing: a Contributor's input can no longer reach eval() through any path,
because every path that would set the trust flag consumes input the
Contributor cannot author (DB-backed widget/snippet/builder settings requiring
manage_options/edit_theme_options). The validator hardening (G2), the
block-renderer allowlist (G3), the save/REST stripper (G4), and the preview
guard (G5) are defense-in-depth on top of G1; even if any one were bypassed in
isolation, G1 still blocks a contributor. No single upstream commit hash was
published with the advisory; the fix ships in plugin version 4.2.4
(readme.txt: "Strengthened security for Authenticated (Contributor+) Remote
Code Execution vulnerability") and is unchanged in 4.2.5.
Reproduction Steps
- Script:
bundle/vuln_variant/reproduction_steps.sh(self-contained; run withPRUVA_ROOT=<bundle> bash bundle/vuln_variant/reproduction_steps.sh). - What it does:
- Ensures the real plugin sources for 4.2.3, 4.2.4, 4.2.5 are extracted
(from
bundle/artifacts/wo-versions/*.zipif the extracted trees are missing). - Runs
bundle/vuln_variant/variant_harness.phpfor each version. The harness token-extracts the realwidgetopts_safe_eval,widgetopts_validate_expression,widgetopts_validate_code_with_tokens,widgetopts_strip_logic_from_blocksetc. from each version's actualextras.php/gutenberg-toolbar.php, stubs the minimal WordPress functions, simulates a Contributor (current_user_can('manage_options')=false, non-preview, no trust flag), and runs:- a parse-error probe (
@@@ $$$ not php (((;) — returnstrueon 4.2.4+ (G1 short-circuited beforeeval) vsfalseon 4.2.3 (eval()ran and raised/caught a ParseError), proving whetherevalis reached; - the candidate variant payloads A/B/D/E through
widgetopts_safe_eval; - the G2 token-validator
}-before-(check in isolation; - the G4 freeform-comment stripper on a
<!--start_widgetopts {"class":{"logic":"system('id');..."}} end_widgetopts-->input.
- a parse-error probe (
- Classifies each version as
eval_reached_for_contributor = True/False, writesbundle/logs/vuln_variant/variant_summary.logandbypass_flag.txt, and exits 0 only if a variant reachesevalon the fixed/latest version (a true bypass); otherwise exits 1 (negative result, script still ran fully).
- Ensures the real plugin sources for 4.2.3, 4.2.4, 4.2.5 are extracted
(from
- Expected evidence:
eval_reached_for_contributor = Trueon 4.2.3 and= Falseon 4.2.4 and 4.2.5;bypass_flag.txt = NO_BYPASS; all variant verdictsBLOCKED_at_trust_gate_G1on 4.2.4/4.2.5.
Evidence
bundle/logs/vuln_variant/variant_summary.log— full classification (vulnerable=True, fixed=False, latest=False; per-variant verdicts; G1/G2/G4 gate states).bundle/logs/vuln_variant/harness_4.2.3.log,bundle/logs/vuln_variant/harness_4.2.4.log,bundle/logs/vuln_variant/harness_4.2.5.log— raw JSON harness output per version.bundle/logs/vuln_variant/bypass_flag.txt—NO_BYPASS.bundle/vuln_variant/variant_harness.php— the token-extraction harness (loads real plugin functions; not a mock).bundle/vuln_variant/patch_analysis.md— detailed fix/assumption analysis.bundle/artifacts/wo-versions/ext-4.2.{3,4,5}/widget-options/— extracted plugin sources tested.
Key excerpts (variant_summary.log):
vulnerable 4.2.3: eval_reached_for_contributor = True
fixed 4.2.4: eval_reached_for_contributor = False
latest 4.2.5: eval_reached_for_contributor = False
4.2.3: G1=False G2_brace_blocked=False ... probe=false (eval_reached_caught_throwable (no G1 on vuln))
variant A_original_cve: reached_eval_on_vuln (no G1)
variant B_stripslashes_diff: reached_eval_on_vuln (no G1)
variant D_brace_var_call: reached_eval_on_vuln (no G1)
4.2.4: G1=True G2_brace_blocked=True ... probe=true (gate_blocked_G1 (eval never reached for contributor))
variant A_original_cve: BLOCKED_at_trust_gate_G1
variant B_stripslashes_diff: BLOCKED_at_trust_gate_G1
variant D_brace_var_call: BLOCKED_at_trust_gate_G1
variant E_concat_callable: BLOCKED_at_trust_gate_G1
4.2.5: (identical to 4.2.4)
Environment: PHP 8.x CLI, Widget Options 4.2.3 / 4.2.4 / 4.2.5 extracted from
wordpress.org distribution zips; harness uses PHP token_get_all to extract the
real functions (no mocked logic).
Recommendations / Next Steps
- No fix gap identified. The closed-default trust gate (G1) is the correct
root-cause fix and fully closes the Contributor ->
eval()trust boundary. The Coding agent should preserve G1 as the authoritative gate and treat G2-G5 as defense-in-depth, not as the primary control. - Defense in depth to retain/extend:
- Keep the
widgetopts_safe_eval_trusted()allowlist of call sites tight; any new call site must prove its expression is admin-controlled. Audit that no future call site passes request-derived data into the trusted wrapper. - Consider making the freeform
<!--start_widgetopts-->extractorrender_block_data-aware (treatinnerContentcomments under the same sha256 allowlist as parsedattrs) so G3 also covers the freeform data path, removing reliance on G4 + reachability for that shape. - Prefer replacing
eval()entirely with an allowlisted-operator expression engine (nosystem/exec/variable-variable calls possible by construction) rather than a blocklist + token validator. - Add regression tests: (1) a contributor cannot trigger
widgetopts_safe_evalvia block-renderer for any block; (2) the token validator rejects${...}(),Foo::{'...'}, closures, concatenation-built callables, andT_FUNCTION/T_FNIIFEs; (3) freeform comments carryingclass.logicare stripped on non-admin save.
- Keep the
- Hardening:
disable_functions=system,exec,passthru,shell_exec,proc_open,popenon the PHP SAPI; least-privilege web-server user; WAF rule flaggingextended_widget_opts_blockattributes andstart_widgetoptscomments carrying PHP-like logic in REST payloads.
Additional Notes
- Idempotency:
reproduction_steps.shwas run twice consecutively; both runs completed without crashing and exited 1 (NO_BYPASS) with identical classification. It only reads/extracts plugin zips and runs a read-only PHP harness; it does not mutate the repro clone or start services. - Bounded search justification: Fewer than 3 bypass candidates exist
because the fix's closed-default trust gate (G1) is input-agnostic — it
blocks every payload shape for a contributor regardless of validator
behaviour, so validator-bypass techniques (stripslashes differential,
alternate dynamic-call shapes, alternate block names) are all moot on the
fixed version by construction. The only class of candidate that could
theoretically bypass G1 is one that sets the trust flag with contributor-
controlled input; all six
widgetopts_safe_eval_trusted()call sites were audited and each consumes admin-controlled/DB-backed input (capabilitiesmanage_options/edit_theme_options), so no such candidate exists. The freeform-comment path is the one shape that sets the trust flag at render time, and it is closed by G4 (save stripper) + endpoint reachability + G5. - Version identity: 4.2.5 (current/latest) is byte-identical to 4.2.4 on
includes/extras.phpandincludes/widgets/gutenberg/gutenberg-toolbar.php(cmpequal); 4.2.5 changelog is solely a WordPress 7.0 compat bump. The 4.2.4 analysis therefore covers the latest release. - Threat-model scope: This is an authenticated Contributor crossing a
server-side trust boundary (REST API -> server
eval()), consistent with the parent CVE's scope and WordPress's security model (low-privilege authenticated RCE is a vulnerability). No out-of-scope "user attacks self with local input" framing is used.
CVE-2026-54823 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
Artifacts and Evidence for CVE-2026-54823
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-54823
FAQ: CVE-2026-54823
How does the CVE-2026-54823 contributor-to-RCE exploit work?
Which Widget Options versions are affected by CVE-2026-54823?
How severe is CVE-2026-54823?
How can I reproduce CVE-2026-54823?
References for CVE-2026-54823
Authoritative sources for CVE-2026-54823 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.