CVE-2026-3124

Download Monitor <= 5.1.7 - Insecure Direct Object Reference to Unauthenticated Arbitrary Order Completion via 'token' and 'order_id'

highAuthorization Bypass Through User-Controlled Key
7.5
CVSS Score
7.5
CVSS Score
high
Severity
5.1.8
Patched in
1d
Time to patch

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.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
None
Confidentiality
High
Integrity
None
Availability

Technical Details

Affected versions<=5.1.7
PublishedMarch 29, 2026
Last updatedMarch 30, 2026
Affected plugindownload-monitor

What Changed in the Fix

Changes introduced in v5.1.8

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan: CVE-2026-3124 ## 1. Vulnerability Summary The **Download Monitor** plugin (up to 5.1.7) contains an Insecure Direct Object Reference (IDOR) vulnerability in its PayPal payment execution logic. The function `ExecutePaymentListener::executePayment()` uses a user-supplied…

Show full research plan

Exploitation Research Plan: CVE-2026-3124

1. Vulnerability Summary

The Download Monitor plugin (up to 5.1.7) contains an Insecure Direct Object Reference (IDOR) vulnerability in its PayPal payment execution logic. The function ExecutePaymentListener::executePayment() uses a user-supplied PayPal token to verify a payment with PayPal's API. However, it fails to verify that the PayPal transaction associated with that token actually corresponds to the local order_id provided in the request.

Because the code proceeds to call $order->set_completed() regardless of whether the PayPal transaction ID matches the local transaction records (due to a logic error in the transaction loop), an attacker can pay for a low-cost item to obtain a valid "COMPLETED" PayPal token and then use that token to finalize an order for a high-value item without paying the full price.

2. Attack Vector Analysis

  • Endpoint: The site's root index (or any page) where the plugin's ExecutePaymentListener is active.
  • Hook: Likely init or template_redirect (via ExecutePaymentListener::run).
  • HTTP Method: GET
  • Vulnerable Parameters:
    • paypal_action: Must be set to execute_payment.
    • order_id: The ID of the target (expensive) order to be completed.
    • order_hash: The unique hash of the target order (required to bypass initial checks).
    • token: A valid PayPal Order ID (token) representing a completed payment from a different (cheap) transaction.
  • Authentication: None (Unauthenticated).

3. Code Flow

  1. Entry Point: src/Shop/Checkout/PaymentGateway/PayPal/ExecutePaymentListener.php:run()
    • Checks if $_GET['paypal_action'] === 'execute_payment'.
  2. Order Retrieval: executePayment()
    • Retrieves $order_id and $order_hash from $_GET.
    • Loads the order: $order = $order_repo->retrieve_single( $order_id ).
  3. PayPal Verification:
    • Retrieves $token from $_GET['token'].
    • Instantiates CaptureOrder.
    • Sets the PayPal target ID to the user-supplied token: $capture->set_order_id( $token ) (Line 91).
    • Calls $capture->captureOrder(), which contacts PayPal to verify the status of that specific token.
  4. Logic Failure:
    • If PayPal returns a "COMPLETED" status for that $token, the code enters a loop to update local transactions.
    • It checks if $transaction->get_processor_transaction_id() == $response->getId().
    • Vulnerability: Even if the transaction ID doesn't match (which it won't for a swapped token), the code proceeds to call $order->set_completed() (Line 124) outside the transaction matching loop.
  5. Sink: $order->set_completed() marks the expensive order as paid and persists it to the database, granting access to the digital goods.

4. Nonce Acquisition Strategy

Based on the source code in src/Shop/Checkout/PaymentGateway/PayPal/ExecutePaymentListener.php, no WordPress nonce is required for this endpoint. The code only checks for the presence of paypal_action, order_id, and order_hash.

5. Exploitation Strategy

The goal is to complete a "Pending" high-value order using a token from a "Completed" low-value order.

Step-by-Step Plan:

  1. Identify Downloads: Find an expensive download and a cheap/minimal-cost download.
  2. Create High-Value Order:
    • Add the expensive item to the cart.
    • Proceed to checkout as a guest.
    • Place the order but do not pay.
    • Intercept the redirect to PayPal to capture the order_id and order_hash from the URL parameters.
  3. Create and Pay for Low-Value Order:
    • Add the cheap item to the cart.
    • Proceed to checkout and complete the payment via PayPal.
    • After successful payment, capture the token parameter from the redirect URL (this is the PayPal Order ID).
  4. Execute Bypass:
    • Construct a GET request to the target site root using the order_id and order_hash of the expensive order, but the token of the paid cheap order.

