Hopp til hovedinnhold
Erik Nilsen
24 min lesing

«Alt er fikset», sa de: full retest av en MSP-portal

  • #pentest
  • #azure
  • #container-apps
  • #ai
  • #security

FIKTIVT: Alle firmanavn, personnavn, domener, e-postadresser, IP-adresser og identifikatorer i dette innlegget er oppdiktet eller sanert. Eventuell likhet med ekte virksomheter eller personer er tilfeldig.

For en stund tilbake skrev jeg notatene fra en ekstern pentest av en Azure Container Apps-stack — WAF-bypass via ACA sin default-FQDN, over-brede OIDC-scopes, DMARC p=none, og et «egenutviklet» CRM som viste seg å være en upatchet fork. Det var Poly AS (polyas.no). Siden har de rebrandet, bygget om, og fortalt kunden sin at «alle tidligere funn er fikset». Så jeg fikk komme tilbake og sjekke.

To ting hadde endret seg. Poly har gått fra én SaaS til å drive en MSP-portal — en governance-plattform som administrerer Azure på vegne av mange kundetenanter. Og jeg kjørte det med en helt annen motor: en AI-agent-sverm, bølge etter bølge, med adversariell verifisering.

Konklusjonen er todelt, og det er hele poenget med et retest:

  1. «Alt er fikset» er ikke sant. Rundt 20 av 22 teknisk-verifiserbare funn fra forrige gang står fortsatt.
  2. Men kjernen er genuint hardnet. Etter åtte runder med sverm fant jeg ingen uautentisert vei inn til kronjuvelene — kundedata, M365-tenanten, Azure. Hver sti dit er nå enten passord- eller operatør-gated.

Dette blir et langt innlegg. Jeg tar hele funn-katalogen — 29 distinkte funn (1 kritisk, 11 høye, 9 medium, 8 lave) pluss et helt lag med positive kontroller — organisert per ressurs, med PoC og negativ kontroll for hvert. Hvis du bare vil ha metoden og de store linjene, hopp til «Hva jeg lærte» til slutt.

Kontekst — arkitekturen og motoren

Estatet er nå en flerdomene-greie: en marketing-front, en governance-portal (portal.polyas.no) med en ASP.NET-backend, den samme CRM-forken fra sist, en Nextcloud, en monitor-hub, og et knippe workforce-apper. Portalen er kjernen: den er en MSP-kontrollflate som holder Azure-tilgang inn i hver eneste kundetenant.

Metodikk-notatet fra sist gjelder fortsatt — solo, men ikke manuelt — bare skrudd opp. Der jeg før lot én agent kjøre recon og plukket ut treffene, kjørte jeg denne gangen multi-agent-workflows: jeg dekomponerte oppdraget i angrepsklasser, fyrte av mange spesialist-agenter i parallell (én per mål × sårbarhetsklasse), og lot hvert kandidatfunn gå gjennom et eget refute-steg der 2–3 uavhengige skeptiker-agenter fikk i oppgave å motbevise det. Jeg styrte retningen, leste resultatene, tok scope- og severity-vurderingene, og skrev rapporten.

20-agent-svermene krasjet prosessen tre ganger. Tunge agenter som laster ned flermegabyte-bundles og kjører mange verktøy samtidig sluker minne. Løsningen ble å holde hver bølge på 6–8 agenter, og å salvere en krasjet kjøring fra kjøre-journalen (som har hver agents returverdi) i stedet for å miste den. Automatisering er en kvalitet — men en sverm er en maskin som må dimensjoneres, ikke en magisk knapp.

AI-svermen, og de 4789 «funnene» som ikke var funn

Første bølge inkluderte en autonom scanner-agent. Den kom tilbake med 4789 funn på ett av målene: 4714 info, en håndfull medium, og tre CRITICAL. De tre kritiske var «Server-side template injection» på /api/search, /api/feedback og /rest/track-order/{id}.

Det er akkurat her en agent i loopen er farligst: den produserer plausible funn fort — og plausible feil like fort. /rest/track-order er en rute fra OWASP Juice Shop. Scanneren hadde matchet responser mot referanse-app-mønstre og ropt «SSTI» på det den så. Jobben min er ikke å tro på den. Jobben er å verifisere.

GET /api/search?q=test HTTP/1.1
Host: polyas.no
 
HTTP/1.1 404 Not Found
x-matched-path: /[locale]/[...rest]
x-powered-by: Next.js
<!DOCTYPE html><html id="__next_error__">...   # Next.js catch-all 404
 
# /rest/track-order/1  -> 307 location: /no/rest/track-order/1   (i18n-middleware, bare språk-prefiks)
# Negativ kontroll: /zzz-finnes-ikke-42 -> 307 location: /no/zzz-finnes-ikke-42  (samme svar som «SSTI»-ruta)

Ingen template rendres noe sted. Det finnes ingen SSTI fordi det ikke finnes noe endepunkt — scanneren hallusinerte kritikaliteten på en soft-404. Jeg lot et lite skript merke alle tre som false_positive, sammen med noen hundre «Discovered path: /admin, /backup-db …» som var samme mønster: en Azure Static Web App og en Next.js-app som svarer 200/307alt. Info — 4389 auto-funn forkastet.

