//nefariousplan

CVE-2026-24061: The CVE Names One Variable. The Patch Sanitized Seven.

pattern

cve

proof of concept

GNU InetUtils telnetd through 2.7 lets an unauthenticated remote caller log in as root by setting the USER environment variable to -f root and sending it over the telnet NEW-ENVIRON option. The CVE record names that one variable. Paul Eggert's initial fix at 01:10 PT on 2026-01-20 sanitized one switch case. Four hours later Simon Josefsson extracted the sanitize logic into a function and called it from six more cases the CVE does not name. Two days after that, a third commit added -- to the login command template so the argv barrier finally matched the input filter. CVE-2026-24061's affected-products field reflects what the public PoC demonstrated, not what the patch closed.

The 2015 commit was a feature

On 2015-03-19, Mats Erik Andersson committed fa3245ac to inetutils with the title "telnetd: Enable autologin in legacy mode." The body of the ChangeLog entry says the quiet part: "Without Kerberos authentication the autologin code was not determining what user name to hand over to the login service. Adding the user name, when applicable, resolves the issue."

The fix was a one-line edit to the template that telnetd uses to build the command line for /usr/bin/login:

 #else /* !SOLARIS */
-  PATH_LOGIN " -p -h %h %?u{-f %u}"
+  PATH_LOGIN " -p -h %h %?u{-f %u}{%U}"
 #endif

%?u{A}{B} is the template's if-else: if user_name is populated (Kerberos has authenticated the caller), insert A; otherwise insert B. Before the commit, B was the empty string. The autologin path simply did nothing when Kerberos was absent, and /usr/bin/login fell through to interactive prompt. After the commit, B was {%U}: insert the value of whatever the client claimed in the USER environment variable.

The same commit added the case 'U' arm to _var_short_name in telnetd/utility.c:

case 'U':
  return getenv ("USER") ? xstrdup (getenv ("USER")) : xstrdup ("");

The feature the commit advertises is "let the client tell us who they are when we cannot ask Kerberos." The bug is that the way the feature was implemented, the client tells the server who they are by writing characters that get spliced into the argument list of a root setuid binary.

The chain: NEW-ENVIRON to execv

expand_line walks the template character by character. %U triggers _var_short_name, which returns getenv("USER") verbatim. The expanded command string flows into telnetd/pty.c:start_login:

cmd = expand_line (login_invocation);
if (!cmd)
  fatal (net, "can't expand login command line");
argcv_get (cmd, "", &argc, &argv);
execv (argv[0], argv);

argcv_get is GNU's shell-like word splitter from argcv.h. It tokenizes on whitespace, honors quotes, and produces an argv array. With the pre-patch template and an unauthenticated caller whose USER env var is -f root, the expanded cmd is:

/usr/bin/login -p -h <reverse-DNS> -f root

argcv_get splits that into {"/usr/bin/login", "-p", "-h", "<host>", "-f", "root"}. execv runs it. login(1) from util-linux reads -f: "do not perform authentication, user is preauthenticated." The named user is root. The connection's pty becomes a root shell before the caller has typed a password.

The telnet client never authenticated. The USER environment variable arrived over the NEW-ENVIRON option, which RFC 1572 defined in 1994 for client-to-server environment exchange. Telnet servers have read USER from NEW-ENVIRON since 1994. The shell tokenizer split the value on whitespace. login(1) honored its own flag. None of those four components did anything they were not built to do.

The runnable form:

USER='-f root' telnet -a localhost

telnet -a is the client's "negotiate the NEW-ENVIRON option and ship the current USER env value." On a Trisquel 11 laptop with the default inetutils-telnetd package, Simon Josefsson's advisory documents the result: an interactive root@trisquel:~# prompt.

The CVE names one variable. The patch sanitized seven.

Paul Eggert's first patch (fd702c0, 2026-01-20 01:10 PT) is six lines:

     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 !\"#$&'()*;<=>?[\\^`{|}~")])
+			? u : "");
+      }

u && *u != '-' rejects leading dashes, so -f no longer reaches argv. strcspn against the shell-metachar set rejects whitespace, redirection, quoting, command separators, and anything argcv_get would treat as a delimiter. Fail either filter, return the empty string. That commit closes the public proof of concept. NVD's vulnDescription names exactly this variable.

