Nezha Monitoring 防御:Go 路径段匹配加固与 JWT 密钥隔离(CVE-2026-53519)
1. 威胁模型与漏洞根因回顾
CVE-2026-53519(CVSS 9.1)是一条“路径遍历泄露配置与数据库 -> 伪造管理员 JWT”的高危组合漏洞链:
- 前缀匹配缺陷:服务端使用
strings.HasPrefix(c.Request.URL.Path, "/dashboard")判断是否走前端静态资源分支,而非按/路径段匹配。/dashboard../data/config.yaml满足前缀条件,随后strings.TrimPrefix(..., "/dashboard")剥离出../data/config.yaml。 - 无边界
path.Join:path.Join(AdminTemplate, "../data/config.yaml")在目录逃逸后直接拼向运行数据目录,而由于原始 URL 首段为dashboard..(非独立..),Go 标准库默认 traversal guard 未能提前阻断。 - 凭据集中暴露与 JWT 伪造:
data/config.yaml与data/sqlite.db分别包含jwt_secret_key、agent_secret_key及管理员user_id,使攻击者离线签发有效HS256Cookie(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;
}防御工作流
- 版本与暴露面核查:确认 Nezha 版本是否
< 2.0.13,检查8008端口是否直接暴露在公网且前置反代未做路径规范化过滤。 - 日志回溯与止血:检查反代与 Nezha 日志中是否存在
/dashboard..、/dashboard%2e%2e、config.yaml、sqlite.db请求;立即部署反代拦截规则并升级到>= 2.0.13。 - 密钥与会话轮换:重新生成高强度随机
jwt_secret_key(至少 256-bit 随机熵)与agent_secret_key,重置管理员口令及关联 OAuth2 Client Secret,迫使所有既有nz-jwt会话重新认证。 - 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 |