Dette er hele grunnen til refute-steget. En sverm som får jobbe fritt vil rapportere tusenvis av plausible ting. Verdien ligger ikke i funnene den genererer, men i verifiseringen som skiller de ekte fra de hallusinerte. «Verifiser mot innhold, ikke mot statuskode» ble et mantra: en Azure SWA som svarer 200 med index.html på hver eneste sti gjør at hvert naivt .git/HEAD- eller appsettings.json-treff blir en false positive helt til du ser på body.

Hva de faktisk fikset — og hva de ikke fikset

Kundens påstand var «alt er fikset». Så jeg kjørte hvert eneste funn fra forrige rapport på nytt.

Det de faktisk fikset (fortjent kreditt): fire inaktive ex-ansatt-kontoer fjernet · overvåkings-stacken (Traefik/monitoring) brannmuret bort · Grafana patchet forbi CVE-2025-4123 · e-post/passord-selvregistrering i CRM-et skrudd av · en Scaleway-hostet admin- og chat-tjeneste firewallet · marketing-siten migrert (gammel .no er nå bare en redirect-stub).

Men rundt 20 av 22 teknisk-verifiserbare funn fra forrige gang sto fortsatt urørt: ACA-appene nås fortsatt direkte på sin rå *.azurecontainerapps.io-FQDN, workforce-OIDC-appen ber fortsatt om altfor brede Graph-scopes, DMARC står fortsatt på p=none, CSP-headeren mangler fortsatt, helse-endepunkter lekker fortsatt topologi, de danglende CNAME-ene er der fortsatt, og Web3Forms-nøkkelen er fortsatt gjenbrukbar. «Fikset» betydde «vi bygget noe nytt og hardere ved siden av», ikke «vi lukket det du fant».

Resten av innlegget er katalogen.

Funn-katalogen

Bruddet: et hardkodet AI-token

Marketing-siten (polyas.no, Next.js) shipper et server-API-Bearer-token hardkodet i en offentlig, world-readable JS-chunk. Det tokenet er eneste auth foran AI-backend-rutene POST /api/ai/generate og POST /api/ai/image. Hvem som helst som leser sidekilden henter det ut og kaller endepunktene som en autentisert klient.

# 1. tokenet ligger i den offentlige chunken
curl -s https://polyas.no/_next/static/chunks/<chunk>.js | grep -oE 'Bearer [0-9a-f]{48}'
 
# 2. uten token               -> 401 {"message":"Unauthorized"}
# 3. feil token               -> 401 {"message":"Unauthorized"}
# 4. LEKKET token + tom body  -> 400 zod "Invalid discriminator value. Expected 'blogPost'"   (FORBI AUTH)
# 5. LEKKET token + gyldig body -> 200, full LLM-generert dokument   (kjørt én gang, autorisert)

401 → 400-flippen på den ene variabelen (tokenet) er selve auth-bypass-beviset: samme request som avvises uten token slipper forbi auth med det lekkede tokenet, og feiler først på body-validering.

