Next.js unstable_cache 对象参数缓存键碰撞(Object Argument Collision)PoC 深度解析:Request / URLSearchParams / FormData 引发的跨请求缓存串扰

原创2026-10-02 09:22:101,326 阅读
文章标签:示例工程网络安全渗透测试

Next.js unstable_cache 对象参数缓存键碰撞(Object Argument Collision)PoC 深度解析:Request / URLSearchParams / FormData 引发的跨请求缓存串扰

本篇文章基于当前仓库中的 nextjs-unstable-cache-object-argument-collision 目录,完整讲解一个针对 Next.js unstable_cache() 的对象参数缓存键碰撞(object argument cache-key collision)PoC:当被缓存函数直接接收 Request、URLSearchParams、FormData 这类请求包装对象作为参数时,缓存键如何坍缩为同一个条目,导致后续携带不同 Cookie、查询值或表单字段的请求拿到第一个请求的缓存结果。读完本文,你将掌握该问题的完整复现流程、三种对象类型的路由实现、运行脚本的全部命令行参数与证据判定逻辑,并能据此评估自己的 Next.js 应用中是否也存在同类缓存串扰风险。

一、问题本质:对象参数序列化坍缩,缓存键丢失请求差异

unstable_cache() 是 Next.js 提供的底层数据缓存 API,用于把任意异步函数的结果写入 Data Cache。调用形态为:

unstable_cache(callback, keyParts, options)

其中 keyParts 是由开发者显式给出的缓存键片段(字符串数组),options 支持 revalidate 等配置。本 PoC 生成的三个路由都按如下模式使用它:

unstable_cache(
  async (request) => { /* 读取 request 内部数据 */ },
  ['request-object', group],
  { revalidate: 3600 }
)

问题出在函数参数参与缓存键生成的方式上。按本目录 README.md 的说明,unstable_cache() 会对传入的实参进行 JSON.stringify() 序列化并纳入缓存键。而对 Request、URLSearchParams、FormData 这三种内置对象执行 JSON.stringify(),结果都是:

{}

它们要么没有可枚举的自有属性,要么自有属性不在序列化范围内,因此对象内部承载的请求级数据(Cookie、查询参数、表单字段)完全没有进入缓存键。与此同时,被缓存的回调函数拿到的仍是活的、未被序列化的原始对象,可以正常读取其中的内容。两条事实叠加的结果就是:

  • 同一缓存组(keyParts 相同)内,多个携带不同请求数据的调用被折叠进同一个缓存条目;
  • 最终缓存下来的计算结果却取决于第一个调用传入对象的内容;
  • 后续调用即使携带不同数据,命中的也是第一条缓存,返回首写者的结果。

这正是 README 所描述的 “first-writer behavior”(首写者行为):谁先写入,谁的数据就长期生效。

二、PoC 结构与复现流程概览

该目录下只包含两个文件,全部为复现所需的源文件:

nextjs-unstable-cache-object-argument-collision/
├── README.md        # 复现说明与证据解读
└── run_demo.mjs     # 一键复现脚本(Node.js)

run_demo.mjs 是一个自包含的 Node.js 脚本,它会自动完成以下流水线(对应 run_demo.mjs 的 main() 主流程):

  1. 创建临时 Next.js 应用:在 --work-dir 指定的目录下生成一个最小的 App Router 工程(package.json、next.config.js、app/layout.jsx、app/page.jsx 以及三个 API 路由);
  2. 安装依赖:npm install --no-audit --no-fund,默认安装 next@16.3.0-canary.70、react@19.2.3、react-dom@19.2.3;
  3. 构建:npm run build(即 next build),生成 .next/ 产物;
  4. 启动:通过 node_modules/next/dist/bin/next start -H 127.0.0.1 -p <port> 拉起生产服务器;
  5. 发起验证请求:对三个路由分别发送 alice/bob 两组请求、反向顺序请求、原始值提取对照组请求;
  6. 重启持久化验证:停止 next start 后不删除生成的应用目录,再次启动,向之前已填充的缓存组发送 charlie 请求,验证 Data Cache 中的旧值仍被返回;
  7. 输出判定结果:将全部检查结果写入 logs/evidence.json,并在全部通过时生成 VULNERABILITY_CONFIRMED.marker 标记文件。

