Hopp til hovedinnhold
Erik Nilsen
19 min lesing

Da cross-tenant-funnet ikke overlevde verifisering

  • #pentest
  • #api-security
  • #multi-tenant
  • #ai
  • #security

FIKTIVT: Alle firmanavn, personnavn, domener og e‑postadresser i dette innlegget er oppdiktet. Eventuell likhet med ekte virksomheter eller personer er tilfeldig. Detaljer fra reelle oppdrag er sanert.

Det mest interessante funnet i denne testen er et jeg endte med å forkaste. Det så ut som et lærebok-BOLA på tvers av leietakere — et endepunkt som svarte 200 der jeg forventet 403. Så testet jeg det med en konto jeg ikke eide objektet fra, og det kollapset. Dette innlegget er hele undersøkelsen: de eksakte reproduksjonskjedene, de negative kontrollene som bestemte alvorlighetsgraden, tre «vinner» som ikke overlevde første ærlige forsøk på å bruke dem, og den ene veien som faktisk kunne brutt isolasjonen — og hvorfor den ikke gjorde det.

Oppsettet

Snadder AS (snadderas.no) lager en fleir-leietaker SaaS for møtenotater og lettvekts-CRM. Frontenden app.snadderas.no er en Vite-SPA som ikke gjør annet enn å snakke med et FastAPI-backend på api.snadderas.no. Leietaker-avgrensning skjer med en header, x-workspace-id, som klienten sender på hvert kall; autentisering er en httpOnly-cookie med en JWT. I tillegg finnes admin-api.snadderas.no (egen admin-realm med egen innlogging), dev-api.snadderas.no og en Electron-skrivebordsapp.

Scope var hele *.snadderas.no pluss offline reversering av skrivebordsappen. Produktsjefen hos Snadder AS, Jonas Berg ([email protected]), formulerte målet enkelt: kan én kunde nå en annen kundes data?

Jeg kjører denne typen oppdrag med AI i loopen. I praksis lar jeg spesialiserte agenter gjøre grovarbeidet — recon, endepunkt-kartlegging, å prøve angrepsvektorer, lese logger og foreslå neste steg — mens jeg styrer retningen, setter scope og etikk, bryter inn der det trengs, og tar de vurderingene som krever menneskelig dømmekraft. Det er verdt å si med en gang: den arbeidsmåten produserer plausible funn fort. Den produserer plausible feil like fort. Denne testen ga meg tre av dem.

Første steg var billig: alle tre backendene eksponerte /openapi.json uåpnet. Jeg lot en agent parse spesifikasjonen (178 ruter på prod) og flagge alt som tok en id i stien eller en URL i body. Person- og selskaps-endepunktene — GET/PATCH/DELETE /v1/person/{id} og /v1/company/{id} — havnet øverst på lista, fordi de henter på global UUID.

Funnet som så for godt ut

Jeg logget inn som konto A, som hadde en aktiv prøveperiode og dermed tilgang til CRM-objektene. Jeg opprettet en person i arbeidsområde A1, og prøvde så å hente den per ID — men med headeren satt til et annet arbeidsområde, A2.

Forutsetninger: innlogget som eier av konto A (rolle member, prøveperiode aktiv), gyldig httpOnly-sesjon. A er medlem av både A1 og A2. Objektet ligger i A1.

  1. Opprett en person i A1 og noter UUID-en.
  2. Hent den per ID, men med x-workspace-id satt til A2:
GET /v1/person/11111111-1111-4111-8111-111111111111 HTTP/1.1
Host: api.snadderas.no
x-workspace-id: aaaa2222-2222-4222-8222-222222222222
Cookie: access_token=<REDACTED>
HTTP/1.1 200 OK
Content-Type: application/json
 
{"status":"success","data":{"id":"1111...","name":"Kari Nordmann",
"email":"<REDACTED>"},"meta":{"workspaceId":"aaaa1111-..."}}
  1. Samtidig skjuler list-endepunktet objektet helt korrekt for A2:
GET /v1/person HTTP/1.1
Host: api.snadderas.no
x-workspace-id: aaaa2222-2222-4222-8222-222222222222
Cookie: access_token=<REDACTED>
HTTP/1.1 200 OK
 
{"status":"success","data":{"items":[],"nextCursor":null}}