Payload Construction:

  • URL: http://localhost:8080/
  • Query Parameters:
    • paypal_action=execute_payment
    • order_id=[EXPENSIVE_ORDER_ID]
    • order_hash=[EXPENSIVE_ORDER_HASH]
    • token=[CHEAP_PAYPAL_TOKEN]

6. Test Data Setup

  1. Plugin Config:
    • Enable "Shop" in Download Monitor settings.
    • Configure PayPal Gateway (Sandbox mode is sufficient).
  2. Content Creation:
    • Use WP-CLI to create two downloads with different prices:
      • wp dlm create_download --title="Expensive Product" --price=999 (Note: command args are illustrative; standard post creation may be needed).
      • wp dlm create_download --title="Cheap Product" --price=1.
  3. Order Generation:
    • Manually or via script, generate a "Pending" order for the Expensive Product to obtain its ID and Hash.

7. Expected Results

  • The server will respond with a 302 Redirect to the "Success" URL associated with the expensive order.
  • The order_id provided in the request will transition from pending to completed in the database.
  • The user (attacker) will be granted access to the digital files associated with the expensive order.

8. Verification Steps

After sending the exploit request, use WP-CLI to check the order status:

# Verify the expensive order is now 'completed'
wp post get [EXPENSIVE_ORDER_ID] --field=post_status
# Or if stored in custom tables/meta:
wp post meta get [EXPENSIVE_ORDER_ID] _dlm_order_status

9. Alternative Approaches

If order_hash validation is stricter in retrieve_single than it appears:

  1. Hash Prediction: Check if the order hash is generated using a predictable algorithm (e.g., md5(order_id + secret_key)).
  2. Log/Debug Leakage: Check if the plugin logs order_hash values in debug.log or a custom PayPal log file which might be publicly accessible.
  3. Database Leakage: If another vulnerability exists (like a minor SQLi), use it to extract the order_hash.

However, since the attacker is the one creating the expensive order (guest checkout), they should naturally have the hash via the standard checkout flow.

Research Findings
Static analysis — not yet PoC-verified

Summary

The Download Monitor plugin for WordPress is vulnerable to an Insecure Direct Object Reference (IDOR) in its PayPal payment processing logic. An unauthenticated attacker can complete high-value orders by providing a valid PayPal transaction token from a low-cost purchase, as the plugin fails to verify that the PayPal token corresponds to the specific local order being finalized.

Vulnerable Code

// src/Shop/Checkout/PaymentGateway/PayPal/ExecutePaymentListener.php

	private function executePayment() {

		/**
		 * Get order
		 */
		$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 );
		}

		/** @var \WPChill\DownloadMonitor\Shop\Order\Repository $order_repo */
		$order_repo = Services::get()->service( 'order_repository' );
		try {
			$order = $order_repo->retrieve_single( $order_id );
		} catch ( \Exception $exception ) {
			$this->execute_failed( $order_id, $order_hash );
			return;
		}

		/**
		 * Get payment identifier
		 */
		$token = '';
		if ( isset( $_GET['token'] ) ) {
			$token = sanitize_text_field( wp_unslash( $_GET['token'] ) );
		}

		/**
		 * Execute the payement
		 */
		try {

			$capture = new CaptureOrder();
			$capture->set_client( $this->gateway->get_api_context() )
					->set_order_id( $token );

			$response = $capture->captureOrder();

			// if payment is not approved, exit;
			if ( $response->getStatus() !== "COMPLETED" ) {
				throw new Exception( sprintf( "Execute payment state is %s", $response->getStatus() ) );
			}

			/**
			 * Update transaction in local database
			 */

			// update the order status to 'completed'
			$transactions = $order->get_transactions();
			foreach ( $transactions as $transaction ) {
				if ( $transaction->get_processor_transaction_id() == $response->getId() ) {
					$transaction->set_status( Services::get()->service( 'order_transaction_factory' )->make_status( 'success' ) );
					$transaction->set_processor_status( $response->getStatus() );
                    // ... (truncated)
					$order->set_transactions( $transactions );
					break;
				}

			}

			// set order as completed, this also persists the order
			$order->set_completed();

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/download-monitor/5.1.7/src/Shop/Checkout/PaymentGateway/PayPal/ExecutePaymentListener.php /home/deploy/wp-safety.org/data/plugin-versions/download-monitor/5.1.8/src/Shop/Checkout/PaymentGateway/PayPal/ExecutePaymentListener.php
--- /home/deploy/wp-safety.org/data/plugin-versions/download-monitor/5.1.7/src/Shop/Checkout/PaymentGateway/PayPal/ExecutePaymentListener.php	2024-11-28 14:28:34.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/download-monitor/5.1.8/src/Shop/Checkout/PaymentGateway/PayPal/ExecutePaymentListener.php	2026-02-26 10:04:54.000000000 +0000
@@ -38,6 +38,7 @@
 
 		if ( empty( $order_id ) || empty( $order_hash ) ) {
 			$this->execute_failed( $order_id, $order_hash );
+			return;
 		}
 
 		/** @var \WPChill\DownloadMonitor\Shop\Order\Repository $order_repo */
@@ -53,16 +54,41 @@
 			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;
+		}
+
 		/**
-		 * Get payment identifier
+		 * Get payment identifier (PayPal order ID / token)
 		 */
 		$token = '';
 		if ( isset( $_GET['token'] ) ) {
 			$token = sanitize_text_field( wp_unslash( $_GET['token'] ) );
 		}
 