运行环境要求

按 README.md 的 Requirements 一节,运行需要:

Node.js 20 或更新版本
npm
网络访问(用于 npm install)

脚本安装的依赖版本为:

next@16.3.0-canary.70
react@19.2.3
react-dom@19.2.3

需要说明的是:本 PoC 针对的是 16.3.0-canary.70 这个 canary(预览)构建版本,属于 Next.js 的迭代中间态;复现结论以该版本在当前仓库中的实测证据为准。

三、三个复现路由:对象模式与原始值对照模式

生成的临时应用暴露了三个路由处理器,分别对应三种对象类型:

/api/request      —— 直接传入路由处理器的 Request 对象
/api/urlsearch    —— 直接传入 URLSearchParams 对象
/api/form         —— 直接传入 FormData 对象(POST)

每个路由都同时内置了两种模式:mode=object(默认,直接传对象)与 mode=value(先提取原始值再调用 unstable_cache(),作为对照组)。mode=value 是验证“问题确实出在对象参数序列化”的关键对照:它把原始字符串作为实参,缓存键因此能区分不同取值,alice 与 bob 各得各的结果。

3.1 /api/request:Request 对象

生成的 app/api/request/route.js 源码如下(由 run_demo.mjs 中的模板写入):

import { unstable_cache } from 'next/cache'

export const dynamic = 'force-dynamic'

function cachedRequestFor(group) {
  return unstable_cache(
    async (request) => {
      return `REQ_COOKIE=${request.headers.get('cookie') ?? 'missing'};REQ_URL=${request.url};JSON=${JSON.stringify(request)};TS=${Date.now()}`
    },
    ['request-object', group],
    { revalidate: 3600 }
  )
}

function cachedRequestValueFor(group) {
  return unstable_cache(
    async (cookieHeader) => {
      return `REQ_VALUE_COOKIE=${cookieHeader ?? 'missing'};TS=${Date.now()}`
    },
    ['request-value', group],
    { revalidate: 3600 }
  )
}

export async function GET(request) {
  const url = new URL(request.url)
  const group = url.searchParams.get('group') || 'default'
  const mode = url.searchParams.get('mode') || 'object'
  const result = mode === 'value'
    ? await cachedRequestValueFor(group)(request.headers.get('cookie'))
    : await cachedRequestFor(group)(request)
  return new Response(result, { headers: { 'content-type': 'text/plain' } })
}

关键点:

  • 对象模式的被缓存回调读取 request.headers.get('cookie')、request.url,并在返回串里嵌入 JSON=${JSON.stringify(request)},用于在证据里直接展示序列化坍缩为 {};TS=${Date.now()} 用于区分是否真的走了缓存(命中缓存时 TS 不变)。
  • 缓存键片段为 ['request-object', group],其中 group 来自查询参数 ?group=;同一 group 即同一缓存组。
  • export const dynamic = 'force-dynamic' 保证路由处理器本身不被静态化,避免干扰观测。
  • 值模式则只把 request.headers.get('cookie') 这个字符串传进缓存回调,缓存键为 ['request-value', group]。

3.2 /api/urlsearch:URLSearchParams 对象

生成的 app/api/urlsearch/route.js 源码如下(由 run_demo.mjs 中的模板写入):

import { unstable_cache } from 'next/cache'

export const dynamic = 'force-dynamic'

function cachedUrlSearchFor(group) {
  return unstable_cache(
    async (searchParams) => {
      return `URL_SECRET=${searchParams.get('secret') ?? 'missing'};JSON=${JSON.stringify(searchParams)};TS=${Date.now()}`
    },
    ['urlsearch-object', group],
    { revalidate: 3600 }
  )
}

function cachedUrlSearchValueFor(group) {
  return unstable_cache(
    async (secret) => {
      return `URL_VALUE_SECRET=${secret ?? 'missing'};TS=${Date.now()}`
    },
    ['urlsearch-value', group],
    { revalidate: 3600 }
  )
}

export async function GET(request) {
  const searchParams = new URL(request.url).searchParams
  const group = searchParams.get('group') || 'default'
  const mode = searchParams.get('mode') || 'object'
  const result = mode === 'value'
    ? await cachedUrlSearchValueFor(group)(searchParams.get('secret'))
    : await cachedUrlSearchFor(group)(searchParams)
  return new Response(result, { headers: { 'content-type': 'text/plain' } })
}