Impact + tak. Gratis, uautentisert bruk av et betalt LLM-backend (OpenRouter → en Claude-modell): budsjett/kvote-misbruk, cost-DoS, og vilkårlig innholdsgenerering på et kundevendt domene (~1900 completion-tokens per kall, ingen rate-limit på endepunktet). Jeg presset foten hardt for eskalering, og alt falt — derfor er dette høyt, ikke kritisk:

  • Prompt-injection for system-prompt/secrets → NEGATIVT. To forsøk; modellen nektet begge og genererte meta-innhold som avviser injeksjonen.
  • SSRF via research-featuren → DEFINITIVT NEGATIVT. Testet med en egen ngrok-callback-server referert både i prompten og i research.urlsnull treff (bare min egen self-test traff). Ingen angriper-kontrollerbar utgående request → ingen sky-metadata, ingen intern pivot.
  • Token-gjenbruk → SCOPET. Virker kun/api/ai/*; avvist (401) mot CRM, portal, hub og MCP-gateway, og er ikke et gyldig Sanity-token.

High — ekte uautentisert tilgang til et betalt backend, men bevist avgrenset til LLM-misbruk. Fiks: revoker tokenet umiddelbart, proxy /api/ai/* serverside med per-bruker-auth + spend-cap.

Kjeden mot M365-tenanten

Fra forrige post: total e-post-spoofbarhet + over-brede OIDC-scopes = en troverdig samtykke-phish. Den kjeden er fortsatt hel, ledd for ledd — og det er retestens farligste funn, selv om ingen enkeltledd er en exploit.

E-post-spoofing (tre domener). polyas.no står på DMARC p=none/sp=none (monitor-only, tross SPF -all/DKIM), og de to nyere brand-domenene har verken SPF, DMARC eller DKIM. Spoofet e-post ble akseptert for levering av Microsoft EOP (PoC til min egen testinnboks). Medium per domene.

Brukerenumerering. Entra-tenanten svarer på GetCredentialType uten throttling:

curl -s https://login.microsoftonline.com/common/GetCredentialType \
  -H 'Content-Type: application/json' -d '{"Username":"<bruker>@polyas.no"}' \
  | grep -o '"IfExistsResult":[0-9]'
# 0 = kontoen finnes ; 1 = fraværende   (ingen auth, ingen throttle)

En enkeltskudd-per-e-post-sveip (~1,2 s mellomrom) bekreftet 18 gyldige postbokser uten throttling — en komplett bemanningsliste, pre-auth. Medium

B2B-gjestebro. Tre .no-ansatte finnes som eksterne B2B-gjester i den tynne .io-app-tenanten (samme IfExistsResult=0-orakel mot <bruker>_polyas.no#EXT#@polyas-io.onmicrosoft.com). En ATO av en spoofbar .no-identitet rekker dermed inn i .io. Medium

Nyttelasten. Workforce-appen ber om delegerte Graph-scopes langt utover behov: Mail.ReadWrite, Mail.Send, Files.ReadWrite.All, Sites.ReadWrite.All, offline_access. Ett samtykke = tenant-bred lese/skrive-tilgang til postbokser og SharePoint/OneDrive. Portal-appen ber i tillegg om Azure ARM (management.azure.com/user_impersonation). High

Tenneren: device-code. Det nye denne gangen — jeg bekreftet at device-code-flyten er skrudd på for begge de høyt-scopede appene. Det gjør samtykke-phishen MFA-forbigående:

POST /organizations/oauth2/v2.0/devicecode
client_id=<workforce-app>&scope=<app>/.default offline_access
 
HTTP/1.1 200 OK
{"user_code":"<REDACTED>","verification_uri":"https://microsoft.com/devicecode",
 "expires_in":900,"interval":5, ...}

Endepunktet utsteder et levende user_code. Offeret logger inn på den ekte microsoft.com/devicecode, og angriperen holder tokenet pollingen returnerer. Negativ kontroll / ærlighet: her stoppet jeg. Ingen bruker ble phishet, ingen kode tastet, intet token fanget — leddet er operatør-gated. De fire kontoene kunden ga meg ble bekreftet gyldige og Managed, men verken sprayet eller phishet. Jeg beviste at vektoren er åpen, ikke at jeg gikk gjennom den.

Den ærlige overskriften på hele retesten: den mest realistiske veien til full M365- og Azure-kompromittering er ikke en exploit — det er en samtykke-phish mot over-scopede apper med device-code på. Den kjeden flagget jeg forrige gang, og den er fortsatt åpen. Fiks: DMARC p=reject, minste-privilegium på Graph-scopene (dropp *.ReadWrite.All), samtykke-styring, og skru av device-code der den ikke trengs.

For ordens skyld: kunden kjører faktisk tre distinkte Entra-tenanterpolyas.no (primær M365), polyas.io (tynn app/gjeste-tenant) og polyas.com (egen NA-tenant) — mens polyas.ai ikke er et forvaltet identitetsdomene i det hele tatt (GetCredentialType → DomainType=1, unmanaged). Kartet er info-nivå intel, men det er ryggraden i cross-tenant-historien. Info

MSP-portalen: der kronjuvelen er kunde-hemmelighetene

Portalen er arkitektonisk den mest interessante delen. Jeg dumpet hele SPA-en og lot en agent lese den mens jeg pekte på det som betød noe. Forretningsmodellen ligger i klartekst: for hver kunde konsenter kunden Poly sin TenantReader-app til Reader + Cost Management Reader i sin egen Azure-tenant, og Poly lagrer per-kunde tenantReaderClientId og tenantReaderClientSecret (i Key Vault). Rollene er Read < Admin < SuperAdmin, og SuperAdmin styrer hele kunde-registeret.

Den som får SuperAdmin får lesetilgang inn i hver eneste administrerte kundes Azure på én gang — og portal-appens ARM-scope gjør at en sesjon potensielt rekker helt inn til Azure-ressursstyring. Det er kronjuvelen. Så alt handlet om en vei til SuperAdmin — eller til hemmelighetene den vokter.

Secret read-back-jakten. Hvis enroll-flyten skriver en hemmelighet, finnes det kanskje en søster som leser den? Jeg hentet dev-backendens uauthenticated /swagger/v1/swagger.json og lot et skript resolve hver responsstruktur:

# AI-generert; jeg satte mønsteret det skulle lete etter
SECRET = re.compile(r'secret|credential|clientsecret|tenantreader|password|token')
for path, ops in swagger["paths"].items():
    for method, op in ops.items():
        hits = [f for f in resolve_response_schema_fields(op) if SECRET.search(f)]
        if hits: print(method.upper(), path, "->", hits)
# GET /api/admin/tenants  [200] -> ['tenantreaderclientid']     # eneste treff i hele API-et

Negativ kontroll: det eneste secret-lignende feltet noe endepunkt returnerer er tenantReaderClientIdclient-ID-en, ikke secreten. tenantReaderClientSecret finnes i ingen responsstruktur, og det finnes ingen rotate/reveal/test-connection-rute. Secreten er write-only: inn i Key Vault ved enroll, aldri ut igjen via API-et. Fyrt med både lavpriv-token og fremmed-tenant-token → begge 403. High som design-risiko (blast radius), Info som utnyttbart hull i dag.

X-TenantId-BOLA + et sterkt positivt funn. SPA-en fester et klient-satt X-TenantId-header på hver dataforespørsel, og rolle-gatingen (Read/AllTenants/Admin/SuperAdmin) er håndhevet kun klientside (en hl()-sjekk i bundelen). Hvis backenden stoler på X-TenantId uten å sjekke det mot tokenets tenant, er det en autentisert cross-tenant-BOLA. Men før jeg kunne teste det med en sesjon, sjekket jeg om jeg kunne forfalske en:

# Alle mot io-p-backendens /api/session/current:
alg:none forged            -> 401 "The signature is invalid"
utløpt RS256               -> 401 "The token expired at ..."
feil issuer                -> 401 "The issuer '(null)' is invalid"
feil audience (alle testet)-> 401 "audience ... is invalid"

Token-forgery er umulig — backenden gjør full standard JWT-validering mot Azure AD JWKS. Og X-TenantId-BOLA-en? Autorisasjonen håndheves serverside; en fremmed-tenant-token blir akseptert som autentisert men svarer 403 på hver dataruta. Den fulle historien om det aksepterte-men-avviste tokenet skrev jeg i da cross-tenant-funnet ikke overlevde verifisering. BOLA-vektoren er reell i kode ( High), men credential-gated — den krever en ekte, provisjonert portal-sesjon jeg ikke fikk.

En siste portal-detalj: SWA-laget bruker Entras multi-tenant default-provider (/.auth/login/aad → /common), så hvem som helst med en Entra-konto kan autentisere på SWA-laget — men det gater ingenting bak seg (backenden re-validerer). Low, auth-misconfig.

CRM-forken: en kronjuvel-bug som er ekte i koden — men gated

CRM-forken bærer en klynge Broken Object/Function Level Authorization-defekter i det workspace-scopede /api/v1/*-API-et. Kildegjennomgang + live-probing bekreftet syv instanser. Mønsteret er alltid det samme: en handler tar en objekt-UUID fra requesten og glemmer å sjekke at objektet tilhører innloggerens workspace.

// PATCH /api/v1/objects/{slug}/attributes
updateAttribute(attributeId, input)   // ingen workspace-sjekk på attributeId; {slug} brukes ikke

Fire slike i REST-laget (cross-workspace write av attributter/lister, en cross-workspace attribute DELETE der handleren filtrerer WHERE id=id og ignorerer {slug}), pluss tre i CRM-ets AI-chat-verktøy — get_notes_for_record, list_list_entries, create_note kalles alle uten ctx.workspaceId. En angripers egen AI-agent kan altså lese en annen tenants private notater på tvers, auto-eksekvert uten bekreftelse.

# Live-flate (uauth, beviser at ruta finnes og hva som gater den):
curl -s -o /dev/null -w "%{http_code}\n" -X PATCH \
  https://crm.polyas.no/api/v1/objects/people/attributes \
  -d '{"attributeId":"000...0","title":"probe"}'
# 401 Unauthorized
 
# Cross-tenant impact (gated — gjengitt, IKKE kjørt):
curl -X PATCH 'https://crm.polyas.no/api/v1/objects/people/attributes' \
  -b 'active-workspace-id=<A_ws>; <A_session>' \
  -d '{"attributeId":"<B_ATTR_UUID>","title":"pentest-canary"}'   # forventet 200, muterer B sin attributt

Negativ kontroll / ærlighet: jeg reproduserte ikke cross-tenant-skrivingen. Autentiseringen er lukket — e-post/passord-registrering og -innlogging er skrudd av serverside (EMAIL_PASSWORD_DISABLED), Microsoft-SSO er eneste vei inn, og den er tenant-pinnet. Credential-sprayen min bommet. Bug-en er kode-bekreftet og live bak 401, men å demonstrere effekten krever én gyldig sesjon og en fremmed UUID. En tidligere hypotese om at workspace-LLM-nøkkelen kunne exfiltreres via CRM-et falt også: API-et returnerer bare et keyPrefix/hasApiKey, aldri hele nøkkelen. High (kode-bekreftet, gated).

Samme CRM har tre mindre følgefunn: /openapi.json serveres offentlig og uautentisert (full API-flate, Low); CSP-headeren mangler helt ( Medium); og og:image-metadataen peker på den interne ACA-FQDN-en i stedet for det brandede vertsnavnet:

curl -sk https://crm.polyas.no/ | grep -Eio 'og:image[^>]*azurecontainerapps\.io[^"]*'
# -> og:image content peker på <crm-app>.<env>.azurecontainerapps.io

Det publiserer bypass-primitivet (se neste seksjon) til enhver link-unfurler. Medium

Edge og infrastruktur

ACA raw-FQDN-bypass (bypass-primitivet). Hver brandede Poly-workload (crm, workforce, insights/Grafana, mcp, rag, tools, sandbox, hub) er en Azure Container App bak et vennlig *.polyas.no-domene. Den underliggende ACA-ingress-FQDN-en er direkte nåbar og svarer byte-identisk:

curl -sk -o /dev/null -w "%{http_code}\n" https://crm.polyas.no/api/auth/ok
curl -sk -o /dev/null -w "%{http_code}\n" \
  https://<crm-app>.<env>.norwayeast.azurecontainerapps.io/api/auth/ok
# begge -> 200 {"ok":true}, identisk body/headers/remote_ip

Enhver kant-kontroll bundet til det brandede navnet (WAF, Front Door-regler, geo/IP-policy) forbigås ved å be om rå-FQDN-en, og per-revisjon-FQDN-en lar en angriper pinne til en gammel, kanskje sårbar revisjon (<app>--<revision>.<env>...). High — dette er primitivet som holder Grafana-/CRM-/MCP-flatene nåbare uansett kant-herding, og et ledd i M365-kjeden.

Eksponert AKS API-server. traefik.polyas.io presenterer et TLS-sertifikat som er et Kubernetes API-server-sertifikat (CN=apiserver, SAN kubernetes.default.svc, IP 10.0.0.1, *.hcp.norwayeast.azmk8s.io) — altså en AKS managed control-plane eksponert på kanten:

echo | openssl s_client -connect 203.0.113.10:443 -servername traefik.polyas.io 2>/dev/null \
  | openssl x509 -noout -subject -ext subjectAltName
# Subject: CN=apiserver ; SAN: ...azmk8s.io, kubernetes.default.svc, IP 10.0.0.1

Negativ kontroll som ble til en positiv: jeg rakk ikke fullføre anonymous-auth-sjekken (/version, /api, /healthz) — under testen tidsavbrøt alle TCP-connect mot host-IP-en, mens en samtidig kontroll mot portal-fronten holdt seg åpen. Ingressen aktiv-blokkerte tester-IP-en min (adaptiv filtrering). Det reduserer opportunistisk eksponering, men lukker ikke funnet: control-plane-en er cert-bekreftet på kanten og anonymous-auth-posituren er uprøvd. High (åpen, anonymous-auth-sjekk utsatt til kunden løfter IP-blokken).

Danglende CNAME-er. app.discovery.polyas.no og app.polyas.io peker på slettede ACA/SWA-miljøer. Azures domene-validering blokkerer ekstern claim, så de er ikke takeover-bare — DNS-hygiene + latent risiko, ikke en subdomain-takeover. (app.polyas.io viste seg dessuten å være en live SWA med et brutt custom-domain-cert-binding — TLS-navn-mismatch, ikke dangling.) Low

Kontrollplan med brutt OIDC. Workforce «Control Plane»-admin-appen (admin.polyas.no + dev-varianten) er internett-nåbar og redirecter til /login, men OIDC-en er i en brutt provider_unknown-tilstand:

curl -skD - -o /dev/null 'https://admin.polyas.no/login?provider=entra'
# 307 /login?error=provider_unknown   (hver provider-verdi feiler likt)

Positiv kontroll: fail-open-hypotesen ble testet og motbevist — den brutte auth-en feiler lukket (ingen fallback/lokal-auth-gren, ingen bypass). Funnet er dermed internett-eksponering av en kontrollplan-flate med degradert auth, ikke en auth-bypass. Medium

Marketing cache-deception + åpne SWA /.auth. Den fortsatt-levende legacy-SWA-en (www.polyas.no) svarer 200 med cache-control: public, max-age=30 på ikke-eksisterende stier (web-cache-deception), har backenden direkte nåbar på *.azurestaticapps.net, og har /.auth/*-flytene åpne:

curl -skI "https://www.polyas.no/nonexistent-$(date +%s).css" | grep -Ei 'HTTP/|cache-control'
# HTTP 200 ; cache-control: public, must-revalidate, max-age=30
curl -sk https://www.polyas.no/.auth/me            # {"clientPrincipal":null}

(Den nye Vercel-siten polyas.no gjør ikke dette — scoped kun til legacy-SWA.) Medium

Nextcloud

Utdatert versjon. nc.polyas.io kjører Nextcloud Server 32.0.3.2 — seks patcher bak, innenfor 2026-CVE-vinduet — og status.php lekker eksakt build. Alle aktuelle CVE-er krever imidlertid en gyldig credential (ingen unauth-vektor). Medium (gated).

Pre-auth brukerenumerering via avatar. nc.polyas.io/index.php/avatar/{bruker}/{størrelse}200 med ekte PNG for eksisterende brukere, 404 for ikke-eksisterende; ingen auth, CSRF eller rate-limit. Innebygd admin bekreftet. Medium

SAML-bypass til lokalt passord-backend. ?direct=1 når det native passord-backendet forbi Entra-SAML-en, uten account-lockout (kun per-IP-delay). Kombinert med avatar-orakelet ga det en reell spray-flate — som jeg kjørte (lockout-bevisst, operatør-autorisert) og som ikke ga tilgang. Low for bypass-flaten; spray-negativet er en positiv kontroll på passordstyrken.

notify_push uauth. Push-endepunktet er nåbart uautentisert (unhandled-error / info-disclosure). Low

En viktig positiv kontroll her: SAML-forgeryen ble motbevist live — en forfalsket/usignert SAMLResponse ble avvist (user_saml validerer signatur/issuer tross WantAssertionsSigned=false). Ingen pre-auth ATO.

Sanity CMS

Verdens-lesbart datasett. Marketing-CMS-et hadde production-datasettet satt til public read. En anonym GROQ-spørring returnerte hele innholdet — inkludert et siteSettings.contact-objekt med en navngitt ansatts direkte mobil og e-post:

curl -sG 'https://<prosjekt-id>.api.sanity.io/v2021-06-07/data/query/production' \
  --data-urlencode 'query=*[_id=="siteSettings"][0]{contact}'
# 200 -> {"contact":{"contactPerson":{"name":"<REDACTED>","phone":"<REDACTED>", ...}}}

166 dokumenter totalt lesbare anonymt. Medium — reell PII. I tillegg er den anonyme real-time listen-endepunktet (SSE) åpent, så en angriper kan live-overvåke endringer i datasettet. Low

admin.workforce: uautentisert OAuth Dynamic Client Registration

admin.polyas.no er sin egen OAuth 2.0-autorisasjonsserver (adskilt fra Entra-innloggingen). /authorize|token|jwks|userinfo er alle login-gated, men /api/oauth/register er det ikke — og det angriper-kontrollerte client_name lagres verbatim:

curl -X POST https://admin.polyas.no/api/oauth/register -H 'Content-Type: application/json' \
  -d '{"client_name":"<img src=x onerror=alert(document.domain)>",
       "redirect_uris":["http://127.0.0.1:8765/cb"],
       "token_endpoint_auth_method":"none"}'
# 201  {"client_id":"<...>", "client_name":"<img src=x onerror=alert(document.domain)>", ...}

Ærlighet om hva dette ikke er: payloaden lagres uescaped, men jeg observerte aldri en render-sink som eksekverer den — så dette er en uautentisert client-registrering + lagret payload, ikke en bekreftet stored-XSS. Og redirect_uri er låst til loopback (400 invalid_redirect_uri ellers), så ingen ekstern token-fangst. Low (ville blitt high hvis en admin-konsoll rendrer client_name uescaped). Fiks: krev auth/initial-access-token på DCR; escape all client-metadata på output.

Uautentiserte info-lekkasjer (resten av katalogen)

Ingen av disse ga en pivot alene, men de er ekte eksponering, og de fortjener PoC-ene:

  • Dev-backendens Swagger, helt åpen. <app>-dev.azurewebsites.net/swagger/v1/swagger.json200, ~148 KB, 74 endepunkter inkludert admin-tenant-CRUD, /api/Users, /api/Devices/export og Azure Resource Graph-passthrough. Negativ kontroll: prod skjuler Swagger, og hvert av de 74 endepunktene svarer 401 (Kestrel) når det kalles — også POST med live KQL. Spec-disclosure, ikke kallbar admin-tilgang. Medium
  • Grafana /metrics uauth. insights.workforce.polyas.no/metrics200, ~425 KB Prometheus-dump uten auth: eksakt build (11.6.14), konto-/admin-/service-account-navn, datasource- og alert-topologi. /api/* svarer korrekt 401 — bare /metrics slipper gaten. Low
  • Helse-endepunkter. mcp.integration.workforce.polyas.no/health lekker ACA-revisjon + K8s pod/replica; io-p-backendens /health lekker DB-avhengighet (resource-db-check); ACA /health lekker revisjon + pod-navn. Selve MCP-/data-flatene er gated (401). Low
  • Hub staging-SPA + API-kart. hub.workforce.polyas.no er en offentlig staging-frontend hvis bundle lekker staging-backend-origin + et fullt 102-endepunkters admin/MCP/RAG-rutekart, og staging-backenden er internett-nåbar og CORS-whitelister prod-originen (miljø-bleed). Rutene er 401-gated. Low
  • /api/auth/me gjest-objekt. Workforce-portalen returnerer et gjeste-identitetsobjekt til uautentiserte kallere i stedet for 401: 200 {"displayName":null,"role":"TENANT_USER","allowedModules":[]} — lekker identitetskontrakten. Low
  • Atlassian-plattform-metadata (to tenanter). polyas-io.atlassian.net/rest/api/2/serverInfo (+ servicedeskapi/info) svarer 200 anonymt med eksakt build-nummer, build-dato og SCM-changeset — men det er Atlassians egen delte Cloud-plattform-commit, ikke klientkode, og ikke tenant-konfigurerbart. JSM anonym kundetilgang er skrudd av (portaler anonymousEnabled:false). Info
  • monitor-hub /api/health.{"status":"degraded"} uautentisert (operativ-tilstand). NextAuth-innloggingen på samme host har ingen rate-limit/lockout + timing-basert brukerenum — en spray-flate (ikke knekt). Info / Low
  • Nøkler i bundelen. Web3Forms-nøkkelen på kontaktskjemaet er ikke origin-bundet — jeg beviste gjenbruk med ett merket PoC-innlegg fra en fremmed origin (200 {"success":true}, relayet inn i Polys egen innboks). Og en Mapbox public-token (pk.) ligger i en polyas.no-chunk. Low
  • DNS-hygiene. Diverse manglende hardening (SPF/DMARC-gaps, polyas.com intel-only DNS-hygiene). Low
  • Svake sikkerhets-headere. io-p-backend, polyas.no og admin.workforce mangler/svak CSP (unsafe-inline) og øvrige security-headere, pluss Request-Context App-Insights-appId-lekkasje. Low

Agentisk infrastruktur: en helt ny angrepsflate

To av de mest interessante funnene handler ikke om web-appene, men om at Poly kjører autonome AI-agenter i produksjon.

Det herreløse produktdomenet. Agentene commiter til ekte GitHub-repoer som <adjektiv>-<dyr>@polyas.dev, og en intern token-broker + en GitHub-App orkestrerer dem. polyas.dev er ikke bare agent-navnerommet — det er produkt- og utviklerdomenet deres, med produkt-mail, OIDC-utsteder-tillit og TLS knyttet til seg. Og det er uregistrert:

curl -s https://rdap.org/domain/polyas.dev
# {"errorCode":404,"description":["polyas.dev not found"]}
curl -s -H 'accept: application/dns-json' "https://dns.google/resolve?name=polyas.dev&type=NS"
# {"Status":3, ...}   # 3 = NXDOMAIN

Den som registrerer det (rundt en tier per år) kan sette opp en catch-all MX og fange all e-post til *@polyas.dev: GitHub-verifiseringer og passord-reset for agent-kontoene, Entra B2B-invitasjoner, reset-mail for et hvilket som helst SaaS der en agent er registrert. Man kan verifisere [email protected] på en egen GitHub-konto og få GitHub til å re-attribuere agentens commits — og sende «som» en agent inn i menneske-review-loopen som merger PR-ene deres. Jeg fant live, uattribuerte agent-commits i offentlige repoer akkurat nå. Det er ikke en shell i seg selv — det er en betingelse som kan bli en fot innenfor — men det er det klareste og billigste funnet i hele rapporten. Critical

Den uautentiserte regnskaps-MCP-en. Poly har forket en MCP-server som kobler agentene til regnskapssystemet sitt. I koden eksponerer forken en HTTP-transport (POST /mcp) helt uten autentisering — en unauthenticated request lager en sesjon, registrerer alle verktøyene, og serverer kallet. Serveren holder regnskaps-tokenene serverside:

# lokal bygg av forken:
POST /mcp  {"jsonrpc":"2.0","method":"tools/list", ...}    # ingen Authorization-header
→ 200, 28 verktøy: create_invoice, create_voucher, create_employee, update_customer, ...
# mot den LIVE hosten:  POST /mcp → 401

Negativ kontroll: den live-deployerte MCP-hosten er en annen, gated gateway (401 på alt), så dette er ikke utnyttbart mot produksjon i dag. Men forkens railway.json/Dockerfile defaulter til MCP_TRANSPORT=http uten auth — så hvis den noen gang deployes slik, driver en anonym angriper regnskapssystemet via serverens lagrede credentials. En klassisk confused deputy. High (kode/residual). Det finnes også en design-only agentisk supply-chain-vei: indirekte prompt-injeksjon → broker-utstedt GitHub-token → tilkoblede kunde-repoer (polyas.dev/GitHub er utenfor scope, så flagget, ikke kjørt).

Autonome agenter er en ny angrepsflate, og både identiteten deres og verktøy-transporten deres er en del av den. Hvis agentene dine har e-postidentiteter og commit-signaturer knyttet til et domene — eie det domenet. Hvis de snakker MCP — autentiser transporten før du registrerer verktøyene. Et herreløst agent-navnerom er subdomain-takeover for AI-alderen; en uautentisert MCP-transport er et RCE-lignende primitiv for den.

Da jeg ba svermen finne på nye angrep

Da hver utførende bølge endte i «hardened», byttet jeg modus: en sverm som skulle generere nye angrepsvinkler — seks kreative «linser», hver matet med hele den kjente tilstanden og listen over det som allerede var motbevist, slik at de bare produserte nye ideer. 56 ideer inn, en rangert backlog ut. Toppen ble testet manuelt, og nesten alt falt — men måten det falt på er lærerikt.

Dependency confusion. CRM-forken er en pnpm-monorepo med interne pakker @crmcore/shared og @crmcore/web, begge uclaimet på npm (404). Ser farlig ut — helt til pinningen:

grep -r '"@crmcore/shared"' apps/*/package.json
# apps/web/package.json:  "@crmcore/shared": "workspace:*"    -> resolves lokalt, aldri fra npm

