Living off the land en Microsoft 365: cázalo con PowerShell y KQL
Estimados amigos de Inseguros !!!
Llevo ya un tiempo dándole vueltas a esto y viéndolo de cerca en tenants reales: el living off the land en Microsoft 365. Atacar el tenant sin malware, usando las mismas capacidades de administración que Microsoft te da para protegerlo.
Y me encuentro lo mismo una y otra vez: la gente no lo tiene claro. Saben que Entra, Intune, Defender y Graph son potentes, pero no que un simple compromiso de cuenta convierte esa potencia en un arma.
Sin binario raro. Sin C2. Sin payload que salte en el EDR. Una cuenta, y a administrar.
Así que hoy nada de teoría. Comandos que puedes lanzar en tu tenant esta misma tarde para ver cómo estás, y las consultas KQL para que tu SOC lo cace si alguien lo intenta.
Nada de esto rompe nada. Es todo lectura y detección. Tranquilo 🙂
Comprobación 1: ¿qué apps de tu tenant pueden traicionarte?
Empezamos por las app registrations, que son las que más papeletas tienen y las que casi nadie mira. Aplicaciones con permisos de aplicación (Files.ReadWrite.All, Mail.Read…) que corren 24×7, cuyo owner puede ser cualquier usuario, y a las que ese owner puede añadirle un client secret para heredar sus permisos. Es la técnica MITRE ATT&CK T1098.001.
Pues vamos a sacar la foto. Este script usa el módulo Microsoft Graph PowerShell y solo pide permisos de lectura. Lista tus apps con sus secretos, cuándo caducan, quién es el dueño y qué permisos de aplicación tienen, marcando en rojo las «peligrosas»:
# Solo lectura: enumera, no cambia nada. Mínimo privilegio.
Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All"
# 1) Diccionario de permisos de Graph: ID -> nombre legible (Files.ReadWrite.All, etc.)
$graphSp = Get-MgServicePrincipal -Filter "AppId eq '00000003-0000-0000-c000-000000000000'"
$graphRoles = @{}
foreach ($role in $graphSp.AppRoles) { $graphRoles[$role.Id] = $role.Value }
# 2) Permisos que le alegran el día a un atacante
$peligrosos = @(
'Application.ReadWrite.All','AppRoleAssignment.ReadWrite.All',
'RoleManagement.ReadWrite.Directory','Directory.ReadWrite.All',
'Mail.ReadWrite','Mail.Read','Files.ReadWrite.All','Sites.ReadWrite.All'
)
# 3) Recorremos las apps y volcamos el resultado a una variable
$resultados = foreach ($app in Get-MgApplication -All) {
$sp = Get-MgServicePrincipal -Filter "AppId eq '$($app.AppId)'" -ErrorAction SilentlyContinue
$perms = @()
if ($sp) {
$perms = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -All |
ForEach-Object { $graphRoles[$_.AppRoleId] } | Where-Object { $_ }
}
$owners = (Get-MgApplicationOwner -ApplicationId $app.Id).AdditionalProperties.userPrincipalName -join ', '
[pscustomobject]@{
App = $app.DisplayName
Secretos = @($app.PasswordCredentials).Count + @($app.KeyCredentials).Count
Caduca = ($app.PasswordCredentials.EndDateTime | Sort-Object | Select-Object -First 1)
Dueños = $owners
Permisos = ($perms -join ', ')
Peligrosa = [bool]($perms | Where-Object { $_ -in $peligrosos })
}
}
# Ahora sí, la variable se pipea sin problema
$resultados | Sort-Object Peligrosa -Descending | Format-Table -AutoSize
# ¿Muchas apps? mejor a CSV: $resultados | Export-Csv apps.csv -NoTypeInformation -Encoding UTF8
Lo que buscas en esa tabla: apps con permisos gordos y varios secretos, dueños que no pintan nada ahí, o esa «backup app» de una migración que quedó viva con Files.ReadWrite.All. Te sorprendería la de veces que aparece justo eso. Ese es tu deber para el finde.
Detección: alguien acaba de añadir un secreto a una app
Auditar está bien, pero tú lo que quieres es enterarte cuando pasa. Añadir un secreto a un service principal es la jugada de persistencia estrella —sobrevive a que cambies la contraseña del usuario— y queda registrada. Esta consulta en Sentinel (tabla AuditLogs) la caza:
// Se añadió un secreto/certificado a una app o service principal — T1098.001
AuditLogs
| where OperationName has_any ("Add service principal", "Certificates and secrets management")
| where Result =~ "success"
| extend Actor = coalesce(tostring(InitiatedBy.user.userPrincipalName),
tostring(InitiatedBy.app.displayName))
| mv-expand Target = TargetResources
| project TimeGenerated, Actor,
IP = tostring(InitiatedBy.user.ipAddress),
App = tostring(Target.displayName), OperationName
Un aviso de los que se agradecen: los sign-in logs de service principal no se envían a Sentinel por defecto. Si quieres ver además a la app usando ese secreto (AADServicePrincipalSignInLogs), tienes que activar esas categorías en las diagnostic settings de Entra. Si no, no tiras de nada y no te enteras.
¿Y el permanentDelete de Graph que se salta la papelera? Ese es más difícil de cazar limpio en el log de ficheros, te lo digo de frente. Por eso la detección de verdad está arriba, en el secreto: si pillas la credencial cuando la añaden, cortas la cadena antes de que borre nada.
Comprobación 2: wipes y scripts en Intune
Pasamos a Intune: borrado masivo de dispositivos y scripts de remediación PowerShell corriendo en el endpoint. Dos consultas sobre la tabla IntuneAuditLogs.
Wipes y deletes de dispositivos:
IntuneAuditLogs
| where OperationName contains "Wipe" or OperationName contains "Delete"
| extend Actor = tostring(parse_json(Properties).Actor.UPN)
| project TimeGenerated, OperationName, Actor
Y esta es la de alta fidelidad, la creación o edición de un script en Intune, que es donde un atacante mete su lógica sin «cantar»:
IntuneAuditLogs
| where OperationName == "Create"
| where Properties has "deviceManagementScript" or Properties has_cs "deviceShellScript"
| extend Actor = tostring(parse_json(Properties).Actor.UPN)
| project TimeGenerated, OperationName, Actor
Truco de correlación: si el Actor no tiene UPN (una cuenta de usuario legible), es que la acción viene por Graph API con autenticación de aplicación. Un script o un wipe disparado por una app y no por una persona es señal para levantar la mano.
La defensa dura aquí es la aprobación multi-admin (MAA): que un segundo admin tenga que aprobar wipe, retire, delete, scripts y apps. Y una novedad importante: desde una actualización del servicio de Intune de 2026, la MAA aplica también a las llamadas por Graph API con autenticación de aplicación, devolviendo un 403 a lo no aprobado. Ese hueco por el que se colaban los scripts, que hasta hace nada era terreno de nadie, por fin se cierra.
Comprobación 3: Live Response en Defender
Y la última, que es la que más respeto me da: Defender Live Response, una terminal en vivo contra el equipo que ejecuta PowerShell como SYSTEM. Buenísima para tu blue team… y para quien comprometa al security admin.
Aquí la comprobación es de configuración, no de KQL, y se hace en el portal. Ruta:
- Settings → Endpoints → Advanced features. Mira tres toggles: Live Response, Live Response for servers y Live Response unsigned script execution. Firma tus scripts (apaga el de unsigned), y plantéate desactivar Live Response para servidores, o del todo si aún no lo puedes asegurar.
- Device groups + RBAC. Separa por tiers: que a tus tier 0 —controladores de dominio y demás joyas— solo lleguen equipos endurecidos y gente de confianza.
- Permisos básico vs avanzado. Dentro de los roles, basic live response solo recupera información; advanced es el que ejecuta PowerShell. Ese «advanced» es el que hay que repartir con cuentagotas.
El uso de Live Response queda registrado en el Action Center de Defender: revísalo de vez en cuando a ver quién abre sesiones y contra qué máquinas.
Qué deberías ver en tu SIEM
Resumiendo lo accionable, que es de lo que va esto:
- Hoy: lanza el script de PowerShell y revisa tus apps peligrosas y sus dueños.
- En detección: las tres KQL de arriba —secretos nuevos, wipes/deletes y scripts de Intune— montadas como analytics rules.
- En configuración: MAA en Intune, diagnostic settings de Entra activadas y Live Response bajo tiers.
Si tu detección funciona, cada una de esas tres consultas debería estar vacía en un día normal… y saltar el día que no lo sea.
Y te voy a ser honesto, que es lo que más veo fallar: copiar y pegar estas queries es el primer paso, pero entenderlas —por qué esa operación, qué campo mirar, cómo bajar los falsos positivos y escribir la tuya para tu entorno— es lo que separa administrar de defender de verdad. Ese trabajo de bajar al barro de Entra, Intune y Sentinel es justo lo que hacemos dentro de la membresía de SeguridadSI: rutas para no perderte, directos cada 15 días y gente peleándose con lo mismo que tú. Y si lo tuyo ahora es la detección y la respuesta, dentro tienes el curso de Respuesta a Incidentes en Azure (DFIR) para trabajar KQL y Sentinel de arriba abajo.
Espero que lo lances y me cuentes qué te sale por la tabla 🙂
Gracias por leerme !!!