Next.js中间件鉴权绕过漏洞复现(CVE-2025-29927)

一些概念了解

Next.js是一个由Vercel开发的基于React的Web开发框架,它是一个“全栈框架”,开发者可以使用javascript或者typescript开发一个完整包含前后端的网站,而且前后端的代码都放在一个项目里,编写十分方便。

Next.js的后端服务,可以使用两种模式来运行,分别是node.js和edge。node.js是默认的运行模式,可以理解为代码运行在传统的node.js服务器上,他没有任何限制,可以用来操作文件、数据库等等;edge是将代码运行在边缘计算服务上,比如Vercel Edge Functions和Cloudflare Pages等。

我们可以将edge理解为一种“受限”的后端处理逻辑,它的代码运行在全球各地的CDN服务器中,离用户更近,非常适合用于处理一些“轻工作”,比如数据校验、鉴权、国际化等。但也是因为其运行在CDN节点中,它无法使用node.js的原生模块,比如文件操作等。

next.js中提供了大部分传统Web开发框架都会有的功能——middleware(中间件)。和传统后端框架相同的是,middleware在next.js中也常用来检查用户身份、国际化、设置HTTP头等;和传统后端框架不同的是,next.js的middleware一定运行在edge模式下,不一定会和后端逻辑运行在一起。

CVE-2025-29927这个漏洞就是位于middleware中。一个middleware用于检查用户身份是否合法,由于检查的过程需要查询数据库,所以它会发送一个新的请求访问用户校验API。 此时会出现一个问题,由于next.js是“全栈”框架,这个检查用户身份的API也会经过middleware,这样数据流就会进入递归死循环。next.js为了解决这个问题,就为middleware中发出的请求增加了一个x-middleware-subrequest头,每次发送请求时值添加一个“middleware”字符串;并且在收到带有x-middleware-subrequest的头且发现递归超过5次后,就不再执行middleware,这样middleware中发出的请求不会再有递归问题。 那么漏洞复现方法就呼之欲出了,攻击者只要在发送给next.js的请求中增加一个头x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware,就可以绕过next.js的中间件。如果开发者将校验用户身份的逻辑放在中间件里,就将导致认证绕过漏洞。

1
用户请求 → 中间件A(身份验证) → 发起API请求 → 中间件A再次执行 → 再次发起API请求 → ...

漏洞描述

Next.js 使用内部头字段“x-middleware-subrequest”来防止递归请求导致的无限循环。攻击者可以通过在请求中伪造该头字段,跳过中间件的执行,从而绕过关键的安全检查,例如授权 Cookie 验证。

受影响的版本

Next.js 15.x < 15.2.3
Next.js 14.x < 14.2.25
Next.js 13.x < 13.5.9

漏洞复现

这里的漏洞环境我们直接用p神的vulhub。

https://github.com/vulhub/vulhub/blob/master/next.js/CVE-2025-29927/README.zh-cn.md

我们用浏览器插件看看

发现是next.js框架的,且版本为15.2.2,符合漏洞版本!那么接下来我们开始漏洞复现。

这是一个需要登录的界面。

然后我们用hackbar加一个请求头即可。

1
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware

若我们没有加请求头,访问http://192.168.79.128:3000/是跳转到/login的,但是我们我们加了这个请求头,就会跳转到/dashboard,实现了鉴权绕过!

这个漏洞的复现是非常简单的,接下来我们开始漏洞的源码分析。

漏洞分析

关键点自然在于请求头x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware

在 Next.js 中,中间件(Middleware)是一种强大的工具,可以用于在请求到达 API 路由或页面组件之前执行全局逻辑,比如身份验证、请求拦截、重定向等。

