Tre Next.js-CVE-er som ikke traff, og kontaktskjemaet som gjorde det
- #pentest
- #nextjs
- #xss
- #ai
- #security
FIKTIVT: Alle firmanavn, personnavn, domener og e‑postadresser i dette innlegget er oppdiktet. Eventuell likhet med ekte virksomheter eller personer er tilfeldig. Detaljer fra reelle oppdrag er sanert.
Det flotte funnet ble aldri noe funn. Jeg hadde tre publiserte middleware-bypass-CVE-er for Next.js, en app som på papiret satt midt i det sårbare vinduet, og en PoC-samling som kjørte rett ut av boksen. Alle tre bomma. Det som til slutt ga fotfeste var det kjedeligste tenkelige: et kontaktskjema som sendte feltene sine usanert videre til et internt adminpanel.
Poenget med innlegget er det mellomrommet. En CVE er en egenskap ved en kodesti, ikke ved
et versjonsnummer — og forskjellen på om den treffer deg eller ikke kan være om utvikleren
skrev export const config = { matcher } eller lot være. Her er hele kjeden, inkludert
hvorfor de tre «riktige» angrepene ble avvist, og hvorfor den ene boringen fungerte.
Kontekst
Kunden var Trollmyr AS (trollmyras.no), en liten norsk skreddersøm-shop som bygger
admin-systemer for andre bedrifter. Scope var uautentisert black-box mot markedsnettstedet
og et tilhørende admin-CRM. Ingen konto utlevert — jeg skulle inn utenfra hvis jeg kom inn
i det hele tatt.
Jeg kjører denne typen oppdrag med AI i loopen: agenter gjør recon, endepunkt-kartlegging og førsterunde-testing i parallell, jeg styrer scope, tar vurderingene og skiller ekte funn fra plausibelt støy. Den siste biten er hele jobben — en agent produserer troverdige funn fort, og troverdige feil like fort. Verifiseringen er der verdien ligger, akkurat som i da et cross-tenant-funn ikke overlevde verifisering.
Appen bak en rewrite
Markedssiden var en Next.js-app på Vercel, og den ga lite. Ingen subdomener løste opp
(crt.sh + en liten brute-sweep ga bare www), ingen source maps, ingen hemmeligheter i
bundlene. En pen, tynn brosjyre.
Men repoet til markedssiden lå åpent på GitHub, og next.config.ts fortalte hvor det
interessante var:
// next.config.ts — markedssiden
async rewrites() {
return [
{ source: "/admin", destination: `${process.env.CRM_URL}/admin` },
{ source: "/admin/:path*", destination: `${process.env.CRM_URL}/admin/:path*` },
];
}CRM-et var en egen Next.js-app, montert på markedssiden under /admin via en
multi-zone-rewrite. Jeg ba en agent lese resten av repoet og de tilhørende MD-dokumentene;
der lå både endepunkt-navnene på CRM-siden og en intern SIKKERHET.md som i praksis var
utviklerens egen liste over ting de var redde for (IDOR på sekvensielle ID-er, mass-assignment
av et erAdmin-flagg). Nyttig kart.
GET /admin svarte 307 til /admin/login. Innloggingen var et vanlig skjema med felt for
e-post, passord og et valgfritt TOTP-felt. Uautentiserte kall mot /admin/api/* ble
redirigert til login. Det betød én ting: det satt en Next.js middleware og gjorde
autorisasjonen. Og middleware-basert auth i App Router er akkurat det de nye CVE-ene sikter
på.
Tre CVE-er som skulle omgå innloggingen
Jeg lot cve-oppslaget triagere versjonen (markedssiden kjørte Next.js 16.2, altså under
16.2.5 — i vinduet). Tre kandidater pekte seg ut, og det finnes en offentlig PoC-samling for
akkurat dette settet: dwisiswant0/next-16.2.4-pocs.
Jeg kjørte dem mot CRM-et, én etter én.
Forutsetninger for alle tre: helt uautentisert. Baseline er at den kanoniske stien redirigerer — altså at middleware faktisk fyrer:
GET /admin HTTP/1.1
Host: trollmyras.no
HTTP/1.1 307 Temporary Redirect
location: /admin/login?next=%2FCVE-2025-29927 — x-middleware-subrequest
Fjorårsklassikeren: en header som lurer Next til å tro at kallet er en intern subrequest og hoppe over middleware. Patchet for lengst, men billig å prøve:
GET /admin HTTP/1.1
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
HTTP/1.1 307 Temporary Redirect
location: /admin/login?next=%2FFortsatt 307. Patchet, som forventet på en 16.2-app.
CVE-2026-44575 — segment-prefetch via .rsc
Denne var jeg mer spent på. App Router serverer en rute i flere «transport-varianter» — den
vanlige HTML-en, en .rsc-flight-payload, og en segment-prefetch-variant. Sårbarheten er at
den kompilerte matcher-regexen (før 16.2.5) bare matchet den kanoniske stien og en gammel
Pages-Router-datavariant, ikke .rsc- og .segment.rsc-formene. Alle løser til samme side
på serveren — så hvis middleware hoppes over for .rsc-varianten, får du sidens innhold uten
auth-sjekken. CRM-et sendte til og med vary: …next-router-segment-prefetch, så
segment-prefetch var påslått. Tekstbok.
GET /admin.rsc HTTP/1.1
Host: trollmyras.no
RSC: 1
Next-Router-Prefetch: 1
Accept: text/x-componentHTTP/1.1 307 Temporary Redirect
location: /admin/login?next=%2FOg segment-varianten:
GET /admin.segments/$c$children/__PAGE__.segment.rsc HTTP/1.1
RSC: 1
Next-Router-Segment-Prefetch: /__PAGE__
Accept: text/x-component307 igjen. Jeg prøvde variantene mot /admin/kunder, /admin/faktura, /admin/nettside-leads
— alle redirigerte til login. På en sårbar app skulle minst én av disse ha returnert 200
med content-type: text/x-component og selve RSC-payloaden. Ingen gjorde det.
CVE-2026-44574 — nxtP-parameter-injeksjon
Siste forsøk: Next representerer matchede dynamiske segmenter internt med reserverte
query-parametre (nxtP<navn>). Trikset er å treffe en offentlig sti, men henge på en
nxtP-parameter som får App Router til å rendre en beskyttet dynamisk rute — mens middleware
fortsatt ser den offentlige stien.
GET /admin/login?nxtPslug=kunder HTTP/1.1
Host: trollmyras.no
HTTP/1.1 200 OK
content-type: text/html200, men på et blunk så det ut som treff. Det var det ikke: svaret var nøyaktig
login-siden (samme byte-størrelse), ikke kunder-innholdet. nxtP-parameteren ble ignorert
— ruteren rendret ikke den beskyttede siden. Den dobbeltkodede sti-varianten
(/admin/api/…%252f..%252fkunder) ga bare 404. Ingen bypass.
Hvorfor ingen av dem traff
Her er innsikten jeg satt igjen med, og den gjelder de fleste av disse Next.js
middleware-CVE-ene: de utnytter et hull i den kompilerte config.matcher-regexen. Det
hullet finnes bare hvis middleware kjører betinget via en matcher.
Jeg hadde ikke CRM-ets kildekode — den lå i et privat repo — men oppførselen avslørte
mønsteret. Et par sonderinger gjorde det tydelig: /admin/api/leadsx ga 404 (behandlet som
offentlig), mens /admin/api/xleads ga 307 (beskyttet). Det er signaturen til en
startsWith-sjekk i selve middleware-kroppen, ikke en matcher. Rekonstruert så CRM-et omtrent
slik ut:
// Rekonstruert fra oppførsel — IKKE kundens kildekode.
// Merk: ingen `export const config = { matcher }`.
const PUBLIC = ["/admin/login", "/admin/api/auth", "/admin/api/nettside-leads/inn"];
export function middleware(req: NextRequest) {
const p = req.nextUrl.pathname;
if (PUBLIC.some((pre) => p.startsWith(pre))) return NextResponse.next();
if (!harGyldigSesjon(req)) {
return NextResponse.redirect(new URL(`/admin/login?next=${p}`, req.url));
}
return NextResponse.next();
}Kontrasten er den sårbare stilen:
// Den sårbare stilen: middleware kjører BARE for det matcheren treffer.
export const config = { matcher: "/admin/:path*" };
export function middleware(req: NextRequest) { /* auth */ }Med en matcher er det matcher-regexen som avgjør om middleware i det hele tatt fyrer for en
gitt URL — og det var den regexen som glemte .rsc-suffiksene. Uten matcher kjører middleware
for alt, inkludert /admin.rsc og /admin.segments/…, og startsWith-sjekken i kroppen
redirigerer dem på lik linje med den kanoniske stien. CVE-en har rett og slett ingen flate å
bite i. Deny-by-default global middleware er tregere (den kjører på hver eneste request), men
den var immun mot hele klassen.
Det er verdt å sette pris på: deny-by-default + startsWith-eksemptering er ikke det
«riktige» mønsteret i Next-dokumentasjonen, men det gjorde appen upåvirket av tre publiserte
auth-bypass-CVE-er. Perimeteret her var reelt godt bygget — jeg prøvde også credential-spray
(møtte fungerende rate-limiting) og secret-skannet ti av utviklerens offentlige repo-er med
full git-historikk uten å finne en eneste CRM-nøkkel. Det fortjener å nevnes like høyt som
funnene.
Det kjedelige som virket: lagret XSS via kontaktskjemaet
Med fronten stengt gikk jeg tilbake til den ene dynamiske flaten markedssiden faktisk hadde:
kontaktskjemaet. Kildekoden viste at /api/contact ikke sendte e-post i det hele tatt — den
videresendte feltene usanert rett inn i CRM-ets lead-mottak, som lagret dem og gjengav dem i
adminpanelets lead-oversikt.
Det er den klassiske site→intern-panel-broen: en uautentisert besøkende skriver inn i en boks på en offentlig side, og teksten havner urørt i en flate en innlogget ansatt åpner.
Forutsetninger: helt uautentisert. Feltene name, firma og message videresendes
usanert til CRM-ets NettsideLead-tabell og rendres i admin-visningen.
- Send inn et lead med en markert nyttelast i navnefeltet:
POST /api/contact HTTP/1.1
Host: trollmyras.no
Content-Type: application/json
{"service_type":"skreddersydd-system","name":"PENTEST <img src=x onerror=<REDACTED>>",
"email":"[email protected]","phone":"99999999","message":"PENTEST-markør"}HTTP/1.1 200 OK
{"ok":true}200 her betyr at CRM-et faktisk tok imot og lagret leadet (ruten returnerer 500 hvis
mottaket avviser). Payloaden ligger nå i lead-tabellen og kjører i den innloggede
admin-sesjonen idet en ansatt åpner lead-oversikten. Nyttelasten var en benign, tydelig merket
markør — aldri en fungerende exploit-streng.
- Bevis — og en ærlig begrensning. Dette er en blind lagret XSS: jeg har ingen admin-sesjon selv, så jeg ser ikke at den kjører direkte. To ting bekreftet den likevel. For det første viste kildekoden at feltene aldri saneres, verken ved mottak eller gjengivelse. For det andre — og dette er det fine — varslet kundens egen overvåking om at det skjedde et same-origin-kall mot deres kundedata-endepunkt i det leadet ble åpnet. Nettleser-callbacken min ut av origin kom derimot aldri fram. Den mest sannsynlige grunnen er en CSP i adminpanelet som blokkerte utgående kall — noe som også betyr at ingen kundedata forlot miljøet. Deteksjonen deres funket, med andre ord, og saneringen manglet. High.
Mitigering. To lag: saner input ved mottak og kontekst-escape ved gjengivelse i CRM-et (f.eks. DOMPurify for HTML), og innfør en streng CSP i adminpanelet som forsvar i dybden. Den ene av dem alene hadde stoppet dette; begge er billig.
En kort note om en blindvei, siden undersøkelsen er poenget: CRM-et hadde også en
firma-søk-proxy som tok fritekst fra brukeren og slo opp mot et offentlig register serverside.
Det luktet SSRF/injeksjon, og en agent flagget det tidlig. Det holdt ikke — inputen ble kjørt
gjennom encodeURIComponent før den ble til et søkeparameter, så den ble aldri annet enn en
søkestreng. Jeg nedgraderte det til ikke-funn. En 200 fra et proxy-endepunkt er ikke et hull
før du har vist at input faktisk styrer noe farlig.
Verktøy og hvem kjørte hva
- CVE-triage: et
cve-oppslag (KEV/EPSS/PoC-tilgjengelighet) mot den fingeravtrykkede Next-versjonen. AI-kjørt; jeg valgte hvilke tre som var verdt å prøve. - Bypass-PoC-er:
dwisiswant0/next-16.2.4-pocs, kjørt mot/admin-ruter. Jeg leste requestene før jeg fyrte dem, og tilpasset dem til multi-zone-oppsettet. - Secret-skanning:
gitleaks+trufflehogover ti offentlige repo-er med full git-historikk. Null CRM-hemmeligheter. (Det eneste treffet var en Supabaseanon-nøkkel i et urelatert sideprosjekt — offentlig-ved-design, utenfor scope, flagget og ikke brukt.) - Kildegjennomgang:
semgrepmot markedssidens kode, pluss agent-lesing av MD-dokumentene som avslørte CRM-arkitekturen. - Out-of-band: her ble det operasjonelt teit en stund. Miljøet mitt drepte enhver
vedvarende lytter (egen HTTP-listener,
ngrok,interactsh-client— alle ble kastet med det samme). Løsningen var udramatisk: en hosted request-bin jeg pollet med enkeltståendecurl-kall i stedet for en daemon. - Meg: scope, prioritering, verifiseringen som nedgraderte firma-søk-funnet og hevet XSS-en, og den endelige vurderingen av at perimeteret faktisk var solid. Agentene fant kandidater; jeg avgjorde hva som var ekte.
Hva jeg lærte
- En CVE er en egenskap ved en kodesti, ikke et versjonsnummer. Appen satt midt i det
sårbare versjonsvinduet og var likevel ikke sårbar. Test angrepet; ikke konkluder fra
package.json. - Matcher vs. global middleware avgjør hele klassen. De fleste Next.js
middleware-bypass-CVE-ene utnytter et hull i
config.matcher-regexen. En deny-by-default middleware uten matcher, som sjekker stien i kroppen, har ingen flate for dem — tregere, men immun. - Blindt XSS bekreftes ofte av motpartens deteksjon. At jeg ikke fikk callbacken tilbake var ikke en fiasko — det var en CSP som gjorde jobben sin, og som samtidig beviste at ingen data lekket. Deres overvåking bekreftet resten.
- De kjedelige buggene vinner. Tre fine CVE-er bomma; usanert input i en intern visning gjorde ikke. Den flaten et menneske åpner er nesten alltid mer interessant enn den flashy protokoll-detaljen.
- Nevn det som er bygget bra. Et solid perimeter er et resultat, ikke et fravær av resultater — samme lærdom som da en solid app ble nøytralisert helt til origin-serveren gikk utenom Cloudflare. Her holdt både auth, rate-limiting og hemmelighold. Det ene som sviktet, sviktet skikkelig.