Security Tip: Leaking Model Existence
[Tip#27] Observing the existence of something you can't access still tells you something important, even if you can't access it.
Greetings everyone! This has been an exciting week for non-Laravel reasons, but it’s time for us to once-again dig into Laravel and Security. This week I want to dive into a topic that has been on my topic list for months, and I was given a kick to write about it a few weeks ago when Tim MacDonald
pinged me on a PR he’d created: Allow authorization responses to specify HTTP status codes.So I bumped it to the top of my list, and will be tacking it in two parts: this week we’ll cover the new feature and when/why you use it, and next week in our In Depth, we’ll be diving into Insecure Direct Object Reference (IDOR) vulnerabilities. We’ve discussed the concepts around IDORs previously, in relation to Signed URLs, Magic Emails, and Policy Objects, and I’m looking forward to bringing it all together for you.
Don’t forget, if you like receiving these security tips, please consider upgrading to a paid subscription (if you haven’t already), and share LSID with other Laravel developers you know
.In addition to LSID, I do Laravel Security Audits and Penetration Tests full time, so reach out if you'd like me to hack your app and work with you to improve your security!
Leaking Model Existence
I’ve talked previously about how much I love Laravel’s Policy Objects. They are perfect for controlling authorisation to models, routes, and well, anything within your application. The one humongous downside of using them is they return a 403 Forbidden HTTP status code when authorisation fails. Compare this to a route with model binding or using the findOrFail()
Eloquent method, which return a 404 Not Found HTTP status code.
Consider the following requests and status code response:
GET /users/8760 => 403 // User exists but we don't have access
GET /users/8764 => 403 // User exists but we don't have access
GET /users/8765 => 200 // We have access - is this me?
GET /users/8766 => 404 // User does not exist
GET /users/8770 => 404 // User does not exist
With this information we can reliably assume that there are at most 8765 users in the database and the IDs are likely to be incremental.
If we’re trying to attack this site, knowing the ID scheme is incredibly helpful for any attacks that requite the User ID. We now have an upper limit to generate IDs within.
It also gives us an idea of how popular the app is. For example, if there are only 100 users, it might not be worth attacking, while 100,000 users has a much greater potential payoff of data we could steal.
To (mostly) work around the problem, and allow for more flexibility with authorisation failures, Tim MacDonald introduced a new feature into the authorisation system within Laravel that allows you to specify the HTTP status code returned when authorisation fails.
In our example above, this allows us to specify that a 404 is returned when authorisation is not allowed. In other situations you may wish to return a contextually appropriate status code. Maybe the user has run out of API calls, or the feature they are trying to access is disabled? Rather than a blanket 403, you can reject with any status code you like. Yep, even a 418… 😉
Applying this new feature in our app would give us these responses, to mask the information we can glean from the status codes:
GET /users/8760 => 404 // ???
GET /users/8764 => 404 // ???
GET /users/8765 => 200 // We have access - is this me?
GET /users/8766 => 404 // ???
GET /users/8770 => 404 // ???
There are three ways you can take advantage of this new feature within your Policies or Gates, and I’ll summarise them for you, but the best place to learn how it all works is the PR itself.
First, there are new methods on the Illuminate\Auth\Access\Response
class:
return Response::deny()->withStatus(404);
return Response::deny()->asNotFound(); // i.e. 404 helper
return Response::denyWithStatus(404);
return Response::denyAsNotFound(); // i.e. 404 helper
The Illuminate\Auth\Access\HandlesAuthorization
policy helper trait has been updated too with matching methods:
class ProjectPolicy
{
use HandlesAuthorization;
public function view(User $user, Project $project)
{
return $this->deny()->withStatus(404);
return $this->deny()->asNotFound();
return $this->denyWithStatus(404);
return $this->denyAsNotFound();
}
}
And finally, you can throw the exceptions themselves and pass in the status codes. This can be done anywhere in your code, not just within Policies or Gates - the example given in the PR is within FormRequest objects. This sounds really helpful if you need to deal with any custom authorisation logic.
throw (new AuthorizationException)->withStatus(404);
throw (new AuthorizationException)->asNotFound();
To find out more about this feature, check out the release announcement on Twitter:


Timing Attacks…
As I mentioned above, this mostly works around the problem, but it doesn’t completely solve it. Status codes may be used mask the existence of models by returning a 404 regardless of the existence of the model. However, measuring the timing of the response may reveal the difference between a model existing or not.
Consider the query time: It might take only 5ms for the database to say “model not found” and the app can return 404 straight away, while it might take 25ms to return the model and another 5ms for the app to process the authorisation checks, and then return a 404.
5ms vs 30ms.
That is noticeable over the internet, even if individual requests may vary in response time. Make enough requests and you can measure the differences.
Ultimately, timing attacks are difficult to avoid, and we don’t have time to cover them today, but I wanted to mention them so you’re informed of why this isn’t the prefect solution.
I’ll be diving into timing attacks next week in our In Depth on Insecure Direct Object Reference (IDOR) vulnerabilities, so look out for that!
If you follow me on Twitter or have seen one of my talks, or used our awesome vulnerability playground, you’ll know I’m a huge Tolkien fan. The trailers and news coming out from SDCC have been epic, and I could literally talk all day about how much I love that *spoiler*. But you didn’t subscribe to read about Tolkien, so ping me on Twitter and we can geek out there. 🤓 🧙♂️
A recent and very awesome addition to the Laravel Core Team.
There is also a Group Subscription available, if you’d like your whole team to receive weekly security Tips and In Depth emails. (Or send this link to you boss and get them to pay for it! 😉)
I'm curious about...
> Depending on your app, you'll be passing the numeric IDs through the browser when dealing with related items on forms, etc.
I never pass the numeric ID in forms. The ID of the thing to be updated is in the URL...
```
public function update(ThingToBeUpdated $thingToBeUpdated): RedirectResponse
{
// Happy stuff happening
}
```
Is that what you meant?
I've become accustomed to never exposing this data in the first place, by maintaining a UUID for routes along with the regular ID for any internal stuff like relationships and cascades.
/path/96dcd361-d42c-4241-b6ed-931d7b24b80d
vs...
/path/3456
I'm always wondering if this is overkill, but it does seem to have the benefit of simplifying the return code combined with the leak prevention.
I question this paradigm all the time. After all, what is the benefit of having two pieces of identifying information? My rational is that the integer ID serves the purpose of traditional database management while the uuid is explicitly for cosmetic presentation only. This leads into a conversation about unifying the identifying information by having one indexed uuid instead of a uuid and an id. Then my brain starts to hurt and I just go with the way I'm comfortable with. :)
What are your thoughts on the uuid paradigm?