Reglas de bandeja sospechosas
Encuentra reglas que reenvían, redirigen, borran o esconden correo, incluidas las ocultas. Es lo primero que deja un atacante en una cuenta comprometida, y marca los reenvíos fuera de tu organización.
Solo lecturaSin permisos de GraphProbado con respuestas simuladas. Sin ejecutar en un tenant real.
Ejemplo en México
Agencia aduanal en Nuevo Laredo, 60 personas
Un cliente recibió de una cuenta de la agencia una factura con otra CLABE, y la persona dueña de esa cuenta jura que no la envió.
.\get-reglas-bandeja-sospechosas.ps1 -Usuario facturacion@empresa.example -RutaCsv .\reglas.csvAparece una regla sin nombre claro que reenvía a una dirección externa y mueve las respuestas a RSS Subscriptions. La deshabilitan, cambian la contraseña y corren el script para toda la agencia.
Cómo implementarlo
- Instala el módulo si no lo tienes: Install-Module ExchangeOnlineManagement -Scope CurrentUser.
- Si sospechas de una persona, corre el script con -Usuario y su correo; eso tarda segundos.
- Para revisar a todos, córrelo sin -Usuario con -RutaCsv, en una ventana que pueda quedarse abierta.
- Abre el CSV y empieza por las filas con Riesgo Alto y ReenvioExterno en Sí.
- Pregunta a la persona si creó la regla. Si no la reconoce, deshabilítala con Disable-InboxRule, cambia su contraseña y revoca sus sesiones.
Ver el código completo de get-reglas-bandeja-sospechosas.ps1
<#
.SYNOPSIS
Encuentra reglas de bandeja de entrada que reenvían, redirigen, borran o esconden correo, típicas de una cuenta comprometida.
.DESCRIPTION
Revisa las reglas de cada buzón, incluidas las ocultas, y deja solo las que reenvían o redirigen, las que borran
y las que mueven correo a carpetas donde nadie mira (RSS, Notas, Correo no deseado, Historial de conversaciones,
Elementos eliminados), también en subcarpetas, y las que mueven y marcan como leído. Marca como externo el destino
cuyo dominio no es un dominio aceptado de tu organización; un destino que no se pueda clasificar queda en riesgo alto.
Sin -Usuario recorre todos los buzones de uno en uno: en un tenant grande puede tardar horas.
Solo lectura: no cambia nada en el tenant.
.PARAMETER Usuario
Uno o más buzones a revisar. Si se omite, revisa todos los del tipo indicado.
.PARAMETER TipoBuzon
Tipos de buzón a recorrer cuando no usas -Usuario. Por defecto, de usuario y compartidos.
.PARAMETER RutaCsv
Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.
.EXAMPLE
.\get-reglas-bandeja-sospechosas.ps1 -Usuario ana@empresa.example -RutaCsv .\reglas.csv
.EXAMPLE
.\get-reglas-bandeja-sospechosas.ps1 -TipoBuzon UserMailbox -RutaCsv .\reglas-todos.csv
.NOTES
Permisos: ninguno de Graph; usa ExchangeOnlineManagement.
Rol mínimo: Administrador de Exchange (Organization Management). Get-InboxRule no funciona con Lector global ni con View-Only Organization Management.
Licencia: Exchange Online, incluido en Microsoft 365 Business Premium.
Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en un tenant de pruebas.
#>
#Requires -Version 7.0
[CmdletBinding()]
param(
# Sin esta validación, un valor vacío haría que se recorrieran todos los buzones.
[ValidateNotNullOrEmpty()][string[]]$Usuario,
[ValidateSet('UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox')][string[]]$TipoBuzon = @('UserMailbox', 'SharedMailbox'),
[string]$RutaCsv
)
$ErrorActionPreference = 'Stop'
if (-not (Get-Command Connect-ExchangeOnline -ErrorAction SilentlyContinue)) {
throw 'Falta el módulo. Instálalo con: Install-Module ExchangeOnlineManagement -Scope CurrentUser'
}
# El CSV se vacía desde el inicio: si algo truena o no hay resultados, no queda uno viejo que parezca actual.
if ($RutaCsv) { Set-Content -LiteralPath $RutaCsv -Value $null -Encoding utf8BOM }
# Carpetas donde un atacante esconde las respuestas a sus correos, en inglés y en español.
$carpetasRaras = '^(rss( feeds| subscriptions)?|suscripciones rss|fuentes rss|notes|notas|junk( e-?mail)?|correo no deseado|conversation history|historial de conversaciones|deleted items|elementos eliminados)$'
Connect-ExchangeOnline -ShowBanner:$false
try {
$dominios = @(Get-AcceptedDomain -ResultSize Unlimited -ErrorAction Stop | ForEach-Object { "$($_.DomainName)".ToLowerInvariant() })
# Sin dominios aceptados no se puede decir qué reenvío es externo.
if (-not $dominios) { throw 'No se pudieron leer los dominios aceptados del tenant.' }
$objetivos = if ($Usuario) { $Usuario } else {
@(Get-EXOMailbox -ResultSize Unlimited -RecipientTypeDetails $TipoBuzon | ForEach-Object {
if ($_.UserPrincipalName) { "$($_.UserPrincipalName)" } elseif ($_.PrimarySmtpAddress) { "$($_.PrimarySmtpAddress)" }
})
}
$objetivos = @($objetivos)
if (-not $Usuario) { Write-Warning "Se revisan $($objetivos.Count) buzones de uno en uno: puede tardar mucho en un tenant grande. Para uno solo usa -Usuario." }
$n = 0
$filas = @(foreach ($buzon in $objetivos) {
$n++
Write-Progress -Activity 'Revisando reglas de bandeja' -Status $buzon -PercentComplete ($n / $objetivos.Count * 100)
try {
$reglas = @(Get-InboxRule -Mailbox $buzon -IncludeHidden -ResultSize Unlimited -ErrorAction Stop)
} catch {
[pscustomobject]@{ Buzon = $buzon; Regla = 'No se pudo consultar'; Habilitada = ''; Riesgo = ''; Motivos = "$_"; ReenviaA = ''; ReenvioExterno = ''; CarpetaDestino = '' }
continue
}
foreach ($r in $reglas) {
$destinos = @(@($r.ForwardTo) + @($r.ForwardAsAttachmentTo) + @($r.RedirectTo) | Where-Object { $_ })
$direcciones = [System.Collections.Generic.List[string]]::new()
$externos = 0
$sinDeterminar = 0
# Un destino llega como texto: "Ana" [SMTP:ana@dominio] o "Ana" [EX:/o=...]. Solo vale el último corchete: el nombre lo elige quien crea la regla.
foreach ($d in $destinos) {
$texto = "$d"
$correo = if ($texto -match '\[SMTP:([^\]\s]+)\]\s*$') { $Matches[1] } elseif ($texto -match '^[^@\s]+@[^@\s]+$') { $texto }
if ($correo) {
$direcciones.Add($correo)
if ($correo.Split('@')[-1].ToLowerInvariant() -notin $dominios) { $externos++ }
continue
}
$direcciones.Add($texto)
# [EX:] puede ser un contacto o invitado con correo externo: solo un buzón es interno con seguridad.
$interno = $false
if ($texto -match '\[EX:([^\]]+)\]\s*$') {
$dn = $Matches[1]
try {
$destinatario = @(Get-EXORecipient -Identity $dn -Properties RecipientTypeDetails -ErrorAction Stop)
$interno = $destinatario.Count -eq 1 -and "$($destinatario[0].RecipientTypeDetails)" -match 'Mailbox$'
} catch { Write-Verbose "No se pudo resolver $dn : $_" }
}
if (-not $interno) { $sinDeterminar++ }
}
# MoveToFolder llega como "ana@dominio:\Carpeta\Subcarpeta"; cada nivel cuenta, porque se puede esconder en una subcarpeta.
$segmentos = @(("$($r.MoveToFolder)" -replace '^[^\\]*:\\?', '') -split '\\' | ForEach-Object { $_.Trim() } | Where-Object { $_ -and $_ -ne '.' })
$carpeta = $segmentos -join '\'
$motivos = @()
if ($externos) { $motivos += 'Reenvía fuera de la organización' }
elseif ($sinDeterminar) { $motivos += 'Reenvía a un destino que no se pudo clasificar' }
elseif ($destinos) { $motivos += 'Reenvía dentro de la organización' }
if ($r.PermanentDelete -eq $true) { $motivos += 'Borra de forma permanente' }
elseif ($r.DeleteMessage -eq $true -or $r.SoftDeleteMessage -eq $true) { $motivos += 'Borra el correo' }
$carpetaRara = [bool]($segmentos -match $carpetasRaras)
if ($carpetaRara) { $motivos += "Mueve a la carpeta $carpeta" }
elseif ($carpeta -and $r.MarkAsRead -eq $true) { $motivos += "Mueve a $carpeta y marca como leído" }
if (-not $motivos) { continue }
# La regla oculta de correo no deseado existe en casi todos los buzones. Su nombre lo puede copiar un atacante: la carpeta también debe coincidir.
if ("$($r.Name)" -eq 'Junk E-mail Rule' -and $motivos.Count -eq 1 -and $carpeta -match '^(junk( e-?mail)?|correo no deseado)$') { continue }
[pscustomobject]@{
Buzon = $buzon
Regla = "$($r.Name)"
Habilitada = if ($null -eq $r.Enabled) { 'No determinado' } elseif ($r.Enabled) { 'Sí' } else { 'No' }
Riesgo = if ($externos -or $sinDeterminar -or $r.PermanentDelete -eq $true) { 'Alto' } else { 'Medio' }
Motivos = $motivos -join '. '
ReenviaA = $direcciones -join '; '
ReenvioExterno = if ($externos) { 'Sí' } elseif ($sinDeterminar) { 'No determinado' } elseif ($destinos) { 'No' } else { '' }
CarpetaDestino = $carpeta
}
}
})
Write-Progress -Activity 'Revisando reglas de bandeja' -Completed
} finally {
Disconnect-ExchangeOnline -Confirm:$false
}
$fallas = @($filas | Where-Object { $_.Regla -eq 'No se pudo consultar' })
Write-Host "Buzones revisados: $($objetivos.Count). Reglas sospechosas: $($filas.Count - $fallas.Count). Buzones con falla: $($fallas.Count)"
if ($RutaCsv) {
# Excel ejecutaría como fórmula un nombre de regla que empiece con estos caracteres, y el nombre lo elige quien creó la regla.
foreach ($f in $filas) { foreach ($p in $f.PSObject.Properties) { if ($p.Value -is [string] -and $p.Value -match '^[=+\-@\t\r]') { $p.Value = "'" + $p.Value } } }
# UTF-8 con BOM para que Excel en español muestre bien los acentos.
$filas | Export-Csv -Path $RutaCsv -NoTypeInformation -Encoding utf8BOM
Write-Host "CSV guardado en $RutaCsv"
} else {
$filas | Format-Table -AutoSize
}
if ($fallas) { throw "No se pudieron consultar $($fallas.Count) buzones: $($fallas.Buzon -join ', ')" }