*How a forgotten environment-variable filter in GNU InetUtils still hands you remote code execution — even on systems that applied the CVE-2026–24061 patch.*

## The headline you already saw

In January 2026, the security industry collectively woke up to CVE-2026–24061: a critical authentication bypass in `inetutils-telnetd`. The exploit was famously trivial — set the Telnet `USER` environment variable to `-f root` via the `NEW_ENVIRON` sub-negotiation, and the daemon would obediently expand its login command to:

/usr/bin/login -h <client> -f root

The `-f` flag tells `login(1)` to skip authentication entirely. One packet, zero credentials, instant root shell. CISA added it to the KEV catalog within days. SafeBreach, Horizon3, OffSec, SonicWall, and a long list of others published proof-of-concepts. Distro maintainers shipped patches. Everyone moved on.

Except the patch only closes one door. A different door — arguably wider — was already open in the same code, and it's still open today on every version of `inetutils-telnetd` that the patch ships against.

This is the story of that second door.

## Recap: how the original bug worked

`inetutils-telnetd`'s login template, set in `telnetd.c`:

char *login_invocation = PATH_LOGIN " -p -h %h %?u{-f %u}{%U}";

When a client completes Telnet sub-negotiation, the daemon expands this template. The `%U` placeholder gets substituted with the value of the `USER` environment variable — which the client controls via `RFC 1572 NEW_ENVIRON`.

The expanded command goes through `argcv_get()`, which splits on whitespace. So if `USER="-f root"`, the resulting `argv` to `execv()` becomes:

argv[0] = /usr/bin/login argv[1] = -p argv[2] = -h argv[3] = client.example argv[4] = -f ← injected argv[5] = root ← injected