这里直接把 new URL(request.url).searchParams 这个 URLSearchParams 实例作为实参传入 unstable_cache();回调读取 searchParams.get('secret')。JSON.stringify(searchParams) 同样是 {},因此 secret=alice 与 secret=bob 共享同一个缓存条目,后到者拿到先到者的 secret 值。

3.3 /api/form:FormData 对象

生成的 app/api/form/route.js 源码如下(由 run_demo.mjs 中的模板写入):

import { unstable_cache } from 'next/cache'

export const dynamic = 'force-dynamic'

function cachedFormFor(group) {
  return unstable_cache(
    async (formData) => {
      return `FORM_SECRET=${formData.get('secret') ?? 'missing'};JSON=${JSON.stringify(formData)};TS=${Date.now()}`
    },
    ['form-object', group],
    { revalidate: 3600 }
  )
}

function cachedFormValueFor(group) {
  return unstable_cache(
    async (secret) => {
      return `FORM_VALUE_SECRET=${secret ?? 'missing'};TS=${Date.now()}`
    },
    ['form-value', group],
    { revalidate: 3600 }
  )
}

export async function POST(request) {
  const formData = await request.formData()
  const group = formData.get('group') || 'default'
  const mode = formData.get('mode') || 'object'
  const result = mode === 'value'
    ? await cachedFormValueFor(group)(formData.get('secret'))
    : await cachedFormFor(group)(formData)
  return new Response(result, { headers: { 'content-type': 'text/plain' } })
}

该路由是 POST 接口,通过 await request.formData() 解析表单后,把 FormData 实例整体传入 unstable_cache();回调读取 formData.get('secret')。JSON.stringify(formData) 同样坍缩为 {}。

3.4 三种对象的期望证据

按 README.md 的 “Expected evidence” 一节,对象模式下每个缓存组的首个请求写入 alice 数据后,期望返回如下证据字段:

REQ_COOKIE=victim=alice
URL_SECRET=alice
FORM_SECRET=alice
JSON={}

其中 JSON={} 是序列化坍缩的直接物证:对象实参没有把任何请求级数据带进缓存键。

四、快速运行与命令行参数

4.1 一键运行

在仓库内该目录下执行:

node run_demo.mjs

脚本会自动完成 安装 → 构建 → 启动 → 验证 → 重启验证 的全流程。默认行为:

  • 工作目录:run/stock-nextjs-unstable-cache(相对于仓库根目录)
  • 监听端口:3560
  • Next.js 版本:16.3.0-canary.70

4.2 命令行参数

run_demo.mjs 通过 parseArgs()(见 run_demo.mjs)解析如下参数:

参数 说明 默认值
--next-version <version> 指定要安装的 Next.js 版本 16.3.0-canary.70
--work-dir <path> 指定生成应用的工作目录 <repo>/run/stock-nextjs-unstable-cache
--port <number> 指定 next start 监听端口 3560
--keep-server 验证结束后保持服务器不关闭 关闭(默认验证后停止)

按 README.md 中的示例,换用其他 Next.js 版本:

node run_demo.mjs --next-version 16.3.0-canary.70

指定其他工作目录(例如 /tmp 下的路径):

node run_demo.mjs --work-dir /tmp/nextjs-unstable-cache-object-poc

指定其他端口:

node run_demo.mjs --port 3561

脚本对未知参数会直接抛出 unknown argument: <arg> 错误(run_demo.mjs)。

4.3 预期的终端输出

复现全部通过时,终端输出如下(与 README 的 “Expected terminal evidence” 一致):

next_version=16.3.0-canary.70
build_exit=0
requestObjectAliceBob=true
requestObjectBobAlice=true
requestValueControl=true
urlSearchAliceBob=true
urlSearchBobAlice=true
urlSearchValueControl=true
formDataAliceBob=true
formDataBobAlice=true
formDataValueControl=true
restartRequestObject=true
restartUrlSearch=true
restartFormData=true
confirmed=true
marker_file=<work-dir>/VULNERABILITY_CONFIRMED.marker

