# CVE-2026-54823 — Widget Options <= 4.2.3 — Contributor Remote Code Execution

## Summary

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.

## Impact

- **Package/component affected:** `widget-options` plugin, file
  `includes/extras.php` (`widgetopts_safe_eval()` / `widgetopts_validate_expression()`
  / `widgetopts_validate_code_with_tokens()`), reached via
  `includes/widgets/gutenberg/gutenberg-toolbar.php` (`blockopts_filter_before_display()`
  on the `render_block` filter) 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.txt`
  containing `PRUVA_RCE` + `id` output, `uid=1000`) **and** dropping an executable
  PHP file `wp-content/wo_rce_proof.php` (`<?php echo 7*6;`) into the webroot,
  which was then fetched over HTTP and returned the computed value `42` — 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

1. **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*:
   ```php
   if ( widgetopts_is_widget_or_post_preview() ) {
       if ( ! current_user_can('manage_options') ) { return true; }
   }
   ```
   `widgetopts_is_widget_or_post_preview()` returns true only for
   `REST_REQUEST && $_GET['context'] === 'edit'`, `is_preview()`, `?preview=true`,
   the Customizer, or Beaver/Elementor edit mode. On any **non-preview** render
   path the `eval()` runs for *every* user, including contributors and logged-out
   visitors.

2. **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 the
   `wp_insert_post_data` / `rest_pre_insert` logic strippers (which clear
   `extended_widget_opts[_block].class.logic` for non-admins on save) never
   execute. The contributor's malicious `class.logic` therefore reaches the
   `render_block` filter → `blockopts_filter_before_display()` →
   `widgetopts_safe_eval()`.

3. **`$_GET['context']` vs REST body context mismatch.** The block-renderer
   endpoint *requires* `context=edit`. Sending `context=edit` in 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.

4. **Blocklist + token-allowlist bypass.** `widgetopts_validate_expression()`
   applies a regex blocklist (e.g. `/\b(eval|system|exec|...)\b/i`) and
   `widgetopts_validate_code_with_tokens()` enforces a function allowlist,
   blocking `T_STRING`/`T_VARIABLE`/`T_CONSTANT_ENCAPSED_STRING` and `]` 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 `system` via concatenation so the literal word
     never appears (regex `\bsystem\b` does 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`, so `widgetopts_safe_eval()` skips its
     `return( ... );` 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 direct
   `system('id')` and `array_map('s'.'y'..., ...)` are correctly rejected.

**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

1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained; run with
   `PRUVA_ROOT=<bundle> bash bundle/repro/reproduction_steps.sh`).
2. **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/archives` with JSON body
     `{"attributes":{"extended_widget_opts_block":{"class":{"logic":"<PAYLOAD>"}}},"context":"edit"}`
     (no `?context=edit` query param). The payload writes a marker file and a
     webroot PHP file via `system()`.
   - Confirms RCE: fetches the dropped PHP file over HTTP (returns `42`) and reads
     the marker file (contains `id` output).
   - Repeats the identical request against the **fixed** 4.2.4 build on a fresh
     server (to avoid PHP `require_once` staleness) and confirms **no** proof
     artifacts are produced.
   - Writes `bundle/repro/runtime_manifest.json` and exits 0 only if the
     vulnerable build is exploited AND the fixed build blocks it.
3. **Expected evidence of reproduction:** HTTP 200 on the exploit request; a
   dropped `wp-content/wo_rce_proof.php` that returns `42` over HTTP; a
   `/tmp/wo_rce_proof.txt` containing `PRUVA_RCE` + `id` output; 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` + `id` output produced by
  `system()`.
- `bundle/logs/exploit_vuln.log` — vulnerable-run exploit output (HTTP 200,
  `PHP_PROOF_EXISTS True`, `TMP_PROOF_EXISTS True`, `id` output).
- `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.3 `widgetopts_validate_expression()`.
- `bundle/artifacts/wo-versions/ext-4.2.3` and `ext-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-`eval` expression engine (allowlisted operators
  only) over a blocklist.
- **Hardening:** disable `system`/`exec`/`passthru`/`shell_exec`/`proc_open` via
  `disable_functions` where possible; enforce least privilege for the web server
  user; use WAF rules to flag `extended_widget_opts_block` attributes carrying
  PHP-like logic in REST payloads.
- **Testing:** add regression tests that (a) a contributor cannot trigger
  `widgetopts_safe_eval` via 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.php` loaded via `require_once` and mask the fixed
  build's behavior. opcache is off for the CLI SAPI and is not a factor.
- **Surface match:** The claim's `claimed_surface` is `api_remote` and the
  reproduction proves the real WordPress REST API path end-to-end (no library
  harness), so `validated_surface = api_remote` and `claim_outcome = confirmed`.
- **Auth:** Application Passwords are used because regular WordPress passwords are
  not accepted over HTTP Basic Auth; `WP_ENVIRONMENT_TYPE=local` permits 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.
