How I Found an IDOR That Exposed Cancer Patient Identities on a Government Healthcare Portal
During an authorized bug bounty engagement with my collaborator itsnotthathard on a program, I discovered an Insecure Direct Object Reference (IDOR) vulnerability in a patient-facing oncology portal. The application was an **Odoo 17.0** instance running a custom cancer center module, used to manage appointments, medical findings, and treatment workflows for cancer patients.
A self-registered portal user — with zero relationship to any patient record — could enumerate the **full names of every cancer patient in the system** by abusing a single JSON-RPC method that the developers forgot to lock down.
The result: **~2,800+ medical finding records**, **~3,000+ appointment records**, and **~48,900 internal medical messages** — all leaking patient identities to anyone who could create an account.
— -
## The Target
The portal allowed patients to self-register using basic personal information and a national social security number. The registration only validated the **checksum format** of the ID number — it never verified it against any backend database. This meant anyone could generate a valid-format number and create an account.
Once registered and logged in, the UI was locked down tight. You could only see your own data. No other patient names, no other appointments, no other records. On the surface, everything looked correct.
But the surface isn't where bugs live.
— -
## Reconnaissance: Understanding Odoo's RPC Layer
If you've hunted on Odoo before, you know that the web interface is a thin client sitting on top of a powerful **JSON-RPC API**. Every button click, every page load, every data fetch is ultimately a call to `/web/dataset/call_kw` with a model name, a method name, and arguments.
Odoo has several data-access methods on every model:
| Method | Purpose | | — — — — | — — — — -| | `read` | Fetch full record fields by ID | | `search_read` | Search + fetch in one call | | `web_read` | Frontend-optimized read | | `export_data` | Bulk export | | `name_get` | Return `(id, display_name)` pairs |
The first four are the "heavy" methods — they return full record data and are typically the first things developers lock down with access control lists (ACLs). But `name_get`? It's the quiet one. It just returns a name. How dangerous could a name be?
On a cancer treatment portal? **Very.**
— -
## The Discovery
I started by testing the obvious. As my low-privilege self-registered account, I tried calling `read` on a medical finding record that didn't belong to me:
``` Model: vcc.medical.finding Method: read IDs: [3809] ```
**Result:** `odoo.exceptions.AccessError` — blocked. Good.
Same for `search_read`, `web_read`, `export_data`. All blocked. The access control rules were doing their job.
Then I tried `name_get`:
```json { "jsonrpc": "2.0", "method": "call", "params": { "model": "vcc.medical.finding", "method": "name_get", "args": [[3809, 3808, 3807, 3800, 3700, 3500, 3000, 2000, 1000]], "kwargs": {} } } ```
Every single record returned. Real patient names. Real people undergoing cancer treatment.
The `display_name` field was constructed as `"Medizinsche Befunde: [Patient Full Name]"` (German for "Medical Findings"), directly embedding the patient's identity into the response.
— -
## Digging Deeper: How Far Does It Go?
Once I confirmed the pattern on `vcc.medical.finding`, I tested other models in the custom cancer center module:
**`vcc.appointment`** (~3,000+ records):
**`res.users`** (all registered patients):
**`mail.message`** (~48,900 records — the biggest exposure):
The `mail.message` model was Odoo's internal messaging system, logging every treatment handoff, every medical finding update, every internal workflow communication — all with patient names baked into the subject line.
Meanwhile, `res.partner` (the core contact model) correctly blocked `name_get`. And `read` was blocked across the board. This wasn't a blanket failure — it was **selective** and **inconsistent**, which made it worse because it meant someone had thought about access controls but missed specific models.
— -
## Root Cause: The `.sudo()` Anti-Pattern
A stack trace from an error response revealed the smoking gun in the source code:
```python # tumor_board.py, line 74 record.display_name = _("Tumor Board: %s") % record.sudo().case_id.patient_id.name ```
The `.sudo()` call. In Odoo, `.sudo()` elevates the operation to run as the superuser, bypassing all access control rules. The developer used it to compute the `display_name` field — probably because computing the name requires traversing a relationship chain (`case_id -> patient_id -> name`) that the current user doesn't have permission to follow.
The fix they likely intended: compute the name with elevated privileges so it displays correctly in the UI for authorized users.
What actually happened: `name_get` calls the `display_name` compute method, which runs with `.sudo()`, which bypasses ACLs, which returns the patient name to **anyone** who asks — authorized or not.
This is a pattern I've seen across multiple Odoo deployments. The `display_name` compute function is treated as "safe" because developers think of it as metadata, not data. But when the display name **contains** the sensitive data, the distinction collapses.
— -
## Key Takeaways for Hunters
### 1. Always Test Every RPC Method Independently Access controls on `read` don't mean access controls on `name_get`, `name_search`, `fields_get`, or any other method. Test each one separately, especially on Odoo targets.
### 2. `display_name` Is an Attack Surface In Odoo, the `display_name` field is computed dynamically. If it contains sensitive data (names, emails, internal IDs), and the compute function uses `.sudo()`, you have a potential IDOR through `name_get`.
### 3. Healthcare Targets Multiply Impact The same bug on a project management tool might be P4. On an oncology portal, it's a GDPR Article 9 violation affecting thousands of vulnerable individuals. Context matters — frame your impact accordingly.
### 4. Inconsistency Is Your Best Evidence When arguing for a finding, the strongest evidence is the application contradicting itself. "Model A blocks `name_get`, Model B doesn't" or "`read` is blocked but `name_get` isn't" proves the behavior is unintended.
### 5. Initial Triage Isn't Final A "Not Applicable" response doesn't mean your finding is invalid. It means the triager didn't see the impact. Reframe, simplify, and resubmit. Two of my response requests expired before the finding was accepted — persistence matters.
## Final Thoughts
This bug was technically simple. One API method, one missing access check, one `.sudo()` call in a compute function. But simplicity doesn't diminish impact. Thousands of cancer patients had their identities exposed through a portal that was supposed to protect them.
The best bugs aren't always the most complex. Sometimes they're hiding in the method everyone assumes is "safe."
— -
*This research was conducted under an authorized bug bounty program. All testing was performed within the program's scope and rules of engagement.
*Found this interesting? Connect with me on [LinkedIn] — I write about web application security, access control vulnerabilities, and the art of not giving up after "Not Applicable."*