GHSA-Q6XX-5VR8-P898

Vulnerability from github – Published: 2026-06-26 22:31 – Updated: 2026-06-26 22:31
VLAI
Summary
Nezha vulnerable to cross-tenant terminal/file-manager session hijack via WebSocket stream UUID without ownership check
Details

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

  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://<dashboard>/ws/terminal/<UUID>. Capture this UUID from the network inspector, server access log, or Referer header.
  3. From a separate session logged in as member, open wss://<dashboard>/ws/terminal/<UUID> (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.
Show details on source website

{
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…