CVE case study

CVE-2026-28145: MasterStudy Completed Orders Without Binding PayPal Payment Data

MasterStudy trusted a VERIFIED PayPal IPN response without checking that the amount, receiver, status, and currency matched the pending LMS order. Version 3.7.40 validates those fields before enrollment.

Severity
Medium (5.3)
Scoring
CVSS 3.1
Weakness
CWE-345
Affected
MasterStudy LMS 3.7.39 and earlier
Remediation state
Upgrade to MasterStudy LMS 3.7.40 or later
Advisory published
31 Jul 2026

Official vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

Why it matters

PayPal's IPN verification answers whether PayPal generated a notification. It does not prove that the notification belongs to the local order or that its commercial terms match what the site expects.

The affected MasterStudy handler accepted an order ID from the unauthenticated notification, posted the supplied fields back to PayPal, and completed the order when the response was VERIFIED. It did not compare the paid amount, recipient, currency, or payment status with local order state.

In a disposable lab, a synthetic pending order changed to completed and its user was enrolled after a verified notification containing mismatched payment data. The public record scores the user-state manipulation Medium at 5.3.

How I found it

On 18 July 2026, I reviewed MasterStudy's public payment callbacks and listed the fields that must bind a provider notification to a local order. The PayPal IPN handler checked only PayPal's VERIFIED response.

I traced the request-supplied invoice into the local order lookup, then followed the completed status into STM_LMS_Order::accept_order(). There was no comparison for amount, receiver, payment status, or currency before enrollment.

In a disposable WordPress lab, a local verification stub represented a provider-authenticated notification. Mismatched synthetic payment data still moved a test order from pending to completed and enrolled its test user.

The 3.7.40 patch added the missing message-to-order invariants and records the transaction ID. The diff confirmed that authenticity and business authorization had previously been collapsed into one decision.

Root cause

check_payment() treated VERIFIED as the complete authorization decision. It then loaded the order's user, changed the status, and called STM_LMS_Order::accept_order().

No local invariant tied mc_gross to _order_total, mc_currency to the configured currency, or receiver_email/business to the configured merchant. The handler also lacked a transaction-ID replay guard.

The endpoint was intentionally public because PayPal must call it. That makes strict message-to-order verification, rather than browser authentication, the critical trust boundary.

Source-to-sink trace

  1. 01
    Public notificationMasterStudy PayPal IPN callback

    The provider must reach the route without a WordPress login, so every notification field is untrusted until independently verified.

  2. 02
    Order selectorinvoice request field

    The affected handler used the supplied invoice as the local post ID without first establishing order type, payment method, or state.

  3. 03
    Incomplete authenticity decisioncheck_payment() in 3.7.39

    A VERIFIED provider response was accepted without matching mc_gross, mc_currency, receiver, or payment_status to local expectations.

  4. 04
    State-changing sinkupdate_post_meta(status, completed) and STM_LMS_Order::accept_order()

    The selected order was completed and its user received the order items.

  5. 05
    Fixed invariant setis_valid_payment() and receiver_matches() in 3.7.40

    The patch binds provider-authenticated data to the exact pending PayPal order before fulfillment.

Safe proof of concept

Prerequisites

  • Public MasterStudy LMS 3.7.39 and 3.7.40 PayPal handler source.
  • Python 3 for the in-memory payment-to-order model.
  • No PayPal credentials, merchant endpoint, WordPress site, or real transaction.

Step-by-step reproduction

  1. Compare the public PayPal handler between 3.7.39 and 3.7.40. Identify the new order-state, field, amount, currency, receiver, and transaction checks.
  2. Run the in-memory model with a synthetic pending order priced at 100 units and a provider-authenticated notification for 1 unit and a different receiver.
  3. Confirm that the affected decision sees only the provider verification result and completes the order.
  4. Confirm that the fixed decision rejects the same notification because the order invariants do not match.
  5. Run the matching notification as a positive control and confirm that the fixed decision completes only that case.

