Skip to content

Nezha Monitoring 防御:Go 路径段匹配加固与 JWT 密钥隔离(CVE-2026-53519) ​

1. 威胁模型与漏洞根因回顾 ​

CVE-2026-53519(CVSS 9.1)是一条“路径遍历泄露配置与数据库 -> 伪造管理员 JWT”的高危组合漏洞链:

  1. 前缀匹配缺陷:服务端使用 strings.HasPrefix(c.Request.URL.Path, "/dashboard") 判断是否走前端静态资源分支,而非按 / 路径段匹配。/dashboard../data/config.yaml 满足前缀条件,随后 strings.TrimPrefix(..., "/dashboard") 剥离出 ../data/config.yaml。
  2. 无边界 path.Join:path.Join(AdminTemplate, "../data/config.yaml") 在目录逃逸后直接拼向运行数据目录,而由于原始 URL 首段为 dashboard..(非独立 ..),Go 标准库默认 traversal guard 未能提前阻断。
  3. 凭据集中暴露与 JWT 伪造:data/config.yaml 与 data/sqlite.db 分别包含 jwt_secret_key、agent_secret_key 及管理员 user_id,使攻击者离线签发有效 HS256 Cookie(nz-jwt)完成管理员接管。

2. 分层防御与加固策略 ​

防线层级控制措施目标资产效果
官方升级升级 Nezha Dashboard 至 >= 2.0.13监控控制面修复路径段校验并限制静态目录边界
凭据全量轮换轮换 jwt_secret_key、agent_secret_key、OAuth2 Secret 与管理员密码config.yaml / sqlite.db使历史可能泄露密钥签发的伪造 JWT 立即失效
代码级路径沙箱使用 path.Clean + 严格前缀 /dashboard/ 检查 + os.Root / filepath.Rel 边界断言Go Web 服务静态资源路由彻底阻断 TrimPrefix 与混合斜杠逃逸
反代与访问收口反向代理统一规范化 URI,拒绝含 .. 或 %2e 访问,限制管理端口 8008 公网直接暴露Nginx / Caddy / 防火墙在到达应用前阻断 /dashboard.. 变体
数据与密钥分离避免将 config.yaml 与静态模板置于同一父级相对路径,使用环境变量或 KMS 注入密钥部署架构即使静态服务异常也不暴露凭据文件

Go 语言安全静态资源路由写法 ​

go
// 加固方案:先路径段精确匹配,再强制校验 clean 后的相对路径不越界
func serveDashboardStatic(baseDir string, rawPath string) (string, error) {
    if rawPath != "/dashboard" && !strings.HasPrefix(rawPath, "/dashboard/") {
        return "", errors.New("route mismatch")
    }
    rel := strings.TrimPrefix(rawPath, "/dashboard")
    cleaned := path.Clean("/" + rel) // 先以根锚定消除所有 ../ 上浮
    fullBase, err := filepath.Abs(baseDir)
    if err != nil {
        return "", err
    }
    target := filepath.Join(fullBase, filepath.FromSlash(strings.TrimPrefix(cleaned, "/")))
    relCheck, err := filepath.Rel(fullBase, target)
    if err != nil || relCheck == ".." || strings.HasPrefix(relCheck, ".."+string(filepath.Separator)) {
        return "", errors.New("path traversal blocked")
    }
    return target, nil
}

前置 Nginx 阻断与管理面隔离配置 ​

nginx
# 规范化拦截任何变形前缀或点段穿越,禁止外部直访问 data 目录
location ~* "(\.\.|%2e%2e|%2f|\\\\)" {
    return 403;
}

location = /dashboard {
    return 301 /dashboard/;
}

location ^~ /dashboard/ {
    proxy_pass http://127.0.0.1:8008;
    proxy_set_header Host $host;
}

防御工作流 ​

  1. 版本与暴露面核查:确认 Nezha 版本是否 < 2.0.13,检查 8008 端口是否直接暴露在公网且前置反代未做路径规范化过滤。
  2. 日志回溯与止血:检查反代与 Nezha 日志中是否存在 /dashboard..、/dashboard%2e%2e、config.yaml、sqlite.db 请求;立即部署反代拦截规则并升级到 >= 2.0.13。
  3. 密钥与会话轮换:重新生成高强度随机 jwt_secret_key(至少 256-bit 随机熵)与 agent_secret_key,重置管理员口令及关联 OAuth2 Client Secret,迫使所有既有 nz-jwt 会话重新认证。
  4. JWT 验签与边界回归:运行下方校验脚本确认路径变体全部被拒,且旧密钥伪造的 JWT 无法通过验证。

