Hopp til hovedinnhold
Erik Nilsen
4 min lesing

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.