the hook

ok so, there's a question that sort of follows me around between engagements: what if the worst thing in an application isn't some fancy exploit nobody's heard of, but just a debug page that nobody bothered to take down? most of the time those are harmless. sometimes... they really aren't.

this one was one of the "aren't" ones.

the finding

I was doing the usual late-night recon on a veterinary practice platform - let's just call the system PMS here, keep the target out of it. the domain had a bunch of subdomains and one of them was acting weird. it actually returned html instead of throwing me at the login page, which in my experience is usually a dev leftover or a half-dead marketing site.

I almost skipped it. seens like a waste of a click, you know? but curiosity won, as it always does.

so I clicked it. and long story short - it was a debug / test interface, fully functional. you could walk around the PMS the same way an authenticated user would, and the kicker is the form came already pre-filled with live identifiers. like, real ones. the page was basically printing a config dump of the whole backend to anyone with a browser:

┌────────────────────────────────────────────────────────────────────────────────────────┐
│                                      PMS IMPOSTER                                      │
├────────────────────────────────────────────────────────────────────────────────────────┤
│  PMS ID              │ [uuid-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx]                         │
│  Clinic ID           │ [uuid-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx]                         │
│  Vet ID              │ [uuid-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx]                         │
│  Pet ID              │ [uuid-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx]                         │
│  Transaction ID      │ [uuid-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx]                         │
│  Consultation ID     │ [uuid-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx]                         │
│  PMS Internal ID     │ [uuid-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx]                         │
│  User ID             │ [uuid-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx]                         │
│  Country             │ [XX]                                                            │
│  Timestamp           │ [2026-04-XXTXX:XX:XX.XXXZ]                                      │
│  Verification Salt   │ [xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx]                              │
└────────────────────────────────────────────────────────────────────────────────────────┘

pms id, clinic id, vet id, pet id, transaction id, consultation id, user id, a country, a timestamp, and a verification salt. all real. there was no authentication to get here, no token in a header, none of that. I didn't have to be anyone to stare at this system's entire entity model. no surprises there, that's the whole joke of a debug page.

what it actually is

let me slap a label on it, because it ticks three boxes:

  • broken access control (CWE-639) - the page requires zero authentication
  • information disclosure (CWE-200) - sensitive identifiers exposed to unauthenticated users
  • insecure design (CWE-697) - debug/test functionality deployed to production

I filed it with exactly that framing and honestly I recieved the usual "low / medium, thanks" energy. fair enough, I thought. exposed uuids alone are boring. every hunter has seen a million of them.

the interesting part started when I sat down and thought about what those uuids actually are.

why the ids matter: the relationships

hear me out - this is the part most people sleep on. in a PMS those identifiers aren't random strings with a fancy wrapping. they describe the structure of the clinic. a clinic owns vets, pets and users. a pet has consultations. a consultation has transactions. drawn out it looks a bit like this:

             ┌──────────────┐
            │    CLINIC    │
            │  (ClinicID)  │
              └──────┬───────┘
                     │
              ┌──────┼──────┐
                     │
                     ▼
 ┌───────────┐ ┌───────────┐ ┌───────────┐
 │    VET    │ │    PET    │ │   USER    │
 │  (VetID)  │ │  (PetID)  │ │  (UserID) │
 └───────────┘ └─────┬─────┘ └───────────┘
                     │
                     ▼
              ┌──────────────┐
              │CONSULTATION  │
              │(ConsultID)   │
              └──────┬───────┘
                     │
                     ▼
             ┌────────────────┐
             │  TRANSACTION   │
             │ (TransactionID)│
             └────────────────┘

each uuid is a key to some api, and put together they're basically an erd of the whole platform being served for free. a pet id alone is just data. but pet id + clinic id + vet id + consultation id? that's an attack surface. or rather, it's the front desk handing you the keys and wishing you a nice day.

escalation #1: api mapping

first move is boring and safe - take the ids and go say hi to the backend api. you're not breaking anything, just reading. but oh boy, reading tells you a lot. the ids you already have let you fingerprint routes you'd never guess from the outside:

/api/clinic/{id}
/api/vet/{id}
/api/pet/{id}
/api/consultation/{id}
/api/transaction/{id}

from there you fuzz for the cousins of those paths and, spoiler warning, the api surface kind of paints itself. classic.

escalation #2: the idor part

with working endpoints and real ids in hand, the missing access control starts to glare at you. you plug a pet id from the debug page into the consultation endpoint and it happily hands back that pet's consultations. so far so good. the issue is there's no check at all tying the request to a session - which, by the way, is exactly what the debug page made sure didn't exist.

  • a pet id gets you it's consultations, and the notes attached to them
  • a vet id gets you schedules and vet notes
  • a transaction id gets you payment history (that one stung, billing data is never fun)
  • a user id gets you profiles to start cross-referencing against real people

