CVE-2026-2512

Code Embed <= 2.5.1 - Authenticated (Contributor+) Stored Cross-Site Scripting via Custom Fields

mediumImproper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
6.4
CVSS Score
6.4
CVSS Score
medium
Severity
2.5.2
Patched in
2d
Time to patch

Description

The Code Embed plugin for WordPress is vulnerable to Stored Cross-Site Scripting via custom field meta values in all versions up to, and including, 2.5.1. This is due to the plugin's sanitization function `sec_check_post_fields()` only running on the `save_post` hook, while WordPress allows custom fields to be added via the `wp_ajax_add_meta` AJAX endpoint without triggering `save_post`. The `ce_filter()` function then outputs these unsanitized meta values directly into page content without escaping. This makes it possible for authenticated attackers, with Contributor-level access and above, to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Changed
Low
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=2.5.1
PublishedMarch 17, 2026
Last updatedMarch 18, 2026
Affected pluginsimple-embed-code

What Changed in the Fix

Changes introduced in v2.5.2

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Research Plan: CVE-2026-2512 - Code Embed Stored XSS ## 1. Vulnerability Summary The **Code Embed** plugin (versions <= 2.5.1) is vulnerable to **Stored Cross-Site Scripting (XSS)**. The plugin uses WordPress Custom Fields (post meta) to store and embed code snippets (JavaScript, HTML, CSS) into …

Show full research plan

Research Plan: CVE-2026-2512 - Code Embed Stored XSS

1. Vulnerability Summary

The Code Embed plugin (versions <= 2.5.1) is vulnerable to Stored Cross-Site Scripting (XSS). The plugin uses WordPress Custom Fields (post meta) to store and embed code snippets (JavaScript, HTML, CSS) into posts and pages.

The security flaw exists in includes/secure.php. While the plugin attempts to sanitize custom fields using wp_kses_post() within the sec_check_post_fields() function, this function is only hooked to save_post. However, WordPress core provides an AJAX endpoint (wp_ajax_add_meta) that allows users with edit_posts capabilities (like Contributors) to add or update custom fields without triggering the save_post hook. Consequently, the sanitization logic is bypassed. When the post is rendered, the plugin's output filter fetches these unsanitized values and injects them directly into the page content.

