Skip to main content

IDOR, and Leaked GUID

Updated 6 min readMarkdown

Continuing from yesterday's post on Broken Access Control, today I worked through three more PortSwigger Web Security Academy labs. Same category, three more distinct mechanisms — and one genuinely surprising find that didn't show up anywhere in the frontend code at all.

Lab 4: User Role Can Be Modified in User Profile (Mass Assignment)

This lab's account page has one visible form field: Email. Nothing in the rendered page or the frontend JavaScript mentions roles at all — I checked thoroughly and there was nothing to find client-side. That absence turned out to be the whole point.

The "Update email" form uses a small script to bundle whatever fields exist in the form into JSON and POST it:

function jsonSubmit(formElement, e, changeEmailPath) {
    e.preventDefault();
    var object = {};
    var formData = new FormData(formElement);
    formData.forEach((value, key) => object[key] = value);
    var jsonData = JSON.stringify(object);
    ...
    postRequest.send(jsonData);
}

Normally that sends just {"email": "..."}. But since JSON is just text I can type myself, nothing stops me from sending extra fields the form never includes. Based on the lab's title, I guessed the backend might also accept a roleid field on this same endpoint — so I built the request by hand instead of clicking the button:

curl -X POST "https://[lab-url]/my-account/change-email" \
  -H "Content-Type: application/json" \
  -H "Cookie: session=[session-value]" \
  -d '{"email":"test@test.com","roleid":2}'

The response confirmed it worked:

{
  "username": "wiener",
  "email": "test@test.com",
  "apikey": "E1Gv9krcCZfFhGBOm4kYUa2C5cIbqZyI",
  "roleid": 2
}

Reloading the account page afterward showed a new Admin panel link, and from there I could delete carlos and solve the lab.

What's actually going on: this is called a mass assignment vulnerability — the backend likely took the entire JSON request body and applied it directly to the user record, instead of picking out only the one field (email) it should have cared about. Something like:

app.post('/my-account/change-email', (req, res) => {
  const { email, roleid } = req.body; // roleid was never supposed to be readable here
  if (roleid) db.updateUserRole(req.user.id, roleid);
  db.updateEmail(req.user.id, email);
});

Why this one's different from earlier labs: every previous bug I found was visible somewhere — a cookie, a URL string sitting in JS. This one wasn't discoverable by reading code at all. There was no clue in the frontend that roleid was even a valid field. I found it by hypothesizing based on the lab's title and testing it directly against the endpoint — which, as it turns out, is genuinely how mass assignment bugs get found in real-world testing: not by reading, but by guessing plausible extra field names and seeing if the server reacts.

The fix: only destructure and use the exact fields you expect. Role changes should live behind a completely separate, admin-gated endpoint — never bundled into a general-purpose "update my profile" route.

Lab 5: Classic IDOR via API Key Endpoint

This one was refreshingly straightforward after the last one. Logging in as wiener showed my own API key on an endpoint shaped roughly like:

GET /api-key?username=wiener

I swapped the parameter to carlos and got his API key back immediately — no additional trickery needed. The server was pulling whatever key matched the username parameter, with no check that it matched the actual logged-in session.

The fix, same principle as every lab so far: never trust an identity parameter from the request. Pull identity from the authenticated session instead:

app.get('/api-key', requireLogin, (req, res) => {
  const key = db.getApiKey(req.user.username); // from session, not from a client-supplied parameter
  res.send(key);
});

Worth noting for severity: a leaked API key is often more dangerous than a leaked password, since API keys frequently skip 2FA entirely and authenticate you directly.

Lab 6: Horizontal Privilege Escalation via a Leaked GUID

This lab used GUIDs instead of usernames or sequential IDs to identify users — e.g. b4967c1c-ef12-4e0e-94d6-15445c0f40e8. A GUID is a 128-bit random value; there's no realistic way to guess or brute-force one. So the question shifted from "can I tamper with this ID" to "where would carlos's GUID even be exposed?"

My own account page confirmed my own GUID sat right in the URL:

/my-account?id=b4967c1c-ef12-4e0e-94d6-15445c0f40e8

But nothing showed carlos's. I checked product pages for comments (nothing there), then found a separate blog section on the site that I hadn't explored yet. Blog posts had author links — clicking an author's name revealed their GUID sitting directly in the URL, exactly the same pattern as my own account link.

I found a post authored by carlos, grabbed his GUID from the author link, and substituted it into my own account URL:

/my-account?id=<carlos's-guid>

That returned his account page, including his API key — solving the lab.

Root cause: this is called horizontal privilege escalation — accessing another same-level user's data, as opposed to escalating to a higher role. The GUID made brute-forcing impossible, but the app still leaked it through an unrelated feature (author bylines on a blog), which defeats the whole point of using an unguessable identifier in the first place.

The fix: the account page should verify that the GUID in the request actually belongs to the currently authenticated session, not just that a GUID matching some user was supplied:

app.get('/my-account', requireLogin, (req, res) => {
  if (req.query.id !== req.user.guid) {
    return res.status(403).send('Forbidden');
  }
  res.render('account', db.getUser(req.query.id));
});

Unguessable IDs raise the bar against brute-forcing, but they're not a substitute for an actual ownership check — if the ID leaks anywhere else in the app, the "unguessable" part stops mattering.

Takeaways From Today

  • Not every vulnerability is visible in the frontend. The mass assignment bug required guessing based on context (the lab's title), not reading code — a different skill than the JS-hunting from yesterday.

  • Unguessable IDs (GUIDs) aren't automatically secure. They stop brute-force attacks, but only if the ID never leaks anywhere else in the app. Always check unrelated features (blogs, comments, bylines) for accidental exposure.

  • Ownership checks matter more than ID complexity. The real fix in every lab today was the same: verify the requester owns the resource they're asking for, server-side, every time — regardless of how hard the ID is to guess.

Six labs into Broken Access Control across two days. Next up: moving into Injection.


Testing was performed exclusively against PortSwigger's own intentionally vulnerable lab environments as part of self-directed learning on the Web Security Academy.

About the author

No comments yet. Be the first to comment.