//nefariousplan

CVE-2026-8832: WPCode Ships A Custom Capability System. The Post Type Was Registered Without It.

pattern

cve

proof of concept

WPCode 2.3.5's includes/post-type.php calls register_post_type('wpcode', ...) with three keys: public, show_ui, can_export. capability_type is not one of them. The plugin also ships its own custom capability system in includes/capabilities.php: wpcode_edit_php_snippets, wpcode_edit_snippets, granted at install time only to roles that already have manage_options. The CPT registration does not reference any of them. CVE-2026-8832 is what happens when an Author calls wp.newPost with post_type=wpcode.

What register_post_type('wpcode', ...) actually registers

The smoking gun is thirty-three lines:

add_action( 'init', 'wpcode_register_post_type', - 5 );

function wpcode_register_post_type() {
	register_post_type(
		'wpcode',
		array(
			'public'     => false,
			'show_ui'    => false,
			'can_export' => false,
		)
	);
}

WordPress core fills in the rest of the options array from defaults. The relevant default for this CVE is capability_type => 'post'. From that one string, WP_Post_Type::set_capabilities() derives the capability map: edit_post, read_post, delete_post, edit_posts, edit_others_posts, publish_posts, read_private_posts, delete_posts, delete_private_posts, delete_published_posts, delete_others_posts, edit_private_posts, edit_published_posts, create_posts.

A WordPress Author role holds edit_posts, publish_posts, delete_posts, and the published-post variants. Under the capability map derived from capability_type => 'post', those same capabilities authorise creating, publishing, and deleting wpcode posts. The post type's name is wpcode. The capability the REST and XML-RPC paths check is publish_posts. The plugin name and the gating capability never meet.

The plugin ships the safe alternative. The post type was registered without it.

includes/capabilities.php defines five custom capabilities the plugin uses to gate sensitive actions:

function wpcode_custom_capabilities() {
	return array(
		'wpcode_edit_text_snippets'       => array( ... ),
		'wpcode_edit_html_snippets'       => array( ... ),
		'wpcode_edit_php_snippets'        => array( ... ),
		'wpcode_manage_conversion_pixels' => array( ... ),
		'wpcode_file_editor'              => array( ... ),
	);
}

includes/class-wpcode-capabilities.php grants two of them at activation time:

public static function add_capabilities() {
	foreach ( self::get_roles() as $role ) {
		if ( $role->has_cap( 'manage_options' ) ) {
			$role->add_cap( 'wpcode_edit_snippets' );
			$role->add_cap( 'wpcode_activate_snippets' );
		}
	}
}