若 confirmed=false,脚本会以退出码 1 结束,并且不会写入 marker 文件(run_demo.mjs)。

五、证据含义逐项解读

README 的 “Evidence Meaning” 一节对每个布尔标志给出了明确定义,结合 run_demo.mjs 中的断言逻辑,整理如下:

5.1 对象模式:首写者行为(正向与反向)

标志 请求内容 第二个响应期望
requestObjectAliceBob=true 同一缓存组先发 Cookie: victim=alice,再发 Cookie: victim=bob REQ_COOKIE=victim=alice 且 JSON={}
requestObjectBobAlice=true 同一缓存组先发 Cookie: victim=bob,再发 Cookie: victim=alice REQ_COOKIE=victim=bob 且 JSON={}
urlSearchAliceBob=true 同一缓存组先发 secret=alice,再发 secret=bob URL_SECRET=alice 且 JSON={}
urlSearchBobAlice=true 同一缓存组先发 secret=bob,再发 secret=alice URL_SECRET=bob 且 JSON={}
formDataAliceBob=true 同一缓存组先提交 secret=alice,再提交 secret=bob FORM_SECRET=alice 且 JSON={}
formDataBobAlice=true 同一缓存组先提交 secret=bob,再提交 secret=alice FORM_SECRET=bob 且 JSON={}

注意每对都使用不同的 group(request-a/request-b、url-a/url-b、form-a/form-b)隔离缓存组,避免两个方向互相污染。反向顺序检查(BobAlice 系列)专门用来证明结果由首写者决定,而不是固定偏爱 alice。

5.2 原始值对照组:问题定位的关键

标志 判定逻辑
requestValueControl=true 值模式下,mode=value&group=request-value 两次请求分别返回 victim=alice 与 victim=bob
urlSearchValueControl=true 值模式下,两次请求分别返回 URL_VALUE_SECRET=alice 与 URL_VALUE_SECRET=bob
formDataValueControl=true 值模式下,两次请求分别返回 FORM_VALUE_SECRET=alice 与 FORM_VALUE_SECRET=bob

对照组证明:只要在调用 unstable_cache() 之前把原始值(Cookie 头、查询值、表单字段)提取成普通字符串作为实参,缓存键就能区分不同取值,产生相互独立的缓存条目。这从反方向印证了对象实参序列化坍缩正是根因。

5.3 重启持久化验证

标志 判定逻辑
restartRequestObject=true 重启后向 request-a 组发送 victim=charlie,响应仍含 REQ_COOKIE=victim=alice
restartUrlSearch=true 重启后向 url-a 组发送 secret=charlie,响应仍含 URL_SECRET=alice
restartFormData=true 重启后向 form-a 组提交 secret=charlie,响应仍含 FORM_SECRET=alice

该环节在 run_demo.mjs 中实现:先 stop(server) 停止 next start,保留生成的应用目录(尤其保留 .next/ 中的 Data Cache 产物),再重新 spawnNext 并发送 charlie 请求。三次请求都命中之前由 alice 填充的缓存条目,说明被坍缩的错误缓存结果不仅存在于进程内存,还落盘到 Data Cache,跨进程重启依然有效——这显著放大了实际影响:服务重启并不能清除污染。

logs/evidence.json 中记录了每一次请求的完整响应体(evidence.results.* 与 evidence.restart.*),以及 startup/restartStartup 的服务器启动日志,可用于人工复核每个判定。

六、run_demo.mjs 源码级执行细节

6.1 进程与网络管理

  • spawnNext()(run_demo.mjs)直接以当前 Node 进程执行 node_modules/next/dist/bin/next start -H 127.0.0.1 -p <port>,宿主固定为 127.0.0.1,避免对外暴露。
  • waitForPort()(run_demo.mjs)通过 TCP 连接探测端口就绪,30 秒超时,期间若子进程提前退出则抛出带输出的错误。
  • stop()(run_demo.mjs)向 next start 发送 SIGTERM 并最多等待 5 秒回收。
  • 请求发送使用内置 fetch:get()(run_demo.mjs)携带可选 Cookie 头并设置 redirect: 'manual';post()(run_demo.mjs)以 application/x-www-form-urlencoded 提交表单字段。

