-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The escape filter knows about one shell metacharacter\n\nThe 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`.\n\nHere is the pre-patch join, exactly as Matej Marusak wrote it in 2021:\n\n```javascript\nlet cmd = journal.build_cmd(match, service_options);\n\n// cribbed from Python's shlex.quote() ← this comment was added in 2026\ncmd = cmd.map(i => i.replaceAll(\" \", \"\\\\ \")).join(\" \");\ncmd = \"set -o pipefail; \" + cmd + \" | grep SYSLOG_IDENTIFIER= | sort -u\";\ncockpit.spawn([\"/bin/bash\", \"-ec\", cmd], { superuser: \"try\", err: \"message\" })\n```\n\nThe 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`.\n\nThe URL options flow into the argv unmolested. Reading `pkg/lib/journal.js`:\n\n```javascript\nif (options.since)\n cmd.push(\"--since=\" + options.since);\nif (options.until)\n cmd.push(\"--until=\" + options.until);\nif (options.grep)\n cmd.push(\"--grep=\" + options.grep);\nif (options.priority)\n cmd.push(\"--priority=\" + options.priority);\nif (options.cursor)\n cmd.push(\"--cursor=\" + options.cursor);\nif (options.after)\n cmd.push(\"--after=\" + options.after);\n```\n\nEvery 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.\n\nThe Hakai PoC reproduces the server-side concatenation client-side and delivers the resulting bash command directly to Cockpit's WebSocket channel:\n\n```python\ndef build_injection(shell_cmd: str) -> str:\n payload: str = f\"$({shell_cmd})\"\n args: list[str] = [\n \"journalctl\", \"-q\", \"--no-tail\", \"--output=verbose\",\n \"PAYLOAD_PLACEHOLDER\", \"--priority=err\", \"--reverse\", \"--\"]\n cmd: str = \" \".join(i.replace(\" \", \"\\\\ \") for i in args)\n cmd = cmd.replace(\"PAYLOAD_PLACEHOLDER\", f\"--since={payload}\")\n return \"set -o pipefail; \" + cmd + \" | grep SYSLOG_IDENTIFIER= | sort -u\"\n```\n\nAuthenticated 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.\n\n## The shell was an admitted mistake\n\nThe 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:\n\n```javascript\n// Ideally this would use `--output cat --output-fields SYSLOG_IDENTIFIER` and do\n// without `sh -ec`, grep, sort, replaceAll and all of those ugly stuff\n// For that we however need newer systemd that includes https://github.com/systemd/systemd/issues/13937\n```\n\nRead 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.\n\nThe commit message says explicitly: *\"Finally mention the right approach that we cannot do just now due to insufficient support from OSes we currently support.\"*\n\nThat sentence is the codebase's own description of CVE-2026-4802, written sixty-one months before the CVE was assigned.\n\n## The 2026 patch did not implement the comment\n\nWhen the bug landed in April 2026, Red Hat shipped commit `e3a47d70` with this diff:\n\n```diff\n- cmd = cmd.map(i => i.replaceAll(\" \", \"\\\\ \")).join(\" \");\n+ // cribbed from Python's shlex.quote()\n+ cmd = cmd.map(i => `'` + i.replaceAll(`'`, `'\"'\"'`) + `'`).join(\" \");\n```\n\nThe 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.\"*\n\nThe 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.\n\nWhat 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.\n\nThe 2026 commit message calls the shlex.quote port \"trivial.\" The trivial fix was five years away.\n\nThat 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.\n\n## The Dockerfile in the PoC repository removes the terminal applet\n\nThe Hakai PoC ships a Dockerfile that builds a vulnerable Cockpit instance for testing. Three of its instructions read in order:\n\n```dockerfile\nRUN useradd -m viewer && echo \"viewer:viewer\" | chpasswd\n\nRUN python3 -c \"\\\nimport json; \\\nf='/usr/share/cockpit/systemd/manifest.json'; \\\nm=json.load(open(f)); \\\nm.get('tools',{}).pop('terminal',None); \\\njson.dump(m,open(f,'w'),indent=4)\" && \\\n rm -f /usr/share/cockpit/systemd/terminal.*\n```\n\nThe 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.\n\nCockpit 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.\n\nThe 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.\n\n## The pattern: a TODO that shipped\n\nThis is [todo-that-shipped](/patterns/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.\n\nThe 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.\n\nPoC: [hakaioffsec/CVE-2026-4802](https://github.com/hakaioffsec/CVE-2026-4802)","closing_line":"The codebase wrote the sentence in 2021. The patch did not change it. The patch added a new sentence about Python.","hook_md":"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.","post_id":304,"slug":"cockpit-cve-2026-4802-the-todo-named-the-fix","title":"CVE-2026-4802: Cockpit's 2021 Comment Named the Fix. The 2026 Patch Kept the Shell.","type":"initial","unreadable_sentence":"That sentence is the codebase's own description of CVE-2026-4802, written sixty-one months before the CVE was assigned."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCagtxSAAKCRDeZjl4jgkQ Jp4MAP9SuW1XM8iSB7nBB9bsPIvu+XUukRyvacq1SU12KyvogAEAnI0B0zr2MNil UAPTzRD3hcMBKZAlu8+ngocL5DCtRgY= =Zv4E -----END PGP SIGNATURE-----