En av agentene jeg kjørte parallelt plukket opp nøyaktig dette og rapporterte det som «sannsynlig kritisk BOLA». Resonnementet var greit på papiret: list-visningen filtrerer på arbeidsområdet i headeren, men oppslag-per-ID gjør det ikke — objektet hentes på global UUID, og serveren ser bare ut til å sjekke at kalleren er medlem av arbeidsområdet i headeren, ikke at objektet faktisk hører til der. Det er den klassiske signaturen på broken object level authorization: to kodestier som er uenige om hvem som eier hva.

Det hadde vært lett å skrive det ned som Critical og gå videre. Jeg lot være — fordi steg 2 og 3 alene ikke skiller mellom to helt forskjellige forklaringer.

Fellen: samme eier, to arbeidsområder

Detaljen som avgjorde alt: A1 og A2 var begge mine egne arbeidsområder, under samme konto og samme organisasjon. Jeg testet ikke isolasjon mellom to kunder — jeg testet om jeg fikk lese mitt eget objekt gjennom en annen av mine egne arbeidsområde-headere.

Da jeg leste koden nøye, ga 200-en mening uten at noen grense var brutt. Tilgangssjekken på oppslag-per-ID er en eierskaps-sjekk, ikke en arbeidsområde-sjekk. Jeg fikk objektet fordi jeg eide det; header-arbeidsområdet var irrelevant for eierskapskontrollen. Det eneste reelle avviket var at oppslag-per-ID og list-visningen tolker headeren ulikt for egne objekter — en inkonsistens, ikke et brudd.

Forskjellen mellom «inkonsistens» og «cross-tenant-brudd» er hele funnet. Og den forskjellen kan man ikke lese seg til fra en 200. Man må sende den samme forespørselen fra en konto som ikke eier objektet — helst i en helt annen organisasjon. Det er den negative kontrollen §6 handler om, og her var den ikke triviell å skaffe.

Den negative kontrollen som avgjorde alt

Jeg trengte en konto i en annen organisasjon, verifisert og med prøveperiode, slik at den nådde de samme kodestiene som A. Det var to porter i veien, og begge er verdt å vise, fordi de er grunnen til at agenten aldri kom frem til svaret på egen hånd.

Først prøvde jeg en gratis andre-konto. Den ble stoppet av en plan-gate før objekt-oppslaget i det hele tatt ble evaluert:

GET /v1/person/11111111-1111-4111-8111-111111111111 HTTP/1.1
Host: api.snadderas.no
x-workspace-id: bbbb0000-0000-4000-8000-0000000000b0
Cookie: access_token=<REDACTED>
HTTP/1.1 403 Forbidden
 
{"status":"error","message":"This feature requires the trial plan or higher."}

En nonexistent UUID (00000000-…) ga samme 403 — bevis for at plan-gaten ligger foran oppslaget, og at en gratis konto aldri når autorisasjonslaget. Så prøvde jeg en fersk konto på et fremmed domene for å få prøveperiode, men da slo verifiserings-gaten inn:

HTTP/1.1 403 Forbidden
 
{"status":"error","message":"Email verification required"}

Fellen var altså: relaterte e-poster (samme domene) fikk ikke ny prøveperiode, og urelaterte e-poster kunne jeg ikke verifisere fordi jeg ikke leste innboksen. Jeg løste det ved å registrere kontoen på et domene jeg selv kontrollerte, verifisere den via lenken jeg da mottok, og starte prøveperioden. Nå hadde jeg tenant D: egen organisasjon, verifisert, med prøveperiode — en ekte uavhengig leietaker.

Så kom den avgjørende forespørselen. Tenant D, mot As objekt-UUID, med Ds egen arbeidsområde-header:

GET /v1/person/11111111-1111-4111-8111-111111111111 HTTP/1.1
Host: api.snadderas.no
x-workspace-id: dddd0000-0000-4000-8000-0000000000d0
Cookie: access_token=<REDACTED>
HTTP/1.1 403 Forbidden
 
{"status":"error","message":"Only the resource owner can perform this action."}

PATCH og DELETE på samme UUID ga samme 403. Og en siste kontroll — D som forsøkte å bruke As arbeidsområde direkte — ga en annen feilmelding, som bekreftet at det var to distinkte sjekker:

HTTP/1.1 403 Forbidden
 
{"status":"error","message":"Not a member of this workspace."}