The patch ([commit in inetutils 2.7–2](https://www.gnu.org/software/inetutils/)) added a `sanitize()` function that rejects USER values starting with `-` or containing shell metacharacters: `\t\n !"#$&'()*;<=>?[\^`{|}~`.

That stops `-f root`. It does not stop everything else.

## The other door

While verifying the patch, I went looking for what else passes through `NEW_ENVIRON`. The code that handles it is in `state.c`:

case NEW_ENV_VAR: case ENV_USERVAR: *cp = '\0' if (valp) setenv (varp, valp, 1); // ← line 1499 else unsetenv (varp); cp = varp = (char *) subpointer; valp = 0; break;

This loop calls `setenv()` for **every** `VAR=VALUE` pair the client sends. There is no allowlist on the variable name. The wire client can set arbitrary environment variables in the daemon's process — and those variables are inherited by the child shell after `execv()`.

There's a sanity layer: `scrub_env()` in `pty.c`, called just before `exec`:

scrub_env (void) { extern char **environ; char **cpp, **cpp2; cpp2 = cpp = environ; while (*cpp) { if (strncmp (*cpp, "LD_", 3) && strncmp (*cpp, "_RLD_", 5) && strncmp (*cpp, "LIBPATH=", 8) && strncmp (*cpp, "IFS=", 4)) *cpp2++ = *cpp; cpp++; } *cpp2 = NULL; }

It's a *blocklist* of four prefixes: `LD_`, `_RLD_`, `LIBPATH=`, `IFS=`. Everything else passes through.

Take a moment to enumerate the dangerous environment variables that aren't on that list:

- `BASH_ENV` — bash sources whatever file this points to on startup - `ENV` — POSIX `sh` does the same - `TERMCAP` — termcap entries with `:tc=` chains can execute commands in some implementations - `NLSPATH` — affects libc message catalog lookup - `LOCPATH` — points libc at attacker-controlled locale data - `LANGUAGE` — used by some locale paths - `PERL5OPT`, `PYTHONSTARTUP` — if the login shell happens to invoke these interpreters - `TERMINFO` — similar to termcap

The two that matter most are `BASH_ENV` and `ENV`. Both are documented as startup-script paths, with subtly different trigger conditions:

- `BASH_ENV` is sourced by **non-interactive** bash invocations. The interactive login shell itself does *not* read it directly — but the moment that shell spawns any subshell (command substitution `$(…)`, an `eval`, `bash -c "…"` from a startup script, the `command-not-found` handler on Debian/Ubuntu, virtualenv prompt helpers, completion scripts, anything), the subshell sources `BASH_ENV` immediately on startup. - `ENV` is sourced by **interactive POSIX-style** shells (`dash`, `sh`, `bash — posix -i`). On many systems where `/bin/sh` is `dash` and a user's account uses `/bin/sh` as its login shell, this fires the moment `login` execs the shell.

So the realistic chain looks like:

1. Pre-auth: attacker connects to telnetd, sends `BASH_ENV=/tmp/x` and `ENV=/tmp/x` via `NEW_ENVIRON` 2. Pre-auth: `setenv()` is called for both names in the daemon 3. Pre-auth: `scrub_env()` runs, doesn't strip either 4. Pre-auth: `execv("/usr/bin/login", …)` runs with the poisoned environment 5. `login` does its normal job — prompts for credentials, validates them 6. *Some user, at some point, authenticates successfully* 7. `login` execs the user's shell with the inherited environment

**If the shell is `/bin/sh`/`dash`** (interactive POSIX): `ENV` fires immediately on startup, sourcing `/tmp/x` before any user prompt.

**If the shell is `/bin/bash`** (interactive login): the login shell itself doesn't read `BASH_ENV`, but virtually any non-interactive subshell triggered from `~/.bash_profile`, `/etc/profile`, `/etc/profile.d/*.sh`, or downstream user activity will source it. On a typical Debian/Ubuntu/Kali system this happens within seconds — `command-not-found`, `lesspipe`, and completion machinery all spawn subshells.

8. Code execution as the now-logged-in user.

Step 6 is "any legitimate user logging in." The attacker has no role beyond setting the trap. The trigger is automatic, and on `dash`-as-`/bin/sh` systems it's near-instant.

## Verifying it lives

I built `inetutils-2.5` (within the patch's affected range) from the official tarball, ran it via a small custom inetd wrapper, and wrote a Python PoC that sends a single batched `NEW_ENVIRON` sub-negotiation containing seven candidate variables.

The login program was replaced (via ` — exec-login`) with a script that prints its environment, so we can see exactly what survives `scrub_env()`.

[+] server greeting: fffd18fffd20fffd23fffd27fffd24 [+] Sent ONE batched NEW_ENVIRON with 8 vars BASH_ENV='/tmp/poc_g4_payload.sh' ENV='/tmp/poc_g4_payload.sh' LD_PRELOAD='/tmp/poc_g4.so' IFS='_' TERMCAP='/tmp/poc_g4_termcap' NLSPATH='/tmp/poc_g4_nls/%N' LANGUAGE='evil' USER='testuser'

[+] Survival analysis: SURVIVED: BASH_ENV=/tmp/poc_g4_payload.sh SURVIVED: ENV=/tmp/poc_g4_payload.sh BLOCKED: LD_PRELOAD BLOCKED: IFS SURVIVED: TERMCAP=/tmp/poc_g4_termcap SURVIVED: NLSPATH=/tmp/poc_g4_nls/%N SURVIVED: LANGUAGE=evil

`BASH_ENV` reaches the spawned shell. `ENV` reaches the spawned shell. The CVE-2026–24061 patch — which sanitizes the `USER` variable — does not touch any of this. The patch and this issue exist in different code paths.

The wire bytes are simple. A single `NEW_ENVIRON IS` sub-negotiation with multiple `VAR=VALUE` pairs:

IAC SB NEW-ENVIRON IS VAR "BASH_ENV" VALUE "/tmp/payload.sh" VAR "USER" VALUE "anyone" IAC SE

That's 37 bytes for the sub-negotiation plus 3 bytes for the prior `IAC WILL NEW-ENVIRON` — call it 40 bytes total on the wire (TCP/IP overhead aside). No exploit framework, no heap shaping, no race condition, no special privilege. Just a Telnet client and a payload file the attacker arranged to write earlier.

## Why everyone missed it

Three reasons, ranked by my best guess.

**1. The CVE patch was scoped to USER.** The commit message and CVE write-up framed the bug as "argument injection via USER." The fix sanitizes USER. Reviewers checked that the fix matched the bug. Nobody asked: "what other variables can we set?"

**2. `scrub_env` looks done.** It exists. It removes `LD_PRELOAD`. To a quick glance, it's "the env filter," and "the env filter exists" is the kind of fact you stop investigating. You have to compare its blocklist against the modern landscape of dangerous env vars to notice what's missing — and that landscape has grown over the decades since this code was written.

**3. The trigger isn't immediate.** A clean exploit would crash the daemon or grant a shell on the spot. This one *waits* — for a legitimate user to log in. Detection through standard pen-test tooling (which expects a shell to pop) misses it. SOC dashboards see normal `login` events. The attacker writes a payload to `/tmp` and disappears; the actual code execution happens hours or days later, attributed to the user who logged in.

## Comparing implementations

I checked the same pattern across the three other major Telnet daemons:

| Implementation | Allowlist on `NEW_ENVIRON` var names? | `BASH_ENV` survives to shell? | |||| | **GNU InetUtils** | **No** | **Yes** ✗ | | netkit-telnet | Yes — `TERM`, `DISPLAY`, `USER`, `LOGNAME`, `POSIXLY_CORRECT` | No | | FreeBSD telnetd | Per-prefix allowlist (looser) | `TERMCAP` yes, `BASH_ENV` no | | BusyBox telnetd | N/A — passes env to `/bin/login` directly | depends on login behavior |

netkit-telnet — the older Linux/Debian implementation that GNU InetUtils forked from — *added* an allowlist at some point that constrains `NEW_ENVIRON` to five known-safe names. GNU InetUtils never adopted that hardening. The fork diverged before the protection was added, and the fix never propagated back.

This is the kind of regression that happens silently. There's no test that asserts "if I send `BASH_ENV` over the wire, it does not reach the spawned shell." There's no fuzzer that walks the `glibc` env-variable manpage and tries each name through every protocol. The bug exists in the *gap between* what the developers thought they were filtering and what the C library actually treats as load-bearing.

## Mitigation

In rough order of "how seriously you should take this":

1. **Don't run telnet on anything reachable.** This is the right answer. Telnet sends credentials in cleartext. There has been no defensible reason to run it over an untrusted network in 25 years. SSH exists. Use SSH.

2. **If you must run telnet (embedded, OT/ICS, legacy):** — Switch to BusyBox telnetd or another implementation that does not have this template-substitution path. — Or patch `pty.c`'s `scrub_env` from a blocklist to an *allowlist*. Keep `TERM`, `DISPLAY`, `LANG`, maybe a handful of `LC_*`. Strip everything else.

3. **If you can't change the daemon:** — Block port 23 at the network edge. — Run a strict shell as the configured login shell — one that doesn't source startup files (`/sbin/nologin`, a custom `restricted_sh`). — On dash-as-`/bin/sh` systems, the `ENV` exposure is acute because `dash` reads it on every interactive startup. Setting `unset ENV` early in `/etc/profile` is a partial mitigation. — For bash-login systems, the `BASH_ENV` exposure is indirect (via subshells). Auditing `/etc/profile`, `/etc/profile.d/*.sh`, and `~/.bash_profile` for `unset BASH_ENV ENV` early in the chain helps.

4. **Detection:** — Network: any `IAC SB 0x27 IS … VAR … VALUE …` sub-negotiation containing variable names not in `{TERM, DISPLAY, USER, LOGNAME, POSIXLY_CORRECT, XAUTHORITY, PRINTER, LANG}` is suspicious. — Endpoint: monitor for `login` processes whose environment includes `BASH_ENV` or `ENV`. There's no legitimate code path where this should happen. — File integrity: monitor `/tmp`, `/var/tmp`, `/dev/shm`, and any world-writable directory for short shell scripts created by the `nobody` or `telnetd` user shortly before a login event.

## What this exercise actually tells us

Two patterns kept showing up across the audit:

**Patches scoped to a CVE only fix the CVE.** The attacker class that wrote the original `USER=-f root` exploit doesn't stop reading the source after their first finding. The defender class that ships the patch often does.

**Blocklists rot.** `scrub_env`'s blocklist was probably state-of-the-art when it was written. `LD_PRELOAD` was the canonical "dangerous env var" of its era. `BASH_ENV` either didn't exist yet or wasn't yet understood as a privilege-boundary problem. Time passed. The blocklist didn't.

When you find a bug class — environment injection, argument injection, format strings, integer overflows — the question to ask isn't "is this specific instance fixed?" It's "where else does this pattern exist in the same code, and where might a similar pattern exist in code I didn't audit yet?"

For `inetutils-telnetd`, the answer to "where else does environment injection live?" is: everywhere, by design, because `NEW_ENVIRON` exists. The protection is supposed to be in `scrub_env`. The protection is wrong.

## Reproducer

I'm not posting the full PoC code here, but the core is small enough to describe end-to-end:

1. Open a TCP socket to the daemon 2. Read the initial 15-byte IAC negotiation 3. Reply `IAC WILL NEW-ENVIRON` (3 bytes) 4. Send the malicious sub-negotiation: IAC SB NEW-ENVIRON IS VAR "BASH_ENV" VALUE "/tmp/x" VAR "USER" VALUE "anyuser" IAC SE 5. Disconnect

Steps 1–5 take under a second and require no authentication. The payload at `/tmp/x` (which you arranged to write earlier — that's the "real" attacker work) executes when any user next logs in via bash.

This is one of those bugs where the proof of concept is less impressive than the implication. There's no clever heap shaping. No corrupted state machine. No race. It's a missing line in a blocklist.

## Disclosure

This finding is shared as defensive research on publicly available open-source code. The bug class — environment injection through a permissive `setenv()` flowing into a permissive `scrub_env()` — has been latent in `inetutils-telnetd` since at least the early 2000s. It does not require new exploit development; the wire protocol and code are exactly as they have been.

If you operate `inetutils-telnetd` and patched CVE-2026–24061 thinking the issue was contained: it isn't. Patch the env filter, or — better — turn the service off.

*This article documents an issue in publicly available open-source code (GNU InetUtils). Reproduction was performed against a locally-built daemon in a controlled environment. No production systems were attacked. The intended audience is defenders, software maintainers, and security researchers — please apply the mitigations above before this becomes someone else's incident report.*