BugForge — 2026.04.13

Cheesy Does It: Refund Amount Manipulation

BugForge Business Logic easy

Overview

  • Platform: BugForge
  • Vulnerability: Business Logic — Unvalidated Client-Supplied Refund Amount
  • Key Technique: Submit an arbitrarily large refund_amount to /api/orders/:id/refund on 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 flag key 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

  1. Business Logic — Unvalidated Refund Amount (CWE-840: Business Logic Errors) — The /api/orders/:id/refund endpoint accepts a refund_amount field 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-token
  • GET /api/menu/pizzas, /bases, /sauces, /toppings
  • POST /api/payment/validate, POST /api/payment/process (amount — client-controlled)
  • POST /api/orders (items carry unit_price/total_price — client-controlled)
  • GET /api/orders, GET /api/orders/:id
  • POST /api/orders/:id/refund (issue_reason, request_refund, refund_amount — client-controlled)
  • PUT /api/profile
  • GET /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. grep for flag in 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:true on 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 that refund_amount was client-controlled.
  • Rule out admin role elevation before assuming it’s the path. A 10-second /api/verify-token + /api/admin/stats check 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/orders and /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


Tags: #business-logic #refund-abuse #over-refund #client-side-trust #api-security #e-commerce #bugforge

#business-logic #refund-abuse #over-refund #client-side-trust #api-security #e-commerce