{"repro_id":"REPRO-2026-00230","version":6,"title":"Widget Options WordPress plugin contributor remote code execution","repro_type":"security","status":"published","severity":"critical","description":"Contributor Remote Code Execution exists in Widget Options <= 4.2.3. A user with contributor privileges can send a crafted request that executes arbitrary code on the server. Reproduce: install WordPress with the vulnerable Widget Options plugin, create a contributor account, send a payload that writes or executes a PHP file, and confirm code execution.","root_cause":"# CVE-2026-54823 — Widget Options <= 4.2.3 — Contributor Remote Code Execution\n\n## Summary\n\nThe **Widget Options – Advanced Conditional Visibility** WordPress plugin\n(MarketingFire) versions up to and including **4.2.3** allow an authenticated\n**Contributor** (and any role with `edit_posts`) to execute arbitrary PHP/server\ncode remotely. The plugin's *Display Logic* feature evaluates user-supplied PHP\nexpressions through `widgetopts_safe_eval()` (which calls `eval()`). In 4.2.3 the\nonly guard for non-administrators is a *preview-context* short-circuit\n(`widgetopts_is_widget_or_post_preview()`), which inspects `$_GET['context']`.\nThe server-side block-rendering REST endpoint\n`/wp-json/wp/v2/block-renderer/<block>` renders attacker-supplied block\nattributes with **no save step**, so the save-time logic stripper never runs; and\nbecause the endpoint accepts `context=edit` in the JSON **body** (not the query\nstring), the plugin's `$_GET['context']` guard is bypassed and the contributor's\nDisplay Logic expression reaches `eval()`. The accompanying blocklist (regex) and\ntoken allowlist are defeated with a variable-variable call built from string\nconcatenation: `$z='s'.'y'.'s'.'t'.'e'.'m'; ${'z'}('CMD'); return true;`, which\nhides the word `system` from the regex and places a `}` before `(` (a token the\n4.2.3 validator did not block). This yields arbitrary command execution as the\nweb server user, including dropping an executable PHP file into the webroot.\n\n## Impact\n\n- **Package/component affected:** `widget-options` plugin, file\n  `includes/extras.php` (`widgetopts_safe_eval()` / `widgetopts_validate_expression()`\n  / `widgetopts_validate_code_with_tokens()`), reached via\n  `includes/widgets/gutenberg/gutenberg-toolbar.php` (`blockopts_filter_before_display()`\n  on the `render_block` filter) when the *Display Logic* module is active\n  (`widgetopts_tabmodule-logic` / `widgetopts_settings['logic'] == 'activate'`).\n- **Affected versions:** Widget Options **<= 4.2.3** (vulnerable). Fixed in\n  **4.2.4**. (Earlier Contributor+ RCE variants were CVE-2025-22630, fixed in\n  4.1.1, and CVE-2026-2052, partially patched in 4.2.0; CVE-2026-54823 is the\n  block-renderer/`}`-token bypass that remained through 4.2.3.)\n- **Risk level:** Critical. A low-privileged authenticated user (Contributor,\n  who cannot publish posts or manage options) obtains remote code execution as\n  the WordPress/web-server user, leading to full site compromise, data exfiltration,\n  and pivoting to the host.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Authenticated (Contributor+) Remote Code\n  Execution — \"send a crafted request that executes arbitrary code on the server …\n  writes or executes a PHP file.\"\n- **Reproduced impact from this run:** Full RCE demonstrated end-to-end through\n  the real WordPress REST API surface. The contributor's single HTTP request\n  executed `system('<cmd>')` on the server, writing a marker file (`/tmp/wo_rce_proof.txt`\n  containing `PRUVA_RCE` + `id` output, `uid=1000`) **and** dropping an executable\n  PHP file `wp-content/wo_rce_proof.php` (`<?php echo 7*6;`) into the webroot,\n  which was then fetched over HTTP and returned the computed value `42` — proving\n  the contributor both executed arbitrary commands and planted executable PHP in\n  the webroot. The identical request against the fixed 4.2.4 build produced **no**\n  proof artifacts (blocked).\n- **Parity:** `full`.\n- **Not demonstrated:** N/A — code execution parity is achieved (not merely a crash).\n\n## Root Cause\n\n1. **Insufficient eval guard (preview-only).** `widgetopts_safe_eval()` in 4.2.3\n   only short-circuits to \"show without eval\" for non-admins *inside a preview\n   context*:\n   ```php\n   if ( widgetopts_is_widget_or_post_preview() ) {\n       if ( ! current_user_can('manage_options') ) { return true; }\n   }\n   ```\n   `widgetopts_is_widget_or_post_preview()` returns true only for\n   `REST_REQUEST && $_GET['context'] === 'edit'`, `is_preview()`, `?preview=true`,\n   the Customizer, or Beaver/Elementor edit mode. On any **non-preview** render\n   path the `eval()` runs for *every* user, including contributors and logged-out\n   visitors.\n\n2. **Block-renderer REST endpoint bypasses the save-time stripper.** The\n   `/wp-json/wp/v2/block-renderer/<block>` route renders request-supplied block\n   attributes server-side without persisting the post, so the\n   `wp_insert_post_data` / `rest_pre_insert` logic strippers (which clear\n   `extended_widget_opts[_block].class.logic` for non-admins on save) never\n   execute. The contributor's malicious `class.logic` therefore reaches the\n   `render_block` filter → `blockopts_filter_before_display()` →\n   `widgetopts_safe_eval()`.\n\n3. **`$_GET['context']` vs REST body context mismatch.** The block-renderer\n   endpoint *requires* `context=edit`. Sending `context=edit` in the JSON **body**\n   satisfies the REST controller (so it returns 200 and renders) while leaving\n   `$_GET['context']` unset, so the plugin's preview guard in step 1 evaluates to\n   false and eval proceeds for the contributor.\n\n4. **Blocklist + token-allowlist bypass.** `widgetopts_validate_expression()`\n   applies a regex blocklist (e.g. `/\\b(eval|system|exec|...)\\b/i`) and\n   `widgetopts_validate_code_with_tokens()` enforces a function allowlist,\n   blocking `T_STRING`/`T_VARIABLE`/`T_CONSTANT_ENCAPSED_STRING` and `]` or `)`\n   immediately before `(`. It does **not** block `}` before `(`. The payload\n   `$z = 's'.'y'.'s'.'t'.'e'.'m'; ${'z'}('CMD'); return true;`:\n   - builds the function name `system` via concatenation so the literal word\n     never appears (regex `\\bsystem\\b` does not match);\n   - calls it through `${'z'}(...)`, i.e. a variable-variable call where the\n     token before `(` is `}` — not blocked in 4.2.3;\n   - contains the substring `return`, so `widgetopts_safe_eval()` skips its\n     `return( ... );` wrapper and evaluates the raw multi-statement code.\n\n   Net effect: `system('CMD')` executes as the contributor.\n\n   This was verified against the **real** plugin validator extracted from 4.2.3\n   (`bundle/repro/validator_harness.php`): the payload returns\n   `['valid' => true, 'message' => 'Expression is safe.']` while direct\n   `system('id')` and `array_map('s'.'y'..., ...)` are correctly rejected.\n\n**Fix (4.2.4):** `includes/extras.php` introduces a closed-default trust flag —\n`if (!current_user_can('manage_options') && !widgetopts_eval_is_trusted()) return true;`\nat the top of `widgetopts_safe_eval()` — so non-admins get no eval unless a\ncallsite wrapped in `widgetopts_safe_eval_trusted()` flagging DB-backed,\nadmin-controlled logic. A `render_block_data` sha-256 allowlist\n(`widgetopts_get_post_logic_allowlist()`), scoped to the block-renderer route via\n`rest_pre_dispatch` (`_widgetopts_in_block_renderer`), zeroes any\n`class.logic` not actually stored in the post. A `rest_request_before_callbacks`\nscrub net clears legacy logic shapes on all non-admin REST writes. Finally, the\ntoken validator now also blocks `}` (catching `${$fn}()` / `Foo::{'m'}()`),\n`T_FUNCTION`, and `T_FN` before `(`. (See `bundle/artifacts/wo-versions/ext-4.2.4`\nvs `ext-4.2.3` diffs.) No single upstream commit hash was published with the\nadvisory; the fix ships in plugin version 4.2.4.\n\n## Reproduction Steps\n\n1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained; run with\n   `PRUVA_ROOT=<bundle> bash bundle/repro/reproduction_steps.sh`).\n2. **What it does:**\n   - Installs PHP + MariaDB deps via apt, starts a dedicated MariaDB instance on a\n     private socket.\n   - Downloads WordPress core (7.0), wp-cli, and the plugin zips for **4.2.3**\n     (vulnerable) and **4.2.4** (fixed) from wordpress.org.\n   - Installs WordPress with `WP_ENVIRONMENT_TYPE=local` (enables Application\n     Passwords over HTTP), enables pretty permalinks, and serves it via the PHP\n     built-in server with a router so `/wp-json/*` routes to WordPress.\n   - Installs the vulnerable plugin 4.2.3, activates the Display Logic module\n     (`widgetopts_settings['logic'] = 'activate'`), creates a **Contributor**\n     user and an Application Password for it.\n   - As the contributor, sends the exploit:\n     `POST /wp-json/wp/v2/block-renderer/core/archives` with JSON body\n     `{\"attributes\":{\"extended_widget_opts_block\":{\"class\":{\"logic\":\"<PAYLOAD>\"}}},\"context\":\"edit\"}`\n     (no `?context=edit` query param). The payload writes a marker file and a\n     webroot PHP file via `system()`.\n   - Confirms RCE: fetches the dropped PHP file over HTTP (returns `42`) and reads\n     the marker file (contains `id` output).\n   - Repeats the identical request against the **fixed** 4.2.4 build on a fresh\n     server (to avoid PHP `require_once` staleness) and confirms **no** proof\n     artifacts are produced.\n   - Writes `bundle/repro/runtime_manifest.json` and exits 0 only if the\n     vulnerable build is exploited AND the fixed build blocks it.\n3. **Expected evidence of reproduction:** HTTP 200 on the exploit request; a\n   dropped `wp-content/wo_rce_proof.php` that returns `42` over HTTP; a\n   `/tmp/wo_rce_proof.txt` containing `PRUVA_RCE` + `id` output; and absence of\n   both on the fixed 4.2.4 control.\n\n## Evidence\n\n- `bundle/repro/runtime_manifest.json` — runtime manifest (api_remote, service\n  started, healthcheck passed, target path reached, proof artifacts listed).\n- `bundle/repro/validation_verdict.json` — structured verdict (`confirmed`).\n- `bundle/repro/wo_rce_proof.php` — the PHP file the contributor dropped into the\n  webroot (`<?php echo 7*6;`).\n- `bundle/repro/wo_rce_proof.txt` — `PRUVA_RCE` + `id` output produced by\n  `system()`.\n- `bundle/logs/exploit_vuln.log` — vulnerable-run exploit output (HTTP 200,\n  `PHP_PROOF_EXISTS True`, `TMP_PROOF_EXISTS True`, `id` output).\n- `bundle/logs/exploit_fixed.log` — fixed-run exploit output (HTTP 200,\n  `PHP_PROOF_EXISTS False`, `TMP_PROOF_EXISTS False`).\n- `bundle/logs/reproduction_steps.log` — full phase log.\n- `bundle/repro/exploit_request.py` — the exploit client (auth + payload +\n  verification).\n- `bundle/repro/validator_harness.php` — local proof that the payload passes the\n  real 4.2.3 `widgetopts_validate_expression()`.\n- `bundle/artifacts/wo-versions/ext-4.2.3` and `ext-4.2.4` — extracted plugin\n  sources used for the vulnerable/fixed comparison.\n\nKey excerpts (vulnerable run, `logs/exploit_vuln.log`):\n```\nPOST http://127.0.0.1:<port>/wp-json/wp/v2/block-renderer/core/archives (context=edit in BODY, none in query string)\nHTTP 200\nPHP_PROOF_EXISTS True\nTMP_PROOF_EXISTS True\nTMP_PROOF_CONTENT>>\nPRUVA_RCE\nuid=1000(vscode) gid=1000(vscode) groups=1000(vscode),969(969)\n<<TMP_PROOF_CONTENT\n```\nFixed control (`logs/exploit_fixed.log`):\n```\nHTTP 200\nPHP_PROOF_EXISTS False\nTMP_PROOF_EXISTS False\n```\nEnvironment: WordPress 7.0, PHP 8.5, MariaDB 11.8, Widget Options 4.2.3\n(vulnerable) / 4.2.4 (fixed), PHP built-in server + custom router, contributor\nauthenticated via WordPress Application Password.\n\n## Recommendations / Next Steps\n\n- **Upgrade immediately** to Widget Options **>= 4.2.4** (4.2.5 is current).\n- **Defense in depth:** restrict the block-renderer REST endpoint to roles that\n  actually need server-side block preview; treat any Display Logic/eval feature\n  as admin-only and never evaluate attacker-supplied expressions on non-admin\n  render paths. Prefer a non-`eval` expression engine (allowlisted operators\n  only) over a blocklist.\n- **Hardening:** disable `system`/`exec`/`passthru`/`shell_exec`/`proc_open` via\n  `disable_functions` where possible; enforce least privilege for the web server\n  user; use WAF rules to flag `extended_widget_opts_block` attributes carrying\n  PHP-like logic in REST payloads.\n- **Testing:** add regression tests that (a) a contributor cannot trigger\n  `widgetopts_safe_eval` via the block-renderer endpoint, and (b) the token\n  validator rejects `${...}()`, `Foo::{'...'}`, closures, and concatenation-built\n  callables.\n\n## Additional Notes\n\n- **Idempotency:** The script was run **twice consecutively**, both runs exited 0\n  with identical results (4.2.3 exploited, 4.2.4 blocked). It cleans up its own\n  MariaDB and PHP-server processes (SIGTERM then SIGKILL) and kills any stale\n  mariadbd bound to its datadir before starting.\n- **require_once staleness note:** The vulnerable and fixed runs are served by\n  **separate fresh PHP built-in server processes**; a single long-lived process\n  would keep 4.2.3's `extras.php` loaded via `require_once` and mask the fixed\n  build's behavior. opcache is off for the CLI SAPI and is not a factor.\n- **Surface match:** The claim's `claimed_surface` is `api_remote` and the\n  reproduction proves the real WordPress REST API path end-to-end (no library\n  harness), so `validated_surface = api_remote` and `claim_outcome = confirmed`.\n- **Auth:** Application Passwords are used because regular WordPress passwords are\n  not accepted over HTTP Basic Auth; `WP_ENVIRONMENT_TYPE=local` permits app\n  passwords over plain HTTP in this isolated reproduction environment.\n- **Bypass minimality:** The exploit uses only the contributor's own credentials\n  and a single REST request — no admin interaction, no post publication, no\n  shortcode/block insertion step required by the victim.\n","cve_id":"CVE-2026-54823","cwe_id":"CWE-94 (Improper Control of Generation of Code / Code Injection)","source_url":"wordpress.org/plugin/widget-options (MarketingFire/Widget Options)","reproduced_at":"2026-07-06T08:15:39.660667+00:00","duration_secs":1725.0,"tool_calls":304,"handoffs":2,"total_cost_usd":6.3649042999999965,"agent_costs":{"hypothesis_generator":0.0092708,"judge":0.016572450000000002,"repro":2.6742760799999985,"support":0.11427846,"vuln_variant":3.550506510000001},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0092708},"judge":{"gpt-5.4-mini":0.016572450000000002},"repro":{"accounts/fireworks/routers/glm-5p2-fast":2.6742760799999985},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.11427846},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":3.550506510000001}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-06T08:16:07.379507+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":15682,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":13281,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":8010,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":18495,"category":"analysis"},{"path":"bundle/repro/exploit_request.py","filename":"exploit_request.py","size":1654,"category":"script"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":10582,"category":"other"},{"path":"bundle/vuln_variant/variant_harness.php","filename":"variant_harness.php","size":8837,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":2464,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":2076,"category":"other"},{"path":"bundle/repro/wo_rce_proof.php","filename":"wo_rce_proof.php","size":16,"category":"other"},{"path":"bundle/repro/wo_rce_proof.txt","filename":"wo_rce_proof.txt","size":73,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":684,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":982,"category":"other"},{"path":"bundle/logs/exploit_vuln.log","filename":"exploit_vuln.log","size":434,"category":"log"},{"path":"bundle/logs/exploit_fixed.log","filename":"exploit_fixed.log","size":323,"category":"log"},{"path":"bundle/logs/vuln_variant/variant_summary.log","filename":"variant_summary.log","size":2667,"category":"log"},{"path":"bundle/logs/vuln_variant/bypass_flag.txt","filename":"bypass_flag.txt","size":10,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":5014,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":3542,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":10631,"category":"documentation"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1927,"category":"other"},{"path":"bundle/logs/vuln_variant/harness_4.2.4.log","filename":"harness_4.2.4.log","size":1211,"category":"log"},{"path":"bundle/logs/vuln_variant/harness_4.2.3.log","filename":"harness_4.2.3.log","size":1089,"category":"log"},{"path":"bundle/logs/vuln_variant/harness_4.2.5.log","filename":"harness_4.2.5.log","size":1211,"category":"log"}]}