How unsafe eval() code execution happens in JavaScript game scripting and how to fix it
Introduction
In the CommandBlock.js script of a Mindustry game mod, we discovered a critical arbitrary code execution vulnerability at line 209. The script handles in-game command blocks — interactive elements that let players trigger actions — and includes a "run-javascript" option that passes user input directly into eval(). The vulnerable line concatenated player-provided text into an eval string: eval("try{ " + text + "} catch(e) { Vars.ui.showText(error,e)}"). This pattern gives any player who interacts with the command block full access to the game's JavaScript runtime, including game state manipulation, rule changes, and potential denial-of-service attacks.
For developers building modding systems, game scripting engines, or any application that accepts user-provided code, this vulnerability illustrates exactly why eval() with string concatenation is considered one of the most dangerous patterns in JavaScript.
The Vulnerability Explained
The CommandBlock.js file handles tap events on command block entities. When a player selects the "run-javascript" option from the command block menu, a text input dialog appears. Whatever the player types is stored in the text variable and then executed:
const error = Core.bundle.format("commandblock.showtoast.run-javascript-2");
lastCommand = text;
eval("try{ " + text + "} catch(e) { Vars.ui.showText(error,e)}");
There are two critical problems here:
1. Direct string concatenation into eval(): The user's input (text) is concatenated directly into the eval string. This means a player can break out of the intended try block structure. For example, inputting } finally { /* malicious code */ } // would alter the control flow entirely.
2. Full local scope access: When eval() runs, it has access to the entire local scope — including the error variable, lastCommand, and any other variables in the enclosing Events.on(EventType.TapEvent, e => {...}) handler. This means executed code can modify the event handler's behavior for subsequent invocations.
Concrete Attack Scenarios
A player accessing the command block could enter:
- Game rule manipulation:
Vars.state.rules.editor = true;— enables editor mode, bypassing intended game restrictions - Denial of service:
while(true){}— freezes the game with an infinite loop - State corruption:
Vars.state.wave = 99999;— skips to an impossible wave, corrupting game progression - Scope escape:
} catch(x){} Vars.state.rules.attackMode = true; try{— breaks out of the try block structure to execute code outside the intended error handling
Because the game's scripting environment exposes Vars, Core, Sounds, and other powerful APIs, the impact extends to anything the game engine can do.
The Fix
The fix at line 211 replaces the dangerous eval() concatenation with a new Function() constructor wrapped in a proper try/catch block:
Before (vulnerable):
eval("try{ " + text + "} catch(e) { Vars.ui.showText(error,e)}");
After (fixed):
try { (new Function(text))(); } catch(e) { Vars.ui.showText(error, e); }
This change provides three specific security improvements:
1. Eliminates string concatenation injection: The user's input is no longer concatenated into a code string. It's passed as the body of a new function, meaning the player cannot break out of the try/catch structure by injecting } characters.
2. Scope isolation: new Function() creates a function in the global scope, not the local scope. The executed code cannot access error, lastCommand, e (the tap event), or any other local variables from the event handler. This significantly reduces the attack surface.
3. Proper error handling: The try/catch is now a real JavaScript control structure, not a string that gets parsed. This means error handling works correctly regardless of what the user inputs — even syntax errors are caught properly, whereas the old eval approach could fail to catch errors if the user's input broke the try/catch string structure.
Note that while new Function() still allows code execution (which is the intended feature of the "run-javascript" command block), it does so with proper isolation and without the injection amplification that string concatenation into eval creates.
Prevention & Best Practices
Avoid eval() entirely
The ESLint no-eval rule should be enabled in any JavaScript project. In virtually every case where eval() is used, a safer alternative exists:
| Instead of | Use |
|---|---|
eval("obj." + prop) |
obj[prop] |
eval("try{" + code + "}...") |
try { new Function(code)() } |
eval(jsonString) |
JSON.parse(jsonString) |
For game scripting systems specifically:
- Allowlist available APIs: If possible, create a sandboxed context that only exposes specific game APIs rather than the entire runtime
- Use Web Workers or iframes: For browser-based games, execute user code in an isolated context
- Implement timeouts: Wrap user code execution with
setTimeoutchecks or use a termination mechanism to prevent infinite loops - Input validation: Even with
new Function(), consider validating input against known dangerous patterns
Detection tools:
- ESLint: Enable
no-evalandno-new-funcrules - Semgrep: Use rules targeting
eval()with tainted input - CodeQL: JavaScript security queries detect eval injection patterns
Key Takeaways
- Never concatenate user input into
eval()strings — the"try{ " + text + "} catch(e){...}"pattern in CommandBlock.js allowed players to break out of the intended control flow structure new Function()provides scope isolation thateval()does not — the fix prevents access to local variables likeerror,lastCommand, and the tap event objecte- String-based try/catch is fragile — wrapping eval'd code in a string try/catch can be bypassed by injecting closing braces; real try/catch blocks cannot be escaped this way
- Game scripting features are attack surfaces — even in single-player contexts, crafted save files or shared maps can contain malicious command block configurations that execute when loaded
- The threat model matters — this game mod requires loading a crafted asset to exploit, but the fix is still critical because players share maps and save files
How Orbis AppSec Detected This
- Source: User text input from the in-game command block dialog (the
textvariable populated from the text input UI at the "run-javascript" option) - Sink:
eval("try{ " + text + "} catch(e) { Vars.ui.showText(error,e)}")atscripts/CommandBlock.js:211 - Missing control: No input sanitization, validation, allowlisting, or scope isolation between user input and code execution
- CWE: CWE-95 (Improper Neutralization of Directives in Dynamically Evaluated Code / Eval Injection)
- Fix: Replaced
eval()with string concatenation withnew Function(text)wrapped in a native try/catch block, eliminating the injection vector and providing scope isolation
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
This vulnerability in CommandBlock.js demonstrates why eval() with string concatenation remains one of the most dangerous patterns in JavaScript — even in contexts like game scripting where code execution is intentional. The critical distinction is between controlled code execution (isolated scope, proper error handling, no injection amplification) and uncontrolled code execution (local scope access, breakable control flow, string injection). By switching to new Function() with a native try/catch, the fix preserves the intended "run JavaScript" feature while eliminating the security amplification that made the original implementation exploitable beyond its intended purpose.
If you maintain game mods, scripting engines, or any code that dynamically executes user input, audit your use of eval() today — and consider whether new Function(), a sandboxed iframe, or a purpose-built interpreter would better serve your security requirements.