I recently stumbled upon a critical instance of broken-access control, and I thought its story would make for an interesting blogpost. I’ve deliberately omitted some details (e.g. irrelevant HTTP headers) in the interest of simplicity and concision. Morever, all clues to the identity of the organisation I was hacking have been expunged from this post, for obvious reasons.

A bit about the target

The target consists in a service that allows users to create server-side apps via a Web-based dashboard. Each app is owned by one and only one account, and accounts are meant to be completely isolated from one another.

Creating an app requires you to choose a title and a URL for it. Each app is attributed a UUID upon creation. An app’s UUID is public, as it’s meant to be explicitly referenced in its frontend counterpart.

An account can

  • create new apps;
  • modify the title and URL of an app it owns;
  • delete an app it owns (an action that cannot be undone).

Shifting my focus to access control

I quickly came across some Low to Medium vulnerabilities, which I promptly reported. Nothing to write home about, really.

Because the UI looked sleek and the system well factured overall, I wasn’t sure I would be able to find anything above Medium severity. That’s when I decided to shift my focus to access control. Perhaps some Insecure Direct Object Reference (IDOR) had so far eluded me…

Gearing up for IDOR hunting

For Web hacking, I typically exercise the Web app in Firefox and I proxy all the associated traffic through Burp. I recently added Firefox Multi-Account Containers to my bag of tricks for testing access control. This Firefox add-on is a bit like private windows on steroids: it allows you to easily switch between sessions but obviates the need to repeatedly log out as one user and log in as another. This tool therefore eliminates some of the tedium of manual IDOR hunting, and I wish I had known about it sooner. For a good introduction to Firefox Multi-Account Containers, check out Katie’s video on the topic (thanks, Katie!).

Like many other security researchers, I usually create at least two dummy accounts, if possible. I can then safely check whether poking at the resources owned by one account while logged in as the other has any effect; that way, I don’t run the risk of interferring with resources owned by legitimate users of the system.

This engagement was no exception: I created two dummy accounts, which got attributed IDs 101 and 102. I decided to use account 101 to play the part of the attacker and account 102 to play the part of the victim.

I proceeded to create one app in each account, which got attributed the following UUIDs (simplified for this post):

  • 10110110-1101-1011-0110-110110110110 (owned by account 101, the attacker)
  • 10210210-2102-1021-0210-210210210210 (owned by account 102, the victim)

Sequential account IDs in the URL, but no IDOR there

In my proxy’s history, I noticed that, whenever I would view the app I created under the attacker’s account, the following request would be issued:

GET /accounts/101/apps/10110110-1101-1011-0110-110110110110
Host: api.example.org
Authorization: Bearer <token-valid-for-account-101>

Something interesting jumped at me straight away: accounts were identified by sequential integers. Resources publicly identified by sequential integers are always of interest to an attacker. Because sequential IDs are trivial to enumerate, they can, at the very least, be exploited to reveal more business intelligence than the organisation realises: external observers can indeed monitor the rate at which new IDs are issued in order to gauge how much the system is getting exercised and infer how well the business is doing.

More to the point, finding a vulnerable injection point where sequential IDs are being used is quite common. Therefore, I initially attempted to access app resources owned by the victim while logged in as the attacker:

GET /accounts/102/apps/10210210-2102-1021-0210-210210210210
Host: api.example.org
Authorization: Bearer <token-valid-for-account-101>

But the response was as you would expect from a secure system:

HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8

{
  "statusCode": 403,
  "message": "You do not have permission to perform this request."
}

In other words:

You shall not pass!

My hopes to find an IDOR were somewhat dashed, but I decided to continue my investigation undeterred.

A suspicious but inconclusive GET response…

After some thinking, I got another idea: what would happen if I substituted the UUID of the attacker’s app for the UUID of the victim’s app in the URL path? So I tried that:

GET /accounts/101/apps/10210210-2102-1021-0210-210210210210
Host: api.example.org
Authorization: Bearer <token-valid-for-account-101>

The response puzzled me:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

null

Why use text/html as content type rather than application/json? More importantly, why use 200 as response status code rather than 404? The latter would have been more appropriate than the former: no app with that UUID existed under account 101, after all. Perhaps this was just an implementation quirk, or perhaps I was onto something.