the endpoint basically said "here's a key, what do you want" and never once asked who you were. no ownership check, no nothing. text-book CWE-639.

escalation #3: the verification salt

and then there was the field I couldn't stop staring at: verification salt, 32 chars, sitting right there in an unauthenticated response. I kid you not.

why would a debug page need to show the verification salt? best guess is some dev logged it years ago to eyeball how signing worked and it just... rode along to production. but whatever the reason, this is the field that moves the finding from "eh" to "oh no". if token generation is weak enough for the salt to be useful - or the salt is reused, or predictable - you're not far from forging tokens for accounts whose ids are printed on the same page.

I'll be straight with you: I couldn't prove full token forgery on this one. but the salt format was suspiciously regular and there was a token-generation function sitting in the js bundle that took that exact salt shape as an input. documenting that potential is honestly what pushed the triager to take it seriously.

escalation #4: business logic (this is where i got uncomfortable)

the debug page also had write operations wired to it - appointments, prescriptions, treatments. and the api accepted the same exposed identifiers on POSTs, not just reads.

so the theoretical chain is:

  1. position yourself using the exposed data (or forged tokens from the salt)
  2. book an appointment in someone's clinic
  3. write a prescription, or modify a consultation
  4. change a transaction, or add a medical record

you don't have to demonstrate all of that in a report - that's a great way to lose a program. but knowing it exists is what changes the severity. injecting false medical records into a system that runs a clinic... that was the part that made the triager reach for something other than the "duplicate" button.

the payload chain

if I had to write the whole thing out as a rough script on a napkin, it looks like this (target and real data obviously removed):

# stage 1 - recon: fingerprint the api with ids off the debug page
ids = { "clinic": "...", "vet": "...", "pet": "..." }

# stage 2 - enumeration: map relationships between entities
for entity, eid in ids.items():
    r = requests.get(f"https://target/api/{entity}/{eid}", headers=bypass_headers)
    if r.status_code == 200:
        # now we know what connects to what
        relationships[entity] = r.json()

# stage 3 - exploitation: try the salt, if its worth it
    forged = generate_token(salt, user_id="...", clinic_id="...")

# stage 4 - impact: authenticated-ish call to a write endpoint
    api.post("/medical-record/create",
             data=malicious_record,
             headers={"Authorization": "Bearer " + forged})

stage 1 is recon, stage 2 turns the ids into a map, stage 3 tries to turn the salt into tokens, and stage 4 is pretty much anything the PMS lets an authenticated actor do. the cherry on top is that none of it needs you to be logged in at any point.

impact

  • read-only disclosure - you can see how other clinics are structured. annoying.
  • unauthorized access across domains - vet notes, billing, owner PII. worse.
  • authentication compromise - if the salt forges tokens you're not "unauthorized" anymore, you're logged in. worst.
  • data integrity - medical records become editable. this is the one that hammers the checkbox.

it's veterinary, but it's still healthcare. animal records don't get the same press as human ones but they sit under the same GDPR / CCPA level of sensitivity. owners trust a clinic with their pets' health. when that trust breaks it stops being a technical problem and becomes a compliance problem, and compliance problems have a funny habit of getting patched in a hurry.

the "don't just report the ids" bit

look, I get it. honestly. most hunters would've seen those ids, filed "information disclosure", taken the medium, and moved on to the next thing. that's not wrong. it's just the ceiling, and a medium pays a medium.

what moved this one to critical were four small things, nothing magical:

  1. demonstrate real access. don't just screenshot the page. take an id, hit an endpoint, show what comes back. even read-only, it's access, and access reads louder than a leaked variable.
  2. show the relationships. a pet id is data. pet id + clinic id + vet id + consultation id is an attack surface. draw the dumb little diagram in the report - it takes two minutes in plain text.
  3. push the salt. if there's any chance a salt, secret or seed can generate or guess tokens, chase it. even unexploited, documenting the potential is the difference between medium and critical.
  4. think healthcare. medical data - even for animals - makes the compliance stakes real, and compliance is the fastest way to get a triager to sit up and pay attention.

takeaway

the whole point of authorization is that there's a barrier you have to cross to touch a system. this debug page deleted that barrier entirely. it's not a bug in one endpoint - it's a complete entity map of an entire practice management system, served to anyone with a web browser and too much time on their hands.

the identifiers paint the picture. the relationships turn the picture into a blueprint. and a salt field left over from development is basically the key to the front door. you don't often get all three handed to you at once.

last thing - this got fixed within about a day of reporting, and the fix was "removed the page". cost the company nothing. paying attention to what your debug surfaces print is a cheap habit, and this is the case I point at whenever someone asks me why I bother.

target and all identifiers redacted. testing was authorized and in-scope. disclosure policy is on the about page.