Feb 17, 2026·7 min read·8 visits
Deno tried to stop command injection by blocking '.bat' and '.cmd' files. They forgot Windows is case-insensitive. By using '.BAT', attackers can trick Deno into executing batch files with unsafe arguments, leading to RCE.
A high-severity command injection vulnerability in Deno runtime on Windows, caused by an incomplete fix for the 'BatBadBut' class of bugs. Attackers could bypass extension blocklists by simply changing the case of the file extension (e.g., '.BAT' instead of '.bat'), allowing arbitrary code execution via argument injection.
Deno markets itself as the "secure by default" runtime for JavaScript and TypeScript. Unlike Node.js, it requires explicit permissions to access the network, file system, or environment. It’s a bold claim, and for the most part, the Deno team does a stellar job upholding it. But security is a chain, and that chain is often anchored to the underlying operating system. When that OS is Windows, things get weird.
This vulnerability, CVE-2026-22864, is a classic example of "developer vs. legacy compatibility." It involves the terrifyingly complex way Windows creates processes and parses arguments. Specifically, it touches on the "BatBadBut" class of vulnerabilities—a recurring nightmare where programming languages fail to account for the fact that cmd.exe handles arguments like a drunk juggler.
The core issue here isn't memory corruption or a buffer overflow. It's a logic error born from a simple misunderstanding: assuming that file.bat and file.BAT are different things. In the Unix world, they are. In the Windows world, they are identical twins, but Deno only recognized one of them as a threat.
To understand this bug, you have to understand CreateProcess on Windows. When you spawn a process pointing to a .bat or .cmd file, Windows implicitly calls cmd.exe /c your_script.bat. The problem is that cmd.exe has its own parsing rules for arguments that are completely separate from the standard C runtime parsing. Characters like &, |, and ^ are interpreted as control operators before the script even sees them.
To mitigate this, runtimes like Python, Rust, and Node have had to implement complex escaping logic. Deno, in an earlier attempt to patch this, decided to simply block the execution of batch files when certain dangerous APIs were used, or at least sanitize them heavily. They implemented a check: if the file extension is .bat or .cmd, apply strict security measures.
Here lies the flaw. The check was case-sensitive. The code looked for bat (lowercase). But the Windows filesystem (NTFS) and the process loader don't care about case. If an attacker passes payload.BAT or payload.cMd, the blocklist sees a "safe" extension, but Windows sees a batch file. The runtime hands the arguments over to the OS without the special "batch file escaping," and cmd.exe happily executes any injected commands found in those arguments.
Let's look at the Rust code responsible for this. In ext/process/lib.rs (prior to the fix), the logic was painfully simple. It treated the extension as a raw string and compared it against lowercase literals. It's the kind of code you write at 3 AM thinking, "Yeah, that covers it."
The Vulnerable Code:
// Vulnerable logic snippet
if ext_str.eq("bat") || ext_str.eq("cmd") {
// Apply security blocking or special escaping
return Err(PermissionDenied);
}This is like locking the front door but leaving the window wide open because the burglar didn't ring the doorbell. The fix, introduced in version 2.5.6 (Commit 1b07f0295dd91f393966f953099ee90505a97e50), had to implement a robust, case-insensitive check. The Deno team ended up porting the logic directly from the Rust standard library (which had already learned this lesson the hard way in CVE-2024-24576).
The Fixed Code:
// Robust, case-insensitive check using bitwise matching
let has_bat_extension = |program: &[u16]| {
matches!(
program.len().checked_sub(4).and_then(|i| program.get(i..)),
// Matches .bat, .BAT, .BaT, .cmd, .CMD, etc.
Some(
[46, 98 | 66, 97 | 65, 116 | 84] | [46, 99 | 67, 109 | 77, 100 | 68]
)
)
};Notice the bitwise | operations? That handles b (98) or B (66), a (97) or A (65), ensuring that any variation of "bat" is caught. They also updated deno_path_util to strip trailing dots and spaces, because file.bat. is also a valid batch file on Windows. It's a game of whack-a-mole.
Exploiting this is trivially easy if you can control the filename and arguments passed to Deno.Command. Imagine a scenario where a Deno application runs a helper script specified by a user or configuration file. If the attacker can point that execution to a file named with uppercase letters, they win.
Here is a functional Proof of Concept (PoC) for a vulnerable Deno version running on Windows:
// victim.ts
// Run with: deno run --allow-run victim.ts
// The attacker provides a file with an uppercase extension
const maliciousFile = "test_script.BAT";
// The attacker injects a command via arguments
// The '&' is a command separator in cmd.exe
const cmd = new Deno.Command(maliciousFile, {
args: ["& calc.exe"],
});
console.log("[+] Attempting to spawn process...");
const output = await cmd.output();The Execution Flow:
test_script.BAT. Does it end in .bat? No. It ends in .BAT..exe) and does not apply the special batch-file escaping rules.CreateProcess..BAT, invokes cmd.exe /c test_script.BAT & calc.exe.cmd.exe runs the script, hits the &, and then executes calc.exe.While the requirement to control the filename limits the surface area slightly, the impact is catastrophic when the condition is met. This is a classic Remote Code Execution (RCE) vector. If a developer uses Deno.Command to execute a user-supplied path—perhaps in a CI/CD runner, a build tool, or a server-side utility wrapper—the attacker gains full control over the Deno process's privileges.
Because Deno is often used to build modern CLI tools and backend services, this bypass effectively neutralizes the sandbox protections regarding subprocess execution. Even if you have tight --allow-run permissions, if you allow running a specific directory, and the attacker can drop a .BAT file there (or use an existing one), they can break out of the intended logic chain.
It is also a stark reminder that "sanitizing input" is impossible if you don't understand how the underlying system interprets that input. The disparity between the application's view of a file (.BAT != .bat) and the OS's view (.BAT == .bat) is a fertile ground for high-severity vulnerabilities.
The remediation is straightforward: Update to Deno v2.5.6. The patch aligns Deno's behavior with the realities of the Windows API. It now explicitly invokes %COMSPEC% (cmd.exe) with proper flags (/d /s /c) and robust argument escaping whenever any case-variation of a batch extension is detected.
For Developers:
.bat files for critical infrastructure. Use PowerShell, or better yet, write the logic in JavaScript/TypeScript directly within Deno.This vulnerability serves as a humorous, dark reminder: You might be writing modern, cutting-edge Rust and TypeScript, but underneath it all, you're still negotiating with MS-DOS ghosts from the 1980s.
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
Deno Deno Land | < 2.5.6 | 2.5.6 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-77 |
| Attack Vector | Network (Context Dependent) |
| CVSS Score | 8.1 (High) |
| Impact | Remote Code Execution (RCE) |
| EPSS Score | 0.00067 |
| Platform | Windows |