Four hours later, Simon Josefsson committed ccba9f7. It extracts the same logic into a function and calls it from every variable expansion:

+static char *
+sanitize (const char *u)
+{
+  /* Ignore values starting with '-' or containing shell metachars, as
+     they can cause trouble.  */
+  if (u && *u != '-' && !u[strcspn (u, "\t\n !\"#$&'()*;<=>?[\\^`{|}~")])
+    return u;
+  else
+    return "";
+}

     case 'h':
-      return xstrdup (remote_hostname);
+      return xstrdup (sanitize (remote_hostname));
     case 'l':
-      return xstrdup (local_hostname);
+      return xstrdup (sanitize (local_hostname));
     case 'L':
-      return xstrdup (line);
+      return xstrdup (sanitize (line));
     case 't':
       q = strchr (line + 1, '/');
       if (q) q++; else q = line;
-      return xstrdup (q);
+      return xstrdup (sanitize (q));
     case 'T':
-      return terminaltype ? xstrdup (terminaltype) : NULL;
+      return terminaltype ? xstrdup (sanitize (terminaltype)) : NULL;
     case 'u':
-      return user_name ? xstrdup (user_name) : NULL;
+      return user_name ? xstrdup (sanitize (user_name)) : NULL;
     case 'U':
-      { ... inline filter from Paul's patch ... }
+      return xstrdup (sanitize (getenv ("USER")));

Six brand-new sanitize() calls. Plus the refactor of Paul's case 'U' to use the same extracted function. Seven cases total. The cases:

  • %h is remote_hostname, populated by getnameinfo() or gethostbyaddr() reverse DNS at connection setup.
  • %l is local_hostname, from the server's own gethostname().
  • %L is the pty line device path.
  • %t is line with the path prefix stripped.
  • %T is terminaltype, populated by the client over the TERMINAL-TYPE subnegotiation defined by RFC 1091.
  • %u is user_name, populated by the Kerberos path when AUTHENTICATION is compiled in.
  • %U is the USER env var the CVE names.

Two of the six cases the CVE does not name are directly attacker-controlled. %h flows from reverse DNS, owned by whoever runs the PTR record for the attacker's IP. %T flows from the client's TERMINAL-TYPE subnegotiation, which the attacker types byte for byte. Either one would carry -f root into argv on any future template that happened to drop them next to the login binary.

Simon's advisory said this aloud, in the same paragraph that asked for a CVE:

"Thus there is potential for similar vulnerabilities for other variables. On non-GNU/Linux systems, only the remote hostname field is of interest. The remote_hostname variable is populated in the function telnetd_setup from telnetd/telnetd.c by calling getnameinfo() or gethostbyaddr() depending on platform. This API is generally not considered to return trusted data, thus relying on it to not return a value such as 'foo -f root' is not advisable."

CVE-2026-24061 covers case 'U'. The patch series closes that case and six others the same patch session decided were the same shape.

The third commit was the architectural fix

Two days later, on 2026-01-22, Simon committed 38b78ad. Title: "Pass USER to /bin/login after a '--' delimiter."

 #else /* !SOLARIS */
-  PATH_LOGIN " -p -h %h %?u{-f %u}{%U}"
+  PATH_LOGIN " -p -h %h %?u{-f -- %u}{-- %U}"
 #endif

-- is login(1)'s end-of-options marker. Everything after it parses as a positional username, not a flag. With -- in the template, even an unsanitized -f root lands as argv[5] = "-f root", a username login(1) rejects as invalid. The structural defense is one delimiter, three bytes long, in a template that has existed since the 1990s.

The patch sequence read in order: sanitize one input, sanitize six more inputs, then add the delimiter that would have made the input filter unnecessary. The fix the bug deserved was the third commit. The first two are what shipped on the morning of the disclosure because the third commit was not ready yet.

The Solaris and SOLARIS10 template branches still do not have --:

#ifdef SOLARIS10
  PATH_LOGIN " -p -h %h %?T{-t %T} -d %L %?u{-u %u}{%U}"
#elif defined SOLARIS
  PATH_LOGIN " -h %h %?T{%T} %?u{-- %u}{%U}"

The SOLARIS branch on line 55 has a -- between %?u{ and %u, the Kerberos arm, but not before {%U}, the env-var fallback the 2015 commit added. The SOLARIS10 branch has no delimiter anywhere. Both branches rely on sanitize() and only on sanitize(). If sanitize()'s metachar set ever drifts off the shell tokenizer's metachar set, or login(1) on Solaris adds a flag whose first character is a letter rather than -, those branches are the bug again. The Linux branch has both barriers. The other two branches have one.

The pattern: convention was the allowlist

RFC 1572 (Telnet Environment Option, January 1994) defines NEW-ENVIRON and lists USER as one of the "well-known" environment variables a telnet client may send. The RFC's definition: "the name of the user for which the login is being attempted." The convention is that USER is a username. Usernames on POSIX systems are [a-zA-Z0-9._-]+ per useradd's default NAME_REGEX, and that convention has lived in every spec author's head and every operator's /etc/passwd for the thirty-two years since the RFC was published.

The convention was never enforced. NEW-ENVIRON's wire format is IAC SB NEW-ENVIRON IS VAR "USER" VALUE <byte string until IAC SE>. The VALUE field is bytes. inetutils-telnetd parsed at the byte-string type and accepted everything the type permitted. The implicit allowlist (username-shaped strings) was spec prose. The 2015 autologin commit promoted that byte string into argv. The 2026 patch installed a narrower allowlist (no leading -, no shell metachars) inside sanitize(). What gets through sanitize() is whatever happens to satisfy that filter today, plus any case 'X' a future maintainer adds to _var_short_name without remembering to wrap the return in sanitize().

This is Convention Is The Allowlist, the protocol-environment-variable instance. The convention lived in RFC 1572 and /etc/passwd. The code never installed it. The patch's narrow filter is the developer making the convention partially explicit on the morning of the disclosure. The Solaris template branches are the same convention still informal.

The 2007 prior art was on bugtraq

CVE-2007-0882 was assigned on 2007-02-12 against Sun Solaris 10 in.telnetd. The mechanism was identical: in.telnetd passed the USER environment variable to login(1) as argv, login's -f flag bypassed authentication, the operational form was telnet -l '-f root' host. The bug was on bugtraq within twenty-four hours of disclosure. Sun's patch added a -- separator.

Mats Erik Andersson's 2015 commit shipped the {%U} branch eight years after that disclosure. The codebase is different. The mechanism is not.

Simon's advisory opens, exactly:

"If you are tired of modern age vulnerabilities, and remember the good old times on bugtraq, I hope you will appreciate this one. If someone can allocated a CVE, we will add it in future release notes."

The acknowledgment that the trick is old is the disclosure author's reading of the same prior art. The trick was old. The trick worked, eleven years dormant in inetutils, until Carlos Cortes Alvarez (Kyu Neushwaistein) tried USER='-f root' telnet -a localhost against a Trisquel laptop on 2026-01-19 and got a root shell.

The KEV gap was six days

The oss-security disclosure landed at 15:00 CET on 2026-01-20. NVD published CVE-2026-24061 the following day. CISA added it to the Known Exploited Vulnerabilities catalog on 2026-01-26 with a federal-agency remediation deadline of 2026-02-16. GreyNoise Labs observed eighteen unique source IPs running exploit attempts within eighteen hours of the KEV addition, against fewer than three thousand reachable hosts on Censys's port:23 protocol:TELNET count.

The campaign was real. The targets are not high-value. The math is that any vulnerability whose proof of concept fits in a one-line shell command will be scanned in the same week it is disclosed, regardless of how legacy the surface is. inetutils-telnetd is not running on Fortune 500 perimeters. It is running on Trisquel laptops, on FOSS-only embedded NAS units, on academic lab routers, and on whatever fraction of Censys's 3K telnet banners actually run inetutils rather than busybox. The attackers do not care about the population. The cost of trying the payload is one TCP connection.

What the third commit admits

The first two patches sanitize input. The third adds the -- delimiter that makes the input filter unnecessary on Linux. The three-commit sequence is a public record of the maintainers working out, in real time, what the actual fix was.

PoC: tc4dy/CVE-2026-24061-PoC-Exploit, K3ysTr0K3R/CVE-2026-24061.

The CVE names one switch case. The patch sanitized seven, added one delimiter, and left two of the three template branches still relying entirely on the input filter.