A note on ethics before anything else. I built Fenrir during a summer internship with a red-teaming company, as authorized research, and I only ever run it inside isolated lab tenants that were spun up for that purpose. It never touches production, client, or third-party infrastructure. I'm writing this up so that defenders can see how the attack path actually works. If you point this kind of tooling at a tenant you don't own, or one you don't have written permission for, that is a crime in most places. Don't.
Why managed identities caught my eye
Over the past year I've spent most of my time on Active Directory and cloud identity, and one thing kept standing out in Azure: a lot of trust has quietly moved off human passwords and onto managed identities. A managed identity is basically an identity that Azure hands to a workload, whether that's a VM, an App Service, a Logic App, a Container Group, or an Automation Account, so the workload can ask for access tokens without anyone stuffing a secret into a config file. As a defensive design I think it's genuinely good. There's no password sitting in source control, rotation happens on its own, and it plugs straight into Entra ID.
The catch is what all of that looks like from the attacker's chair. If I already hold control-plane rights over a host, meaning I can run a command on it, then I can reach that host's local IMDS (Instance Metadata Service) endpoint and ask for a token as the host's own identity. There's no password to crack, because the identity never had one. I'm just borrowing the machine that Azure has already decided to trust.
That's what makes one ordinary compromised user interesting. It stops being "I have a foothold" and becomes a question: how far does this actually reach? Answering it by hand is slow and repetitive work. You list your roles, figure out which resources carry an identity, remember which extraction trick each host type needs, pull the token, then start the whole thing over as the new identity you just stole. Fenrir runs that loop for me.
What I wanted the tool to do
I set a few rules for myself early, and honestly they ended up shaping the whole codebase.
The first was that recon should tell me whether it's even worth continuing. A lot of real engagements just end at "you don't have the rights for this," and I wanted the tool to say that out loud, with reasons, before I burned time trying to exploit something that was never going to work.
The second was that the offensive parts stay opt-in. Read-only enumeration lives in different modules from anything that abuses a credential or pulls down data, and every harvesting capability sits behind an explicit flag. Nothing destructive runs on its own or by default.
The third was that the two halves of the tool shouldn't have to repeat each other's work, so discovery writes its results to a JSON state file that the exploit phase reads back in. And the last one was that I had to be able to test the logic without a live tenant, which is why the whole suite mocks the Azure APIs. It sits at 231 passing tests right now.
What came out of that is a single typer app in Python 3.11, with seven commands:
fenrir login | logout | status | token | enumerate | exploit | post-exploit
Everything reuses a silent MSAL token cache under ~/.config/fenrir/. You sign in once and every later command refreshes the token quietly in the background. There are three flows depending on what you've got:
One choice here was deliberate. The default MSAL client ID is the Azure CLI's own public client (04b07795-...). Since that's a first-party, already-consented public client, the device-code and ROPC flows need no app registration at all, which is much closer to how someone holding a stray set of credentials would really operate. The authenticator tries the cache first, falls back to whichever flow you asked for, and it can mint tokens for different scopes as it goes, Graph for directory data and ARM for resource data, all off the same session.
Phase 1, enumerate: recon and a go/no-go call
fenrir enumerate is recon plus a decision. It builds two very different pictures of the tenant and then fuses them.
From Microsoft Graph it pulls who you are, your verified domains, directory role assignments, PIM-eligible roles, group memberships, any apps or service principals you own (with their credential counts), and managed devices. From Azure Resource Manager it walks subscriptions down through resource groups to resources, resolves your direct role assignments at each scope, and flags every resource that carries a managed identity.
The ARM walk runs across a small thread pool because listing resource groups and resources is the slow part, and role-definition IDs get resolved to readable names through a cache so the same role isn't looked up twice:
The verdict is the part I'm proudest of. Instead of dropping a wall of role data on you, enumerate boils it down to one of four states:
| Verdict | What it means | Where you go next |
|---|---|---|
| READY | You hold an exploit-relevant role at RG scope and there are MI-bearing resources to hit | run exploit |
| BLOCKED_NO_TARGETS | You have the rights, but nothing MI-capable is carrying an identity right now | wait for one, or attach a user-assigned identity |
| BLOCKED_NO_RIGHTS | No exploit-relevant role at RG scope, so the MI path ends here | go after non-MI data access instead |
| UNVERIFIED | You ran with --no-resources, so RG roles are unknown | run it again without that flag |
I kept the logic conservative on purpose. It only counts assignments that sit directly at the resource-group scope, with no inheritance expansion, because that's exactly what the exploit phase can act on. Anything looser would produce a green light that falls apart the moment you try to use it. A group only becomes a target when it holds a resource of an MI-capable type (a VM, an App Service, a Container Group, a Logic App workflow, or an Automation Account) and that resource is actually carrying an identity. The roles that gate the whole thing are the control-plane ones that let you run code on those hosts or attach identities to them: Owner, Contributor, Virtual Machine Contributor, Website Contributor, Logic App Contributor, Automation Contributor and Operator, Managed Identity Operator and Contributor, User Access Administrator, and the Azure Container Instances Contributor role.
Beyond the yes-or-no, the verdict also points out escalation openings per group by intersecting the roles you hold against a couple of known primitives.
One is self role-assignment. If you're holding Owner, User Access Administrator, or Role Based Access Control Administrator, then you've got roleAssignments/write at that scope, which means you can grant yourself data-plane roles like Storage Blob Data Contributor or Key Vault Secrets User and reach the data directly, skipping the managed identity entirely. The other is attaching a user-assigned identity. If you can both assign a UAI and write to a host, you can bolt an existing identity onto a box you control and then request its token from IMDS. If there's no host in the group, the tool tells you that you'd have to create one first.
That reasoning all lives in one small, well-tested function so the same rules feed both the panel you read and the JSON output:
When resource groups get enumerated, enumerate also writes an exploit state file to ~/.config/fenrir/state.json with the subscriptions, the interesting groups and their inventories, your principal ID, and a timestamp. That way the next phase never has to rediscover anything.
Phase 2, exploit: taking the token
fenrir exploit picks up from that state file, and for each interesting resource group it tries to pull a managed-identity token through whatever host types it finds. The thing that made this tractable is that every compute service has some "run code here" surface, and from the inside they all reach the same IMDS token endpoint:
VM -> RunCommand -> IMDS
App Service -> Kudu execution -> IMDS
Logic App -> workflow callback
Container Group -> exec endpoint
Automation Acct -> runbook artifacts
For every token it manages to extract, Fenrir exchanges it against ARM to see what that identity can actually reach, then caches it for the post-exploitation phase. There's also a cross-referencing step: for any standalone user-assigned identity it finds in a group, it goes looking for a host that has that identity attached and pulls a token for that specific client ID. So even identities that aren't obviously sitting on a box still become reachable.
The output tree labels each resource so you can read the blast radius at a glance. [TOKEN] means a token can be pulled, [TOKEN-X] means it's conditional (an App Service plan that may or may not support Kudu execution, say), and [CREDS] marks credential-bearing resources like Storage, Key Vault, and registries.
I also baked in a few honest limits, because a tool that lies about what worked is worse than no tool at all when you're in front of a client. Extraction is RG-scope only, and subscription-scope roles get surfaced as a note rather than acted on. RunCommand can be disabled by policy on a given host. Kudu execution isn't available on every App Service plan. When a host fails, the orchestrator reports it instead of quietly swallowing it.
Phase 3, post-exploit: living as the stolen identity
Once you're holding an MI token, the interesting question flips around: what does this new identity control? fenrir post-exploit takes a cached or directly injected token and re-walks the whole tenant from that identity's point of view.
It reports the identity's RBAC assignments anywhere in the tenant, every subscription, resource group, and resource it can see with read, write, and action flags, and its resolved data-plane capabilities. That last bit matters: not just which roles it holds, but which concrete actions those roles actually grant.
Data harvesting is where I was strictest about keeping things opt-in. Pulling blobs, Azure Files, or registry images only happens if you pass --dump-blobs or --dump-images, and it's gated on capability, so if the identity can't read something, nothing gets downloaded. It asks for confirmation unless you pass -y, there's a --dry-run that inventories everything without writing a single byte, and objects are capped at 10 MiB each. Whatever does come down runs through a secret scanner that writes out findings and a manifest.json, so the result of a harvest is an auditable report rather than a silent pile of loot.
What building it taught me
A few things stuck with me.
The verdict ended up mattering more than the exploits. Writing five different token-extraction techniques was the fun part, but the piece that actually made the tool useful day to day was that honest, conservative "here's whether this is even worth trying, and here's exactly why" gate. Good offensive tooling respects your time.
Modeling escalation as data instead of code paid off too. I expressed the escalation primitives as small sets of role names that get intersected against what you're holding, rather than a tangle of if-statements, and that meant the same logic drives the display, the JSON, and the tests. Adding a new primitive is basically a one-line change.
And the discipline of keeping read-only code away from the dangerous code was worth the extra friction. Splitting enumeration, token abuse, and exfiltration into separate modules behind explicit flags didn't just make the thing safer to run in a lab, it made the code far easier to reason about and test.
If you're reading this from the defensive side, the takeaways carry over cleanly. A managed identity is only as safe as the control-plane roles that can run code on its hosts. Keep an eye on roleAssignments/write at resource-group scope. Treat Virtual Machine Contributor and its cousins as a path to every identity in the group, not just the box. And remember that a user-assigned identity is only as trustworthy as the least-trusted host it has ever been attached to.