Atomic Edge analysis of CVE-2025-14461:
The Xendit Payment plugin for WordPress, versions up to and including 6.0.2, contains a critical missing authorization vulnerability. This flaw allows unauthenticated attackers to arbitrarily update WooCommerce order statuses to ‘PAID’ or ‘SETTLED’ via a publicly accessible callback endpoint. The vulnerability stems from the plugin’s failure to authenticate or cryptographically verify payment notification requests from the Xendit gateway.
Atomic Edge research identifies the root cause in the plugin’s callback handler function `wc_xendit_callback`. This function is registered as a public WordPress action hook and processes incoming payment notifications. The vulnerable code resides in the main plugin file `woocommerce-xendit-pg.php` at lines 270-272. The handler directly reads raw JSON input from `php://input` and passes it to `WC_Xendit_Sanitized_Webhook::map_and_sanitize_invoice_webhook` without performing any authentication checks, signature verification, or origin validation. The callback endpoint accepts POST requests containing JSON payloads with `external_id` and `status` parameters.
Exploitation requires an attacker to send a crafted POST request to the WordPress site’s `/wc-api/wc_xendit_callback` endpoint. The JSON payload must contain an `external_id` parameter matching a WooCommerce order ID pattern (typically sequential integers) and a `status` parameter set to ‘PAID’ or ‘SETTLED’. Attackers can enumerate order IDs through brute-force or information disclosure. No authentication tokens, API keys, or cryptographic signatures are required in vulnerable versions. The attack vector is network-accessible and requires no prior access to the target system.
The patch in version 6.1.0 introduces signature verification for all incoming callback requests. The fix adds a new class `WC_Xendit_Signature_Verifier` in `libs/helpers/class-wc-xendit-signature-verifier.php`. This class implements ECDSA signature verification using P-384 public keys defined in `constants.php`. The callback handler now calls `WC_Xendit_Signature_Verifier::verify_signature()` with the `callback_id`, `invoice_id`, `status`, and `signature` parameters extracted from the request. If verification fails, the handler returns HTTP 401 and exits. The patch also adds new fields (`callback_id`, `version`, `signature`) to the sanitized webhook data mapping in `class-wc-sanitized-webhook.php`.
Successful exploitation results in financial loss and inventory depletion. Attackers can mark any order as paid without actual payment processing. This allows fraudulent acquisition of digital goods, physical products, or services. Merchants face revenue loss, inventory mismanagement, and potential chargeback liabilities. The vulnerability also enables order status manipulation that could bypass fraud detection systems and fulfillment workflows.
--- a/woo-xendit-virtual-accounts/libs/autoload.php
+++ b/woo-xendit-virtual-accounts/libs/autoload.php
@@ -1,5 +1,7 @@
<?php
+if ( ! defined( 'ABSPATH' ) ) exit;
+
/**
* You only need this file if you are not using composer.
* Why are you not using composer?
--- a/woo-xendit-virtual-accounts/libs/constants/constants.php
+++ b/woo-xendit-virtual-accounts/libs/constants/constants.php
@@ -8,4 +8,9 @@
define("XENDIT_DASHBOARD_URL_PRODUCTION", "https://dashboard.xendit.co");
define("XENDIT_OAUTH_CLIENT_ID_PRODUCTION", "906468d0-fefd-4179-ba4e-407ef194ab85");
define("XENDIT_OAUTH_REDIRECTION_URL_PATH", "/tpi/authorization/xendit/redirect/v2");
-define("XENDIT_INTEGRATION_APP_ID_PRODUCTION", "61e12bf5bfd5ff82ab9d6d15");
No newline at end of file
+define("XENDIT_INTEGRATION_APP_ID_PRODUCTION", "61e12bf5bfd5ff82ab9d6d15");
+
+define("INTEGRATION_NOTIFICATION_SIGNATURE_PUBLIC_KEYS", [
+ "-----BEGIN PUBLIC KEY-----nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEqaSYgkq632WHte+NOc1QqCbDlYC7sB1tlDUf/8EBDApTwsB5k4pB9h5BDCG/8xOQhwl2dr1nNoOarD4uCNARgByQJ+S91iyfJqLp+JuQF1z3HKu51f1biV80RLqcaTPon-----END PUBLIC KEY-----",
+ "-----BEGIN PUBLIC KEY-----nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE5Zg9WXeJw0GbEYZREVI+WOfPE4AbHCN8XfOsPFYge7EOrQsU1Vh24ZLzIOocQbRj76r1Kjp8JCZJM5iT2++RkCmIaY/iV/lxKaOL9/jINMQPqYeKFLbrliCejSEg8EE/n-----END PUBLIC KEY-----"
+]);
No newline at end of file
--- a/woo-xendit-virtual-accounts/libs/helpers/class-wc-sanitized-webhook.php
+++ b/woo-xendit-virtual-accounts/libs/helpers/class-wc-sanitized-webhook.php
@@ -20,7 +20,10 @@
'platform' => self::safe_sanitize($data, 'platform'),
'invoice_id' => self::safe_sanitize($data, 'invoice_id'),
'business_id' => self::safe_sanitize($data, 'business_id'),
- 'external_id' => self::safe_sanitize($data, 'external_id')
+ 'external_id' => self::safe_sanitize($data, 'external_id'),
+ 'callback_id' => self::safe_sanitize($data, 'callback_id'),
+ 'version' => self::safe_sanitize($data, 'version'),
+ 'signature' => self::safe_sanitize($data, 'signature')
);
if (!empty($data->description)) {
--- a/woo-xendit-virtual-accounts/libs/helpers/class-wc-xendit-signature-verifier.php
+++ b/woo-xendit-virtual-accounts/libs/helpers/class-wc-xendit-signature-verifier.php
@@ -0,0 +1,135 @@
+<?php
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Xendit Webhook Signature Verifier
+ *
+ * @since 6.1.0
+ */
+class WC_Xendit_Signature_Verifier
+{
+ public static function verify_signature($callback_id, $invoice_id, $status, $signature, $version = 1)
+ {
+ try {
+ if ($version !== 1) throw new Exception("Unidentified signature version from server");
+
+ if (empty($callback_id) || empty($invoice_id) || empty($status) || empty($signature)) {
+ WC_Xendit_PG_Logger::log('Signature verification failed: Missing required fields');
+ return false;
+ }
+
+ $message = sprintf('%s.%s.%s', $callback_id, $invoice_id, $status);
+
+ $signature_binary = base64_decode($signature, true);
+ if ($signature_binary === false) {
+ WC_Xendit_PG_Logger::log('Signature verification failed: Invalid base64 signature');
+ return false;
+ }
+
+ $public_keys = self::load_public_keys();
+ if (empty($public_keys)) {
+ WC_Xendit_PG_Logger::log('Signature verification failed: No public keys available');
+ return false;
+ }
+
+ foreach ($public_keys as $index => $public_key_pem) {
+ if (self::verify_ecdsa($message, $signature_binary, $public_key_pem)) {
+ WC_Xendit_PG_Logger::log(sprintf('Signature verification success for id %s with %s status', $invoice_id, $status));
+ return true;
+ }
+ }
+
+ WC_Xendit_PG_Logger::log(sprintf('Signature verification failed for id %s with %s status', $invoice_id, $status));
+ return false;
+ } catch (Exception $e) {
+ WC_Xendit_PG_Logger::log('Signature verification error: ' . $e->getMessage());
+ return false;
+ }
+ }
+ private static function load_public_keys()
+ {
+ if (!defined('INTEGRATION_NOTIFICATION_SIGNATURE_PUBLIC_KEYS') || !defined('INTEGRATION_NOTIFICATION_SIGNATURE_PUBLIC_KEYS_STAGING')) {
+ WC_Xendit_PG_Logger::log('Public key constant not defined');
+ return [];
+ }
+
+ $keys = XENDIT_ENV === 'production' ? INTEGRATION_NOTIFICATION_SIGNATURE_PUBLIC_KEYS : INTEGRATION_NOTIFICATION_SIGNATURE_PUBLIC_KEYS_STAGING;
+
+ if (!is_array($keys)) {
+ WC_Xendit_PG_Logger::log('Public keys must be an array');
+ return [];
+ }
+
+ return $keys;
+ }
+ private static function verify_ecdsa($message, $signature, $public_key_pem)
+ {
+ try {
+ $public_key = openssl_pkey_get_public($public_key_pem);
+ if ($public_key === false) {
+ $error = openssl_error_string();
+ WC_Xendit_PG_Logger::log('Failed to load public key: ' . $error);
+ while (openssl_error_string() !== false);
+ return false;
+ }
+
+ $signature_der = self::raw_to_der_signature($signature);
+
+ $result = openssl_verify($message, $signature_der, $public_key, OPENSSL_ALGO_SHA384);
+
+ // Free the key resource (only needed for PHP < 8.0)
+ if (PHP_VERSION_ID < 80000) {
+ openssl_free_key($public_key);
+ }
+
+ if ($result === 1) {
+ return true;
+ } elseif ($result === 0) {
+ return false;
+ } else {
+ WC_Xendit_PG_Logger::log('OpenSSL verify error: ' . openssl_error_string());
+ return false;
+ }
+
+ } catch (Exception $e) {
+ WC_Xendit_PG_Logger::log('ECDSA verification error: ' . $e->getMessage());
+ return false;
+ }
+ }
+ private static function raw_to_der_signature($signature)
+ {
+ if (strlen($signature) > 0 && ord($signature[0]) === 0x30) {
+ WC_Xendit_PG_Logger::log('Signature already in DER format');
+ return $signature;
+ }
+
+ if (strlen($signature) !== 96) {
+ WC_Xendit_PG_Logger::log('Invalid signature length: ' . strlen($signature) . ', expected 96 for P-384');
+ // Try to use as-is, might already be DER
+ return $signature;
+ }
+
+ $r = substr($signature, 0, 48);
+ $s = substr($signature, 48, 48);
+
+ $r = ltrim($r, "