Isolasjonen holdt. 200-en i det opprinnelige funnet var eierskap, ikke tenant-lekkasje. En annen leietaker kan verken lese eller endre en annens objekter — verken via header-triks eller direkte oppslag på UUID.

Tolkningen, linje for linje: den opprinnelige 200-en kom fordi A eide objektet (eierskapssjekken passerte); header-arbeidsområdet påvirket bare list-filteret. Da en annen eier ba om det samme, slo eierskapssjekken inn (Only the resource owner). Og da D forsøkte å påstå medlemskap i et arbeidsområde D ikke var med i, slo medlemskapssjekken inn (Not a member). Det finnes altså en fjerde sjekk — eierskap på objektet — som agenten aldri nådde, fordi den aldri kom forbi plan- og verifiserings-portene med en ekte andre-org-konto.

Funnet ble nedgradert fra «sannsynlig kritisk» til Low: oppslag-per-ID bør avgrenses til det oppgitte arbeidsområdet, så det stemmer med list-visningen. Mitigeringen er å legge til arbeidsområde-predikatet i selve spørringen og svare 404 ved avvik (ikke 403, for å ikke bekrefte at UUID-en finnes i en annen kontekst):

-person = db.query(Person).filter(Person.id == person_id).first()
+person = db.query(Person).filter(
+    Person.id == person_id,
+    Person.workspace_id == request_workspace_id,
+).first()
 if person is None:
     raise HTTPException(status_code=404, detail="Person does not exist")

For to uker siden hadde jeg det motsatte utfallet på et lignende oppdrag, der isolasjonen holdt men autorisasjonen sprakk. Samme klasse mekanisme, motsatt konklusjon. Den gangen fant jeg en reell grense som var borte; denne gangen så det likere ut, men var det ikke. Det er nettopp derfor samme-eier-testen aldri kan stå alene — den ser identisk ut i begge tilfeller helt til du legger til en fremmed eier.

Den tredje falske positiven: role=admin som ikke ga noe

Mens jeg var i profil-endepunktet, prøvde en agent en klassisk mass-assignment: send felter du ikke burde få sette. PATCH /v1/user/me godtok et klient-satt role-felt, og det persisterte.

Forutsetninger: innlogget som konto A.

  1. Baseline — mitt eget role er tomt:
GET /v1/user/me  ->  200  {"data":{"role":null, ...}}
  1. Sett role til admin via profiloppdatering:
PATCH /v1/user/me HTTP/1.1
Host: api.snadderas.no
Content-Type: application/json
Cookie: access_token=<REDACTED>
 
{"role":"admin","isVerified":true,"plan":"enterprise"}
HTTP/1.1 200 OK
{"status":"success","data":{"role":"admin", ...},"message":"Profile updated successfully"}

Et nytt oppslag bekreftet at role:"admin" overlevde, også på tvers av en ny innlogging. På dette punktet ropte agenten «privilege escalation». Igjen: verdt et sekunds skepsis, ikke en rapportlinje.

  1. Negativ kontroll — gir role=admin faktisk noe? Jeg gjorde nettopp de tingene en ekte admin-rolle skulle låst opp:
GET /v1/person/<Bs objekt>  x-workspace-id: <fremmed ws>   ->  403 "Not a member"   (ingen bypass)
GET /v1/admin/users         (på api.snadderas.no)          ->  404 Not Found        (ingen admin-ruter her)

Tolkningen: role er et profilfelt (tenk «tittel»), ikke en autorisasjonsrolle. Ingenting i tilgangslaget leser det. plan og isVerified i samme forespørsel ble dessuten ignorert — bare role ble ekko-et tilbake, og bare kosmetisk. Admin-flaten lever på admin-api.snadderas.no med en egen innlogging som avviser bruker-cookien helt. Dette er mass-assignment (OWASP API3), men uten privilegie-effekt: Low. Jeg satte feltet tilbake til null etterpå.

Mitigeringen er å ikke la server-styrte felter være skrivbare — fjern role fra input-modellen i stedet for å stole på at ingenting leser det:

 class UserProfileUpdate(BaseModel):
     name: str | None = None
     bio: str | None = None
-    role: str | None = None      # server-styrt — skal ikke kunne settes av klienten
     nickname: str | None = None

Tre funn inn, tre «kritiske» som ikke var det. Mønsteret er ikke tilfeldig: en agent som prøver alt, treffer mange ting som ser ut som brudd, fordi den signaturen er lett å produsere og vanskelig å skille fra det ekte uten en negativ kontroll.

