Cheesy Does It: Refund Amount Manipulation
Overview
- Platform: BugForge
- Vulnerability: Business Logic — Unvalidated Client-Supplied Refund Amount
- Key Technique: Submit an arbitrarily large
refund_amountto/api/orders/:id/refundon an order owned by the authenticated user; the server approves the refund and returns the flag in the response body - Result: Over-refunded a $12.99 order for $999,999.99 (≈77,000× the order total), flag returned as an extra
flagkey in the refund response JSON
Objective
Find and exploit a vulnerability in a pizza ordering application to capture the flag.
Initial Access
# Target Application
URL: https://lab-1776113661896-qdkq31.labs-app.bugforge.io
# Auth details
Registered user via POST /api/register
Auth mechanism: JWT HS256 (Authorization: Bearer header)
JWT payload: {"id":4,"username":"haxor","iat":...}
Role stored server-side (NOT in JWT), loaded via /api/verify-token
Key Findings
- Business Logic — Unvalidated Refund Amount (CWE-840: Business Logic Errors) — The
/api/orders/:id/refundendpoint accepts arefund_amountfield from the client body and processes the refund at that amount with no server-side validation against the original order total, any upper cap, or prior refunds on the same order. A single state-changing request captured the flag on the first attempt.
Attack Chain Visualization
┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Register user │ │ Map API surface │ │ JS bundle analysis │
│ via /api/ │────>│ (auth, menu, cart, │────>│ Refund response │
│ register │ │ payment, orders, │ │ renders `g.flag` │
│ │ │ refund, admin) │ │ when present │
└──────────────────┘ └──────────────────────┘ └─────────┬────────────┘
│
Flag is emitted by the
refund endpoint under
a specific abuse condition
│
v
┌────────────────────────┐
│ Place normal order │
│ POST /api/orders │
│ total: $12.99 │
└───────────┬────────────┘
│
v
┌────────────────────────┐
│ Over-refund own order │
│ POST /api/orders/1/ │
│ refund │
│ refund_amount: │
│ 999999.99 │
└───────────┬────────────┘
│
v
┌────────────────────────┐
│ 200 OK │
│ refund_approved:true │
│ flag: bug{...} │
└────────────────────────┘
Application Architecture
| Component | Path | Description |
|---|---|---|
| Frontend | React + MUI SPA | Pizza ordering interface with menu, cart, checkout, order history |
| Backend | Express/Node.js | REST API, X-Powered-By: Express |
| Auth | JWT HS256 | Token holds {id, username, iat} only; role loaded server-side |
| Menu API | /api/menu/* |
Pizzas, bases, sauces, toppings |
| Payment API | /api/payment/* |
Card validation and processing |
| Orders API | /api/orders, /api/orders/:id |
Order creation and history (numeric IDs) |
| Refund API | /api/orders/:id/refund |
Refund request endpoint (target of this attack) |
| Profile API | PUT /api/profile |
Profile update |
| Admin API | /api/admin/* |
Stats, users, orders — properly gated server-side |
Exploitation Path
Step 1: Reconnaissance — Map the API Surface
Registered an account, captured traffic in Caido, and reviewed every endpoint referenced via the source code. Full surface:
POST /api/register,POST /api/login,GET /api/verify-tokenGET /api/menu/pizzas,/bases,/sauces,/toppingsPOST /api/payment/validate,POST /api/payment/process(amount— client-controlled)POST /api/orders(items carryunit_price/total_price— client-controlled)GET /api/orders,GET /api/orders/:idPOST /api/orders/:id/refund(issue_reason,request_refund,refund_amount— client-controlled)PUT /api/profileGET /api/admin/stats,/api/admin/users,/api/admin/orders
Multiple candidate vulnerabilities — client-supplied prices on order/payment, refund amount manipulation, IDOR on numeric order IDs, mass-assignment on register/profile, missing admin auth.
Step 2: JS Bundle — The Tell
Searched source code for flag. Hit in the refund response handler:
g.flag && <Alert severity="info"><Typography monospace>{g.flag}</Typography></Alert>
g.refund_approved && "Refund Amount: $" + g.refund_amount
The React component that renders the refund response conditionally renders g.flag when the server returns one. Refund endpoint is the intended vulnerability — the flag ships in the refund response under some abuse condition.
Key inference: refund_approved:true alone is not enough (a normal at-value refund would have already returned it). The flag is gated on a specific condition unique to the attack.
Step 3: Role / Admin Boundary Checks (Dead Ends, Fast)
Before testing the refund hypothesis, burned two quick checks so the refund attempt wasn’t influenced by stale assumptions.
Test A — /api/verify-token:
GET /api/verify-token HTTP/1.1
Authorization: Bearer <user JWT>
Response:
{"user":{"id":4,"username":"haxor","email":"test@test.com","full_name":"1234","phone":"1234","address":"12345","role":"user"}}
Role is stored in the DB and loaded per request. JWT does not carry role. Mass-assignment on register is viable but untested (objective was met before it was needed).
Test B — /api/admin/stats with user JWT:
GET /api/admin/stats HTTP/1.1
Authorization: Bearer <user JWT>
Response: 403 {"error":"Admin access required"} — server-side role enforcement is working. Admin endpoints are only reachable via role elevation (JWT forge, mass-assignment, or equivalent). Not needed for this objective.
Step 4: Exploitation — Over-Refund on Own Order
The existing refund UI computes refund_amount from a user-editable field via parseFloat(...) and POSTs it to the server. Direct Caido replay with a massively inflated amount:
POST /api/orders/1/refund HTTP/1.1
Host: lab-1776113661896-qdkq31.labs-app.bugforge.io
Authorization: Bearer <user JWT>
Content-Type: application/json
{"issue_reason":"Order not complete","request_refund":true,"refund_amount":999999.99}
Response:
{
"success": true,
"message": "Refund request processed successfully",
"refund_approved": true,
"refund_amount": 999999.99,
"flag": "bug{IDzuo7OQjWYAFWmah2z170tRwfJAc0oh}"
}
Original order total was $12.99. The server approved a refund of $999,999.99 — approximately 77,000× the order value — and emitted the flag. No IDOR was required; the order was owned by the refunding user. The vulnerability is purely a business-logic failure: the server never cross-references the client-supplied refund_amount against the stored order total.
Flag / Objective Achieved
bug{IDzuo7OQjWYAFWmah2z170tRwfJAc0oh}
Returned in the refund response body on the first state-changing request of the engagement.
Key Learnings
- The JS bundle told me exactly where the flag lived.
grepforflagin the minified bundle pinpointed the refund response handler. That narrowed the entire API surface (13+ endpoints, 10 candidate vulns) to a single code path before any state-changing tests were attempted. Recon discipline paid for the whole engagement in a few minutes. refund_approved:trueon a normal refund means the abuse is something richer than “can I refund my order.” The absence of the flag on a legitimate refund told me the gate was a specific abuse condition — over-refund was the highest-signal candidate given thatrefund_amountwas client-controlled.- Rule out admin role elevation before assuming it’s the path. A 10-second
/api/verify-token+/api/admin/statscheck ruled out both the BOLA path and confirmed where mass-assignment would land. That let the refund over-payment hypothesis stand on its own rather than competing with several unresolved others. - Business logic ≠ price tampering. Previous runs of this lab (same name, different deploys) used client-sent prices on
/api/ordersand/api/payment/process. This deploy gated the flag behind the refund endpoint specifically. Same application, different intended path — don’t assume the vulnerability just because you’ve seen the app before. - One state-changing test, flag captured. Engagements are won in recon and lost in flailing. Mapping the surface and reading the bundle before touching any write endpoint meant the very first state-changing request was the exploit.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
GET /api/admin/stats with user JWT |
403 Admin access required |
Admin endpoints enforce role server-side; BOLA path ruled out |
Tools Used
- Caido — HTTP interception, request replay for refund exploit
- Browser DevTools — JS bundle analysis (
main.2962dba5.js) to locate flag render site - Firefox — Initial walkthrough of the ordering and refund flow
Remediation
1. Unvalidated Client-Supplied Refund Amount (CVSS v3.1: 9.1 — Critical)
Vector: AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:H
Issue: The /api/orders/:id/refund endpoint trusts a refund_amount value from the request body without validating it against the stored order total, without capping it, and without tracking cumulative refunds. Any authenticated user can process an arbitrarily large refund on any order they own.
CWE Reference: CWE-840 — Business Logic Errors (also CWE-602 — Client-Side Enforcement of Server-Side Security)
// BEFORE (Vulnerable)
app.post('/api/orders/:id/refund', authenticate, async (req, res) => {
const { id } = req.params;
const { issue_reason, request_refund, refund_amount } = req.body;
const order = await db.getOrder(id);
if (!order || order.user_id !== req.user.id) {
return res.status(404).json({ error: 'Order not found' });
}
// Server trusts client-supplied refund_amount with no validation
await db.createRefund({
order_id: order.id,
amount: refund_amount,
reason: issue_reason
});
res.json({
success: true,
refund_approved: true,
refund_amount: refund_amount
});
});
// AFTER (Secure)
app.post('/api/orders/:id/refund', authenticate, async (req, res) => {
const { id } = req.params;
const { issue_reason } = req.body; // refund_amount from client ignored
const order = await db.getOrder(id);
if (!order || order.user_id !== req.user.id) {
return res.status(404).json({ error: 'Order not found' });
}
// Derive refund amount from the stored order, never from the client
const priorRefunds = await db.sumRefundsForOrder(order.id);
const refundable = order.total - priorRefunds;
if (refundable <= 0) {
return res.status(409).json({ error: 'Order has already been fully refunded' });
}
// Single-shot full refund — no partial-refund abuse surface
await db.createRefund({
order_id: order.id,
amount: refundable,
reason: issue_reason,
created_by: req.user.id
});
res.json({
success: true,
refund_approved: true,
refund_amount: refundable
});
});
Defense-in-depth:
- Never trust client-supplied monetary values. All refund, payment, and order totals must be derived server-side from the stored record.
- Enforce
refund_amount <= order_total - sum(prior_refunds_for_order)as a hard invariant at the DB / service layer, not just the handler. - Add an audit log for every refund with
user_id,order_id,amount,timestamp,source_ip— refund abuse must be detectable even if the handler is re-broken in the future. - Apply the same pattern across every other endpoint that currently accepts a client-supplied price:
/api/payment/process(amount),/api/orders(items[].unit_price,items[].total_price,total_price). These were not tested in this engagement but the same anti-pattern is present. - Consider rate-limiting refund requests per user and alerting on refunds exceeding configured thresholds.
OWASP Top 10 Coverage
- A04:2021 — Insecure Design — The refund workflow delegates amount determination to the client. The architecture itself is the bug, not a single missing check.
- A08:2021 — Software and Data Integrity Failures — The server fails to verify the integrity of the refund amount received from the client, accepting any value without cross-referencing the authoritative order record.
- A01:2021 — Broken Access Control (adjacent) — While the refund endpoint correctly verifies order ownership (no IDOR), it fails to enforce the policy boundary on what a user is allowed to refund (not more than they paid).
References
- CWE-840: Business Logic Errors
- CWE-602: Client-Side Enforcement of Server-Side Security
- OWASP Testing Guide: Business Logic Testing
- PortSwigger: Business Logic Vulnerabilities
- OWASP: Insecure Design (A04:2021)
Tags: #business-logic #refund-abuse #over-refund #client-side-trust #api-security #e-commerce #bugforge