Isolasjonen holdt, autorisasjonen sprakk: pentest av en regnskaps-SaaS
- #pentest
- #api-security
- #multi-tenant
- #ai
- #security
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.
Oppdraget hos Kvittring AS (kvittringas.no) hadde en tydelig bestilling fra
kundens tekniske ansvarlige, Jonas Bakke ([email protected]): «kom
inn, få tak i data fra andre kunder». Det er en ærlig og nyttig måte å ramme
inn en test på — den tvinger meg til å angripe det som faktisk betyr noe for en
multi-tenant regnskaps-SaaS: skillet mellom leietakerne.
Kortversjonen: jeg kom ikke gjennom det skillet. Tenant-isolasjonen holdt på hver eneste vei jeg prøvde. Men rundt den solide kjernen lå tre reelle hull i selve autorisasjonsmodellen — og det ene lot seg demonstrere helt frem til en falsk, betalbar leverandørgjeld. Dette er notatene.
Kontekst
Kvittring AS drifter et regnskaps- og ERP-API på app.kvittringas.no. Det er
multi-tenant: mange bedrifter deler samme plattform, og hele
sikkerhetsmodellen står og faller på at tenant A aldri ser tenant B sine
fakturaer, kunder eller lønnstall.
Jeg fikk et signert SOW, en testkonto, og to typer legitimasjon:
- Et brukertoken som må sende
X-Tenant-Id: <id>for tenant-spesifikke kall. Serveren skal håndheve at tokenet faktisk er medlem av den tenanten. - En tenant-API-nøkkel som er bundet til én tenant og bærer en liste over
scopes (
customer:read,bank:write,attachment:writeosv.).
API-et er dokumentert med en OpenAPI-spec på 188 stier og 270 operasjoner. Det er en stor angrepsflate, og måten jeg jobber på i dag handler like mye om å temme den flaten som om å skrive enkeltangrep.
Hvordan jeg jobbet — AI i loopen
Jeg kjører moderne pentester med en AI-agent i loopen. Jeg styrer retningen, tar de vurderingene som krever menneskelig dømmekraft — scope, etikk, prioritering, hva som faktisk er et funn — mens agenten gjør mesteparten av mekanikken: leser spec, skriver scripts, kjører verktøy, leser output og foreslår neste steg.
Konkret startet jeg med å gi agenten hele OpenAPI-spec'en og be den bygge en
prioritert angrepsplan. Den fanet ut over de ~40 feature-gruppene i API-et
(kunder, fakturaer, bank, lønn, hovedbok …) og produserte per gruppe en rangert
liste med hypoteser: hvilke {id}-parametere som er IDOR-kandidater, hvilke
felt i request-body som lukter mass assignment, hvor injeksjon kunne bo, hvilke
endepunkter som er privilegerte. Jeg leste synteserapporten, kastet det
åpenbart feilrettede, og plukket de hypotesene som var verdt å teste live.
Fra der ble det en vekselvirkning: agenten skrev og kjørte sweepene, jeg leste
resultatene og brøt inn der noe krevde et menneske. Verktøyene som faktisk
gjorde jobben var nuclei, sqlmap, swaks, en OOB-collaborator (interactsh
bak en ngrok-tunnel), malicious-pdf, og en haug små curl-scripts. Under
vises de sanert.
Det som holdt — og hvorfor det er et funn i seg selv
Før funnene: det viktigste resultatet er egentlig at kjernen holdt. En test som bare rapporterer det som er ødelagt, gir et skjevt bilde. Isolasjonen ble angrepet fra minst et dusin vinkler, og alle ble avvist korrekt.
Den mest åpenbare: brukertokenet mitt var medlem av én test-tenant. Jeg byttet
bare ut X-Tenant-Id til en tenant jeg ikke var medlem av.
# brukertoken, men X-Tenant-Id for en tenant jeg ikke tilhorer
curl -s -H "Authorization: Bearer <REDACTED>" \
-H "X-Tenant-Id: 2" \
https://app.kvittringas.no/api/customers
# {"error":"Forbidden","message":"User access token does not have access to the requested tenant"}Samme mønster gjentok seg overalt. Objektoppslag på {id} for fremmede IDer ga
404 (ikke 403 — den lekker ikke engang at objektet finnes). Fremmede IDer
smuglet inn via request-body ble re-scopet til min egen tenant. List-filtre som
?employeeId=<fremmed> returnerte tomt, ikke andre tenanters rader. Til og med
bulk-endepunkter som tar en postingIds[]-array validerte hver eneste ID mot
kallerens tenant. Dette er den samme lærdommen som i
RLS beskytter raden, ikke rettigheten,
bare speilvendt: her var rad-isolasjonen konsekvent på plass.
sqlmap mot rapport- og hovedbok-parametrene (accountFrom, amountFrom,
vatCode …) fant ingenting — backenden er Spring med typebinding, og
malformert input avvises med en ren 400 lenge før noe når databasen.
sqlmap -u "https://app.kvittringas.no/api/ledger/general?startDate=2024-01-01&endDate=2024-12-31&accountFrom=1000&accountTo=9999&amountFrom=0" \
-H "Authorization: Bearer <REDACTED>" -p "accountFrom,accountTo,amountFrom" \
--level 3 --risk 2 --batch --dbms postgresql
# all tested parameters do not appear to be injectableSesjons-JWT-en (ES256) motsto alg:none, HS256-forveksling, CVE-2022-21449 og
jku/x5u-injeksjon. nuclei mot CVE- og misconfig-templates ga null høy/
kritisk. Og — verdt å merke seg med tanke på
Da WordPress holdt, men e-posten åpnet døren
— DMARC var her riktig satt opp (p=reject), så ren avsender-spoofing var
blokkert. Det siste blir viktig om et øyeblikk.
Negative funn er funn. «Jeg klarte ikke å bryte tenant-skillet på tolv forskjellige måter» er en setning kunden bør få svart på hvitt — den er verdt mer enn en liste med lavgraderte header-tips.
Funn 1: en API-nøkkel med scope som ikke betyr noe
High — Broken Function Level Authorization.
Tenant-API-nøkler presenteres i grensesnittet som scope-begrensede: «denne
nøkkelen virker bare med scopene under». Testnøkkelen min hadde blant annet
attachment:write, men bevisst ikke attachment:read. Den asymmetrien ble
den reneste testen: kan en nøkkel som kun har skrivetilgang til vedlegg,
lese et vedlegg?
# nokkelen har attachment:write, men IKKE attachment:read
curl -s -H "Authorization: Bearer <REDACTED>" \
-F "[email protected]" https://app.kvittringas.no/api/attachments
# 201 {"id": 16913, "contentUrl": "/api/attachments/16913/content", ...}
curl -s -H "Authorization: Bearer <REDACTED>" \
https://app.kvittringas.no/api/attachments/16913/content
# 200 -> "PENTEST-CANARY-..." (filinnholdet, uten attachment:read)Den leste innholdet. Og det stoppet ikke der: samme nøkkel opprettet og slettet ressurstyper den ikke hadde noe scope for i det hele tatt. Scope-listen var kosmetikk — den ble aldri håndhevet på serversiden.
Konsekvensen er lett å undervurdere fordi den ikke krysser tenant-grenser. Men den bryter hele løftet om minste privilegium: en nøkkel som en administrator tror hun deler ut som «read-only, kun kunder» til en tredjeparts-integrasjon, har i praksis full lese-, skrive- og slettetilgang til hele tenanten — bank, faktura, lønn, dokumenter, brukere. En slik nøkkel som havner i et git-repo eller en logg, eksponerer alt.
Dette er den samme distinksjonen som i RLS-posten: kontrollen beskyttet det ene laget (tenant-skillet) helt fint, men sa ingenting om det andre (hva nøkkelen faktisk får lov til innenfor tenanten).
Test scope-håndhevelse med en nøkkel som mangler nettopp det scopet du
prøver. En nøkkel med write men uten read er en gavepakke — den skiller
«virker fordi jeg egentlig har tilgang» fra «virker fordi ingen sjekker».
Tiltak: håndhev scope-listen server-side på hvert endepunkt via et sentralt autorisasjonsfilter, og legg til en kontraktstest som itererer alle operasjoner i OpenAPI-spec'en og bekrefter at en nøkkel med scope-sett S avvises overalt utenfor S.
Funn 2: fakturamottaket tok imot fra hvem som helst
Medium — uautentisert dokumentinjeksjon.
Plattformen lar deg sende fakturaer på e-post til et mottak: <tenant-slug>@ ea.kvittringas.no. Dokumentet OCR-tolkes, en leverandør opprettes automatisk,
og resultatet legger seg i regnskapskøen klart til å konverteres til en
leverandørgjeld.
Her er nyansen som skiller dette fra e-post-postene jeg har skrevet før: DMARC
var ikke hullet. Da jeg testet ren spoofing med swaks, ble jeg avvist som
forventet:
<** 550 5.7.1 DMARC checks failed.Det er god hygiene. Problemet lå et annet sted: mottaket bryr seg ikke om hvem avsenderen er, bare at e-posten består DMARC for sitt eget domene. En melding fra en helt urelatert, tilfeldig avsender — ikke knyttet til tenanten på noen måte — havnet like fullt i den tenantens fakturakø, ble analysert, og kunne konverteres til en åpen, betalbar post. Mottaksadressen er dessuten bare firmanavnet som slug, altså gjettbar for hvilken som helst målbedrift.
Jeg demonstrerte kjeden ende-til-ende og stoppet ved en betalbar post i en test-tenant — jeg viser bevisst ikke selve innsendingen eller en mal her, siden det ville være en ferdig oppskrift på fakturasvindel. Poenget er autorisasjonshullet: mottaket mangler avsenderverifisering. En travel regnskapsfører som ser en tilsynelatende legitim faktura i køen, er ett klikk unna å betale den til en angriperkontrollert konto.
Dette er en fetter av «appen holdt, e-posten var døra» fra WordPress-posten — men her var ikke e-postoppsettet feil, det var forretningslogikken rundt mottaket som manglet en sjekk.
Tiltak: avsender-hviteliste per tenant (eller eksplisitt godkjenning av førstegangsavsendere), karantene og tydelig «ekstern avsender»-merking i køen, og ikke-gjettbare mottaksadresser.
Funn 3: kundebasen kunne telles opp via org.nr
Medium — informasjonslekkasje og uautorisert opprettelse.
Endepunktet for å opprette en tenant tar et organisasjonsnummer. Responsen skiller pent mellom tre tilstander:
curl -s -X POST -H "Authorization: Bearer <REDACTED>" \
-H "Content-Type: application/json" \
-d '{"organizationNumber":"<org.nr>"}' \
https://app.kvittringas.no/api/tenants
# 400 {"detail":"tenant already exists for organization number"} <- finnes allerede = kunde
# 201 {...} <- ny tenant OPPRETTET (squatting)
# 400 {"detail":"organization number was not found in Brreg"} <- finnes ikke i registeretDet er et eksistens-orakel. Kombinert med at det ikke fantes noen rate-limiting
noe sted (jeg kjørte 80 samtidige kall uten en eneste 429), kan en angriper —
selvregistrering er åpen — iterere det norske org.nr-rommet og telle opp hvem
som er kunder av plattformen. Verre: den negative grenen oppretter en tenant
for hvert selskap som ikke allerede er kunde, altså masse-squatting av vilkårlige
selskapers identiteter.
Her ble menneskelig dømmekraft viktig. Jeg bekreftet mekanismen på mitt eget test-org og et syntetisk nummer, men kjørte den bevisst aldri i skala — å telle opp en ekte kundebase og strø systemet fullt av squattede tenants ville vært både destruktivt og utenfor det en representativ PoC trenger. Agenten kan kjøre fort; en av jobbene mine er å si «langt nok, ikke lenger».
Merk også: dette gir ikke tilgang til de opptalte tenantenes data. Isolasjonen holder fortsatt. Man lærer hvem kundene er, ikke hva de har.
Tiltak: lik respons uansett om selskapet finnes, krav om dokumentert fullmakt (Altinn/Brreg-rolle) før en tenant opprettes, og rate-limiting.
Verktøy og oppsett
Litt om reproduserbarheten, siden det ofte er der en leser stopper opp.
- Angrepsplan: agenten parset OpenAPI-spec'en og fanet ut per feature-gruppe til en rangert hypoteseliste. Jeg orkestrerte det som en liten workflow — flere parallelle analyser som ble syntetisert til én prioritert test-plan. Jeg kjørte ikke live-angrep i parallell mot produksjon; den delen holdt jeg sekvensiell og ratet den ned.
sqlmap(--level 3 --risk 2, eksplisitt--dbms postgresql): agenten satte opp kjøringen med riktigAuthorization-header og parameter-liste. Null injiserbare parametere.nucleimed CVE-, misconfig- og exposure-templates motapp.kvittringas.no: null høy/kritisk. Nyttig mest som en rask bekreftelse på at det ikke lå noe åpenbart og uparchet i kanten.- OOB: en interactsh-collaborator bak en ngrok-tunnel for blind SSRF/XXE. Agenten la callback-URLen inn i payloadene; jeg fulgte lytteren.
malicious-pdffor PDF-pipelinen. Agenten genererte 69 ondsinnede PDF-er (XXE/XFA/SSRF/NTLM-varianter) og lastet dem opp via dokumentmottaket.
python3 malicious-pdf.py https://<min-collaborator>
# 69 PDF-er generert -> lastet opp via /api/documents
# 0 tilbakekall mot collaboratorDen siste er et fint eksempel på et verktøy hvis fravær av treff er poenget.
Ingen callback betyr at parseren ikke løser eksterne entiteter eller følger
referanser. Fingeravtrykket forklarte hvorfor: Producer-metadataen på
plattformens egne PDF-er avslørte iText Core 9.6.0 — en fersk, patchet
versjon der samtlige kjente iText-CVE-er (XXE i 5.5.x/7.0.x, Ghostscript-
injeksjonen i 7.1.x) for lengst er lukket. Jeg sjekket de bekreftede
versjonene mot CVE-databasene og fant ingenting utnyttbart; backend-versjonene
var dessuten ikke eksponert over HTTP, som er god herding.
Hva jeg lærte
- Isolasjon og autorisasjon er to lag. Denne plattformen fikk det første helt riktig og det andre feil. Test dem separat — «kan A se B?» og «får denne legitimasjonen lov til X?» er ulike spørsmål med ulike svar.
- En nøkkel uten scopet du tester er det beste beviset. Asymmetrien
writeutenreadskilte reell tilgang fra manglende håndhevelse på ett kall. - God DMARC stopper spoofing, ikke injeksjon. Riktig e-postoppsett sier ingenting om hvorvidt mottaket verifiserer at avsenderen har noe der å gjøre.
- Negative funn hører hjemme i rapporten. «Holdt på tolv vinkler» er et resultat kunden har betalt for.
- AI i loopen er fart; dømmekraft er bremsen. Agenten fant orakelet og kunne trivielt kjørt det i skala. Å la være — å holde seg til en representativ PoC — var min jobb, ikke dens.
Veien videre
Retest er en del av oppdraget. Det jeg er mest nysgjerrig på til neste runde er scope-håndhevelsen: den riktige fiksen er et sentralt filter, og den enkleste måten å verifisere at den faktisk dekker alle 270 operasjonene, er nettopp en generert kontraktstest som går gjennom hele spec'en. Det er en test AI-en kan skrive på minutter — og en fin sirkel: samme spec som ga meg angrepsplanen, blir grunnlaget for regresjonstesten som lukker funnet.