Deny-lista glemte én IP-skrivemåte: SSRF via IPv4-mapped IPv6
- #ssrf
- #security
- #pentest
- #cloud
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.
Mange SaaS-produkter har et onboarding-steg der du limer inn nettadressen din,
og tjenesten henter og analyserer siden server-side for å sette opp profilen din.
Hver gang en server henter en URL du kontrollerer, tenker jeg SSRF. Hos Fjordlys AS
(fjordlysas.no) hadde de et slikt steg — og de hadde tenkt på det meste. Bare
ikke på én måte å skrive en IP-adresse på.
Kontekst
Oppdraget var en autentisert test av et webprodukt. Onboardingen tok en URL, validerte den mot et eget endepunkt, og hentet så siden med en backend-scraper. Validatoren var faktisk god: den blokkerte loopback, private nett, link-local og en haug med kjente omgåelser.
Arbeidsmåten min er ganske moderne på dette punktet: jeg lar en AI gjøre mye av grovarbeidet. Jeg ba AI-en generere en liten payload-liste med klassiske SSRF-omgåelser og kjøre dem mot validator-endepunktet, så plukket jeg ut de interessante svarene selv. Det er raskt, og det er lett å være systematisk når maskinen ikke blir lei av å prøve variant nummer 30.
POST /api/onboarding/validate-url
Content-Type: application/json
{"url":"http://169.254.169.254/"}Svaret var URL_NOT_ALLOWED. Det samme var http://[::1]/, desimal-IP,
heksadesimal-IP, oktal-IP og nip.io-trikset. Validatoren slo tydeligvis opp
adressen og sjekket den resolverte IP-en — ikke bare strengen.
Det jeg fant
En av variantene AI-en sendte skilte seg ut:
POST /api/onboarding/validate-url
{"url":"http://[::ffff:169.254.169.254]/"}{"valid":true}::ffff:169.254.169.254 er den IPv4-mapped IPv6-skrivemåten av
169.254.169.254 — sky-metadata-adressen. Validatoren normaliserte alle de
andre formatene, men ikke denne. Den så [::ffff:...], klarte ikke å kjenne
igjen at det egentlig var en link-local IPv4-adresse, og slapp den gjennom.
Jeg fikk AI-en til å bygge et lite differensial-script for å se hva som faktisk skjedde på baksiden:
http://[::ffff:169.254.169.254]/ -> valid:true (metadata-nettet nåbart)
http://[::ffff:127.0.0.1]:3000/ -> valid:true (intern tjeneste på app-verten)
http://[::ffff:10.0.0.1]/ -> URL_TIMEOUT (rutet inn i privat nett)
http://[::ffff:10.255.255.254]/ -> URL_TIMEOUT (død IP — kontroll)
http://169.254.169.254/ -> URL_NOT_ALLOWED (fortsatt blokkert)Forskjellen mellom rask valid:true, treg URL_TIMEOUT og URL_NOT_REACHABLE
gjorde endepunktet til en intern portskanner. Jeg kunne se hvilke interne porter
som svarte.
En deny-liste som slår opp IP-en er riktig tilnærming — men den må normalisere
alle representasjoner av en adresse, inkludert IPv4-mapped IPv6
(::ffff:0:0/96). Strengsammenligning alene er ikke nok, og her var det
én normaliseringssti som manglet.
Fra blind til ikke-blind
Et valid:true er hyggelig, men jeg ville vite om dette var blind SSRF eller om
jeg fikk innhold tilbake. Jeg satte opp en enkel callback-server bak en tunnel,
oppga den som «nettstedet mitt» i en fersk onboarding, og lot AI-en lese tilbake
onboarding-objektet via API-et.
Tittelen og meta-beskrivelsen fra min server dukket opp i produktet:
{
"websiteMetadata": {
"title": "OOB-PENTEST-MARKER",
"description": "ssrf-callback-confirmed"
}
}Server-kontrollert innhold ble hentet og lagret i et felt jeg kunne lese. Det gjorde SSRF-en ikke-blind: oppgi en URL, serveren henter den, og innholdet kommer tilbake til deg. Callback-loggen avslørte også at to ulike hentere var i sving — en Node-basert backend-klient og en headless Chrome-renderer — fra flere utgående adresser.
Hva som ikke gikk (og hvorfor det er verdt å nevne)
Det er fristende å overselge et SSRF-funn. Jeg bruker en del tid på å finne taket også, fordi det er det som avgjør alvorligheten:
- Sky-token: metadata-API-et krever headeren
Metadata-Flavor: Google. Scraperen sender den ikke, og jeg kunne ikke injisere headere — så tjenestekonto-tokenet var utenfor rekkevidde. - Filer:
file://ble avvist, og en HTTP-scraper leser uansett ikke lokale filer. - SQL: en HTTP-klient snakker ikke Postgres-protokollen, og databaseporten var ikke åpen på app-verten uansett (managed DB på egen vert).
- Redirect til intern: da jeg fikk callback-serveren til å svare med en 302 mot en intern adresse, re-validerte backend redirect-målet og blokkerte det. God kontroll — den stien var tett.
Så: reell, autentisert SSRF med innholdsrefleksjon mot interne og metadata-nett — men ikke en rett linje til tokens eller filer.
Hva jeg lærte
- High — SSRF med innholdsrefleksjon er alvorlig selv uten token-tyveri, fordi interne tjenester og metadata-nett blir nåbare.
- IP-deny-lister må normalisere alle adresseformer.
::ffff:-varianten er en klassiker som ofte glipper. - Det er verdt å teste hver sti som henter en bruker-URL likt — her var redirect-stien strengere enn førstegangs-validatoren.
- AI i loopen er bra til nettopp dette: generere payload-varianter, kjøre differensialer og lese tilbake API-svar. Jeg styrer retningen og tar vurderingene; maskinen tar repetisjonen.
Veien videre
Fiksen er grei: slå opp vertsnavnet, og avvis hvis den resolverte adressen er
loopback/privat/link-local/ULA — inkludert ::ffff:0:0/96 — med et utprøvd
IP-bibliotek fremfor strengsjekk. Slå opp på nytt ved hentetidspunkt, og ikke
følg redirects til interne adresser. Og bruk samme kontroll overalt der serveren
henter en URL brukeren har oppgitt.