CVE-2026-23870:单个 POST 请求可冻结 Next.js 服务器
CVE-2026-23870: A Single Post Freezes Any Next.js Server
作者披露 React Server Components 表单解析中的拒绝服务漏洞 CVE-2026-23870:每个 $K 嵌套表单指针都会完整遍历一次请求字段列表,发送 10000 个指针加 10000 个字段约 900 KB 的请求即可触发上亿次字符串检查,阻塞 Node.js 单线程。
作者以第一手复现记录拆解 React Server Components 表单解析的复杂度问题,并给出受影响版本与修复方式。
How I Found It
Modern React lets you wire a form straight to a function that runs on your server, a “server action.” The user submits the form, the function runs on the backend. Before it can run, React has to take the raw HTTP request and rebuild your form data out of it. That rebuilding code runs on every single server-action call, and it works entirely on data the sender controls. Exactly the kind of code worth reading.
React’s format lets one field in the form point at another. One of those pointers, which React writes as $K, means “there’s a nested form in here.” This is the code that resolves a single $K:
case 'K': {
const data = new FormData();
const keys = Array.from(response._formData.keys()); // list every field in the request
for (let i = 0; i < keys.length; i++) { // then check all of them
if (keys[i].startsWith(formPrefix)) { /* ... */ }
}
return data;
}In plain terms: for each $K pointer, React walks the entire list of fields in the request looking for matches. So one pointer costs one full pass over every field.
Here’s the catch. Nothing limits how many $K pointers a request can contain, and nothing limits how many fields it can contain. The sender picks both. Put 10,000 pointers and 10,000 fields in one request, and React makes 10,000 passes over 10,000 fields. That’s 100 million string checks, and it runs them all back to back without ever pausing.
The “without pausing” part is what turns it into a server killer. Node.js handles every incoming request on a single thread. While it’s grinding through those 100 million checks, it can’t do anything else. Every other visitor just waits for it to finish.
The Caps That Don’t Help
React does have two size limits right next to this code, but neither one covers it. The first only limits how deeply arrays can nest. The second limits how many arguments an action call can take, which sounds like it should stop me, except you get around it by burying the giant list of pointers one level deeper than the limit bothers to look:
level 1 (counted): ["$2"]
level 2 (not counted): ["$Knomatch0", … ,"$Knomatch9999"]The limit sees the one item on the top level and never notices the 10,000 hiding underneath.
No Login Required
The rebuilding happens before any security check runs. No login, no CSRF token, no origin check looks at the request until after the damage is done. The only thing you need is the app’s “action ID,” a hash that names the server function, and apps basically hand it out: it’s printed in the page’s HTML, and it sits in plain text inside the JavaScript files the browser downloads anyway. Grab it and send one POST. If the action is on a public page, you don’t even need an account. If it’s behind a login, any normal account works, because every logged-in user goes through the same path.
With 10,000 pointers and 10,000 filler fields, that request is about 900 KB.
Proof
I pulled an action ID from the target, built the request above, sent it, and timed how long it took, while a second loop kept pinging another page to see if the server was still answering.
On a plain production build on my laptop, one request froze the server for about 4 seconds. The action does eventually return, but only once the whole scan finishes, and anything that arrives during that window either waits in line or times out. Five requests back to back gave about 20 seconds of straight downtime.
A real production-shaped setup was worse: around 10 seconds per request, and three in a row were enough for the load balancer in front to decide the server was dead, take it out of rotation, and start handing 503 errors to people who had nothing to do with me.
Who’s Affected
This code lives in a piece React shares across its different setups, so it isn’t specific to one framework. React fixed it in 19.0.6, 19.1.7, and 19.2.6. Any 19.0, 19.1, or 19.2 release below those is vulnerable, in all three published packages:
react-server-dom-webpackreact-server-dom-turbopackreact-server-dom-parcel
(The react-server-dom-esm build runs the identical code but wasn’t named in the advisory.)
In practice that’s basically any Next.js 14+ app, or any other React Server Components setup, on a pre-patch React 19.0 to 19.2 release with at least one server action anywhere it can be reached. A single action that just logs a form field is enough.
The Fix
The whole problem was that React rescanned the full field list once for every pointer. The fix makes it scan the list once for the entire request instead.
React added a small wrapper around the incoming form data that keeps a bookmark of where it left off and deletes each field as it gets used. So the pointers now share a single walk through the fields, start to finish, instead of each one restarting from the top. With that, 10,000 pointers no longer mean 10,000 scans, and my payload does nothing special anymore. It shipped in React’s advisory GHSA-rv78-f8rc-xrxh.
Meta shipped the patch upstream as CVE-2026-23870. I found out by watching the commit land, not from any status update. As I’m writing this, the report is still sitting on “New” on the bug bounty platform, I’m not credited anywhere in the advisory, and I’ve never gotten a reply beyond the automated acknowledgement. So: silently fixed, then ghosted. I checked in twice and held off publishing this the whole time. Nothing came back, so here it is.
Timeline
- Apr 21, 2026
Reported to Meta
- May 6, 2026
Patched upstream as CVE-2026-23870
- Oct 8, 2026
Public writeup
来源:Hacker News · simonkoeck.com