Uten en .npmrc som faller tilbake til public registry er det ingen confusion-vei. Forkastet.

Claim-forvirring i rollen. Mapper backenden rollen din fra et angriper-settbart string-claim i stedet for en immutabel GUID? Jeg er Global Administrator i min egen tenant (Tjall AS, tjallas.no) — wids-claimet mitt inneholder rolle-template-ID-en for Global Admin (62e90394-69f5-4237-9190-012177145e10). Hvis Poly sin backend naivt sjekket «er dette en Global Admin?» uten å sjekke hvilken tenant, ville min utenfrakonto fått SuperAdmin:

# dekod portal-API-tokenet (utstedt til min egen tjallas.no-konto):
{'aud': '<portal-app>', 'tid': '<min-tenant>', 'scp': 'access_as_user',
 'roles': None, 'wids': None, 'groups': None}
# fyrt mot backenden:
GET /api/Session/Current -> 401 ;  GET /api/Applications -> 403 ;  GET /api/admin/tenants -> 403

Negativ kontroll er hele funnet. Portal-API-tokenet bærer ingen roles/wids/groups. wids: Global Admin lever bare i Graph-tokenet mitt, i min egen tenant — det når aldri Poly sin API. Backenden resolver rollen serverside fra Polys egen katalog. En Global Admin i en vilkårlig fremmed tenant er en nobody her. Info — motbevist, positiv kontroll.