Only roles that already carry manage_options (WordPress's administrator capability) receive wpcode_edit_snippets and wpcode_activate_snippets. The plugin's admin pages then check current_user_can( 'wpcode_edit_php_snippets' ) and similar before rendering the snippet editor. Through the UI, an Author is locked out.

The CPT registration in includes/post-type.php does not pass a capabilities array, does not set capability_type, and does not set map_meta_cap. The register_post_type capabilities option is the seam where the plugin would bind its custom capability strings into the per-CPT slots WordPress derives. It is unused.

The map_meta_cap filter callback the plugin does install is narrower than its name suggests:

function wpcode_map_meta_cap( $caps, $cap, $user_id, $args ) {
	$custom_capabilities = array(
		'wpcode_edit_php_snippets',
		'wpcode_edit_html_snippets',
		'wpcode_manage_conversion_pixels',
		'wpcode_file_editor',
		'wpcode_manage_settings',
	);
	if ( in_array( $cap, $custom_capabilities, true ) ) {
		return array( 'wpcode_edit_snippets' );
	}
	return $caps;
}

When WordPress checks one of the five granular capabilities, the filter rewrites it to wpcode_edit_snippets. The filter never sees publish_posts, because publish_posts is not on the custom list. The XML-RPC and REST create paths check publish_posts, find it on the Author, and short-circuit before any plugin capability is consulted.

The chain

The PoC in funixone's repository is six labelled steps. Three of them carry the bug.

wp.getUsersBlogs against /xmlrpc.php authenticates an Author. wp.newPost follows:

post_content = {
    "post_type": "wpcode",
    "post_title": "Security Test Snippet",
    "post_content": payload,
    "post_status": "publish",
    "terms_names": {
        "wpcode_type": ["php"],
    },
}
xmlrpc_request(xmlrpc_url, "wp.newPost", [1, username, password, post_content])

WordPress's wp.newPost calls wp_insert_post after current_user_can( 'publish_posts' ). The Author has publish_posts. The post is created as post_type => wpcode, post_status => 'publish', with the wpcode_type taxonomy term 'php' attached via terms_names, which auto-creates the term if it does not already exist.

The render path the shortcode handler walks is mechanical. From includes/shortcode.php:

function wpcode_shortcode_handler( $args, $content, $tag ) {
	// ...
	$snippet = new WPCode_Snippet( absint( $atts['id'] ) );

	if ( ! $snippet->is_active() ) {
		return '';
	}
	// ...
	return wpcode()->execute->get_snippet_output( $snippet );
}

is_active() reads one field:

public function is_active() {
	if ( ! isset( $this->active ) ) {
		$this->active = isset( $this->post_data->post_status ) && 'publish' === $this->post_data->post_status;
	}
	return $this->active;
}

The Author already set post_status => 'publish' through XML-RPC. The activation gate is the WordPress post status. There is no separate wpcode_activate_snippets check at render time.

get_snippet_output dispatches by code_type. The PHP executor's prepare_snippet_output from includes/execute/class-wpcode-snippet-execute-php.php:

protected function prepare_snippet_output() {
	$code = $this->get_snippet_code();
	// ...
	return wpcode()->execute->safe_execute_php( $code, $this->snippet );
}

safe_execute_php runs is_code_not_allowed, then run_eval:

public function run_eval( $code ) {
	if ( ! empty( $this->snippet_executed->attributes ) ) {
		extract( $this->snippet_executed->attributes, EXTR_SKIP );
	}
	eval( $code );
}

is_code_not_allowed is the only filter between Author input and eval:

public static function is_code_not_allowed( $code ) {
	if ( preg_match_all( '/(base64_decode|error_reporting|ini_set|eval)\s*\(/i', $code, $matches ) ) {
		if ( count( $matches[0] ) > 5 ) {
			return true;
		}
	}
	if ( preg_match( '/dns_get_record/i', $code ) ) {
		return true;
	}
	return false;
}

The threshold for the four named functions is count > 5. Five base64_decode( calls pass. dns_get_record is the only token blocked outright. The PHP language's entire surface beyond those four function names is the throughput.

The Author then creates a second post: a regular post whose post_content is [wpcode id=<snippet_id>], again gated by publish_posts, again satisfied. The PoC issues GET /?p=<trigger_post_id> and the shortcode renders inline, which is to say the eval runs.

The developer guarded one inbound path

includes/post-type.php registers three add_action/add_filter calls beside the CPT registration. One of them is this:

add_filter( 'wp_import_post_data_raw', 'wpcode_prevent_wp_importer_import' );

function wpcode_prevent_wp_importer_import( $post_data ) {
	if ( 'wpcode' === $post_data['post_type'] ) {
		$post_data['post_type']    = '';
		$post_data['post_content'] = '';
	}
	return $post_data;
}

wp_import_post_data_raw is the WordPress Importer plugin's hook. When an administrator runs the importer against a WXR file that contains a wpcode post, this filter zeros the post type and content before the importer ingests it. The threat model encoded in the filter is correct: a WXR file is external content, and an administrator who imports it should not thereby inherit arbitrary PHP from whoever authored the file.

wp.newPost is also external content. The XML body of the XML-RPC request carries post_type=wpcode and post_content=<PHP payload>. There is no wp_xmlrpc_new_post_data_raw filter for the same threat. There is no rest_pre_insert_wpcode callback for the WordPress REST API either. The author guarded the WP Importer path. They did not guard the two paths a publish_posts-bearing user reaches without administrator credentials.

The CPT capability binding would have closed every inbound route at once. Setting 'capability_type' => 'wpcode', 'map_meta_cap' => true, and a 'capabilities' array that points publish_posts and edit_posts at wpcode_edit_snippets would have forced WordPress's REST, XML-RPC, and core wp_insert_post paths to check the same custom capability the plugin already grants only to administrators. The check exists. The binding does not.

Why does this exist

This is safe-mode-was-opt-in. register_post_type is the library function. capability_type and capabilities are the security-relevant options with unsafe defaults. Any plugin that wires the resulting CPT into a sink that interprets post_content as code (PHP via eval, raw HTML rendered inline, JavaScript appended to <head>) and accepts the defaults inherits the WordPress post-edit capability family for its code-execution sink.

Prior exhibits on this pattern sit at library and consumer boundaries: EspoCRM's Markdown defaultTransform, where the parser's no_markup and no_entities flags existed and defaultTransform did not set them; DeepSeek-TUI's unwrap_or(true), which distributed shell access to anything that did not explicitly opt out; vm2's Buffer.alloc limit, whose regression test asserted that the default-configured sandbox could still allocate 64 MB host buffers. The library exposed a safety option; the consumer reached for the factory or static path and inherited the default.

WPCode collapses the distance. The custom capabilities are in includes/capabilities.php. The grant logic is in includes/class-wpcode-capabilities.php. The CPT registration that ignores them is in includes/post-type.php. The filter that proves the developer understood untrusted inbound content should not become a wpcode post is in the same file as the CPT registration, sixteen lines below it. Three files, one plugin, one author. Everest Forms 3.4.3 was the prior shortest distance on this pattern: the safe wrapper and the unsafe primitive were fifty-four lines apart inside the same foreach. WPCode is shorter than that. The author who wrote wpcode_edit_php_snippets did not write 'capability_type' => 'wpcode'.

The author considered visibility. The three keys that did make it into the register_post_type call are public => false, show_ui => false, can_export => false. The decision to hide the CPT from the admin UI is in the array. The decision to bind it to the custom capability family the same plugin defines is not. The plugin opted out of the UI and into the post-edit permission model.

PoC: funixone/EXPLOIT-CVE-2026-8832

The custom capability system gates the UI. The post type does not gate the API.