Deno on Windows: How a Capital Letter Broke the Security Model
Jan 16, 2026·6 min read
Executive Summary (TL;DR)
Deno tried to stop you from spawning batch files to prevent command injection. But they checked for '.bat', not '.BAT'. Because Windows is case-insensitive and cmd.exe is a parsing nightmare, this allowed attackers to bypass the filter and inject arbitrary shell commands simply by shouting the file extension.
A command injection vulnerability in the Deno runtime on Windows allowing arbitrary code execution via crafted batch file extensions.
The Hook: Secure by Default, Until Windows Happens
Deno markets itself as the "secure by default" runtime for JavaScript and TypeScript. Unlike Node.js, which assumes you want to give every random npm package access to your file system and network, Deno locks everything down. You have to explicitly grant permission to read files, access the net, or—crucially for today's story—spawn subprocesses.
But here is the thing about "secure runtimes": they eventually have to talk to the Operating System. And if that OS happens to be Windows, you are entering a world of pain, legacy debt, and parsing logic that predates the collapse of the Soviet Union.
This specific vulnerability lies in Deno.Command (and the legacy Deno.run), the API used to spawn child processes. The Deno team knew that spawning Windows batch files (.bat or .cmd) is incredibly dangerous because cmd.exe has parsing rules that defy logic and sanity. So, they implemented a check to block them or sanitize them aggressively. It was a good idea. The execution, however, missed a detail so simple it's almost funny: case sensitivity.
The Flaw: Case Sensitivity in a Case-Insensitive World
The root cause here is a classic mismatch between the programmer's mental model and the operating system's reality. The developer wrote a check that essentially said: if (file_extension == ".bat" || file_extension == ".cmd") { block_or_sanitize(); }.
In the world of Linux or generic programming logic, .bat is not .BAT. But in the Windows filesystem (NTFS), they are effectively the same file. You can name a file malware.BAT, ask Windows to execute it, and the OS will happily invoke cmd.exe to handle it.
Because Deno's check was performing a case-sensitive string comparison, a file named evil.BAT sailed right past the security filter. The runtime looked at it, shrugged, and said, "Well, it's not lowercase .bat, so it must be a safe binary executable." It then passed this file to the Windows CreateProcess API.
Here is where it gets dark. When CreateProcess sees a batch file, it implicitly invokes cmd.exe. And cmd.exe does not handle arguments like a normal program (argv[]). It pastes them all together into a string and interprets them. If your argument string contains special characters like &, |, or %, cmd.exe executes them as shell operators. Deno thought it was launching a safe process; instead, it was handing a loaded gun to the command interpreter.
The Code: Bytes, ASCII, and Regret
Let's look at the fix, because it highlights just how low-level you have to go to fix high-level logic errors on Windows. The patch, specifically in commit 1b07f0295dd91f393966f953099ee90505a97e50, throws out the string comparison entirely.
Instead of comparing strings, the Deno team switched to raw byte matching on UTF-16 sequences (since Windows paths are WTF-16/UTF-16). Look at this beauty:
// Matches .bat or .cmd case-insensitively
let has_bat_extension = |program: &[u16]| {
matches!(
program.len().checked_sub(4).and_then(|i| program.get(i..)),
Some(
// 46 is '.'
// 98|66 is 'b'|'B', 97|65 is 'a'|'A', 116|84 is 't'|'T'
[46, 98 | 66, 97 | 65, 116 | 84] |
// 99|67 is 'c'|'C', 109|77 is 'm'|'M', 100|68 is 'd'|'D'
[46, 99 | 67, 109 | 77, 100 | 68]
)
)
};This code manually checks the last four 16-bit integers of the filename to see if they match the ASCII values for .bat or .cmd in any casing combination. It's ugly, it's raw, and it's necessary.
But they didn't stop there. Once they identify it as a batch file, they don't just block it—they sanitize the arguments using cmd.exe-specific escaping rules. They replace % with %%cd:~,% (a hacky no-op to prevent variable expansion) and explicitly ban \r and \n characters to prevent argument truncation. This is the difference between "patching a bug" and "hardening an architecture."
The Exploit: Pwning via Punctuation
To exploit this, we don't need buffer overflows or heap spraying. We just need a file extension in uppercase and an ampersand. The goal is to get cmd.exe to interpret our argument as a new command.
Here is the scenario: A Deno web server takes a user-supplied filename and runs a "cleanup script" on it. The developer expects the script to be cleanup.bat.
Step 1: The Setup
Create a batch file named pwn.BAT. The contents don't even matter much, but let's say it just echoes input.
Step 2: The Trigger
We invoke the Deno script that calls Deno.Command("pwn.BAT", { args: ["& calc.exe"] }).
Step 3: The Execution Flow
- Deno checks:
"pwn.BAT".endsWith(".bat")? -> False. Security check bypassed. - Deno calls Windows API:
CreateProcess("pwn.BAT", "pwn.BAT & calc.exe"). - Windows sees
.BAT, invokescmd.exe /c pwn.BAT & calc.exe. cmd.exeexecutespwn.BAT.cmd.exesees the&operator (command separator) and executes the next instruction:calc.exe.
// The PoC that kills the box
const cmd = new Deno.Command("exploit.BAT", {
args: ["& whoami > pwned.txt"],
});
await cmd.output();On a patched version, Deno detects .BAT, wraps the arguments in quotes, escapes the &, and effectively runs cmd.exe /c "exploit.BAT "^& whoami > pwned.txt"", which treats the payload as a harmless string literal.
The Impact: Why You Should Care
This is Remote Code Execution (RCE) served on a silver platter. If you have a Deno application running on a Windows server that spawns processes based on user input—or even spawns its own helper scripts—you are vulnerable.
In a serverless environment or a backend API, this means an attacker can break out of the Deno sandbox logic and execute commands with the privileges of the host user. They can read environment variables, exfiltrate source code, install persistence mechanisms, or pivot to other systems on the network.
The irony is palpable: Deno's permission system (--allow-run) was designed to make you think about what you are running. But because of this bug, granting permission to run one specific file effectively granted permission to run any command the shell supports.
Official Patches
Fix Analysis (2)
Technical Appendix
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:HAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
Deno Deno Land | < 2.5.6 | 2.5.6 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-77 (Command Injection) |
| CVSS v3.1 | 8.1 (High) |
| Attack Vector | Network (AV:N) |
| Impact | Remote Code Execution |
| Platform | Windows (x64/x86) |
| Exploit Status | Functional PoC Available |
MITRE ATT&CK Mapping
Improper Neutralization of Special Elements used in a Command ('Command Injection')
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.