IMDS-SSRF via LLM-verktøykanalen (rank-5-ideen): bruk det hardkodede AI-tokenet til å tvinge et funksjonskall som henter http://169.254.169.254/metadata/identity/... og stjeler managed-identity-tokenet. Den var kreativ, men forutsatte at research-fetcheren egresserte angriper-kontrollert — som allerede var motbevist (null ngrok-treff). Falt før den kom i gang. Secret read-back (rank 2) og io-d delt datastore (rank 6) — begge testet og motbevist over (write-only / 401).

Det som holdt — positive kontroller

Dette er kanskje den viktigste delen av en «proven-hardened»-rapport: bevis på hva som virker. Krediter kunden for hvert av disse — jeg dyttet på dem alle:

  • App-autentisering: CRM/workforce/sandbox/portal/Grafana er alle Entra-SSO eller sunn-JWT; ingen uautentisert fot på noen app-API. Forfalskede session-cookies gir bare app-skallet (dataflatene re-validerer kryptografisk).
  • Token-forgery umulig: alg:none, RS256→HS256-confusion, utløpt, feil issuer/audience — alle 401 mot Azure AD JWKS.
  • CRM-SSO tenant-pinnet: e-post/passord-innlogging av; Microsoft-SSO pinnet til hjemme-tenanten → en utenfrakonto kommer ikke inn.
  • SAML-signering validert: forfalsket SAMLResponse avvist tross WantAssertionsSigned=false.
  • Cross-tenant string-claim-mapping: motbevist (over).
  • Passord: low-and-slow spray mot Entra + monitor-hub + sandbox + Nextcloud = rene bom (ingen svake passord); Entra-enum-beskyttelse aktiv (den auto-oppblåste enum-listen ble forkastet som FP).
  • Framework/CVE: Next.js patchet (CVE-2025-29927 x-middleware-subrequest negativ på alle fire apper); Grafana CVE-2025-4123/2024-9264/2021-43798 patchet/gated; request-smuggling + cache-poisoning motbevist over heterogene kanter.
  • Deploy-hygiene: ingen .git/.env/sourcemaps/backups eksponert (alle «200s» var Azure-SWA soft-200s som serverte index.html, verifisert mot body); ingen committed secrets i GitHub-org-skann.
  • MCP-integrasjonsgateway: fail-closed (401 på 40+ prober); Scaleway-kanten default-drop, ingen bypass; io-p-backendens authN-vegg robust (CORS exact-match).
  • AI-endepunktet: prompt-injection, research/image-SSRF (ngrok-verifisert), system-prompt-lekkasje — alt negativt.
  • Dev-creds: [email protected] / devpassword123 funnet i CRM-bundelen var inerte på prod (EMAIL_PASSWORD_DISABLED; ingen dev-CRM-instans finnes).
  • Device-code: stoppet ved bevist vektor — ingen live token-fangst.

