GHSA-Q6XX-5VR8-P898
Vulnerability from github – Published: 2026-06-26 22:31 – Updated: 2026-06-26 22:31Summary
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": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/nezhahq/nezha"
},
"ranges": [
{
"events": [
{
"introduced": "1.14.13"
},
{
"last_affected": "1.14.14"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.0.9"
},
"package": {
"ecosystem": "Go",
"name": "github.com/nezhahq/nezha"
},
"ranges": [
{
"events": [
{
"introduced": "2.0.0"
},
{
"fixed": "2.0.10"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-639",
"CWE-862"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-26T22:31:41Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"details": "### Summary\n\nIn nezha **v1.14.13\u2013v1.14.14** and **v2.0.0\u2013v2.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 \u2014 i.e. cross-tenant RCE.\n\nThis was silently fixed in commit [`6661d6a`](https://github.com/nezhahq/nezha/commit/6661d6a7fc1c269f55c7f4e775082ad23fbe0f54) (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.\n\n### Details\n\n**Stream allocation \u2014 `service/rpc/io_stream.go` (v2.0.9):**\n\n```go\nfunc (s *NezhaHandler) CreateStream(streamId string) {\n s.ioStreamMutex.Lock()\n defer s.ioStreamMutex.Unlock()\n\n s.ioStreams[streamId] = \u0026ioStreamContext{\n userIoConnectCh: make(chan struct{}),\n agentIoConnectCh: make(chan struct{}),\n }\n}\n```\n\nNo creator is bound to the stream.\n\n**Stream attach \u2014 `cmd/dashboard/controller/terminal.go` (v2.0.9):**\n\n```go\n// @Router /ws/terminal/{id} [get]\nfunc terminalStream(c *gin.Context) (any, error) {\n streamId := c.Param(\"id\")\n if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil {\n return nil, err\n }\n defer rpc.NezhaHandlerSingleton.CloseStream(streamId)\n // ... WebSocket upgrade and bidirectional pipe ...\n}\n```\n\nThe only authorization check is `GetStream(streamId)` \u2014 \"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`.\n\n**Where the UUID leaks:**\n\n`createTerminal` returns the UUID to the legitimate client, which then opens `wss://\u003cdashboard\u003e/ws/terminal/\u003cUUID\u003e`. As a URL path component the UUID is exposed via:\n\n- Reverse-proxy access logs (nginx, Caddy, Cloudflare).\n- Referer headers when the page embeds external resources or error reporters.\n- Browser history / bookmark sync.\n- Frontend telemetry (Sentry, Bugsnag) breadcrumbs that include the WebSocket URL.\n- Any shared-tenant or multi-operator log viewer.\n\nAny authenticated user with access to one of these side channels can attach to a live session.\n\n### PoC\n\n1. 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).\n2. As `admin`, open the web terminal for the server. The browser opens `wss://\u003cdashboard\u003e/ws/terminal/\u003cUUID\u003e`. Capture this UUID from the network inspector, server access log, or `Referer` header.\n3. From a separate session logged in as `member`, open `wss://\u003cdashboard\u003e/ws/terminal/\u003cUUID\u003e` (same UUID). The member\u0027s WebSocket attaches to the same `ioStreamContext` because `terminalStream` only checks `GetStream(streamId)` \u2014 no ownership check.\n4. The member can now read the admin\u0027s shell output and inject keystrokes, achieving shell-level RCE on the target server, with no visible signal to the legitimate session owner.\n\nSame flow works against `/ws/file/:id` (file-manager hijack: arbitrary read/write on the target server\u0027s filesystem).\n\n### Impact\n\n- **Severity**: Critical. Interactive RCE on a server administered by another user, with no audit signal to the rightful session owner.\n- **Attack complexity**: Low. The attacker needs an authenticated dashboard account (which any `RoleMember` is) and one captured UUID from a side channel.\n- **Confidentiality / Integrity / Availability**: all High. `/ws/file/:id` exposes arbitrary read+write on the target filesystem; `/ws/terminal/:id` is a full shell.\n\nThis 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 \u2014 attackers do not need direct dashboard interaction once the UUID is leaked through logs or telemetry.\n\n### Fix reference\n\nAlready fixed in master by commit [`6661d6a`](https://github.com/nezhahq/nezha/commit/6661d6a7fc1c269f55c7f4e775082ad23fbe0f54) (\"fix(rpc): bind io_stream sessions to creator to prevent terminal/fm hijack\"):\n\n- `CreateStream` now accepts a `creatorUserID uint64` and stores it on the `ioStreamContext`.\n- New `IsStreamAuthorizedForUser(streamId, userID, isAdmin)` helper.\n- `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.\n\nShipped in v2.0.10 (2026-05-19). The v1.14 line has not received a backport.\n\n### Why this advisory\n\nThe fix landed silently. The other May 17\u201321 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 \u2014 but none cover the terminal / file-manager session hijack. This advisory closes that gap so operators of v1.14.x and v2.0.0\u2013v2.0.9 know to upgrade.\n\n### Recommended action\n\n- Publish this GHSA so v2.x operators below v2.0.10 see the alert in their dependency scanners.\n- 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.",
"id": "GHSA-q6xx-5vr8-p898",
"modified": "2026-06-26T22:31:41Z",
"published": "2026-06-26T22:31:41Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nezhahq/nezha/security/advisories/GHSA-q6xx-5vr8-p898"
},
{
"type": "PACKAGE",
"url": "https://github.com/nezhahq/nezha"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Nezha vulnerable to cross-tenant terminal/file-manager session hijack via WebSocket stream UUID without ownership check"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.