Andre runde: en knekt dev-nøkkel som ikke ble en overtagelse

Da jeg reverserte skrivebordsappen offline dukket dev-api.snadderas.no opp, og en agent la merke til at dev — i motsetning til prod — returnerte access-tokenet direkte i responskroppen fra /v1/auth/refresh. Jeg fikk agenten til å kjøre det tokenet gjennom hashcat.

Forutsetninger: en registrert konto på dev-api.snadderas.no (for å få et gyldig token å analysere), hashcat v6.2.6, rockyou.txt.

# tokenet i dev-jwt.txt er en HS256-JWT (mode 16500)
hashcat -m 16500 dev-jwt.txt rockyou.txt -r rules/best64.rule
Status...........: Cracked
# signeringsnøkkelen var en triviell ordbokstreng — jeg gjengir den ikke

Nøkkelen falt umiddelbart. En plassholder-hemmelighet i et levende miljø er et reelt funn — High for dev, om enn isolert fra produksjon. Den fristende neste konklusjonen: med signeringsnøkkelen kan jeg forfalske hvilken som helst brukers token, altså kontoovertagelse.

Jeg testet det i stedet for å anta det. Jeg registrerte to dev-kontoer (angriper og «offer»), verifiserte begge, hentet et gyldig token fra hver, og forfalsket så et token med offerets sub, men angriperens egen jti, signert med den knekte nøkkelen.

  1. Kontroll — forfalsk mitt eget token med forlenget levetid:
forged {sub: angriper, jti: angriper, exp: +10år}  signert med dev-nøkkel
GET /v1/user/me  ->  200 OK   ("Dev PoC 1")
  1. Negativ kontroll — bytt sub til offeret, behold min egen gyldige jti:
forged {sub: offer, jti: angriper, exp: +10år}  signert med dev-nøkkel
GET /v1/user/me  ->  401 Unauthorized  {"message":"Invalid token"}
  1. Ytterligere kontroller — oppdiktet jti og alg:none:
forged {sub: offer, jti: 00000000-...}  ->  401 Invalid token
forged {alg: none, sub: offer}          ->  401 Invalid token

Tolkningen: serveren binder jti til sesjonen og brukeren. Jeg kunne forfalske og forlenge mine egne tokens (steg 1), men ikke bytte ut sub og bli noen andre (steg 2). Session-bindingen gjorde en svak nøkkel til et hygiene-funn, ikke et brudd. Jeg testet også om dev droppet eierskapssjekken fra forrige seksjon — D2 → D1s notat via UUID ga 404, samme som prod. Prod-api brukte dessuten en helt annen nøkkel, som motsto både rockyou med regler og masker på et skikkelig GPU-kjør.

Mitigeringen er kjedelig og viktig: rullér nøkkelen, hent den fra en hemmelighetstjeneste, og forby plassholder-hemmeligheter i CI. Vurder asymmetrisk signering med aud/iss så en lekket nøkkel ikke kan gjenbrukes på tvers av tjenester:

-JWT_SECRET = os.getenv("JWT_SECRET", "changeme-dev")
+JWT_PRIVATE_KEY = load_secret("jwt-signing-key")   # RS256, fra secrets manager
+JWT_AUDIENCE = "snadder-api"

Den ene veien som faktisk kunne brutt isolasjonen

Her er det ærlige forbeholdet til «isolasjonen holdt»: det fantes én mekanisme som ville omgått eierskapssjekken helt, og den er verdt å beskrive fordi den er grunnen til at hele konklusjonen hviler på én hemmelighet.

Appen bruker itsdangerous (Flask-stil signerte tokens) til e-postverifisering, passord-reset — og til arbeidsområde-invitasjoner. En join-link-token dekoder til noe sånt som {type:"workspace_join_link", workspace_id, nonce}, og valideringsendepunktet tar imot den uautentisert:

GET /v1/workspaces/invites/join-link/validate?token=<itsdangerous-token> HTTP/1.1
Host: api.snadderas.no

Logikken er åpenbar når man ser den: hvis jeg kunne forfalske en join-link for et vilkårlig workspace_id, ville jeg blitt medlem av et fremmed arbeidsområde — og da spiller eierskapssjekken ingen rolle, for da er jeg innenfor. Det ville vært det reelle cross-tenant-bruddet. Alt henger på om SECRET_KEY-en som signerer disse tokenene er svak.