Hva jeg lærte

  • En sverm produserer plausible funn og plausible feil i samme tempo. 4789 inn, en håndfull ekte ut. Verdien er ikke genereringen — det er refute-steget. Kjører du agenter i loopen uten en skeptiker som defaulter til «ikke ekte», shipper du hallusinasjoner.
  • «Fikset» må verifiseres, funn for funn. Kunden mente det ærlig — de hadde bygget noe nytt og hardere. Men 20 av 22 gamle funn sto urørt ved siden av. Et retest er ikke å spørre om noe er fikset; det er å kjøre hvert funn på nytt.
  • Hardnet der det teller er et ekte, verdifullt resultat — selv når kanten lekker. Kjernen holdt mot hele flåten. Derfor krediterer rapporten de positive kontrollene like eksplisitt som funnene.
  • Den farligste kjeden er sjelden en exploit. Her er det en samtykke-phish mot over-scopede apper — menneskelig, ikke teknisk, og uendret siden sist.
  • Agentisk infrastruktur er en egen angrepsflate. De to mest handlingsrettede funnene var et uregistrert domene og en MCP-transport uten auth — ingen av dem en tradisjonell web-bug.
  • Alvorlighet er en funksjon av arkitektur, ikke bare av bugs. MSP-portalens SuperAdmin-konsentrasjon er den største risikoen i estatet, og det er ikke en sårbarhet — det er et designvalg om blast radius.
  • En sverm må dimensjoneres. Tre krasj lærte meg å holde bølgene små og alltid kunne salvere fra journalen.

