Posted in: Uncategorized

从青龙鉴权绕过说起:一个容易被忽略的路径鉴权漏洞

2026 年 2 月底,青龙面板的鉴权绕过问题被公开讨论。社区中已有用户反馈面板遭到未授权登录、密码被修改。随后披露的分析指出:通过改变 API 路径的大小写,可以绕过认证逻辑,同时请求仍然能够被 Express 正常路由到对应接口,最终导致未授权访问高权限 API。

青龙 v2.20.2 已包含该问题的修复。

对于仍在使用 2.20.2 以下版本的实例,不建议继续将面板直接暴露在公网。如果实例此前已经开放公网,并且怀疑遭到扫描、爆破或入侵,更稳妥的处置方式是全新安装新版,再迁移经过检查的数据,而不是简单地将原实例原地升级后继续使用。

‼️ 青龙 2.20.2 以下版本不建议继续开放公网访问。对于已经遭到爆破或怀疑被入侵的实例,应优先全新安装新版,不要把“原地升级”直接等同于“安全处置完成”。

这件事也让我重新审计了自己的一个 NestJS + Express 项目。

结果发现了一个非常相似的问题。

漏洞不在 JWT 算法,也不在复杂的权限系统,而是一段看起来再普通不过的路径判断。

一个 startsWith,怎么把认证绕开了?

项目使用全局 Guard 校验 JWT。

当时为了只保护 /api 下的接口,在 Guard 中写过类似这样的代码:

const path = context.switchToHttp().getRequest().path;

if (!path.startsWith('/api/')) {
  return true;
}

只有通过这段判断之后,程序才会继续执行 Token 提取、JWT 校验以及黑名单检查。

设计意图很简单:

