The Exploit
An unauthenticated attacker can modify any WooCommerce order's payment registration ID by sending a single AJAX request with no authentication token.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: vulnerable-woo-site.local
Content-Type: application/x-www-form-urlencoded
Content-Length: 67
action=peachCardUpdateOrder&cardID=attacker_card_id_123&orderID=42
The server responds with 1 (indicating successful post meta update). An attacker observes the HTTP 200 response containing the post meta update result, confirming that order 42 now has its payment registration ID overwritten to attacker_card_id_123. On the next payment processing attempt, the order will attempt to charge the attacker's saved card instead of the legitimate customer's card.
The vulnerability exists because the AJAX handler peachCardUpdateOrder_funct() is registered with wp_ajax_nopriv_, allowing unauthenticated access, and performs no nonce verification, capability check, or parameter validation before updating order metadata.
What the Patch Did
Before:
function peachCardUpdateOrder_funct(){
$cardID = $_REQUEST['cardID'];
$orderID = $_REQUEST['orderID'];
$new_reg_id = update_post_meta( $orderID, 'payment_registration_id', $cardID );
echo $new_reg_id;
die();
}
add_action('wp_ajax_nopriv_peachCardUpdateOrder', 'peachCardUpdateOrder_funct');
After:
function peachCardUpdateOrder_funct() {
check_ajax_referer( 'ajax-nonce', 'ajax_nonce' );
if ( ! is_user_logged_in() ) {
wp_die(
esc_html__( 'Unauthorized', 'woocommerce-gateway-peach-payments' ),
'',
array( 'response' => 403 )
);
}
$card_id = isset( $_REQUEST['cardID'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['cardID'] ) ) : '';
$order_id = isset( $_REQUEST['orderID'] ) ? absint( $_REQUEST['orderID'] ) : 0;
if ( ! $card_id || ! $order_id ) {
wp_die(
esc_html__( 'Invalid request parameters', 'woocommerce-gateway-peach-payments' ),
'',
array( 'response' => 400 )
);
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
wp_die(
esc_html__( 'Invalid order', 'woocommerce-gateway-peach-payments' ),
'',
array( 'response' => 404 )
);
}
$current_user_id = get_current_user_id();
$order_user_id = (int) $order->get_user_id();
if ( $order_user_id && $order_user_id !== $current_user_id ) {
wp_die(
esc_html__( 'Unauthorized', 'woocommerce-gateway-peach-payments' ),
'',
array( 'response' => 403 )
);
}
$new_reg_id = update_post_meta( $order_id, 'payment_registration_id', $card_id );
echo $new_reg_id;
die();
}
add_action('wp_ajax_peachCardUpdateOrder', 'peachCardUpdateOrder_funct');
The patch added three defense-in-depth security controls: (1) CSRF protection via check_ajax_referer( 'ajax-nonce', 'ajax_nonce' ), which validates a server-issued nonce that an attacker cannot forge without executing JavaScript in the victim's browser; (2) authentication enforcement via is_user_logged_in(), which blocks completely unauthenticated requests; and (3) authorization checks comparing the order's owner user ID against the current logged-in user, preventing one logged-in user from modifying another user's orders. The handler was also moved from wp_ajax_nopriv_ to wp_ajax_, removing the explicit unauthenticated entry point. Input was hardened with sanitize_text_field() and absint(), and missing parameter checks guard against null/empty values.
Root Cause
CWE-352 (Cross-Site Request Forgery) and CWE-862 (Missing Authorization) combined. The AJAX handler peachCardUpdateOrder_funct() accepts user-controlled parameters cardID and orderID from $_REQUEST without verifying a CSRF token, without checking the user's authentication status, and without validating that the requesting user owns the order being modified. The dataflow is direct: $_REQUEST['cardID'] → update_post_meta( $orderID, 'payment_registration_id', $cardID ), crossing the trust boundary from untrusted HTTP input to persistent order metadata with no gate. Registration of the handler with wp_ajax_nopriv_ signals WordPress to invoke the function even when is_user_logged_in() returns false, explicitly permitting unauthenticated execution.
Why It Works
The load-bearing line is check_ajax_referer( 'ajax-nonce', 'ajax_nonce' ). Removing it leaves the handler still vulnerable because an attacker can forge a simple POST request from any origin; the nonce is cryptographically bound to the logged-in user's session and expires after 24 hours. However, the other lines are not redundant: is_user_logged_in() prevents unauthenticated access entirely (the primary threat model); the order user ID check if ( $order_user_id && $order_user_id !== $current_user_id ) enforces that a customer cannot modify another customer's orders; and absint() on $order_id prevents injection of non-numeric values that could lead to unexpected query behavior. The engineer also removed the order from wp_ajax_nopriv_, which is a companion control ensuring the hook fires only for authenticated users. Together, these layers form a defense: nonce (CSRF), authentication (unauthenticated block), ownership check (authorization), type-safety (integer coercion), and registration scope (privileged hook only).
Hardening Checklist
- Always register AJAX handlers with
wp_ajax_(authenticated only) unless there is an explicit business reason forwp_ajax_nopriv_, and document that reason. Review everywp_ajax_nopriv_registration in the codebase weekly. - Call
check_ajax_referer( 'nonce-name', 'nonce-param' )at the start of every AJAX handler, and include the nonce in the front-end request viawp_localize_script()or similar. - Perform input validation and type-coercion for all parameters before use:
absint()for numeric IDs,sanitize_text_field()for strings,rest_sanitize_boolean()for booleans. Never assume$_REQUESTor$_POSTcontain safe data. - When an AJAX action modifies a resource (order, post, user), verify that the current user owns or has capability to modify that resource. Use
current_user_can( 'manage_posts' )or resource-ownership checks; do not rely on opaque user roles alone. - Audit all post meta updates via AJAX by grepping the codebase for
update_post_meta(and cross-referencing nearby AJAX registrations; look for handlers lackingcheck_ajax_referer()or authentication guards.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-67942