Det jeg ikke fikk — og veien videre

Trådene som gjenstår er alle operatør-styrte eller gated:

  • CRM-ets cross-workspace-BOLA (syv instanser) krever en gyldig sesjon og en fremmed UUID — SSO-en er tenant-pinnet, så en utenfrakonto kommer ikke inn. Dekkes bare av et authenticated- eller assumed-breach-oppdrag.
  • SuperAdmin-veien finnes fortsatt der ute, hypotetisk. Jeg fant den ikke på åtte runder — nyttig informasjon, men ikke det samme som at den ikke finnes.
  • AKS API-server-cert-en: anonymous-auth-sjekken rakk jeg ikke — hosten auto-blokkerte tester-IP-en. Åpen tråd, flagget for en runde der kunden løfter blokken.
  • polyas.dev-kjeden kan bare tas videre ved å faktisk registrere domenet og se hva som lander i catch-all-en — en forretnings- og scope-beslutning, ikke en teknisk en. Det er også rapportens #1 anbefaling: registrer domenet før noen andre gjør det.

En ting til fra sist gang som fortsatt gjelder: en ekstern retest bekrefter at grensene holder utenfra, men den ser ikke det som ligger bak login. I en MSP-portal, der SuperAdmin er hele ballspillet, er det nettopp der neste oppdrag bør begynne.