Technique · sanitized
Enumerate to Escalate: A Two-Stage Web Chain
Read this first
This write-up is deliberately sanitized
Every name, hostname, endpoint path, role string and credential below has been substituted. No flag appears anywhere on this page, and none will.
The chain is real and was executed end to end against a live target during an authorized engagement. What has been removed is anything that would identify the target or let a reader replay the exact solution somewhere it would spoil the exercise for someone else. What has been kept is the part worth keeping: the shape of the mistake, which is not unique to one application and shows up in production far more often than it should.
The fictional company here is Arden Logistics. It does not exist. If a path, a role name or a username below looks like it belongs to a system you recognise, that is a coincidence of how ordinary these mistakes are, not a leak.
Stage one
The login that never logs in
The first thing worth checking on any portal is whether the login is a login at all.
Arden's HR portal presents a username and password form. It looks like every other corporate sign-in. Viewing source ends the question in one line:
<form onsubmit="alert('Invalid Login!')">There is no request. No endpoint, no token, no session. The form's entire behaviour is a browser alert. Whatever the portal is doing about authentication, it is not doing it here — which means the API behind the page is either checking authorization itself, or not checking it at all.
That distinction is the whole engagement. A front end that fakes a login is not automatically vulnerable; plenty of applications gate at the API and leave a decorative client. It is a signal to go look, not a finding.
Stage one
The object reference nobody checked
The portal's own JavaScript names its API surface. It always does.
Pulling the page's script files gives the routes without a single guess:
GET /api/Employee/{id}
GET /api/Employee/Admin/{role}
POST /api/Terminal/LoginThe first route answers unauthenticated, for any id, with no ownership check whatsoever — a textbook insecure direct object reference. Walking it from 1 upward enumerates the entire staff directory:
| id | Name | role | |
|---|---|---|---|
| 1 | M. Schroeder | schroeder@arden.example | AttRM |
| 2 | S. Nabi | admin@arden.example | helpdesk |
| 3 | E. Vance | vance@arden.example | Owner |
| 4 | R. Halloran | halloran@arden.example | Sales |
| 5 | Guest | guest@arden.example | guest |
An IDOR that leaks names and email addresses is a reportable finding on its own. But the column that matters is the last one. Each record carries the account's role string, verbatim, in the response body.
Stage one
The endpoint that checks a secret it also gives away
The second route is genuinely access-controlled. That turns out not to help.
/api/Employee/Admin/{role} rejects almost everything with a clean 401.
It is not broken. It is checking. The problem is what it checks:
/api/Employee/Admin/AttRM 401
/api/Employee/Admin/Owner 401
/api/Employee/Admin/Sales 401
/api/Employee/Admin/guest 401
/api/Employee/Admin/helpdesk 200 <-- The route treats the role string as a shared secret. Present the right one and it returns that role's private administrative note. And the right one was sitting in the response body of the other endpoint, which needs no authentication at all.
This is the hinge of the whole chain, and it is worth stating plainly:
The endpoint that leaks the roles is the endpoint that unlocks the endpoint that checks them.
Neither route is catastrophic alone. The directory read is an information-disclosure bug. The admin route is arguably working as designed. Chained, they are a full authorization bypass, because the design assumed the role string was hard to know and the other half of the same API published it on request.
The note that comes back is the second half of the prize. Alongside its contents, it contains an operational aside from the help-desk operator explaining how they reach the internal terminal — including the username and password, in plain text, in a field never intended to be read by anyone outside the role.
Why credentials end up in notes like this. The author was not careless in a vacuum. They wrote an operational reminder into a field they believed only their own role could read, and they were right about the intent and wrong about the enforcement. Secrets stored behind an access check are only as private as the weakest path to that check.
Stage two
A real shell over a WebSocket
The internal terminal is not a simulated console. It is a PTY.
Authentication is a token exchange, and the session is a raw byte stream:
POST /api/Terminal/Login {"username": "...", "password": "..."}
→ {"token": "9098F354...."}
wss://<host>/api/Terminal/Session?token=<token>The credentials recovered in stage one work here. The socket delivers a genuine interactive shell with ANSI escapes and job control — the browser client strips the escape sequences client-side, which is a good hint that nothing is being sanitized on the way in either.
Driving it from a script rather than the browser is worth the five minutes. A scripted client can send a queued list of commands and capture the raw stream, which turns reconnaissance into one pass instead of a lot of typing into a web textarea:
async with websockets.connect(url, ssl=ctx) as ws:
for cmd in ("id", "sudo -l", "ls -la /", "cat /etc/passwd"):
await ws.send(cmd + "\n")
print(strip_ansi(await drain(ws)))The landing account is unprivileged and owns nothing interesting: a bare home directory, default dotfiles, no group memberships beyond its own.
Stage two
One line of sudoers
sudo -l is the first command for a reason.
$ sudo -l
User helpdesk may run the following commands:
(root) NOPASSWD: /usr/bin/mailOne binary, no password. To an administrator writing that rule, it reads as a narrow, boring grant — the help desk needs to send notification mail as the system, so let them run the mailer and nothing else. It is precisely scoped. It is also a root shell.
mail(1), like a surprising number of ordinary Unix tools, has a
shell escape. A line beginning ! inside its command loop runs a shell
command, and the non-interactive flag feeds that loop directly:
$ sudo /usr/bin/mail --exec='!id'
uid=0(root) gid=0(root) groups=0(root)That is the entire escalation. The target file was mode -r-------- and owned by
root, so nothing short of root would read it — and the sudoers rule handed root over on
the first attempt.
This class of bug is catalogued. GTFOBins documents the escape for well over a
hundred standard binaries: editors, pagers, archivers, interpreters, and plenty of tools
nobody thinks of as programmable. mail, find, vi,
less, tar, awk and git are all on it.
If a binary appears in a NOPASSWD rule, checking that list should be
reflexive — for defenders writing the rule at least as much as for anyone testing it.
Takeaways
What actually went wrong
Four decisions, none of them obviously reckless in isolation.
- The client was treated as a control. A login form that only raises an alert is not a security boundary, and hiding administrative UI from a signed-out visitor does not hide the routes behind it. Authorization has to be enforced where the data is served.
- An identifier was mistaken for a secret. Role strings, user ids, account numbers and internal hostnames are identifiers. They are not entitlements. The moment authorization depends on knowing a value, every endpoint that returns that value becomes part of the authentication system — usually without anyone deciding that it should.
- Findings were assessed alone rather than chained. A directory-enumeration bug and a role-keyed admin route would likely be triaged separately, and each looks minor on its own form. The severity is in the composition, and composition is exactly what a bug-by-bug review is worst at seeing.
- A narrow sudo grant was assumed to be a narrow capability. Scoping a rule to one binary limits what the user may invoke, not what they may do. The right question is never "how many binaries can they run" — it is "what can the ones they can run be persuaded to execute."
The uncomfortable part is that every one of these is defensible in a design review. The chain only becomes obvious once someone walks it, which is the argument for walking it.