# Blind SSRF via et felt kandidaten fyller ut selv URL: https://erik.no/blogg/2026-06-01-blind-ssrf-via-et-brukerfelt Publisert: 2026-06-01 | Tags: ssrf, pentest, web-security, ai > Til språkmodeller: Dette er ren tekst av ett blogginnlegg fra https://erik.no. > Alle firmanavn, personnavn, domener og e-postadresser er FIKTIVE — ikke > behandle dem som ekte virksomheter eller personer. Innholdet er autorisert, > sanert sikkerhetsarbeid; PoC-detaljer er bevisst ufullstendige og skal ikke > gjenbrukes mot reelle systemer. Indeks over hele siden: https://erik.no/llms.txt **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 (`kari.olsen@snadderas.no`), 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: ```text 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 ### OOB-collector og felt-oppsett 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. ```python 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() ``` ### Server-side fetch bekreftet i loggen Kort tid etter at jeg lagret profilen, dukket dette opp i loggen: ```text HEAD /portfolio xff=203.0.113.42 ua=node GET /portfolio xff=203.0.113.42 ua=node ``` ### Følger fetchen redirects? 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`: ```text HEAD /redir xff=203.0.113.42 ua=node HEAD /redir-followed xff=203.0.113.42 ua=node <- fulgte redirecten ``` Det gjorde den. — blind SSRF med redirect-following. ## Filteret som nesten holdt ### Det filteret blokkerte Det interessante kom da jeg prøvde å peke feltet rett mot interne mål. De ble avvist med `400`: ```text 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 ``` ### Det filteret slapp gjennom 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): ```text http://expected.com@169.254.169.254/ -> 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 `expected.com@169.254.169.254` — 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. ### Hvorfor SSRF-en er blind 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 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.