6.2 生成的临时应用

createApp()(run_demo.mjs)每次运行会先删除并重建工作目录,随后写入以下文件(对应 README 的 “Generated Files” 一节):

app/package.json
app/next.config.js
app/app/layout.jsx
app/app/page.jsx
app/app/api/request/route.js
app/app/api/urlsearch/route.js
app/app/api/form/route.js
app/.next/
app/node_modules/
logs/npm-install.stdout.log
logs/npm-install.stderr.log
logs/next-build.stdout.log
logs/next-build.stderr.log
logs/evidence.json
VULNERABILITY_CONFIRMED.marker

其中 package.json 的 scripts 为 build: next build 与 start: next start -H 127.0.0.1,依赖按 --next-version 参数写入 next 版本。安装与构建的标准输出/错误分别落盘到 logs/ 下的四个日志文件,便于失败时排查。

6.3 判定与标记文件

全部 12 项检查汇总到 evidence.checks,只有每项都为 true 时 confirmed 才为 true。随后写入 VULNERABILITY_CONFIRMED.marker,内容如下(与 README 一致):

confirmed=true
next_version=16.3.0-canary.70
request_object_second_seen=alice
urlsearch_second_seen=alice
formdata_second_seen=alice
restart_request_object_seen=alice
restart_urlsearch_seen=alice
restart_formdata_seen=alice

*_second_seen 字段记录了每个对象缓存组第二个请求实际观测到的值(均为首写者 alice 的值),restart_*_seen 记录重启后请求观测到的旧缓存值,全部作为碰撞与持久化的机器可读证据。

七、影响评估、适用前提与防御思路

从本 PoC 的观测可以得出以下有依据的结论:

  • 触发条件:应用把承载请求数据的对象(Request、URLSearchParams、FormData 等)整体作为实参传入 unstable_cache(),且这些对象的内容没有以任何形式进入 keyParts。此时即使 keyParts 数组完全相同,不同请求也会命中同一条缓存。
  • 影响效果:同一缓存组内,后续请求会长期获得第一个请求的计算结果;由于该结果写入 Data Cache 并落盘,服务重启无法清除,污染的持续窗口可能很长。
  • 版本前提:本仓库实测于 next@16.3.0-canary.70;canary 版本行为后续可能变化,结论以该版本为准。
  • 披露状态:按仓库根目录 README.md 的声明,这些发现发布时尚未被官方报告;根目录 cves.md 中也没有与本条目对应的 CVE 编号,因此本文不引用任何 CVE。

防御/规避思路(由本 PoC 的对照组反推,属于从源码结构得出的建议):

  1. 先提取原始值再缓存:在调用 unstable_cache() 之前,把 Cookie、查询值、表单字段等请求相关数据提取为字符串/数字等原始值,作为实参传入——这正是本 PoC 三个 *ValueControl 对照组所演示的安全写法;
  2. 把对象内容显式纳入缓存键:若确实需要对象参与计算,应将其中参与结果差异化的字段显式拼入 keyParts 数组,确保不同请求生成不同缓存键;
  3. 对包含请求敏感数据的缓存结果设置合理的 revalidate 与标签管理,并在上线前针对缓存命中路径做多请求串扰测试。

八、清理

复现产生的临时工程与产物都位于 --work-dir 指定的目录(默认 run/stock-nextjs-unstable-cache,相对于仓库根目录)。按 README.md 的 Cleanup 一节,删除即可:

rm -rf run/stock-nextjs-unstable-cache

如果用了自定义 --work-dir,则删除对应路径。

小结

这个 PoC 用最小的工程(一个临时 Next.js 应用 + 三个路由 + 一个 Node 复现脚本)完整呈现了 unstable_cache() 对象参数缓存键碰撞的成因、现象与持久性:对象实参经 JSON.stringify() 坍缩为 {},而回调仍读取活对象,导致同一缓存组内“首写者获胜”。仓库中的 README.md 与 run_demo.mjs 提供了开箱即用的复现路径与逐项证据判定,适合作为同类缓存串扰问题的验证模板;而其中的 mode=value 对照组,也为实际工程中如何安全使用 unstable_cache() 提供了直接可借鉴的规避范式。

登录后查看全文
exploitarium