Code Embed <= 2.5.1 - Authenticated (Contributor+) Stored Cross-Site Scripting via Custom Fields
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:NTechnical Details
<=2.5.1What Changed in the Fix
Changes introduced in v2.5.2
Source Code
WordPress.org SVN# 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-metaAJAX action for those posts. - Preconditions:
- The attacker must have a post they are permitted to edit.
- The post must contain a "placeholder" identifier (e.g.,
{{CODE1}}) that the plugin will replace with the malicious meta value.
3. Code Flow
- Injection:
- The attacker sends a POST request to
admin-ajax.phpwithaction=add-meta. - WordPress core executes
wp_ajax_add_meta(), which callsadd_post_meta()orupdate_post_meta(). - The
save_posthook is not triggered by this AJAX action. sec_check_post_fields()inincludes/secure.phpis never called, sowp_kses_post()is bypassed.
- The attacker sends a POST request to
- Storage: The raw payload (e.g.,
<script>alert(1)</script>) is stored in thewp_postmetatable. - Execution:
- A victim (e.g., Administrator) views the post.
- The plugin (likely via
includes/add-embeds.php, referred to asce_filterin 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.
- Identify Post: Create a post as a Contributor.
- Navigate to Editor: Use
browser_navigateto go to the edit page for that post:wp-admin/post.php?post=POST_ID&action=edit. - Extract Nonce: The nonce for adding meta is stored in a hidden input field with the ID
_ajax_nonce-add-meta. - JavaScript Execution:
browser_eval("document.getElementById('_ajax_nonce-add-meta').value") - Alternative (Global): If the hidden input is missing (due to Gutenberg), the nonce is often found in the
wp-listsinitialization or the_wpnonceparameter of other meta-related requests. However, for most WordPress versions,_ajax_nonce-add-metaremains the standard.
5. Exploitation Strategy
- Setup User: Create a Contributor user (
contributor/password). - Setup Content:
- As the Contributor, create a post with the title "XSS Test" and content
{{CODE1}}. - Capture the
POST_ID.
- As the Contributor, create a post with the title "XSS Test" and content
- Acquire Nonce:
- Log in as the Contributor.
- Navigate to the edit screen for
POST_ID. - Extract the
add-metanonce using the strategy in Section 4.
- Inject Payload:
- Use
http_requestto calladmin-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-metapost_id:POST_IDmetakeyselect:#NONE#metakeyinput:CODE1metavalue:<script>alert(document.domain)</script>_ajax_nonce-add-meta:[EXTRACTED_NONCE]
- Use
- 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
- Title:
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
- Database Check:
Confirm the output is the raw payloadwp post meta get [POST_ID] CODE1<script>alert(document.domain)</script>and has not been stripped to empty or sanitized. - Frontend Check:
Check the response body for the presence of the unescaped script tag.http_request GET http://vulnerable-wp.local/?p=[POST_ID]
9. Alternative Approaches
- Identifier Variation: The
readme.txtmentions 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_embedto find thekeyword_identvalue. - 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.phpCSRF.
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
@@ -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.