Så jeg angrep den. itsdangerous bruker HMAC-SHA1 med en salt-avledet nøkkel, ikke en ren hashcat-modus, så en agent skrev en liten dedikert cracker:

python3 itsd_crack.py --token <verify-token> --wordlist rockyou.txt \
  --salts itsdangerous,email-verify,password-reset,verify,activate,snadder \
  --deriv django-concat,concat,hmac,none
[-] no hit
# rockyou (14,3M) × 18 salt-varianter × 4 nøkkel-avledninger — 0 treff

Nøkkelen motsto det hele. Den samme nøkkelen signerer også prod sine JWT-er, som falt like lite på et fullt GPU-kjør. Med andre ord: den ene døra som ville brutt isolasjonen var låst med en sterk, tilfeldig nøkkel — så nær-bommen forble en nær-bom.

Jeg tok det med som et Info-forhold likevel, fordi det er en designavhengighet verdt å skrive ned: en symmetrisk SECRET_KEY uten en rotasjons- eller splitting-historie betyr at én lekkasje (env-variabel, logg, repo) gjenåpner nøyaktig denne veien. Anbefalingen ble en hemmelighets-skann av CI/infra og en plan for nøkkelrotasjon — ikke fordi jeg kom inn, men fordi marginen er tynnere enn den ser ut.

Det som faktisk holdt vann

Testen var ikke tørr av den grunn. De reelle funnene lå i periferien, ikke i datagrensen.

E-postforfalskning

Det mest konkrete. Domenet manglet SPF helt, og DMARC sto på p=none.

Forutsetninger: ingen — uautentisert DNS-oppslag, og en levering til en postkasse jeg selv kontrollerte, etter avtale med kunden.

  1. Sjekk publisert e-postpolicy:
dig +short TXT _dmarc.snadderas.no
# "v=DMARC1; p=none; sp=none; rua=mailto:..."
dig +short TXT snadderas.no | grep -i spf
# (tomt — ingen SPF-post)
  1. Lever en forfalsket melding med swaks, fra en @snadderas.no-avsender, til min egen postkasse:
swaks --to <min-egen-postkasse> \
      --from [email protected] \
      --header "Subject: [autorisert test] spoofing-PoC"
<-  250 2.6.0 ... Queued mail for delivery
  1. Kontroll / tolkning: p=none betyr «overvåk, men ikke håndhev». Mottakende tjener aksepterte derfor forfalskningen (250) i stedet for å avvise den, og meldingen havnet i innboksen. Uten SPF finnes det heller ingen avsenderliste å feile mot. Det er ikke en tjener-bug — det er domenets egen policy som sier «slipp gjennom».

Mitigeringen er tre DNS-records og en gradvis innstramming (nonequarantinereject) så man ser rapportene før man håndhever:

snadderas.no.         TXT  "v=spf1 include:_spf.google.com -all"
_dmarc.snadderas.no.  TXT  "v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s; rua=mailto:[email protected]"
# + DKIM-signering aktivert hos e-postleverandøren

Medium, og et tiltak kunden eier fullt ut. Det samme mønsteret — appen holder, men e-posten åpner døren — har jeg skrevet om før, og det gjentar seg oftere enn de fleste tror.

CSRF på tilstandsendrende kall

Sesjonskaken er SameSite=None, som betyr at nettleseren sender den på kryss av nettsteder. Kombinert med at enkelte tilstandsendrende kall ikke sjekker Origin eller et CSRF-token, blir de angripbare fra en fremmed side.

Forutsetninger: et offer med en aktiv innlogget nettleser-sesjon.

  1. En «simple request»-POST behandles uten Origin-/CSRF-kontroll:
POST /v1/billing/start-trial HTTP/1.1
Host: api.snadderas.no
Origin: https://evil.example
Content-Type: text/plain
Cookie: access_token=<REDACTED>
 
{}
HTTP/1.1 200 OK
{"status":"success","message":"Trial ..."}   # behandlet — ingen Origin/CSRF-avvisning

Kallet ble behandlet identisk med Origin: https://evil.example, uten Origin, og med Content-Type: text/plain.

  1. Negativ kontroll — JSON-endepunkter er tilfeldig beskyttet, ikke av design:
