# Notater fra en ekstern pentest av en Azure Container Apps-stack URL: https://erik.no/blogg/2026-05-28-notater-fra-en-ekstern-pentest-av-en-azure-container-apps-stack Publisert: 2026-05-28 | Tags: pentest, azure, container-apps, oidc, security > 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 har akkurat avsluttet en autorisert ekstern pentest mot en Azure-basert SaaS-plattform hos **Poly AS** (`polyas.no`). Engasjementet var rent eksternt — ingen credentials, ingen auth-fase — så denne posten dekker alt jeg fikk se. Målet er å vise mønstrene og metodikken: fra hvordan WAF-en omgås via ACA sin default-FQDN, til hvordan en `p=none`-DMARC kjeder sammen med over-broad OIDC-scopes til en realistisk tenant-takeover. Alt jeg fant og demonstrerer i denne posten er identifisert med utelukkende offentlig tilgjengelig informasjon og standardverktøy — DNS-oppslag, Certificate Transparency, vanlige HTTP-forespørsler, og lesing av åpne metadata. Ingen credentials, ingen interne dokumenter, ingen privilegert tilgang. Hvem som helst med en nettleser og `curl` kan reprodusere det samme. Det er nettopp poenget med en ekstern pentest: å speile hva en angriper kan se uten å ha brutt seg inn først. ## Kontekst Plattformen kjører på **Azure Container Apps (ACA)** med Next.js på frontend, OIDC mot Entra ID (`polyas.onmicrosoft.com`), et CRM bygget på Better-Auth, en Grafana-instans for intern observability, og en håndfull subdomener bak en WAF. Scope var rent eksternt: ingen credentials, ingen DoS, ingen forsøk på social engineering mot ekte ansatte, og ingen brute-force mot login-skjemaer. UA var satt til en signert pentest-streng på alle prober slik at trafikken kunne korreleres med engagementet. Et notat om metodikken: dette er et solo-engagement, men ikke 100% manuelt arbeid — og jeg gjemmer ikke at AI gjør mye av tunge-løftet. AI-en skriver custom scripts, orkestrerer recon-pipelines rundt `subfinder` / `dnsx` / `httpx` / `nuclei`, parser `/openapi.json`-differ og `semgrep`-output, foreslår neste angrepsvektor basert på det den har sett, og rydder i Burp-historikk. Jeg styrer retningen, leser resultatene, bryter inn der det trengs, og gjør det som krever menneskelig dømmekraft: scope-grenser, severity, etiske vurderinger, prioritering, og den endelige rapporten. Moderne pentest med AI i loopen — ikke autonomi, og ikke "se så manuell jeg er". Resten av posten antar at du er kjent med ACA, Entra ID app-registreringer, OIDC PKCE-flyt, og hvordan Microsoft Graph delegated scopes fungerer. ## Recon Før selve testingen kjørte jeg en bred AI-orkestrert recon-pass — `subfinder` for subdomain-enum, `crt.sh` for cert-historikk, `httpx` for live-status og response-fingerprinting, `nuclei` for kjente fingerprinter. To av kanalene skiller seg ut, fordi de illustrerer hvor lite invasivt det faktisk er: **Certificate Transparency.** `crt.sh` ga meg en sortert liste av alle subdomener som har hatt et offentlig sertifikat utstedt under apex-domenet de siste tre årene — inkludert flere stagings-/preview-miljøer som ikke var lenket fra hovedsiden. To av dem viste seg å være aktive Container Apps med samme image som produksjon, bare uten WAF. ```bash curl -s "https://crt.sh/?q=%25.polyas.no&output=json" \ | jq -r '.[].name_value' \ | tr ',' '\n' | sort -u ``` **`og:image` og andre metadata.** En triviell wget på forsiden + `grep` etter `azurecontainerapps.io` ga meg den interne ACA-FQDN'en direkte fra ``. Det var den lekkasjen som ble inngangen til funn 1. ```bash curl -s https://app.polyas.no/ \ | grep -oE 'https://[a-z0-9-]+\.[a-z0-9-]+\.azurecontainerapps\.io[^"]*' ``` Begge teknikkene er gratis, ikke-invasive, og treffer langt oftere enn man skulle tro. ## 1. ACA-direct ingress bypass Det mest interessante funnet — og det jeg tror er mest representativt for Azure-stacker generelt. Når du publiserer en Container App med custom domain (`app.polyas.no`), beholder ACA også den default-genererte FQDN'en på formatet `-..azurecontainerapps.io`. Begge svarer med samme applikasjon. Hvis WAF, IP-allowlist eller rate-limiter sitter på `app.polyas.no`, går alt det forbi ved å snakke direkte til `.azurecontainerapps.io`. ```http GET / HTTP/1.1 Host: app-abc123.westeurope.azurecontainerapps.io User-Agent: Mozilla/5.0 (pentest-...) HTTP/1.1 200 OK Server: envoy < samme respons som app.polyas.no, men uten WAF foran > ``` Verifikasjon var enkel — jeg sendte et kjent WAF-trigget pattern (en SQLi-signatur i query-string) mot begge: ```text GET https://app.polyas.no/?q=' OR 1=1-- → 403 (WAF) GET https://app-abc123...azurecontainerapps.io/?q=... → 200 (passerer) ``` Funn: . ACA har per i dag ingen innebygd måte å disable default-FQDN'en på. Mitigeringen er enten å sette Front Door eller en annen reverse proxy foran ACA med private ingress (`ingress.allowInsecure=false` + `external=false`), eller å validere `Host`-headeren i applikasjonen og avvise alt som ikke matcher en allowlist av custom domains. Det samme mønsteret fant jeg på fem av seks publiserte subdomener i denne stacken: CRM, sandbox, observability, en MCP-tjeneste, og en intern API-gateway. Den ene som ikke var sårbar hadde Front Door foran og hadde også gått videre til private ingress på selve ACA-en. ## 2. OIDC-flyten — broad scopes og tx-cookie-rekkefølge Tenant-portalen bruker Entra ID som identity provider. Authorize-URL'en (sanert) ser slik ut: ```text https://login.microsoftonline.com//oauth2/v2.0/authorize ?client_id= &response_type=code &redirect_uri=https://app.polyas.no/api/auth/callback &response_mode=query &scope=openid profile email offline_access Mail.ReadWrite Mail.Send Files.ReadWrite.All Sites.ReadWrite.All Chat.ReadWrite ChannelMessage.Send &code_challenge=...&code_challenge_method=S256 &state=...&nonce=... ``` Tre observasjoner: **(a) Single-tenant authorize URL.** Tenant-GUID'en sitter i path — ikke `/common/` eller `/organizations/`. Dette er en stille positiv: hele kategorien av Azure AD takeover-bugs som forutsetter multi-tenant authorize URL (typisk CVE-2023-3128-familien for Grafana og andre OSS-apper) gjelder ikke for denne deployen. Verdt å nevne i rapporten fordi det er en konkret arkitektonisk beslutning som har slått ut en kjent angrepsklasse. **(b) Over-broad Microsoft Graph delegated scopes.** Listen inkluderer `Mail.ReadWrite`, `Mail.Send`, `Files.ReadWrite.All`, `Sites.ReadWrite.All`, `Chat.ReadWrite`, `ChannelMessage.Send` og `offline_access`. Det er tenant-wide write access til mail, OneDrive, SharePoint og Teams under hver bruker som consenter, pluss offline refresh-token for å gjenta det uten at brukeren er til stede. Konsekvensen: hvis app-secret eller refresh-token lekker fra applikasjonens backend (gjennom logging, en SSRF, en uavhengig kompromittering), oversettes det direkte til "send mail som hvem som helst i tenanten som har logget inn minst én gang". Riktig løsning er **incremental consent**: be om minimumet ved login, så `Mail.Send` (eller hva det nå er) når brukeren faktisk trigger funksjonen som trenger det. Da blir også consent-dialogen forståelig for sluttbrukeren — `Files.ReadWrite.All` på en login-prompt får selv tekniske brukere til å klikke avbryt. Funn: . **(c) tx-cookie valideres før `code`/`state`.** Etter callback gjør appen først en sjekk av `oidc_tx`-cookien (PKCE verifier + nonce + provider-navn), så validerer den `code` og `state`. Det er riktig rekkefølge — en angriper som ikke har tx-cookien fra samme browser session får aldri sjansen til å levere en stjålet `code`. Verdt å nevne både fordi det er sjelden gjort rett, og fordi det betyr at en open redirect alene ikke kan brukes til å stjele auth codes her. ## 3. Auth-bypass på guest-endpoint — en grundig negativ finding `/api/auth/me` på tenant-portalen returnerer en guest-respons til unauthenticated kallere: ```json {"displayName": null, "role": "TENANT_USER", "allowedModules": []} ``` Det _ser_ ut som en ubeskyttet endpoint, men er Better-Auth/iron-session SPA-mønsteret: klienten poller denne ruten for å avgjøre om den skal rendre login-knappen eller en authenticated shell. Det er open by design. Jeg ba AI-en generere testmatrisen og kjøre den gjennom Burp Intruder + en kort Python-wrapper. 15 minutter senere hadde jeg svaret: ingen role-flip-vei. Matrisen: - **27 trust-header-varianter** — `X-User`, `X-Role`, `X-Forwarded-User`, `X-Forwarded-Email`, `X-Forwarded-Groups`, `X-Original-URL`, `X-Authenticated`, `X-Remote-User`, `X-SSL-Client-DN`, `X-Auth-Request-User`, og 17 til. - **19 session cookie-navn** — `next-auth.session-token`, `authjs.session-token`, `connect.sid`, `jwt`, `token`, `role`, og diverse interne navn jeg gjettet ut fra ` ``` Nonces uten en policy som krever dem er dekorasjon. Enhver injisert `