taraonsecurity

IDOR never dies

IDOR never dies

There is a bug I have been finding for years, and I found it again last month reading the news instead of a client’s app. It is almost embarrassingly simple, and that simplicity is the whole reason it outlives every new framework and every new best practice we throw at it.

 

green and orange bug

A very cool bug (just not the kind we’re talking about) by Stephen Hocking on Unsplash

 

Let me show you the shape of it first, because once you see it you cannot unsee it.

You are logged into some app. It loads your invoice, and the address bar reads invoice_id=4821. The app checked that you are signed in, so far so good. Then it fetched invoice 4821 and handed it over without ever asking whether 4821 was yours. So you change the number to 4822, and you are now reading a stranger’s invoice. That is it. That is the whole thing.

It has a proper name, insecure direct object reference, IDOR to those of us who say it too often. It has sat on the OWASP Top 10 since 2007. You can explain it to someone in a lift. Which is exactly why it is tempting to assume we must have fixed it by now.

We have not.

In June last year, 2 researchers submitted a job application to McHire, the hiring chatbot most McDonald’s franchises in the US run on. They noticed their own application sat at ID number 64,185,742. Then they tried the obvious thing, the thing I would have tried, and asked the system for other numbers. It handed them over. More than 64 million applicants’ chat records, names, email addresses, phone numbers, sitting behind an integer anyone could count down from. It took them about 30 minutes from starting a dummy application.

If that sounds like a one-off, it is not. Go back to 2019 and First American, one of the biggest title insurance firms in the US, was serving 885 million documents the same way. Sequential numbers in the URL, no login in front of them, bank account numbers and Social Security numbers and driver’s licence images reachable by changing a digit. The earliest file dated back to 2003, and the whole thing had been open since at least 2017. The person who first noticed was a property developer who changed a number in a link.

A title insurance vault and an AI hiring bot, 6 years apart, failing in the exact same sentence. This is why OWASP moved broken access control to the number one spot on its list in 2021, and why in 2023 three government agencies, CISA, the NSA, and Australia’s cyber centre, put out a joint advisory about this single bug. Agencies do not team up to warn you about problems that are on their way out.

So here is the part I actually want to explain, because it is the part that surprised me when it finally clicked.

Frameworks are brilliant at killing bugs that have a safe default. SQL injection faded once every database library started handling queries safely on your behalf, and you now have to go out of your way to reintroduce it. The framework could fix that without knowing the first thing about your business.

Authorization is different, and the difference is baked in. Your framework can prove who you are. It cannot know whether invoice 4821 belongs to you, because that answer lives in your data, in your rules, in a relationship only your app understands. There is no default it can ship that answers it. So the check has to be written by a person, by hand, on every single endpoint that takes an identifier, and it has to be right every time. Forget it once and you have built the whole vulnerability.

And the number of places that take an identifier keeps growing. Every REST route, every mobile API, every service quietly calling another service and assuming someone upstream already checked. The surface for this one small mistake gets bigger with each new way we build things. That is the plain reason it refuses to die.

Two things that look like fixes and are not. Swapping those countable numbers for long random ones helps a little, but the identifier still leaks, in other responses, in exports, in shared links, and the missing ownership check is still missing the moment a valid one turns up. And a clean automated scan proves very little here, because a scanner asking for object 4822 gets a perfectly valid response whether or not it should have. Catching this needs 2 accounts and a comparison between them. That is the whole reason it stays invisible.

Which is also how I test for it, if you are curious what the authorized version looks like. I ask for 2 accounts before I start. I log every request that carries an identifier, then replay each one as the other user and see what comes back. I spend extra time on the actions that change or delete things, not just the ones that read, because those are the ones that quietly rewrite someone else’s data. And I always walk the side doors, the search results and export jobs and notifications, because they tend to reach for your data through a different path than the screens do, and that path is usually the one that forgot to check.

If you write or review code, I will leave you with the one question that catches this whole class, and it costs you a single line per pull request. This endpoint takes an identifier, so where is the check that the person asking actually owns it? If the answer is “the framework handles that,” look again, because it does not know. And if you test things for a living like I do, get the second account before you start. Almost everything I have ever found in this class came from the least glamorous move there is, signing in as someone else and politely asking for the first person’s things.

How do you rate this article?

2


Tara reads security
Tara reads security

Cybersecurity researcher


taraonsecurity
taraonsecurity

Security researcher writing about web application, cloud, and smart contract security. Notes on vulnerabilities I find interesting and how they get fixed.

Publish0x

Send a $0.01 microtip in crypto to the author, and earn yourself as you read!

20% to author / 80% to me.
We pay the tips from our rewards pool.

Page not displaying correctly?