Smoke and Mirrors: Terminal Injection in badkeys (CVE-2026-21439)
Jan 6, 2026·6 min read
Executive Summary (TL;DR)
badkeys versions <= 0.0.15 trusted user input too much when printing results. Attackers could inject ANSI escape codes (like `\x1b[2K`) into key metadata or filenames. When `badkeys` printed these strings, the terminal executed the codes, allowing attackers to delete lines (hiding positive results) or spoof output. Fixed in 0.0.16 via `repr()` sanitization.
A classic terminal escape injection vulnerability in the 'badkeys' cryptographic auditing tool allowed attackers to manipulate scan results. By embedding ANSI escape sequences in filenames, SSH comments, or DKIM records, malicious actors could hide vulnerability warnings or forge fake alerts directly in the auditor's terminal.
The Hook: Who Watches the Watchmen?
In the world of cryptographic housekeeping, badkeys is the janitor. It’s a Python utility designed to sweep through your authorized_keys, TLS certificates, and DKIM records, looking for the digital equivalent of dust bunnies—weak keys, ROCAS vulnerabilities, and other cryptographic sins.
But here's the irony: the tool built to find the "bad" stuff was, itself, bad at handling the stuff it found. It turns out that when you build a tool specifically to parse untrusted, potentially malicious cryptographic blobs, you probably shouldn't blindly trust the metadata attached to them.
CVE-2026-21439 isn't a complex memory corruption bug or a fancy ROP chain. It's a reminder of a simpler time, when you could crash a BBS with a well-placed ANSI bomb. It’s a Terminal Injection vulnerability that turns the security auditor's own interface into a liar.
The Flaw: Trusting the Terminal
The root cause is a tale as old as cat. The developers committed the cardinal sin of CLI development: piping untrusted input directly into a print() statement using Python f-strings without sanitization.
Terminal emulators (xterm, GNOME Terminal, iTerm2) are not just passive text displays; they are state machines. They interpret control characters. When a program sends the byte 0x1B (ESC), the terminal stops printing text and starts listening for a command. This is how we get colored text, cursor movement, and progress bars.
In badkeys, the vulnerability manifested in badkeys/runcli.py and badkeys/dkim.py. The tool would identify a key (maybe a weak one), and then helpfully print the filename or the key's comment field to tell the user where the problem was.
# The offending logic in badkeys/runcli.py
for check, result in key["results"].items():
# 'where' could be a filename controlled by the attacker
print(f"{check}{sub} vulnerability, {kn}, {where}")If where contains \r (Carriage Return) followed by new text, the terminal moves the cursor to the start of the line and overwrites the "vulnerability" warning with whatever the attacker wants. The code didn't see a threat; it just saw a string. The terminal, however, saw an instruction.
The Code: The Smoking Gun
Let's look at the fix to understand the break. The patch, applied in commit 635a2f3b1b50a895d8b09ec8629efc06189f349a, introduces a sanitization function that effectively neuters the escape sequences.
Before the patch, the code was naked:
# VULNERABLE
print(f"{check}{sub} vulnerability, {kn}, {where}")After the patch, the developers wrapped the untrusted input in a helper function called _esc():
# PATCHED
print(f"{check}{sub} vulnerability, {kn}, {_esc(where)}")And what is _esc? It's deceptively simple:
def _esc(inp):
# repr() returns the printable representation of the object
# e.g., turning the byte 0x1B into the string '\x1b'
return repr(inp)[1:-1]By using Python's repr(), any non-printable character is converted to its escaped string literal format. The terminal no longer receives the byte for Escape (0x1B); it receives the characters backslash, x, one, b. The magic spell is broken, and the invisible ink becomes visible.
The Exploit: The Disappearing Act
Let's construct a Proof of Concept. Imagine you are an attacker trying to sneak a compromised SSH key onto a server, and you know the admin runs badkeys regularly to check authorized_keys.
Normally, badkeys would flag your weak key:
> SSH key vulnerability, [filename]
To exploit this, we manipulate the comment field of the SSH key. We want to clear the line and replace it with a benign message.
The Payload:
We need \033[2K (Clear Line) and \r (Carriage Return).
# Constructing a malicious comment
# \x1b[2K clears the current line
# \x1b[1G moves cursor to column 1
# Then we print "All keys secure."
PAYLOAD="comment_part\x1b[2K\x1b[1GAll keys secure."The Attack Chain:
- Generate a weak key (or use a known vulnerable one).
- Edit the
authorized_keysfile. - Paste the key:
ssh-rsa AAAA... $PAYLOAD.
The Result:
When the admin runs badkeys --ssh-lines /home/user/.ssh/authorized_keys:
- Python constructs the string:
"weak key found... comment_part\x1b[2K\x1b[1GAll keys secure." - It sends this to stdout.
- The terminal prints "weak key found...".
- The terminal hits
\x1b[2Kand wipes the line it just printed. - The terminal hits
All keys secure.and prints it.
The admin sees a green (metaphorically) light, while a backdoor remains wide open.
The Impact: Low Score, High Deception
The CVSS score is a measly 2.0 (Low). Why? Because technically, confidentiality, integrity, and availability of the system aren't compromised. You aren't popping a shell (RCE) and you aren't stealing /etc/shadow.
However, in the context of a security audit tool, Integrity of Information is everything. If a tool tells you "Safe" when you are "Vulnerable," the tool has failed completely.
This vulnerability allows for:
- Hiding Compromises: Masking the detection of backdoored keys.
- Social Engineering: Injecting fake critical errors (e.g., red text saying "CRITICAL ERROR: VISIT bad-site.com TO FIX") via DKIM records (
badkeys --dkim-dns). - Log Poisoning: If the output is piped to logs that are viewed in a terminal later, the logs themselves become booby traps.
It’s a low-severity bug that enables high-severity negligence.
The Mitigation: Escape Everything
The fix is straightforward: Update to badkeys version 0.0.16.
For developers reading this, the lesson is broader: Output is an attack vector.
Never treat stdout as a dumb pipe. If you are printing data derived from files, network requests, or user input, you must assume that data contains control characters designed to mess with your UI.
Mitigation Strategies for Devs:
- Sanitize: Use functions like
repr()in Python orprintf %qin Bash to escape control characters. - Strip: Regex replace
[\x00-\x1F\x7F](ASCII control codes) before printing. - Use Libraries: Use TUI libraries (like
richorcurses) that handle rendering safely, rather than rawprint()statements.
If you cannot update badkeys immediately, you can mitigate the specific rendering issue by piping output through cat -v:
badkeys [args] | cat -vThis forces the terminal to display control characters as caret notation (e.g., ^[ for Escape), revealing the attack attempt.
Official Patches
Fix Analysis (2)
Technical Appendix
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:PAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
badkeys badkeys | <= 0.0.15 | 0.0.16 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-150 |
| Attack Vector | Local / User Interaction |
| CVSS v4.0 | 2.0 (Low) |
| Impact | UI Spoofing / Integrity Loss |
| Exploit Status | Proof of Concept Available |
| Vector | Argument Injection / File Content |
MITRE ATT&CK Mapping
Improper Neutralization of Escape, Meta, or Control Sequences
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.