CVE-2026-4802 is a command injection in Cockpit's system logs page. CVSS 8.0, authentication required, user interaction required. Red Hat published the advisory in May 2026 under Bugzilla 2451155. The story is the comment three lines above the bug. It says: "Ideally this would use --output cat --output-fields SYSLOG_IDENTIFIER and do without sh -ec, grep, sort, replaceAll and all of those ugly stuff." Matej Marusak wrote it on March 10, 2021, in the same commit that introduced the line below it, the one that builds a shell string by escaping spaces and nothing else. Allison Karlitskaya patched that line on April 21, 2026, by changing the escape. The 2021 comment is still in the file. The architecture it names as the fix is still not implemented.
CVE-2026-4802: Cockpit's 2021 Comment Named the Fix. The 2026 Patch Kept the Shell.
pattern
cve
proof of concept
The escape filter knows about one shell metacharacter
The vulnerable function is loadServiceFilters in pkg/systemd/logsJournal.jsx. It runs every time a user visits the Cockpit system logs page, in order to populate the "services" filter dropdown with whatever syslog identifiers appear in the current query. The flow is straightforward: the URL fragment becomes cockpit.location.options, the options are handed to journal.build_cmd to assemble a journalctl argv, the argv is joined into a shell string, and the shell string is piped through /bin/bash -ec to a grep and sort.
Here is the pre-patch join, exactly as Matej Marusak wrote it in 2021:
let cmd = journal.build_cmd(match, service_options);
// cribbed from Python's shlex.quote() ← this comment was added in 2026
cmd = cmd.map(i => i.replaceAll(" ", "\\ ")).join(" ");
cmd = "set -o pipefail; " + cmd + " | grep SYSLOG_IDENTIFIER= | sort -u";
cockpit.spawn(["/bin/bash", "-ec", cmd], { superuser: "try", err: "message" })The escape is one call to replaceAll. It replaces a space with \ . It does not replace $. It does not replace (. It does not replace ). It does not replace ;, &, |, >, <, `, or newline. Every other shell metacharacter passes through, into a string that is then fed to bash as the script body of -ec.
The URL options flow into the argv unmolested. Reading pkg/lib/journal.js:
if (options.since)
cmd.push("--since=" + options.since);
if (options.until)
cmd.push("--until=" + options.until);
if (options.grep)
cmd.push("--grep=" + options.grep);
if (options.priority)
cmd.push("--priority=" + options.priority);
if (options.cursor)
cmd.push("--cursor=" + options.cursor);
if (options.after)
cmd.push("--after=" + options.after);Every one of those concatenations is a string-builder where one half is a fixed flag prefix and the other half is whatever the URL said. A user who navigates to …/systemd/logs#/?since=$(id) gets --since=$(id) placed in the argv unchanged, then space-escaped (nothing to escape), then handed to bash. Bash sees set -o pipefail; journalctl -q --no-tail --output=verbose --since=$(id) --priority=err --reverse -- | grep SYSLOG_IDENTIFIER= | sort -u and runs id as command substitution. Six options reach this code path. All six are injectable. The PoC picks since because it is the first one with an obvious place to put a payload, but the vulnerability is the join, not the parameter.
The Hakai PoC reproduces the server-side concatenation client-side and delivers the resulting bash command directly to Cockpit's WebSocket channel:
def build_injection(shell_cmd: str) -> str:
payload: str = f"$({shell_cmd})"
args: list[str] = [
"journalctl", "-q", "--no-tail", "--output=verbose",
"PAYLOAD_PLACEHOLDER", "--priority=err", "--reverse", "--"]
cmd: str = " ".join(i.replace(" ", "\\ ") for i in args)
cmd = cmd.replace("PAYLOAD_PLACEHOLDER", f"--since={payload}")
return "set -o pipefail; " + cmd + " | grep SYSLOG_IDENTIFIER= | sort -u"Authenticated as the unprivileged user viewer, the PoC writes /tmp/cockpit-rce-check containing the output of id. Authenticated as a user with sudo, the same channel spawns the bash as root, because the spawn requests superuser: "try" and "try" upgrades when the upgrade is available.
The shell was an admitted mistake
The comment above the bug is not a stylistic complaint. It is the author saying, in 2021, that the right way to populate the services dropdown is to ask journalctl for structured output and parse it in JavaScript:
// Ideally this would use `--output cat --output-fields SYSLOG_IDENTIFIER` and do
// without `sh -ec`, grep, sort, replaceAll and all of those ugly stuff
// For that we however need newer systemd that includes https://github.com/systemd/systemd/issues/13937Read the comment as testimony. The author understood that sh -ec was the dangerous primitive. They understood that replaceAll was a shaky way to feed a shell. They understood that the right fix was to remove the shell from the path entirely. They knew which systemd feature would let them do it; they linked the issue. And in the same commit that wrote this comment, they wrote the line that fed sh -ec an attacker-influenceable string with a one-character escape filter.
The commit message says explicitly: "Finally mention the right approach that we cannot do just now due to insufficient support from OSes we currently support."
That sentence is the codebase's own description of CVE-2026-4802, written sixty-one months before the CVE was assigned.
The 2026 patch did not implement the comment
When the bug landed in April 2026, Red Hat shipped commit e3a47d70 with this diff:
- cmd = cmd.map(i => i.replaceAll(" ", "\\ ")).join(" ");
+ // cribbed from Python's shlex.quote()
+ cmd = cmd.map(i => `'` + i.replaceAll(`'`, `'"'"'`) + `'`).join(" ");The commit message reads: "If we were in Python we'd be using shlex.quote() here. Unfortunately we don't have that in JavaScript but the approach is trivial, so let's use it here."
The fix is correct. Single-quoting each argv element, and escaping internal single quotes via the canonical '"'"' sequence, neutralises shell metacharacters in the joined string. After the patch, $(id) arrives at bash as the literal characters --since=$(id), journalctl gets --since=$(id) as a since argument, rejects it as an unparseable timestamp, and the channel returns an error. The CVE is closed.
What the patch did not do is implement the architecture the 2021 comment named. /bin/bash -ec is still in the spawn argv. grep SYSLOG_IDENTIFIER= | sort -u is still piped onto the end. The author of the 2026 commit added a new comment, // cribbed from Python's shlex.quote(), directly above the changed line, and left the 2021 comment three lines higher untouched. Both comments are now in the file. One names the architecture as wrong. The other names the escape as right. The architecture the first comment names is still not implemented.
The 2026 commit message calls the shlex.quote port "trivial." The trivial fix was five years away.
That is the difference between fixing a CVE and fixing a bug. CVE-2026-4802 names the specific failure of the specific escape on the specific date. The substrate that made the escape necessary in the first place, building shell strings from URL parameters and handing them to bash, is still the substrate. The next time someone adds an option to journal.build_cmd, or a future maintainer decides to skip the single-quote wrap on a code path that does not go through loadServiceFilters, the same bug class returns with a different CVE.
The Dockerfile in the PoC repository removes the terminal applet
The Hakai PoC ships a Dockerfile that builds a vulnerable Cockpit instance for testing. Three of its instructions read in order:
RUN useradd -m viewer && echo "viewer:viewer" | chpasswd
RUN python3 -c "\
import json; \
f='/usr/share/cockpit/systemd/manifest.json'; \
m=json.load(open(f)); \
m.get('tools',{}).pop('terminal',None); \
json.dump(m,open(f,'w'),indent=4)" && \
rm -f /usr/share/cockpit/systemd/terminal.*The Dockerfile creates an unprivileged user, then deletes the in-browser terminal applet from Cockpit's manifest. The deletion is the part that names the threat model.
Cockpit is the web administration interface that ships with Fedora, RHEL, and CentOS Stream. Its design contract with administrators is that a user with cockpit-only access, no SSH key, no sudoers entry, no physical console, can see and manage the parts of the system the admin grants them, and only those. The terminal applet is the explicit shell-access feature. An admin who wants to give a delegate full shell access turns it on. An admin who wants strictly-bounded read access turns it off. The applet is the boundary. The PoC's Dockerfile turns the applet off, then demonstrates that the user gets a shell anyway through the system logs page.
The CVSS vector reads PR:L and UI:R. The "user interaction required" piece captures the link-click mode: an admin who is logged in to Cockpit and clicks a crafted URL receives the injected command in their session, with sudo upgrade if their account has it. The PoC's three modes (check, exec, reverse) all authenticate as viewer directly and skip the link-click entirely. The viewer's shell is not root, but it is a shell on a host the admin's contract said viewer could not have a shell on. That contract is what Cockpit sells. The PoC's Dockerfile is what the patch should be measured against.
The pattern: a TODO that shipped
This is todo-that-shipped in a slightly off-canonical shape. The pattern in its strict form is an in-source comment that names a missing security control and adjacent code that operates without that control. The Cockpit comment does not say "TODO: add escaping." It says: do not use this primitive at all. The named fix is to remove the dangerous interpreter, not to harden the call into it. Five years on, the dangerous interpreter is still there. The harden-the-call patch is the one that landed.
The pattern's playbook applies in full. Grep your own codebases, and the codebases of your dependencies, for TODO, FIXME, XXX, HACK in any file that handles authentication, input parsing, filesystem operations, subprocess spawning, or template rendering. Sort by age. Anything older than eighteen months is the codebase telling you where it knows it is weak. Treat each such comment as a published vulnerability the project chose to leave in place. The 2021 Cockpit comment did exactly that, and a researcher reading the file in 2026 did exactly what attackers do: read the comment, found the gap below, wrote the exploit.
The codebase wrote the sentence in 2021. The patch did not change it. The patch added a new sentence about Python.