Hopp til hovedinnhold
Erik Nilsen
10 min lesing

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=%2F

CVE-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=%2F

Fortsatt 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-component
HTTP/1.1 307 Temporary Redirect
location: /admin/login?next=%2F

Og segment-varianten:

GET /admin.segments/$c$children/__PAGE__.segment.rsc HTTP/1.1
RSC: 1
Next-Router-Segment-Prefetch: /__PAGE__
Accept: text/x-component

307 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/html

200, 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.

  1. 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.

  1. 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 + trufflehog over ti offentlige repo-er med full git-historikk. Null CRM-hemmeligheter. (Det eneste treffet var en Supabase anon-nøkkel i et urelatert sideprosjekt — offentlig-ved-design, utenfor scope, flagget og ikke brukt.)
  • Kildegjennomgang: semgrep mot 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ående curl-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.