GitHub Advisoryadvisory · Go

GHSA-q6xx-5vr8-p898 Nezha vulnerable to cross-tenant terminal/file-manager session hijack via WebSocket stream UUID without ownership check

CRITICAL Published 2026-06-26 Modified 2026-06-26 Official page ↗
Ecosystem
Go
Packages
github.com/nezhahq/nezha
Severity
Critical
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
Weaknesses
CWE-639, CWE-862
Reviewed
2026-06-26

Affected packages

• Go github.com/nezhahq/nezha: >= 1.14.13, <= 1.14.14 (no fix yet) • Go github.com/nezhahq/nezha: >= 2.0.0, < 2.0.10 → fixed in 2.0.10

Description

Summary In nezha v1.14.13–v1.14.14 and v2.0.0–v2.0.9, the WebSocket endpoints `GET /ws/terminal/:id` and `GET /ws/file/:id` authenticate the caller only by the presence of a valid stream UUID, with no ownership check tying that UUID to the user who created the stream. Any authenticated dashboard user (including a `RoleMember`) who learns a live stream UUID can attach to the session and gain interactive shell access or full file-manager control on the target server — i.e. cross-tenant RCE. This was silently fixed in commit `6661d6a` (2026-05-18, shipped in v2.0.10). At submission time no public CVE/GHSA covers this fix, so operators of v1.14.x and pre-v2.0.10 v2.x deployments have no signal that they are running vulnerable code. Details Stream allocation — `service/rpc/io_stream.go` (v2.0.9): ```go func (s *NezhaHandler) CreateStream(streamId string) { s.ioStreamMutex.Lock() defer s.ioStreamMutex.Unlock() s.ioStreams[streamId] = &ioStreamContext{ userIoConnectCh: make(chan struct{}), agentIoConnectCh: make(chan struct{}), } } ``` No creator is bound to the stream. Stream attach — `cmd/dashboard/controller/terminal.go` (v2.0.9): ```go // @Router /ws/terminal/{id} [get] func terminalStream(c *gin.Context) (any, error) { streamId := c.Param("id") if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil { return nil, err } defer rpc.NezhaHandlerSingleton.CloseStream(streamId) // ... WebSocket upgrade and bidirectional pipe ... } ``` The only authorization check is `GetStream(streamId)` — "does this UUID exist in the in-memory map". `getUid(c)` is never compared against the user who called `createTerminal`. The same pattern is present in `fmStream(c)` in `cmd/dashboard/controller/fm.go`. Where the UUID leaks: `createTerminal` returns the UUID to the legitimate client, which then opens `wss:///ws/terminal/`. As a URL path component the UUID is exposed via: • Reverse-proxy access logs (nginx, Caddy, Cloudflare). • Referer headers when the page embeds external resources or error reporters. • Browser history / bookmark sync. • Frontend telemetry (Sentry, Bugsnag) breadcrumbs that include the WebSocket URL. • Any shared-tenant or multi-operator log viewer. Any authenticated user with access to one of these side channels can attach to a live session. PoC 1. Deploy nezha v2.0.9. Add at least one server. Configure two accounts: `admin` (RoleAdmin, owns the server) and `member` (RoleMember, no access to that server). 2. As `admin`, open the web terminal for the server. The browser opens `wss:///ws/terminal/`. Capture this UUID from the network inspector, server access log, or `Referer` header. 3. From a separate session logged in as `member`, open `wss:///ws/terminal/` (same UUID). The member's WebSocket attaches to the same `ioStreamContext` because `terminalStream` only checks `GetStream(streamId)` — no ownership check. 4. The member can now read the admin's shell output and inject keystrokes, achieving shell-level RCE on the target server, with no visible signal to the legitimate session owner. Same flow works against `/ws/file/:id` (file-manager hijack: arbitrary read/write on the target server's filesystem). Impact • Severity: Critical. Interactive RCE on a server administered by another user, with no audit signal to the rightful session owner. • Attack complexity: Low. The attacker needs an authenticated dashboard account (which any `RoleMember` is) and one captured UUID from a side channel. • Confidentiality / Integrity / Availability: all High. `/ws/file/:id` exposes arbitrary read+write on the target filesystem; `/ws/terminal/:id` is a full shell. This is the same impact tier as CVE-2026-46716 (cross-tenant cron RCE) and arguably worse, because the entry point is a passively-leaked URL rather than an authenticated POST — attackers do not need direct dashboard interaction once the UUID is leaked through logs or telemetry. Fix reference Already fixed in master by commit `6661d6a` ("fix(rpc): bind io_stream sessions to creator to prevent terminal/fm hijack"): • `CreateStream` now accepts a `creatorUserID uint64` and stores it on the `ioStreamContext`. • New `IsStreamAuthorizedForUser(streamId, userID, isAdmin)` helper. • `terminalStream` and `fmStream` call this helper before the WebSocket upgrade and before the `defer CloseStream(streamId)`, so a rejected attempt does not tear down a legitimate stream. Shipped in v2.0.10 (2026-05-19). The v1.14 line has not received a backport. Why this advisory The fix landed silently. The other May 17–21 fixes received public GHSAs (GHSA-99gv-2m7h-3hh9, GHSA-rxf6-wjh4-jfj6, GHSA-hvv7-hfrh-7gxj, GHSA-w4g9-mxgg-j532, GHSA-6x26-5727-rrm9, GHSA-4g6j-g789-rghm) covering cron RCE, AlertRule trigger, telemetry leak, notification SSRF, DDNS SSRF, and agent forge-results respectively — but none cover the terminal / file-manager session hijack. This advisory closes that gap so operators of v1.14.x and v2.0.0–v2.0.9 know to upgrade. Recommended action • Publish this GHSA so v2.x operators below v2.0.10 see the alert in their dependency scanners. • Either backport `6661d6a` to a v1.14.15 release, or mark the v1.14 line end-of-life in `SECURITY.md` so operators understand the support boundary.

Related

Weakness · CWE (2)

  • CWE-639 Authorization Bypass Through User-Controlled Key weakness
  • CWE-862 Missing Authorization weakness

References