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
- 01Public notification
MasterStudy PayPal IPN callbackThe provider must reach the route without a WordPress login, so every notification field is untrusted until independently verified.
- 02Order selector
invoice request fieldThe affected handler used the supplied invoice as the local post ID without first establishing order type, payment method, or state.
- 03Incomplete authenticity decision
check_payment() in 3.7.39A VERIFIED provider response was accepted without matching mc_gross, mc_currency, receiver, or payment_status to local expectations.
- 04State-changing sink
update_post_meta(status, completed) and STM_LMS_Order::accept_order()The selected order was completed and its user received the order items.
- 05Fixed invariant set
is_valid_payment() and receiver_matches() in 3.7.40The 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
- 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.
- 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.
- Confirm that the affected decision sees only the provider verification result and completes the order.
- Confirm that the fixed decision rejects the same notification because the order invariants do not match.
- 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
Truefor the provider-verified but commercially mismatched notification. - The fixed decision returns
Falsefor that mismatch. - The matching positive control returns
Trueunder the fixed invariant set. - No network call, payment, WordPress order, or user enrollment occurs in the model.
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.
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.