+		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 (PayPal order ID).
+		$transactions = $order->get_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;
+		}
+
 		/**
-		 * Execute the payement
+		 * Execute the payment
 		 */
 		try {
 
@@ -70,7 +96,15 @@
 			$capture->set_client( $this->gateway->get_api_context() )
 					->set_order_id( $token );
 
-			$response = $capture->captureOrder();
+			$capture_result = $capture->captureOrder();
+
+			// Handle capture failures safely (e.g. network error, invalid token).
+			if ( null === $capture_result || ! $capture_result->has_response() ) {
+				$this->execute_failed( $order->get_id(), $order->get_hash() );
+				return;
+			}
+
+			$response = $capture_result;
 
 			// if payment is not approved, exit;
 			if ( $response->getStatus() !== "COMPLETED" ) {
@@ -80,11 +114,11 @@
 			/**
 			 * Update transaction in local database
 			 */
-
-			// update the order status to 'completed'
-			$transactions = $order->get_transactions();
+			// Update the transaction that belongs to this token (already validated above).
+			$transaction_updated = false;
+			$transactions         = $order->get_transactions();
 			foreach ( $transactions as $transaction ) {
-				if ( $transaction->get_processor_transaction_id() == $response->getId() ) {
+				if ( hash_equals( (string) $transaction->get_processor_transaction_id(), (string) $token ) ) {
 					$transaction->set_status( Services::get()->service( 'order_transaction_factory' )->make_status( 'success' ) );
 					$transaction->set_processor_status( $response->getStatus() );
 
@@ -95,9 +129,15 @@
 					}
 
 					$order->set_transactions( $transactions );
+					$transaction_updated = true;
 					break;
 				}
+			}
 
+			// Only complete the order if we actually updated a matching transaction (prevents token/amount mismatch).
+			if ( ! $transaction_updated ) {
+				$this->execute_failed( $order->get_id(), $order->get_hash() );
+				return;
 			}

Exploit Outline

The exploit leverages a logic error where the plugin checks if a PayPal token is "COMPLETED" but doesn't verify if it belongs to the target order. 1. Identify a high-value digital product. Add it to the cart, proceed to checkout as a guest, and initiate payment to generate a 'Pending' order. 2. Record the `order_id` and `order_hash` for this expensive order from the URL or intercept the initial redirect to PayPal. 3. Identify a low-value digital product ($1 or less). Complete the purchase and payment via PayPal. 4. Capture the `token` parameter provided by PayPal during the return redirect for the cheap purchase. 5. Execute a GET request to the site's root with the parameters: `paypal_action=execute_payment`, `order_id=[Expensive Order ID]`, `order_hash=[Expensive Order Hash]`, and `token=[Paid Cheap Token]`. 6. The plugin will verify the cheap token with PayPal, confirm it is 'COMPLETED', and mistakenly mark the expensive order as paid, allowing the attacker to download the expensive product.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.