3. Python 路径段边界与 JWT 密钥强度校验脚本 ​

python
#!/usr/bin/env python3
"""CVE-2026-53519 路径段隔离校验与 HS256 JWT 密钥强度检查工具。"""

from pathlib import Path
from urllib.parse import unquote
import hmac
import hashlib
import base64
import json
import posixpath
import secrets


def safe_dashboard_resolve(raw_url_path: str, static_root: str = "/opt/nezha/admin-dist") -> tuple[bool, str]:
    decoded = unquote(raw_url_path)
    if "%" in decoded or "\\" in decoded or "\x00" in decoded:
        return False, "rejected: double encoding or illegal char"
    # 必须严格匹配 /dashboard 或 /dashboard/ 完整路径段
    if decoded != "/dashboard" and not decoded.startswith("/dashboard/"):
        return False, f"rejected: invalid prefix segment ({decoded})"

    rel = decoded[len("/dashboard"):]
    if ".." in posixpath.normpath(rel).split("/") or "/../" in rel or rel.endswith("/.."):
        return False, "rejected: traversal segment"
    anchored = posixpath.normpath("/" + rel)
    candidate = Path(static_root).resolve() / anchored.lstrip("/")
    resolved_root = Path(static_root).resolve()
    try:
        candidate.resolve().relative_to(resolved_root)
    except ValueError:
        return False, "rejected: escaped static root"
    return True, str(candidate)


def verify_hs256_jwt(token: str, current_secret: str) -> bool:
    parts = token.split(".")
    if len(parts) != 3:
        return False
    signing_input = f"{parts[0]}.{parts[1]}".encode("utf-8")
    expected_sig = base64.urlsafe_b64encode(
        hmac.new(current_secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
    ).rstrip(b"=").decode("ascii")
    if not hmac.compare_digest(expected_sig, parts[2]):
        return False
    header = json.loads(base64.urlsafe_b64decode(parts[0] + "=="))
    return header.get("alg") == "HS256"


if __name__ == "__main__":
    variants = [
        "/dashboard../data/config.yaml",
        "/dashboard%2e%2e/data/config.yaml",
        "/dashboard..%2fdata/sqlite.db",
        "/dashboard/../../data/config.yaml",
        "/dashboard/assets/app.js",
    ]
    for v in variants:
        ok, info = safe_dashboard_resolve(v)
        print(v, "->", ok, info)
    assert not safe_dashboard_resolve("/dashboard../data/config.yaml")[0]
    assert not safe_dashboard_resolve("/dashboard%2e%2e/data/config.yaml")[0]
    assert safe_dashboard_resolve("/dashboard/assets/app.js")[0]

    new_secret = secrets.token_hex(32)
    assert len(new_secret) >= 64

验证与判定标准 ​

检查项判定为已加固(PASS)判定为存在风险(FAIL)验证方式
Nezha 版本>= 2.0.13< 2.0.13面板版本信息或二进制 --version
穿越变体响应/dashboard../data/config.yaml 与 %2e%2e 均返回 403/404返回 200 且泄露 YAML/SQLite 内容http_probe 对三类路径变体做负向检查
密钥轮换与熵值已更换 256-bit 以上随机 jwt_secret_key,旧伪造 token 返回 401沿用可能已泄露密钥或弱口令密钥核对配置变更记录并验证 /api/v1/profile 拒绝旧 token
运行目录隔离data/config.yaml 与 sqlite.db 权限 0600,且 8008 不裸露公网静态目录可直接跨越到 data/ 且无反代过滤文件权限 stat 检查与端口映射复核

MCP 工具映射 ​

防御步骤MCP 工具说明
检索路径穿越与 JWT 防护kb_router按 CVE-2026-53519 / Nezha / JWT 搜索关联方案
对照攻击面kb_read_file读取 ctf-website/09-cve/04-nezha-path-traversal-jwt 确认敏感文件与变体
验证变体拦截结果http_probe对 /dashboard%2e%2e/data/config.yaml 等路径验证已返回 403/404

Apache-2.0 · 仅供授权环境下的学习与防御性研究使用 · 免责声明