-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## What `register_post_type('wpcode', ...)` actually registers\n\nThe smoking gun is thirty-three lines:\n\n```php\nadd_action( 'init', 'wpcode_register_post_type', - 5 );\n\nfunction wpcode_register_post_type() {\n\tregister_post_type(\n\t\t'wpcode',\n\t\tarray(\n\t\t\t'public' => false,\n\t\t\t'show_ui' => false,\n\t\t\t'can_export' => false,\n\t\t)\n\t);\n}\n```\n\nWordPress 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`.\n\nA 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.\n\n## The plugin ships the safe alternative. The post type was registered without it.\n\n`includes/capabilities.php` defines five custom capabilities the plugin uses to gate sensitive actions:\n\n```php\nfunction wpcode_custom_capabilities() {\n\treturn array(\n\t\t'wpcode_edit_text_snippets' => array( ... ),\n\t\t'wpcode_edit_html_snippets' => array( ... ),\n\t\t'wpcode_edit_php_snippets' => array( ... ),\n\t\t'wpcode_manage_conversion_pixels' => array( ... ),\n\t\t'wpcode_file_editor' => array( ... ),\n\t);\n}\n```\n\n`includes/class-wpcode-capabilities.php` grants two of them at activation time:\n\n```php\npublic static function add_capabilities() {\n\tforeach ( self::get_roles() as $role ) {\n\t\tif ( $role->has_cap( 'manage_options' ) ) {\n\t\t\t$role->add_cap( 'wpcode_edit_snippets' );\n\t\t\t$role->add_cap( 'wpcode_activate_snippets' );\n\t\t}\n\t}\n}\n```\n\nOnly 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.\n\nThe 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.\n\nThe `map_meta_cap` filter callback the plugin does install is narrower than its name suggests:\n\n```php\nfunction wpcode_map_meta_cap( $caps, $cap, $user_id, $args ) {\n\t$custom_capabilities = array(\n\t\t'wpcode_edit_php_snippets',\n\t\t'wpcode_edit_html_snippets',\n\t\t'wpcode_manage_conversion_pixels',\n\t\t'wpcode_file_editor',\n\t\t'wpcode_manage_settings',\n\t);\n\tif ( in_array( $cap, $custom_capabilities, true ) ) {\n\t\treturn array( 'wpcode_edit_snippets' );\n\t}\n\treturn $caps;\n}\n```\n\nWhen 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.\n\n## The chain\n\nThe PoC in funixone's repository is six labelled steps. Three of them carry the bug.\n\n`wp.getUsersBlogs` against `/xmlrpc.php` authenticates an Author. `wp.newPost` follows:\n\n```python\npost_content = {\n \"post_type\": \"wpcode\",\n \"post_title\": \"Security Test Snippet\",\n \"post_content\": payload,\n \"post_status\": \"publish\",\n \"terms_names\": {\n \"wpcode_type\": [\"php\"],\n },\n}\nxmlrpc_request(xmlrpc_url, \"wp.newPost\", [1, username, password, post_content])\n```\n\nWordPress'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.\n\nThe render path the shortcode handler walks is mechanical. From `includes/shortcode.php`:\n\n```php\nfunction wpcode_shortcode_handler( $args, $content, $tag ) {\n\t// ...\n\t$snippet = new WPCode_Snippet( absint( $atts['id'] ) );\n\n\tif ( ! $snippet->is_active() ) {\n\t\treturn '';\n\t}\n\t// ...\n\treturn wpcode()->execute->get_snippet_output( $snippet );\n}\n```\n\n`is_active()` reads one field:\n\n```php\npublic function is_active() {\n\tif ( ! isset( $this->active ) ) {\n\t\t$this->active = isset( $this->post_data->post_status ) && 'publish' === $this->post_data->post_status;\n\t}\n\treturn $this->active;\n}\n```\n\nThe 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.\n\n`get_snippet_output` dispatches by `code_type`. The PHP executor's `prepare_snippet_output` from `includes/execute/class-wpcode-snippet-execute-php.php`:\n\n```php\nprotected function prepare_snippet_output() {\n\t$code = $this->get_snippet_code();\n\t// ...\n\treturn wpcode()->execute->safe_execute_php( $code, $this->snippet );\n}\n```\n\n`safe_execute_php` runs `is_code_not_allowed`, then `run_eval`:\n\n```php\npublic function run_eval( $code ) {\n\tif ( ! empty( $this->snippet_executed->attributes ) ) {\n\t\textract( $this->snippet_executed->attributes, EXTR_SKIP );\n\t}\n\teval( $code );\n}\n```\n\n`is_code_not_allowed` is the only filter between Author input and `eval`:\n\n```php\npublic static function is_code_not_allowed( $code ) {\n\tif ( preg_match_all( '/(base64_decode|error_reporting|ini_set|eval)\\s*\\(/i', $code, $matches ) ) {\n\t\tif ( count( $matches[0] ) > 5 ) {\n\t\t\treturn true;\n\t\t}\n\t}\n\tif ( preg_match( '/dns_get_record/i', $code ) ) {\n\t\treturn true;\n\t}\n\treturn false;\n}\n```\n\nThe 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.\n\nThe Author then creates a second post: a regular `post` whose `post_content` is `[wpcode id=]`, again gated by `publish_posts`, again satisfied. The PoC issues `GET /?p=` and the shortcode renders inline, which is to say the `eval` runs.\n\n## The developer guarded one inbound path\n\n`includes/post-type.php` registers three `add_action`/`add_filter` calls beside the CPT registration. One of them is this:\n\n```php\nadd_filter( 'wp_import_post_data_raw', 'wpcode_prevent_wp_importer_import' );\n\nfunction wpcode_prevent_wp_importer_import( $post_data ) {\n\tif ( 'wpcode' === $post_data['post_type'] ) {\n\t\t$post_data['post_type'] = '';\n\t\t$post_data['post_content'] = '';\n\t}\n\treturn $post_data;\n}\n```\n\n`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.\n\n`wp.newPost` is also external content. The XML body of the XML-RPC request carries `post_type=wpcode` and `post_content=`. 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.\n\nThe 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.\n\n## Why does this exist\n\nThis is [safe-mode-was-opt-in](/patterns/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 ``) and accepts the defaults inherits the WordPress post-edit capability family for its code-execution sink.\n\nPrior exhibits on this pattern sit at library and consumer boundaries: [EspoCRM's Markdown defaultTransform](/posts/espocrm-cve-2026-33657-triple-brace-was-correct), where the parser's `no_markup` and `no_entities` flags existed and `defaultTransform` did not set them; [DeepSeek-TUI's `unwrap_or(true)`](/posts/deepseek-tui-cve-2026-45374-defaults-were-doing-the-work), which distributed shell access to anything that did not explicitly opt out; [vm2's Buffer.alloc limit](/posts/vm2-cve-2026-44004-fix-is-off-by-default), 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.\n\nWPCode 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](/posts/everest-forms-cve-2026-3296-wrapper-was-in-the-file) 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'`.\n\nThe 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.\n\nPoC: [funixone/EXPLOIT-CVE-2026-8832](https://github.com/funixone/EXPLOIT-CVE-2026-8832)","closing_line":"The custom capability system gates the UI. The post type does not gate the API.","hook_md":"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`.","post_id":509,"slug":"wpcode-cve-2026-8832-custom-caps-not-on-the-cpt","title":"CVE-2026-8832: WPCode Ships A Custom Capability System. The Post Type Was Registered Without It.","type":"initial","unreadable_sentence":"The author who wrote `wpcode_edit_php_snippets` did not write `'capability_type' => 'wpcode'`."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCahjHgwAKCRDeZjl4jgkQ JrhxAQDQaoxlSiSIq3s1zGi+uwDmi2KfXaQc1ZydE7+FVceuuQD5AXJ8QNcsQAId TuKE4dSI2/my9KlleTAj6YrSThq3og8= =h3GL -----END PGP SIGNATURE-----