Compare the public PayPal IPN handlers

LAB_DIR=$(mktemp -d "\${TMPDIR:-/tmp}/masterstudy-cve.XXXXXX")

curl -fsSL \
  https://plugins.svn.wordpress.org/masterstudy-lms-learning-management-system/tags/3.7.39/_core/lms/classes/paypal.php \
  -o "$LAB_DIR/affected.php"
curl -fsSL \
  https://plugins.svn.wordpress.org/masterstudy-lms-learning-management-system/tags/3.7.40/_core/lms/classes/paypal.php \
  -o "$LAB_DIR/fixed.php"

diff -u "$LAB_DIR/affected.php" "$LAB_DIR/fixed.php"

Synthetic payment-to-order binding model

order = {
    "status": "pending",
    "method": "paypal",
    "amount": 100.00,
    "currency": "USD",
    "receiver": "merchant@example.invalid",
}

mismatch = {
    "verified": True,
    "payment_status": "Completed",
    "amount": 1.00,
    "currency": "EUR",
    "receiver": "other@example.invalid",
    "txn_id": "SYNTHETIC_TXN_1",
}

matching = mismatch | {
    "amount": 100.00,
    "currency": "USD",
    "receiver": "merchant@example.invalid",
    "txn_id": "SYNTHETIC_TXN_2",
}

def affected(notification):
    return notification["verified"]

def fixed(order, notification):
    return all([
        notification["verified"],
        order["status"] == "pending",
        order["method"] == "paypal",
        notification["payment_status"].lower() == "completed",
        notification["amount"] == order["amount"],
        notification["currency"] == order["currency"],
        notification["receiver"] == order["receiver"],
        bool(notification["txn_id"]),
    ])

print("affected mismatch:", affected(mismatch))
print("fixed mismatch:   ", fixed(order, mismatch))
print("fixed matching:   ", fixed(order, matching))

Expected evidence

  • The affected decision returns True for the provider-verified but commercially mismatched notification.
  • The fixed decision returns False for that mismatch.
  • The matching positive control returns True under the fixed invariant set.
  • No network call, payment, WordPress order, or user enrollment occurs in the model.
Negative control

Vary amount, currency, receiver, payment status, method, and prior order state one at a time. The fixed decision must reject every single-field mismatch while allowing the exact synthetic match.

Fixed-version re-test

On MasterStudy LMS 3.7.40 or later, only a provider-verified notification whose order type, pending state, payment status, amount, currency, receiver, and transaction ID all match may complete the order.

Impact

A crafted but PayPal-verified notification could cause an affected site to complete a pending LMS order whose payment properties did not match. The associated local user could then receive the order's course enrollment.

The official vector records Low integrity impact and no confidentiality or availability impact. The safe reproduction uses a synthetic order and local verification stub; it makes no transaction against PayPal and purchases nothing.

Fix and retest

Upgrade to MasterStudy LMS 3.7.40 or later. Before contacting PayPal, the fixed handler validates that the invoice names a pending PayPal order and that required notification fields are present and scalar.

After PayPal returns VERIFIED, the patch requires a completed payment status, exact amount match, currency match, and receiver match against either receiver_email or business. It records the transaction ID before accepting the order.

Retest a matching notification as the positive control and vary one commercial field at a time. Any amount, currency, receiver, status, order type, or prior-state mismatch must leave the synthetic order pending and the user unenrolled.

Engineering lesson

A provider signature or verification response authenticates a message, not its business meaning. Payment handlers must bind every security-relevant field to immutable local order state before fulfillment.

Public webhook endpoints should be modeled as authenticated data pipelines: validate structure, authenticate the provider response, enforce local invariants, and make the state transition replay-safe.

References

Follow this thread

Related research, source, and writing.

These paths share a published research boundary, project source, article series, or authorized lab context with this record.

Back to article start