普通页面 → 不需要 Token
/api/*  → 需要鉴权

问题也恰恰出在这里。

JavaScript 的 startsWith() 区分大小写

/api/v1/auth/client  → 以 /api/ 开头
/API/v1/auth/client  → 不以 /api/ 开头

所以对于第二种请求,Guard 会认为:

这不是 /api 下的请求
→ 不需要鉴权
→ return true

但 Express 默认的路由匹配却不区分大小写

于是:

/api/v1/auth/client

和:

/API/v1/auth/client

可能仍然命中同一个业务路由。

最终形成了一个危险的状态:

               /API/v1/auth/client
                        │
              ┌─────────┴─────────┐
              │                   │
         鉴权 Guard          Express Router
              │                   │
    startsWith('/api/')      路由默认忽略大小写
              │                   │
           false               匹配成功
              │                   │
          跳过鉴权          进入 Controller
              └─────────┬─────────┘
                        │
                   未授权访问

同一个 URL,被鉴权层和路由层得出了两个完全不同的结论。

这才是这个漏洞真正值得注意的地方:

鉴权层和路由层对路径的解析规则不一致。

我是怎么确认问题的

在授权测试环境中,我对一个原本需要登录才能访问的只读接口进行了路径大小写变形测试。

正常路径在未携带凭据时会被认证层拒绝。

但修改路径大小写后,请求没有被认证层拦截,却仍然被 Express 路由到了原来的 Controller,并返回了需要登录才能读取的数据。

到这里其实已经足够确认问题:

认证层:认为请求不需要认证
路由层:认为请求属于受保护接口
业务层:正常处理并返回数据

不需要继续尝试修改数据、调用管理接口或扩大测试范围。

为什么这种代码很危险?

旧 Guard 实际上承担了两个职责:

1. 根据 URL 判断这个请求是否需要认证
2. 如果需要认证,再校验 Token

也就是:

/api/... → 校验 Token
/API/... → 直接放行

问题在于,Guard 并不是最终决定请求进入哪个 Controller 的组件。

最终的路由匹配仍然由 Express / NestJS 完成。

只要两者对 URL 的理解存在差异,就可能出现:

Guard:这个请求不需要认证
Router:这是一个受保护接口

大小写只是最容易观察到的一种情况。

类似的问题还可能来自:

重复斜杠
URL 编码
. / .. 路径段
尾部斜杠
反向代理路径重写
不同层级的 URL 解码

所以真正的问题并不是:

“有没有把 /API 这种大小写情况补上?”

而应该是:

“为什么认证系统需要自己解析 URL,来判断一个接口是否需要认证?”

推荐修复:默认鉴权,显式公开

我不建议继续在 Guard 中维护这样的逻辑:

if (!request.path.startsWith('/api/')) {
  return true;
}

因为这相当于把安全边界建立在 URL 字符串判断上。

更稳妥的 NestJS 设计应该反过来:

所有 Controller 默认需要认证,只有明确允许匿名访问的接口才主动放行。

例如:

@Post('login')
@NoAuthorize()
login() {
  // 登录接口
}

全局 Guard 默认执行 JWT 校验:

Controller
    │
    ▼
Global AuthGuard
    │
    ├── @NoAuthorize() → 放行
    │
    └── 默认 → 校验 JWT

这样做有两个明显好处。

第一,新增加一个接口时,开发人员即使忘记配置权限,它默认也是需要登录的。

第二,项目中所有公开接口都可以通过搜索:

@NoAuthorize()

快速审计出来。

这就是更适合作为安全边界的模型:

默认拒绝,按需公开。

静态文件本身不会经过 Controller Guard,因此也没有必要为了放行前端资源,在 Guard 中额外写 /api 前缀判断。

如果根路径跳转、健康检查等 Controller 确实需要公开,也应该显式使用 @NoAuthorize()

临时缓解方案

如果短期内无法完成权限模型调整,可以先降低当前风险。

1. 启用 Express 大小写敏感路由

const app = await NestFactory.create(AppModule);

app
  .getHttpAdapter()
  .getInstance()
  .set('case sensitive routing', true);

这样,当项目只注册:

/api/...

时:

/API/...

不会再命中同一个路由。

通常会直接得到 404

2. 修正 Guard 的路径判断

如果现阶段仍然必须根据 /api 判断是否执行认证,至少应该保证边界和大小写处理一致:

const request = context.switchToHttp().getRequest<Request>();

if (!/^\/api(?:\/|$)/i.test(request.path)) {
  return true;
}

这里:

^          → 必须从路径开头开始
/api       → API 前缀
(?:/|$)    → 后面只能是 / 或路径结束
i          → 忽略大小写

因此:

/api        ✓
/api/user   ✓
/API/user   ✓
/Api/user   ✓

/apiOther   ✗
/api123     ✗

这两项措施可以封住当前最直接的大小写绕过问题。

但需要强调:

它们属于缓解措施,而不是对设计问题的根治。

只要 Guard 仍然通过 URL 判断“这个请求需不需要认证”,路径解析差异带来的风险就依然存在。

完成权限模型调整后,应该删除 Guard 中基于路径的放行逻辑。

最小复现 Demo

下面用纯 Express 构造一个最小环境,不依赖 NestJS,就可以看到这个问题是如何产生的。

const express = require('express');

const app = express();

// Express 默认 false:路由匹配不区分大小写
app.set('case sensitive routing', false);

// 模拟存在漏洞的鉴权中间件
function authMiddleware(req, res, next) {
  console.log('auth middleware:', req.originalUrl);

  // ❌ JavaScript startsWith 区分大小写
  if (req.path.startsWith('/api')) {
    const token = req.headers.authorization;

    if (!token) {
      return res.status(401).json({
        error: 'Unauthorized'
      });
    }
  }

  next();
}

app.use(authMiddleware);

// 模拟受保护接口
app.get('/api/user', (req, res) => {
  res.json({
    user: 'hello'
  });
});

// 模拟公开接口
app.get('/public', (req, res) => {
  res.json({
    public: true
  });
});

app.listen(3000, () => {
  console.log('server running http://localhost:3000');
});

正常访问:

curl -i http://localhost:3000/api/user

没有 Token,因此:

{
  "error": "Unauthorized"
}

然后只修改路径大小写:

curl -i http://localhost:3000/API/user

此时:

'/API/user'.startsWith('/api')
// false

鉴权中间件直接放行。

但 Express 默认又不区分路由大小写,所以:

app.get('/api/user', ...)

仍然能够匹配 /API/user

最终返回:

{
  "user": "hello"
}

这就是完整的绕过过程。

Express 项目可以怎么修?

如果是纯 Express 项目,一个很重要的改进是:

不要自己通过 startsWith() 判断请求是不是 /api

直接让 Express 的路由机制决定:

function authApiMiddleware(req, res, next) {
  const token = req.headers.authorization;

  if (!token) {
    return res.status(401).json({
      error: 'Unauthorized'
    });
  }

  next();
}

app.use('/api', authApiMiddleware);

然后再注册 API:

app.get('/api/user', (req, res) => {
  res.json({
    user: 'hello'
  });
});

这样鉴权 middleware 和业务路由使用的是 Express 自己的路径匹配机制,可以避免 startsWith() 与 Express Router 在大小写语义上的差异。

对于大型项目,还可以进一步使用 Router:

const apiRouter = express.Router();

apiRouter.use(authApiMiddleware);

apiRouter.get('/user', (req, res) => {
  res.json({
    user: 'hello'
  });
});

app.use('/api', apiRouter);

这样新增 API 时,也更不容易因为 middleware 注册顺序错误而意外漏掉鉴权。

自测:不要只测试正常 URL

修复完成后,不应该只验证:

/api/v1/example/protected-resource

还应该检查认证层、代理层和路由层对变形 URL 的处理是否一致。

测试时建议使用一个自己有权限验证的、只读的受保护接口。

首先发送不携带 Cookie 和 Authorization 的请求:

BASE_URL='https://your-host.example'

curl --path-as-is -i \
  "$BASE_URL/api/v1/example/protected-resource?page=1&size=10"

这里使用 --path-as-is,是为了避免 curl 在客户端提前规范化部分路径。

大小写

/api/v1/example/protected-resource
/API/v1/example/protected-resource
/Api/v1/example/protected-resource
/aPi/v1/example/protected-resource
/api/V1/example/protected-resource

重复斜杠与点号

//api/v1/example/protected-resource
/api//v1/example/protected-resource
/api/./v1/example/protected-resource
/api/v1/./example/protected-resource
/api/v1/example/../example/protected-resource
/api/../api/v1/example/protected-resource

URL 编码

/%61pi/v1/example/protected-resource
/ap%69/v1/example/protected-resource
/api%2fv1/example/protected-resource
/api/%76%31/example/protected-resource
/%2fapi/v1/example/protected-resource

其他边界情况

/api;xxx/v1/example/protected-resource
/api/v1;xxx/example/protected-resource
/api/v1/example/protected-resource/
/api%20/v1/example/protected-resource
/api%09/v1/example/protected-resource

判断标准其实很简单。

对于未携带任何认证凭据的请求:

401 / 403 / 项目自定义未登录状态
→ 正常,说明鉴权生效

404
→ 也正常,说明该变形路径没有被业务路由接受

返回受保护数据
→ 存在问题,需要继续排查

第二轮可以携带测试账号的有效 Token 再执行一遍。

正常路径应该能够访问;非规范化路径返回 404 完全可以接受。

如果带 Token 后某些特殊路径出现 500,虽然不一定属于鉴权绕过,但说明请求可能进入了预期之外的动态路由,也值得单独检查。

另外,#fragment 不属于 HTTP 请求路径的一部分。浏览器和 curl 都不会把 fragment 发送给服务端,因此没有必要把它作为服务端路径绕过用例。

写在最后

这次问题并不复杂。

没有破解 JWT,没有伪造 Token,也没有利用复杂的权限模型。

真正的问题只是:

鉴权层:
使用大小写敏感的字符串判断

        ↓

路由层:
使用大小写不敏感的路径匹配

两层代码对同一个 URL 得出了不同结论,就足以让认证边界出现缺口。

所以以后再看到:

“项目已经配置了全局 Guard。”

我会再多问一句:

如果给它一条变形后的 URL,鉴权层和路由层,真的会把它理解成同一个请求吗?

相比“有没有写鉴权”,这可能才是更值得检查的问题。

订阅
提醒
guest

0 评论
最新
最早的 投票最多的
Back to Top