POST /v1/notes/new   (Content-Type: application/json)   -> krever CORS-preflight
                                                         -> en fremmed <form> kan ikke sette den headeren
                                                         -> beskyttet i praksis

Tolkningen: angrepsflaten er nettopp «simple requests» — kall uten kropp, med URL-parametere, eller text/plain. JSON-kropp- og DELETE-ruter slipper unna fordi de utløser en preflight en angripers skjema ikke kan tilfredsstille — men det er en bivirkning av CORS, ikke en bevisst CSRF-forsvar. Medium. Mitigeringen er å validere Origin/Referer eller kreve et bundet CSRF-token på alle tilstandsendringer, og helst sette cookien til SameSite=Lax.

E-postverifisering ikke håndhevet ved innlogging

En fersk konto fikk full API-sesjon før adressen var bekreftet.

Forutsetninger: ingen.

  1. Registrer en ny konto:
POST /v1/auth/register HTTP/1.1
Host: api.snadderas.no
Content-Type: application/json
 
{"email":"[email protected]","password":"<REDACTED>","fullName":"Test Testesen"}
HTTP/1.1 200 OK
{"status":"success","message":"Registration successful. Please check your email to verify your account."}
  1. Logg inn uten å verifisere:
POST /v1/auth/login HTTP/1.1
Host: api.snadderas.no
Content-Type: application/json
 
{"email":"[email protected]","password":"<REDACTED>"}
HTTP/1.1 200 OK
Set-Cookie: access_token=<REDACTED>; HttpOnly; Secure; SameSite=None
 
{"status":"success","data":{"user":{"email_verified":false},
"workspace":{"id":"..."}}}

email_verified:false sammen med en gyldig sesjon og et opprettet arbeidsområde er hele funnet. Datalaget krever riktignok verifisering (det er grunnen til at det er Medium og ikke høyere), men innlogging og provisjonering skjer før. Mitigeringen er å gate innlogging på email_verified — eller å ikke opprette sesjon/arbeidsområde før adressen er bekreftet. Verdt å merke seg: nettopp denne svakheten var det som lot meg skaffe tenant D over — den ene gaten som var åpen hjalp meg forbi en annen.

Brukeropptelling

Registrering skiller åpent mellom eksisterende og nye adresser:

POST /v1/auth/register  {"email":"[email protected]", ...}  -> 400 "Email already registered"
POST /v1/auth/register  {"email":"[email protected]", ...}       -> 200 "Registration successful"

Det samme gjentok seg i to andre orakler: booking-slug-oppslag ga distinkte 404-tekster for en reell versus en ikke-eksisterende bruker, og passord-reset brukte målbart lengre tid for en eksisterende konto (bcrypt + token-generering) enn for en ukjent. Low. Mitigering: uniforme svar på registrering og reset, og en konstant-tid-sti for eksistens-sjekken.

Åpen API-dokumentasjon og en for vid CORS

/openapi.json, /docs og /redoc svarte 200 uautentisert på alle tre backendene — inkludert admin-api, som dermed publiserer hele sitt privilegerte rutekart (53 ruter). Det var også det som gjorde recon triviell: agenten trengte ikke gjette endepunkter, den leste dem. Medium for admin-flaten spesielt.

CORS på admin-api reflekterte i tillegg bruker-appens opphav med legitimasjon:

Origin: https://app.snadderas.no   ->  Access-Control-Allow-Origin: https://app.snadderas.no
                                        Access-Control-Allow-Credentials: true

Negativ kontroll: vilkårlige, null, suffiks- (app.snadderas.no.evil.com) og falske-subdomene-opphav ga ingen Access-Control-Allow-Origin — allowlisten er ellers stram. Problemet er at admin-api stoler på den lavere-privilegerte bruker-appens opphav; med en XSS i app.snadderas.no kunne det gitt legitimerte kall mot admin-flaten. Mitigering: begrens admin-api til kun admin-domenet, og steng dokumentasjonsendepunktene i produksjon.

En rask notis om skrivebordsappen

