July 31, 2026
Zero-click Admin Takeover via Unguarded login_email
Been meaning to write this one up since December. It’s a HR platform, name left out, and the bug is one of those ones where you can tell…

By ͏ ͏Dukrov 🍏
2 min read
Been meaning to write this one up since December. It's a HR platform, name left out, and the bug is one of those ones where you can tell someone actually thought about the problem and still missed.
Quick context on how the app logs you in: there's no password. You type your email, they mail you a one-time code, you're in. So whatever address the login flow reads is basically the password. Hold that thought.
Roles go Admin at the top, then a bunch of specialist roles under it. One of them is People Manager, the HR person who can create users, edit profiles, hand out roles. Privileged, but not Admin.
Poking at the profile page
I made a People Manager account, logged in, opened the Admin's profile. Contact information section, and the email field is greyed out with a little padlock next to it.
That padlock is the interesting bit. It means a developer sat there and decided this specific field is dangerous for a People Manager to touch. Which is a hint about where the good stuff is, so I hit Save with Burp in the middle and tried changing it anyway.
PATCH /api/v1/users/{adminId}/
Authorization: <people_manager_JWT>
{ "email": "attacker@attacker.com", ... }PATCH /api/v1/users/{adminId}/
Authorization: <people_manager_JWT>
{ "email": "attacker@attacker.com", ... }Response
{"detail":"Updating other admin emails is not allowed"}{"detail":"Updating other admin emails is not allowed"}Which, fine. The check is real, it's server side, it works. Normally that's where you shrug and move on.
Except the user object had two email fields.
login_email
email is the display one, the one on the profile page behind the padlock. login_email is the one the auth flow actually uses to figure out where to send the code. Same object, same PATCH, no check on it at all.
PATCH /api/v1/users/{adminId}/
Authorization: <people_manager_JWT>
{
"email": "admin@company.com",
"first_name": "Admin",
"last_name": "User",
"login_email": "attacker@attacker.com"
}PATCH /api/v1/users/{adminId}/
Authorization: <people_manager_JWT>
{
"email": "admin@company.com",
"first_name": "Admin",
"last_name": "User",
"login_email": "attacker@attacker.com"
}Response
HTTP/2 200 OK.HTTP/2 200 OK.Launched a new browser tab, typed my address into the login page, and the Admin's one-time code showed up in my inbox. That's it. The Admin doesn't click anything, doesn't get an email, has no idea. Their account just answers to a different mailbox now.
Why it happened
Somebody wrote the authorization rule against the field that looks like the identity instead of the field that is the identity. The UI made the same mistake in the same place, locking the visible one and leaving the real one unmentioned. Both layers were consistent with each other and both were wrong.
The lesson I took from it is about what a disabled input actually tells you. It's not a boundary, it's a note from the developer saying "this one's sensitive." So when the API turns out to be enforcing it properly, don't stop there, go look for the other field that gets you the same result under a different name. Recovery phone, SSO subject, TOTP binding, whatever. If changing it changes who can log in, it's a credential and it should be locked down like one, no matter what it's called in the schema.
What it got, and the argument I lost
Medium(5.9) :(
The program's reasoning was that you need People Manager to pull this off PR:H, and a People Manager can already create users and assign roles like Finance, so becoming Admin doesn't really give you anything new. It just makes it easier.
Where I'd still argue: the vendor built a padlock and a server-side rule specifically to stop a non-admin moving an Admin's address. They decided that boundary mattered. Saying afterwards that the boundary didn't matter much is a slightly awkward position to be in.
And granting yourself Finance is a trail of separate actions across roles, all logged, all reversible. Sitting in the Admin account is one seat with everything, including control over the role model itself. Similar reach, different shape, and it reads differently in an audit log too.