Next.js 的中间件(Middleware)设计用于处理全局请求逻辑(如身份验证、请求拦截,假设开发者编写了一个身份验证中间件,其功能是:当用户访问受保护资源时,中间件需向校验 API 发送请求以验证用户权限(如查询数据库或调用外部服务)。

由于 Next.js 是「全栈框架」,所有请求(包括中间件内部发起的 API 请求)均会再次经过中间件本身。这导致以下递归流程:

1
用户请求 → 中间件A(身份验证) → 发起API请求 → 中间件A再次执行 → 再次发起API请求 → ...

最终引发无限递归,导致服务崩溃或响应超时。

为打破递归循环,Next.js 引入了两项机制:

  • 请求标记: 中间件内部发起的请求会自动添加 x-middleware-subrequest 头,其值按层级递增(如 middleware:middleware:middleware)。
  • 递归终止条件: 当检测到 x-middleware-subrequest 头中包含当前中间件标识(如 middleware)的次数超过阈值(默认5次),中间件直接返回空响应,终止递归

主要逻辑如下:next.js-15.2.2\packages\next\src\server\web\sandbox

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const run = withTaggedErrors(async function runWithTaggedErrors(params) {
const runtime = await getRuntimeContext(params)
const subreq = params.request.headers[`x-middleware-subrequest`]
const subrequests = typeof subreq === 'string' ? subreq.split(':') : []

const MAX_RECURSION_DEPTH = 5
const depth = subrequests.reduce(
(acc, curr) => (curr === params.name ? acc + 1 : acc),
0
)

if (depth >= MAX_RECURSION_DEPTH) {
return {
waitUntil: Promise.resolve(),
response: new runtime.context.Response(null, {
headers: {
'x-middleware-next': '1',
},
}),
}
}

这里解释一下这段代码,将x-middleware-subrequest请求头赋值给subreq,再以:分割为数组subrequests。

若subrequests中包含当前中间件的名字,那么执行acc+1,也就是递增。当acc的值>=5时,中间件返回空相应,设置返回头为’x-middleware-next’: ‘1’,表示忽略中间件的所有逻辑(包括鉴权检查)。

所以漏洞的利用思路是,只要在发送给next.js的请求中增加一个头**x-middleware-subrequest:**,让其循环5次中间件文件路径,就可以绕过next.js的中间件。如果开发者将校验用户身份的逻辑放在中间件里,就将导致认证绕过漏洞。

旧版本(v<12.2)

主要的处理逻辑:

next.js/packages/next/server/next-server.ts

如果我们有中间件,那么就会调用 runMiddleware 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
protected async runMiddleware(params: {
request: IncomingMessage
response: ServerResponse
parsedUrl: ParsedNextUrl
parsed: UrlWithParsedQuery
}): Promise<FetchEventResult | null> {
this.middlewareBetaWarning()

const page: { name?: string; params?: { [key: string]: string } } = {}
if (await this.hasPage(params.parsedUrl.pathname)) {
page.name = params.parsedUrl.pathname
} else if (this.dynamicRoutes) {
for (const dynamicRoute of this.dynamicRoutes) {
const matchParams = dynamicRoute.match(params.parsedUrl.pathname)
if (matchParams) {
page.name = dynamicRoute.page
page.params = matchParams
break
}
}
}

const subreq = params.request.headers[`x-middleware-subrequest`]
const subrequests = typeof subreq === 'string' ? subreq.split(':') : []
const allHeaders = new Headers()
let result: FetchEventResult | null = null

for (const middleware of this.middleware || []) {
if (middleware.match(params.parsedUrl.pathname)) {
if (!(await this.hasMiddleware(middleware.page, middleware.ssr))) {
console.warn(`The Edge Function for ${middleware.page} was not found`)
continue
}

await this.ensureMiddleware(middleware.page, middleware.ssr)

const middlewareInfo = getMiddlewareInfo({
dev: this.renderOpts.dev,
distDir: this.distDir,
page: middleware.page,
serverless: this._isLikeServerless,
})

if (subrequests.includes(middlewareInfo.name)) {
result = {
response: NextResponse.next(),
waitUntil: Promise.resolve(),
}
continue
}

result = await run({
name: middlewareInfo.name,
paths: middlewareInfo.paths,
request: {
headers: params.request.headers,
method: params.request.method || 'GET',
nextConfig: {
basePath: this.nextConfig.basePath,
i18n: this.nextConfig.i18n,
trailingSlash: this.nextConfig.trailingSlash,
},
url: (params.request as any).__NEXT_INIT_URL,
page: page,
},
ssr: !!this.nextConfig.experimental.concurrentFeatures,
})

for (let [key, value] of result.response.headers) {
allHeaders.append(key, value)
}

if (!this.renderOpts.dev) {
result.waitUntil.catch((error) => {
console.error(`Uncaught: middleware waitUntil errored`, error)
})
}

if (!result.response.headers.has('x-middleware-next')) {
break
}
}
}

if (!result) {
this.render404(params.request, params.response, params.parsed)
} else {
for (let [key, value] of allHeaders) {
result.response.headers.set(key, value)
}
}

return result
}

我们看一下以下代码:

1
2
3
4
5
6
7
if (subrequests.includes(middlewareInfo.name)) {
result = {
response: NextResponse.next(),
waitUntil: Promise.resolve(),
}
continue
}

如果subrequests数组中包含middlewareInfo.name,那么这个中间件直接跳过,返回NextResponse.next(),请求会继续前进,完全绕过该中间件的执行。

所以我们的绕过思路就是让x-middleware-subrequest请求头包含中间件的名称,从而欺骗runMiddleware认为该中间件已经执行过,导致其被跳过。

1
2
3
GET /dashboard/team/admin HTTP/1.1
Host: example.com
x-middleware-subrequest: pages/_middleware

那么接下来我们需要知道的是中间件的名称,只要有了中间件的名称那么我们就可以实现鉴权绕过了。

首先中间件必须命名为 _middleware.ts

必须位于 pages/ 目录或其子目录下(因为 App Router 在 13 版本才推出)

因此:

1
x-middleware-subrequest: pages/_middleware

若中间件位于 /pages/dashboard/_middleware.ts,则 middlewareInfo.namepages/dashboard/_middleware,攻击者只需在请求头中设置 x-middleware-subrequest: pages/dashboard/_middleware 即可绕过。

新版本(v>=12.2)

新版本其实就是我们在漏洞分析中所说的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const run = withTaggedErrors(async function runWithTaggedErrors(params) {
const runtime = await getRuntimeContext(params)
const subreq = params.request.headers[`x-middleware-subrequest`]
const subrequests = typeof subreq === 'string' ? subreq.split(':') : []

const MAX_RECURSION_DEPTH = 5
const depth = subrequests.reduce(
(acc, curr) => (curr === params.name ? acc + 1 : acc),
0
)

if (depth >= MAX_RECURSION_DEPTH) {
return {
waitUntil: Promise.resolve(),
response: new runtime.context.Response(null, {
headers: {
'x-middleware-next': '1',
},
}),
}
}

那么目前就是寻找中间件名称了,而在高版本,对中间件的文件又要了要求,中间件文件更名为 middleware.ts,位于 src/ 或根目录。此时,攻击者只需设置 x-middleware-subrequest: middlewarex-middleware-subrequest: src/middleware 即可绕过。

漏洞修复

升级 Next.js 版本

  • ≥15.2.3(15.x)
  • ≥14.2.25(14.x)

直接看github上的官方的commit

https://github.com/vercel/next.js/commit/9704c8e9fcc58236349ed787903831579440a879

官方修复就是直接删掉x-middleware-subrequest请求头

参考文章:

https://govuln.com/topic/

https://zhero-web-sec.github.io/research-and-things/nextjs-and-the-corrupt-middleware

https://xz.aliyun.com/news/17406?time__1311=eqUxn7DQG%3DeqBDBdPdK0QOGQNPY5q63x&u_atoken=61d9e3cacb71fd68c52f2ec066d76aa9&u_asig=1a0c380917433328356222442e009d

https://xz.aliyun.com/news/17403?u_atoken=7a1e484af2e2cf99ecceeef1933abf0b&u_asig=1a0c380917433328366722478e009d

https://forum.butian.net/article/679