2. Attack Vector Analysis

  • Vulnerable Endpoint: /wp-admin/admin-ajax.php
  • Action: add-meta
  • Required Parameter: metakeyinput (must match the plugin's keyword prefix, default: CODE)
  • Payload Parameter: metavalue (the XSS payload)
  • Authentication Level: Authenticated (Contributor or higher). Contributors can edit their own posts and thus access the add-meta AJAX action for those posts.
  • Preconditions:
    1. The attacker must have a post they are permitted to edit.
    2. The post must contain a "placeholder" identifier (e.g., {{CODE1}}) that the plugin will replace with the malicious meta value.

3. Code Flow

  1. Injection:
    • The attacker sends a POST request to admin-ajax.php with action=add-meta.
    • WordPress core executes wp_ajax_add_meta(), which calls add_post_meta() or update_post_meta().
    • The save_post hook is not triggered by this AJAX action.
    • sec_check_post_fields() in includes/secure.php is never called, so wp_kses_post() is bypassed.
  2. Storage: The raw payload (e.g., <script>alert(1)</script>) is stored in the wp_postmeta table.
  3. Execution:
    • A victim (e.g., Administrator) views the post.
    • The plugin (likely via includes/add-embeds.php, referred to as ce_filter in descriptions) parses the content for identifiers like {{CODE1}}.
    • It retrieves the meta value for the key CODE1.
    • The plugin outputs the raw value into the HTML without further escaping, triggering the XSS.

4. Nonce Acquisition Strategy

The add-meta action requires a core WordPress nonce. This nonce is specific to the post being edited.

  1. Identify Post: Create a post as a Contributor.
  2. Navigate to Editor: Use browser_navigate to go to the edit page for that post: wp-admin/post.php?post=POST_ID&action=edit.
  3. Extract Nonce: The nonce for adding meta is stored in a hidden input field with the ID _ajax_nonce-add-meta.
  4. JavaScript Execution:
    browser_eval("document.getElementById('_ajax_nonce-add-meta').value")
    
  5. Alternative (Global): If the hidden input is missing (due to Gutenberg), the nonce is often found in the wp-lists initialization or the _wpnonce parameter of other meta-related requests. However, for most WordPress versions, _ajax_nonce-add-meta remains the standard.

5. Exploitation Strategy

  1. Setup User: Create a Contributor user (contributor / password).
  2. Setup Content:
    • As the Contributor, create a post with the title "XSS Test" and content {{CODE1}}.
    • Capture the POST_ID.
  3. Acquire Nonce:
    • Log in as the Contributor.
    • Navigate to the edit screen for POST_ID.
    • Extract the add-meta nonce using the strategy in Section 4.
  4. Inject Payload:
    • Use http_request to call admin-ajax.php.
    • Method: POST
    • URL: http://vulnerable-wp.local/wp-admin/admin-ajax.php
    • Headers: Content-Type: application/x-www-form-urlencoded
    • Body Parameters:
      • action: add-meta
      • post_id: POST_ID
      • metakeyselect: #NONE#
      • metakeyinput: CODE1
      • metavalue: <script>alert(document.domain)</script>
      • _ajax_nonce-add-meta: [EXTRACTED_NONCE]
  5. Trigger: Navigate to the public URL of the post (as any user).

6. Test Data Setup

  • Plugin Configuration: Default settings (Keyword: CODE, Identifiers: {{ and }}).
  • User Role: contributor
  • Target Post:
    • Title: Vulnerable Post
    • Content: This is a test. {{CODE1}}
    • Status: publish

7. Expected Results

  • The AJAX request should return a successful response (usually a partial HTML block for the custom fields table).
  • When viewing the post, the HTML source should contain: <div>...<script>alert(document.domain)</script>...</div>.
  • A browser alert box should appear showing the domain.

8. Verification Steps

  1. Database Check:
    wp post meta get [POST_ID] CODE1
    
    Confirm the output is the raw payload <script>alert(document.domain)</script> and has not been stripped to empty or sanitized.
  2. Frontend Check:
    http_request GET http://vulnerable-wp.local/?p=[POST_ID]
    
    Check the response body for the presence of the unescaped script tag.

9. Alternative Approaches

  • Identifier Variation: The readme.txt mentions identifiers could be % (e.g., %CODE1%). If {{CODE1}} fails, try %CODE1%.
  • Keyword Variation: If the site has changed the keyword identifier in settings, use wp option get artiss_code_embed to find the keyword_ident value.
  • Global Embeds: The plugin supports global embeds. An attacker might try to set meta on a "global" post if configured, potentially affecting all pages on the site.
  • XSS to RCE: In a real-world scenario, the payload would be a script to create a new Administrator user via the /wp-admin/user-new.php CSRF.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Code Embed plugin for WordPress is vulnerable to Stored Cross-Site Scripting via custom field meta values because it only performs sanitization during the 'save_post' hook. Authenticated attackers with Contributor-level access can bypass this by using the WordPress AJAX 'add-meta' endpoint to inject malicious scripts into custom fields, which the plugin then renders without escaping.

Vulnerable Code

/* includes/secure.php lines 32-62 */
function sec_check_post_fields( $post_id, $post, $update ) {

	$options = get_option( 'artiss_code_embed' );

	// Check if it's an autosave or if the current user has the 'unfiltered_html' capability.
	if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || ( current_user_can( 'unfiltered_html' ) ) ) {
		return;
	}

	// Fetch all post meta (custom fields) associated with the post.
	$custom_fields = get_post_meta( $post_id );

	// If there are custom fields, read through them.
	if ( ! empty( $custom_fields ) ) {

		foreach ( $custom_fields as $key => $value ) {

			// Check to see if any begining with this plugin's prefix.
			if ( substr( $key, 0, strlen( $options['keyword_ident'] ) ) === $options['keyword_ident'] ) {

				// Filter the meta value.
				$new_value = wp_kses_post( $value[0] );

				// Now write out the new value.
				update_post_meta( $post_id, $key, $new_value );
			}
		}
	}
}

add_action( 'save_post', 'sec_check_post_fields', 10, 3 );

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/simple-embed-code/2.5.1/includes/secure.php /home/deploy/wp-safety.org/data/plugin-versions/simple-embed-code/2.5.2/includes/secure.php
--- /home/deploy/wp-safety.org/data/plugin-versions/simple-embed-code/2.5.1/includes/secure.php	2024-11-05 18:57:12.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/simple-embed-code/2.5.2/includes/secure.php	2026-03-15 10:00:20.000000000 +0000
@@ -1,8 +1,8 @@
 <?php
 /**
- * Meta boxes
+ * Security
  *
- * Functions related to meta-box management.
+ * Functions related to sanitizing Code Embed meta values.
  *
  * @package simple-embed-code
  */
