When Microsoft first disclosed the January Midnight Blizzard attack and posted their subsequent deeper analysis I followed the resulting content with great interest – risks posed by Enterprise Applications are a topic near and dear to me.
I will try to keep this article standalone, but it might be a good idea to skim the Articles before continuing
If you use Microsoft 365 to a reasonable level, you will have applications with a large scope of permissions in your Tenant (think Backup Solutions, Script Automations, etc.) – which is why you should treat the Entra ID Role "(Cloud) App Administrator" like a Global Administrator. By setting Credentials for the System Identity that holds the applications’ permissions, they can connect to the associated APIs in its name and use privileges you might never have granted the Users directly. The same applies to the application-level Graph API role "Application.ReadWrite.All".
Since I don’t have anything to add to the main Points of Interest for the breach, lateral movement between Tenants through enterprise Applications and how to correctly grant API permissions in Exchange Online, I won’t be revisiting those topics. However, there is one area where I would like to see more Information – once discovered in your Tenant, it is sometimes not easy to find how to avoid or get rid of highly privileged API Roles. One such Example is Application.ReadWrite.All.
The whole Midnight Blizzard attack chain might have started directly with an App Owner of the Multi-Tenant App, but it could easily have been an application in the Test Tenant with this Permission, through which the Attacker moved to the Multi-Tenant App (Microsoft is not entirely clear on this).
So, let’s take a look at some use cases where the first reflex might be to use "Application.ReadWrite.All", but there are better solutions:
I will be using PowerShell in my demos, but since Graph is a REST API, examples can be transferred to any Programming Language with support for HTTP requests
I hold these truths to be self evident: Delegated Permissions are preferable over Application Permissions, and if you only need Read permissions you don’t use a ReadWrite scope
General Configuration Management
To start out, in the vast majority of cases, we do not want to manage all applications in Tenant, but only a specific set. Let’s begin with an application wanting to manage its own configuration.
Remember: In the Graph API App Registrations are on the application Endpoint, an Enterprise app is a servicePrincipal – Quick summary of the difference
When used too often I might refer to App Registrations as "AppRegs", and Enterprise Apps as "EApps"
First Attempt
If we take a look at the Graph API documentation for managing App Registrations, we can already see a potential Option (the same goes for Enterprise Apps):
So, we add the Scope to our App Registration, Grant the Permissions on the Enterprise App and are done, right?
Well, now that I have the scope in my Graph Session, I try to change my EApp to require a User to be assigned before they can log in:
Invoke-MgGraphRequest "PATCH" "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$($graphConfig.clientID)')" -Body @{ appRoleAssignmentRequired = $true }
And promptly get denied:
Adding Ownership
This is logical if we think about it. We just gave the Enterprise App permission to manage applications that it Owns, but we don’t own anything (Click here for Microsofts Overview of application ownership). So how do we fix that? At time of writing (Feb. 2024), the Entra Portal does not offer EApps when we try to add an Owner to an Enterprise App or an App Registration.
However, if we authenticate against the Graph API with the delegated Scope "Application.ReadWrite.All" as a user with at least Application Administrator (or Owner), we can add the necessary ownership – an Object can even own itself.
Remember – Even though credentials are configured on the App Registration, the Enterprise App / service Principal holds all the Permissions in a Tenant
See also the Microsoft Documentation of Adding Owners to Enterprise Apps
# Fetch the Object ID of our EApp by App / Client ID
$enterpriseAppID = ( Invoke-MgGraphRequest "GET" "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$($graphConfig.clientID)')?select=id" ).id
# Add the EApp as an Owner to itself
$params = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/servicePrincipals/$enterpriseAppID" }
Invoke-MgGraphRequest "POST" "https://graph.microsoft.com/v1.0/servicePrincipals/$enterpriseAppID/owners/`$ref" -Body $params
So let’s try again (We don’t get a response on successful execution so I won’t show the command line):
Invoke-MgGraphRequest "PATCH" "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$($graphConfig.clientID)')" -Body @{ appRoleAssignmentRequired = $true }
Success! And we can’t take over the entire Tenant from here, since we only control one Application 🥳
Funnily enough, even though we can’t add Enterprise Apps as Owners through the UI, once we added them, they are visible:
If we need the ability to change the properties of our App Registration, we can add Ownership rights like this:
In this example I am still using the App / Client ID of the EApp I am logged in as, replace as needed
See also the Microsoft Documentation of Adding Owners to App Registrations
# Fetch the ID of The EApp that we want to use for Management
$enterpriseAppID = (Invoke-MgGraphRequest "GET" "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$($graphConfig.clientID)')?select=id").id
$appRegistrationID = (Invoke-MgGraphRequest "GET" "https://graph.microsoft.com/v1.0/applications(appId='$($graphConfig.clientID)')?select=id").id
# We add our EApp as the Owner of the AppReg
$params = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/servicePrincipals/$enterpriseAppID" }
Invoke-MgGraphRequest "POST" "https://graph.microsoft.com/v1.0/applications/$appRegistrationID/owners/`$ref" -Body $params
Now I should be able to manage the App Registration as well, so let’s try to add a redirect URI for SSO:
$params = @{ web = @{ redirectUris = @("https://demo.com") } }
Invoke-MgGraphRequest "PATCH" "https://graph.microsoft.com/v1.0/applications(appId='$($graphConfig.clientID)')" -Body $params
And again we are successful:
Application Credential Management
Probably one of the most common requests in application management is to self-manage credentials. The consistent Expiry of Secrets and Certificates is either in need of close monitoring or, preferably, automation.
With "Application.ReadWrite.OwnedBy", and our Enterprise App already having ownership of the App Registration from the last chapter, we can update Credentials – since they are just another property.
Secrets
See also the Microsoft Documentation of Updating App Registrations
$appRegistrationID = (Invoke-MgGraphRequest "GET" "https://graph.microsoft.com/v1.0/applications(appId='$($graphConfig.clientID)')?select=id").id
$params = @{
passwordCredential = @{
displayName = "Secret$(get-date -f "ddMMyy")"
}
}
# Depending on where you need the secret, it is probably a good idea to put it there as quickly as possible ;)
$response = Invoke-MgGraphRequest POST "https://graph.microsoft.com/v1.0/applications/$appRegistrationID/addPassword" -body $params
If I actually have to use secrets like this I prefer only storing them in secure strings, since they are better protected in memory:
$secret = ConvertTo-SecureString $(Invoke-MgGraphRequest POST "https://graph.microsoft.com/v1.0/applications/$appRegistrationID/addPassword" -body $params).secretText -AsPlainText -Force
In our Response we get the secret as secretText, along with some metadata (remember to store it somewhere, or it is gone forever):
We can also see the Secret in the UI (just not the Value):
Keys / Certificates
However, I am sure noone needs secrets anymore. They are just so much worse than Asymmetric Authentication Methods, so obviously all Applications have moved to Certificates for Authentication right? RIGHT? A man can dream…
In theory we could do manage Certificates even without "Application.ReadWrite.OwnedBy" Permissions, but I was unable to get it to work in PowerShell – I may revisit it later or would very much appreciate a pointer to a working implementation in the open Q&A question…
With me being unable to demonstrate the really cool solution (for now), I don’t really have much to add to the Microsoft Documenation. I do use the Value from the Certificate Store though:
Keep in mind that this command REPLACES ALL CURRENTLY CONFIGURED CERTIFICATES on the enterprise application
$Certificate = Get-Item "Cert:\CurrentUser\My\$($graphConfig.newThumb)" -ErrorAction Stop
$params = @{
keyCredentials = @(
@{
type = "AsymmetricX509Cert"
usage = "Verify"
key = [convert]::ToBase64String($Certificate.GetRawCertData())
displayName = "CN=20240228"
}
)
}
Invoke-MgGraphRequest PATCH "https://graph.microsoft.com/v1.0/applications(appId='$($graphConfig.clientID)')" -Body $params
Scaling
So, we now have a good grasp of how we can manage single applications. But how do expand our Scope? There are usecases where we want to hold control over many Enterprise Apps / App Registrations (I will shorten to "Apps" to refer to both from here on out).
Think Infrastructure as Code Solutions like Terraform, or maybe we have a backup system that wants to circumvent Graph API rate limits by creating more EApps
So essentially we would need at least one "Manager" App and a bunch of "Workers". The annoying solution would be to create a bunch of Apps in advance – and go through the base ownership assignment afterwards. Not great, but thankfully it is much simpler – if we use "Application.ReadWrite.OwnedBy" on our "Head" Application, we can then use it to create Apps as needed.
We can also call the servicePrincipal Endpoint to create associated Enterprise Apps, but that just looks the same
Invoke-MgGraphRequest POST "https://graph.microsoft.com/v1.0/applications" -body @{ displayName = "CreatedByApp" }
The executing Enterprise App is automatically added as the Owner of the new App:
However, keeping track of all our ownership relations will be challenging. The solutions I know as currently available are building an automation based on Custom Security Attributes or creating a tracking system in directory extensions. Either way, these are not great options – so are we doomed to either give far too broad permissions or try to keep track of a potentially complex web of ownership relations?
The "Best" Option
If we truly want to go for "least privilege", Graph API Scopes are not the way. At their core, Apps are Entra ID Objects, making custom roles a significantly more granular option for permissions management. "Application.ReadWrite.OwnedBy" just happens to be almost a drop in replacement for the commonly used "Application.ReadWrite.All".
OAuth scopes have the advantage of being easily transferrable between Tenants – when creating an Application only for your Tenant this is not really a fair argument and you better really REALLY trust the developer to give these permissions to a multi-tenant App…
Once we look at the Options available in Entra ID, there would be one painfully obvious solution: For Users, Groups and Devices we can already create Administrative Units. These allow scoping Built-In and Custom Roles only to a subset of Objects in a directory, including the creation of new objects within its’ scope.
This would result in an easily scalable and auditable collection of Apps.
Alas, using AUs to manage Apps is currently (March 2024) not supported by Microsoft…
Conclusion
On your next review of App Permissions, ask developers if it would be possible to use less privileges. Remember, prioritizing safety is often secondary in the development process. Challenge the assertion ‘we just need it‘ by looking for the specific usecases that have to be covered – maybe there are solutions that have not been considered.
On the other hand, even though I go into how we can and should replace Application.ReadWrite.All, this is not always a good idea. If there is a System that Owns 90% of Apps, the management Overhead usually does not justify the little added security.
Additionally, if you find the option of Entra ID custom roles interesting, understanding which Entra ID Permissions are necessary for a given Graph operation is not easy – you have to be willing to take on some trial and error.
Did I miss a common usecase or you want to debate me why one might need Application.ReadWrite.All? I don’t want your email address, so please discuss on my associated LinkedIn Post.
If you are interested in the things I do follow me on LinkedIn.
Comments