Den offline-reverserte Electron-appen hadde ett forhold verdt å nevne: deep link-handleren for snadder://auth/callback tok imot innloggings-tokens uten binding til en state/PKCE-verdi. En nettside offeret besøker kan dermed navigere til snadder://auth/callback?access_token=… og tvinge appen inn i en angripers konto — hvorpå offerets møtenotater synkes til angriperen. I tillegg sto flere Electron-«fuses» usikkert (bl.a. RunAsNode på og manglende ASAR-integritet), som letter lokal tukling på en allerede kompromittert maskin. Medium for deep link-en. Mitigeringen er å binde callbacken til en klientgenerert nonce og levere tokens via en lokal kanal, ikke via URL-en. Dette er statisk analyse, ikke en request/response-PoC, så jeg lar den ligge her og holder detaljene i rapporten.

Verktøy og hvem som kjørte hva

Jeg prøver å være presis på dette, fordi «jeg testet grundig» ikke sier noe.

  • Endepunkt-kartlegging: /openapi.json lå åpent på alle tre backendene. En agent parset spesifikasjonen (178 ruter på prod, 53 på admin) og flagget ID-baserte oppslag og URL-tagende felter. AI-generert; jeg valgte hva som var verdt å forfølge.
  • API-testing: rå HTTP med håndsatte x-workspace-id-headere for de avgjørende oppslagene, kjørt både manuelt av meg og av agentene. Request/response-parene over er saniterte utdrag av den faktiske trafikken.
  • Mass-assignment: en agent muterte PATCH-body-er systematisk (role, plan, isVerified, id, ownerId); jeg definerte hvilke felter som var interessante og kjørte de negative kontrollene som avgjorde om noe faktisk låste opp.
  • Knekking: hashcat -m 16500 for dev-nøkkelen; en egen itsdangerous-cracker (HMAC-SHA1 med salt-avledninger) for SECRET_KEY-en — den motsto rockyou på tvers av salt og avledning (negativt funn, men det viktigste negative funnet i testen). Agenten kjørte, jeg tolket.
  • Token-forfalskning: et lite Python-script som bygde og signerte JWT-er for de tre kontrollene i dev-seksjonen. AI-generert, jeg definerte testmatrisen (egen sub, fremmed sub, alg:none).
  • E-post: dig for DMARC/SPF og swaks for selve leverings-PoC-en — den satt jeg på selv, siden den sender noe utover.
  • Skrivebordsapp: @electron/asar for å pakke ut appen og lese hovedprosessen og fuse-konfigurasjonen offline.
  • SSRF: en interactsh-kanal mot alle felt som tok en URL. Null tilbakekall fra målet — et negativt funn jeg tok med fordi fraværet av en henteprimitiv i seg selv er verdt å dokumentere.

AI-en genererte scriptene, kjørte sweepene og foreslo neste steg — inkludert de tre «kritiske» rapportene som viste seg feil. Jeg satte scope og de etiske grensene, tok prioriteringene, skaffet den ekte andre-org-kontoen som avgjorde saken, angrep den ene nøkkelen som faktisk betydde noe, og skrev rapporten. Arbeidsmåten er rask nettopp fordi den er villig til å foreslå ti ting; verdien ligger i å motbevise de ni som ikke holder.

Hva jeg lærte

  • Én 200 er ikke et funn før en konto som ikke eier objektet får den samme. Den negative kontrollen er alvorlighetsgraden. Uten den har du bare vist at et endepunkt returnerte 200.
  • Test tilgangskontroll med en ekte, uavhengig leietaker — også når det er tungvint. Samme-eier-testen ser identisk ut enten grensen holder eller ikke. Her måtte jeg forbi en plan-gate og en verifiserings-gate for å få en gyldig andre-org-konto; å hoppe over det ville gitt feil konklusjon.
  • Et felt som lar seg sette er ikke en privilegie-eskalering før noe faktisk leser det. role=admin persisterte og betydde ingenting — mass-assignment, ikke privesc. Sjekk alltid effekten, ikke bare at skrivingen gikk gjennom.
  • En knekt nøkkel er ikke en overtagelse før tokenet faktisk gir deg tilgang du ikke skulle hatt. Session-binding på jti gjorde en triviell dev-nøkkel til et hygiene-funn, ikke et brudd.
  • Angrip eksplisitt det ene som ville brutt isolasjonen. Her var det join-link-signeringen. At nøkkelen holdt er en del av konklusjonen — men bare fordi jeg forsøkte å knekke den, ikke antok at den var trygg.
  • «Isolasjonen holdt» er et reelt leveranse-resultat. Men det er bare troverdig hvis du kan vise at du seriøst prøvde å bryte den — og var villig til å nedgradere dine tre beste funn da de ikke holdt.