…PUT me on the right track

After inspecting my proxy’s history, I noticed that modifying the settings of the attacker’s app would issue a request like the following:

PUT /accounts/101/apps/10110110-1101-1011-0110-110110110110 HTTP/1.1
Host: api.example.org
Authorization: Bearer <token-valid-for-account-101>
Content-Type: application/json

{
  "title": "dummy app in account 101",
  "url": "https://jub0bs.com"
}

Although I couldn’t access another account’s app, perhaps I could modify it. I issued the following PUT request in an attempt to modify the victim’s app while logged in as the attacker:

PUT /accounts/101/apps/10210210-2102-1021-0210-210210210210 HTTP/1.1
Host: api.example.org
Authorization: Bearer <token-valid-for-account-101>
Content-Type: application/json

{
  "title": "pwned by account 101!",
  "url": "https://evilzone.org"
}

The response looked promising:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
  "uuid": "10210210-2102-1021-0210-210210210210",
  "title": "pwned by account 101!",
  "url": "https://evilzone.org",
  "isLive": true,
  "accountId": 102
}

I had apparently just changed the title and URL of the victim’s app while logged in as the attacker. I switched to the victim’s session to double-check: sure enough, after a page refresh, the attacker’s changes were reflected on the victim’s dashboard.

The IDOR I so coveted had finally materialised in front of my eyes! I had just discovered that any account could change the title and URL of any app in the system. As such, this vulnerability would already have rated as a High, but I’m glad I didn’t stop there.

Stealing the victim’s app

I was about to report the IDOR I had just found, when something in the body of the PUT response caught my attention: the suspicious presence of an accoundId field. This repetition seemed superfluous: why specify the ID of the account owning the app in the response body, since that ID was already specified in the URL path?

This suggested another attack to me: even though the only attributes of an app meant to be modifiable were its title and its URL, perhaps some business-logic error would allow me to change other app attributes simply by specifying the relevant fields in the JSON body of the PUT request. Because modifying an app’s account ID was of particular interest to me, I issued the following request:

PUT /accounts/101/apps/10210210-2102-1021-0210-210210210210 HTTP/1.1
Host: api.example.org
Authorization: Bearer <token-valid-for-account-101>
Content-Type: application/json

{
  "accountId": 101
}

Here is the response I got:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
  "uuid": "10210210-2102-1021-0210-210210210210",
  "title": "pwned by account 101!",
  "url": "https://evilzone.org",
  "isLive": true,
  "accountId": 101
}

Shocking! I had seemingly just transferred ownership of the victim’s app to the attacker’s account, simply by issuing a PUT request from the attacker’s session. With uncontained excitement, I quickly double-checked in the UI that the attack had been successful. Yes! The attacker’s dashboard now listed both apps (the app created by the attacker and the one created by the victim), whereas the victim’s dashboard listed none.

Total compromise of the system

Of course, this attack wasn’t restricted to my two dummy accounts: just by knowing an app’s UUID (remember: they’re public), I could transfer ownership of the app from any account to an account I controlled.

I subsequently realised that I could modify more app attributes that way: the PUT request more or less blindly accepted any known field that I would specify in the body and effect the required changes. This particular business-logic error falls under CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes. It’s similar in nature to the mass-assignment vulnerability that Egor Homakov reported to GitHub back in 2012. Moreover, app creation and deletion (via POST and DELETE requests, respectively), also suffered from the same IDOR vulnerability.

At that stage, I could literally own any app in the system. Needless to say, my findings totally violated the system’s permission model. What compounded the problem is that the organisation behind this system was dogfooding it, and their app’s UUID could trivially be discovered on their website! I resisted (with some difficulty) the temptation to mess with their main app; after all, I had already gathered all the evidence I needed, and facetiousness could only have compromised my relationship with the organisation. I reported the issue with a Critical severity rating.

Conclusion

Hunting for IDORs can be tedious, but finding one is exhilarating. Many IDORs are right for the taking—insofar as detecting and exploiting them doesn’t require any advanced technical skills—and they often have a significant impact on the target’s security, especially when you can chain them with some other vulnerability. I know I will keep my eyes peeled for overtrusting PUT requests from now on.