@@ -14,42 +14,58 @@
 }
 
 /**
- * Remove Custom Fields
+ * Sanitize Code Embed meta on every write
  *
- * Remove the custom field meta boxes if the user doesn't have the unfiltered HTML permissions.
+ * Filter that fires on every call to update_metadata / add_metadata — including the
+ * wp_ajax_add_meta AJAX handler and the REST API, not just save_post.
  *
- * @param    string  $post_id   Post ID.
- * @param    string  $post      Post object.
- * @param    boolean $update    Whether this is an existing post being updated.
+ * @param mixed  $check      Null to allow the operation, non-null to short-circuit.
+ * @param int    $object_id  Post ID.
+ * @param string $meta_key   Meta key being written.
+ * @param mixed  $meta_value Meta value being written.
+ * @return mixed             Null (to proceed with the write).
  */
-function sec_check_post_fields( $post_id, $post, $update ) {
+function sec_sanitize_meta_on_write( $check, $object_id, $meta_key, $meta_value ) {
+
+	// Allow admins / editors with unfiltered_html to write without restriction.
+	if ( current_user_can( 'unfiltered_html' ) ) {
+		return $check;
+	}
 
 	$options = get_option( 'artiss_code_embed' );
 
-	// Check if it's an autosave or if the current user has the 'unfiltered_html' capability.
-	if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || ( current_user_can( 'unfiltered_html' ) ) ) {
-		return;
+	if ( ! is_array( $options ) || empty( $options['keyword_ident'] ) ) {
+		return $check;
 	}
 
-	// Fetch all post meta (custom fields) associated with the post.
-	$custom_fields = get_post_meta( $post_id );
+	$prefix = $options['keyword_ident'];
 
-	// If there are custom fields, read through them.
-	if ( ! empty( $custom_fields ) ) {
+	// Only act on meta keys that belong to this plugin.
+	if ( substr( $meta_key, 0, strlen( $prefix ) ) !== $prefix ) {
+		return $check;
+	}
 
-		foreach ( $custom_fields as $key => $value ) {
+	// Strip dangerous markup while preserving safe HTML.
+	$clean = wp_kses_post( $meta_value );
 
-			// Check to see if any begining with this plugin's prefix.
-			if ( substr( $key, 0, strlen( $options['keyword_ident'] ) ) === $options['keyword_ident'] ) {
+	if ( $clean === $meta_value ) {
+		// Value is already clean — let the normal write proceed.
+		return $check;
+	}
 
-				// Filter the meta value.
-				$new_value = wp_kses_post( $value[0] );
+	// The value was dirty. Remove this filter temporarily to avoid infinite recursion, write the sanitized value ourselves, then
+	// re-add the filter and short-circuit the original write.
+	remove_filter( 'update_post_metadata', 'sec_sanitize_meta_on_write', 10 );
+	remove_filter( 'add_post_metadata', 'sec_sanitize_meta_on_write', 10 );
 
-				// Now write out the new value.
-				update_post_meta( $post_id, $key, $new_value );
-			}
-		}
-	}
+	update_post_meta( $object_id, $meta_key, $clean );
+
+	add_filter( 'update_post_metadata', 'sec_sanitize_meta_on_write', 10, 4 );
+	add_filter( 'add_post_metadata', 'sec_sanitize_meta_on_write', 10, 4 );
+
+	// Return a non-null value to short-circuit the original (unsanitized) write.
+	return true;
 }
 
-add_action( 'save_post', 'sec_check_post_fields', 10, 3 );
+add_filter( 'update_post_metadata', 'sec_sanitize_meta_on_write', 10, 4 );
+add_filter( 'add_post_metadata', 'sec_sanitize_meta_on_write', 10, 4 );

Exploit Outline

1. Authenticate as a user with Contributor level permissions or higher. 2. Create a post and include a placeholder for a custom field in the content (e.g., {{CODE1}}), then publish it. 3. Navigate to the post editor page to obtain a valid WordPress AJAX nonce for the 'add-meta' action (typically found in the '_ajax_nonce-add-meta' hidden input field). 4. Send a POST request to /wp-admin/admin-ajax.php with the following parameters: action=add-meta, metakeyinput=CODE1, metavalue=<script>alert(1)</script>, and the extracted nonce. 5. Because the WordPress AJAX 'add-meta' action bypasses the 'save_post' hook, the payload is stored in the database without being sanitized by the plugin's wp_kses_post filter. 6. View the published post; the plugin will replace {{CODE1}} with the unsanitized script payload, resulting in execution in the victim's browser.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.