Nezha vulnerable to cross-tenant terminal/file-manager session hijack via WebSocket stream UUID without ownership check
📋 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):
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):
// @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://<dashboard>/ws/terminal/<UUID>. 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
- Deploy nezha v2.0.9. Add at least one server. Configure two accounts:
admin(RoleAdmin, owns the server) andmember(RoleMember, no access to that server). - As
admin, open the web terminal for the server. The browser openswss://<dashboard>/ws/terminal/<UUID>. Capture this UUID from the network inspector, server access log, orRefererheader. - From a separate session logged in as
member, openwss://<dashboard>/ws/terminal/<UUID>(same UUID). The member's WebSocket attaches to the sameioStreamContextbecauseterminalStreamonly checksGetStream(streamId)— no ownership check. - 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
RoleMemberis) and one captured UUID from a side channel. - Confidentiality / Integrity / Availability: all High.
/ws/file/:idexposes arbitrary read+write on the target filesystem;/ws/terminal/:idis 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"):
CreateStreamnow accepts acreatorUserID uint64and stores it on theioStreamContext.- New
IsStreamAuthorizedForUser(streamId, userID, isAdmin)helper. terminalStreamandfmStreamcall this helper before the WebSocket upgrade and before thedefer 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
6661d6ato a v1.14.15 release, or mark the v1.14 line end-of-life inSECURITY.mdso operators understand the support boundary.
🎯 Affected products2
- go/github.com/nezhahq/nezha:>= 1.14.13, <= 1.14.14
- go/github.com/nezhahq/nezha:>= 2.0.0, <= 2.0.9