Do all security specialists have a favorite vulnerability?

When I started out in Web AppSec, I often heard influencers, professionals, and bloggers talk about their favorite vulnerability. I always thought that was a bit strange. How do you even have a favorite vulnerability?

For a long time, I didn't have an answer to that myself. I had learned about a lot of different issues, but none of them stood out as the one.

Then, while building labs, making these blog posts, something became obvious. I had to actively stop myself from using the same type of vulnerability over and over again.

That's when I realised I did have a favorite. I just hadn't noticed it yet.

My favorite

Creating labs revealed that I really enjoy hunting (and making) vulnerabilities that enable a user to do or see things that they are not supposed to do or to see. You probably already feel that this is not really tightly scoped. It can range from changing a simple ID (and exploiting Insecure Direct Object Reference or IDOR) to changing the permission level of regular users (Broken Function Level Authorization or BFLA).

The common thread? They all fall under Broken Access Control or BAC.

When I make a lab with the intention of making it vulnerable to BAC, I can go on different paths to implement insufficient code. In this blog I'll walk you through some things I implement in my labs.

Demo

Let's say I have an EJS application with a node.js backend.

Sidenote: if you ever think about creating your own labs, I think this is the stack to begin with.

JWT signature

Here we have a function that decodes our JWT. This enables us to get the different properties from the JWT inside route declaration.

function requireLogin(req, res, next) {
  const token = req.cookies.token;
  try {
    const decoded = jwt.decode(token);
    req.user = decoded;
    next();
  } catch (err) {
    return res.redirect('/login');
  }
}

In this route to /administrator, we use requireLogin so we can check whether the role of a user is authorized to access the endpoint. In the code we can see that when role in the JWT is administrator, the backend will handle this as someone who has the proper rights to access that page and the secretData displayed on that page.

app.get("/administrator", requireLogin, (req, res) => {
if(req.user.role === "administrator"){
const secretData = DBRun(`SELECT * FROM secrets`);
res.render("administrator", {secretData});
} else {
res.render("denied");
}
})

The frontend is just a simple EJS-template where the secretData is displayed.

<div class="card-body">
  <div class="card-header">
    <h1 class="h4 mb-0">Administrator Panel</h1>
   </div>
</div>
<div class="card-body">
  <h5 class="card-header">Secret Data</h5>
    <hr>
    <p class="mb-0"><%= secretData %></p>
 </div>

This is what I like about creating labs with EJS, you can pretty easily pass data from a backend to the frontend without setting up an API and calling a fetch() in a administrator.js.

Issue

The issue with this code is the check on the JWT, it doesn't actually happen. You might think so because the route is protected by checking the role. However requireLogin() just decodes the JWT. So if you change the values, it does just that, decode it, nothing more. Which means if you set that manually to administrator the backend will render /administrator.

{
"name": "testUser",
- "role": "user",
+ "role": "administrator"
}

Solution

So you need to make sure your code checks the signature using the JWT_SECRET that ideally is stored in a .env file.

const JWT_SECRET = process.env.JWT_SECRET;
function requireLogin(req, res, next) {
const token = req.cookies.token;
try {
- const decoded = jwt.decode(token);
+ const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.redirect('/login');
}
}dd

Property check

Even with proper JWT verification in place, a small logic mistake can undo all of it. In this scenario, the developer forgot to check if the user has {"role":"administrator"} — they're just checking if the user has a role at all.

app.get("/administrator", requireLogin, (req, res) => {
if(req.user.role){
const secretData = DBRun(`SELECT * FROM secrets`);
res.render("administrator", {secretData});
} else {
res.render("denied");
}
})

It's an easy fix, but this kind of mistake happens more often than you'd think — especially during late-night coding sessions or when copy-pasting from another route.

app.get("/administrator", requireLogin, (req, res) => {
- if(req.user.role){
+ if(req.user.role === 'administrator'){
const secretData = DBRun(`SELECT * FROM secrets`);
res.render("administrator", {secretData});
} else {
res.render("denied");
}
})

Ownership

Usually we want our users to see data that they own. In practice we do this by querying the database and checking ownership. This happens most of the time with the user_id. Let's say we have this code that retrieves the shopping history of a customer. We use EJS once more for our frontend.

Issue

app.get('/orders/:id', requireLogin, (req, res) => {
const orderId = req.params.id;
const order = dbGet(`SELECT * FROM orders WHERE id = ?`, [orderId]);
if (!order) {
return res.redirect('/dashboard');
}
res.render('orderPage', { order });
});

At first sight, this looks like a legit SQL-query. And honestly, I thought so too for a very long time.

I used to code for software in a world where nobody wants to do something that I didn't intend for.

My frontend fills the id parameter, this is correct because the user obviously wants to see his order. But, that's not the world we live in. Sure, you can argue that if you use uuidv4 for your ids, the chances of guessing another order's id are astronomically low — and that's true. But unpredictability alone is not access control, and you shouldn't rely on it as your only protection.

And, let's call them curious minds, would probably wonder what would happen if they just change the id parameter. In this case, the order that is requested will be rendered. The backend really does not care about it. You ask, the server says "Here ya go buddy".

Solution

The way to solve this, is to add the user_id in the WHERE-clause. We could go into another rabbit hole and say that you would be able to bypass this if a developer didn't properly make the requireLogin function like we saw a few paragraphs ago, but for the sake of this blog's length, we're just gonna say that you can try and make something like that yourself or maybe in a future post.

app.get('/orders/:id', requireLogin, (req, res) => {
const orderId = req.params.id;
- const order = dbGet(`SELECT * FROM orders WHERE id = ?`, [orderId]);
+ const order = dbGet(`SELECT * FROM orders WHERE id = ? and user_id = ?`,
+ [orderId, req.user.id]);
if (!order) {
return res.redirect('/dashboard');
}
res.render('orderPage', { order });
});

Take-aways

I used to be the one writing code like this. No one ever told me to think like an attacker. Building labs and breaking my own code changed that.

If you take anything from this post:

  1. ALWAYS check for ownership — it's the one thing I see junior developers overlook the most.
  2. Make sure a JWT (or a token, cookie, …) is verified, not just decoded.
  3. Use that verified token to check the level of authorization for assets.

I break web apps for fun, make vulnerable labs to learn, and write about it so you can too. More writeups, vulnerable labs, and pentesting war stories on blog.forgesec.be. Come say hi on LinkedIn, no exploits required!