Un sistema que detecta y bloquea ataques de hackers dentro de SQL Server, sin herramientas externas
SQL Server con su propio sistema inmunológico
Cómo detectar y bloquear ataques de fuerza bruta dentro del motor, en T-SQL puro — sin firewall, sin PowerShell, sin xp_cmdshell.
Casi todo SQL Server expuesto a internet recibe ataques de fuerza bruta contra la cuenta sa. La defensa estándar vive fuera del motor: firewall, VPN, IPSec o herramientas externas. El problema es que esa capa externa puede fallar — una regla mal configurada, un puerto que queda abierto, una migración — y en ese momento el SQL Server queda completamente desnudo. Este artículo le da al motor su propia defensa interna.
01 / EL PROBLEMAEl log que se repite todos los días
Si un SQL Server tiene autenticación SQL habilitada y el puerto 1433 accesible desde internet, su error log se ve así, todos los días:
Login failed for user 'sa'. Reason: Password did not match... [CLIENT: 186.170.16.92] Login failed for user 'sa'. Reason: Password did not match... [CLIENT: 186.170.16.92] Login failed for user 'mssqla'. Reason: Could not find a login... [CLIENT: 186.170.16.92] Login failed for user 'sa'. Reason: Password did not match... [CLIENT: 186.170.16.92] ...
En un caso real de producción, una sola IP atacante generó más de 60 intentos en 50 segundos — alrededor de 1.3 intentos por segundo. Eso no es un humano: es un script automatizado que recorre internet buscando puertos 1433 abiertos y dispara diccionarios de contraseñas contra sa.
La recomendación universal — y correcta — es no exponer SQL Server a internet. Pero la realidad operativa es más complicada:
- Muchos sistemas legacy de punto de venta y ERP conectan directo por cadena de conexión a una IP pública.
- Las migraciones entre proveedores de hosting dejan ventanas donde el puerto queda expuesto.
- Una regla de firewall mal aplicada puede abrir el 1433 sin que nadie se dé cuenta.
- En entornos multi-tenant con muchos clientes y usuarios, cerrar todo de golpe rompe la operación.
La pregunta entonces no es «¿debería estar expuesto?» (la respuesta es no), sino: «si por cualquier razón el puerto queda expuesto, ¿cómo se defiende el SQL Server por sí mismo?»
02 / ESTADO DEL ARTELo que ya existe, y por qué no alcanza
Antes de construir nada, revisé exhaustivamente qué hay publicado. El panorama se divide en dos mitades que nadie ha unido.
Mitad A — detectar leyendo el error log
Existen artículos que usan T-SQL y xp_readerrorlog para detectar los intentos fallidos. El más completo es una serie de CodeProject de 2018. Pero el bloqueo lo realizan invocando xp_cmdshell para llamar a netsh y crear reglas en el Windows Firewall. Esto tiene dos problemas graves: requiere habilitar xp_cmdshell — precisamente la funcionalidad que los atacantes buscan activar para ejecutar comandos en el sistema operativo — y depende de un componente externo al motor.
Mitad B — bloquear con un LOGON trigger
Desde 2009 existen artículos que muestran cómo crear una tabla de IPs y un LOGON trigger que rechaza las conexiones que coincidan. Pero en todos los casos la lista es estática y manual: las IPs se insertan a mano. No hay detección automática que alimente esa tabla.
El hueco
| Característica | CodeProject 2018 | LOGON trigger 2009-13 | fail2ban / GitHub |
|---|---|---|---|
| Detección automática | Sí | No (manual) | Sí |
| Bloqueo dentro del motor | No (firewall) | Sí | No (firewall) |
Sin xp_cmdshell | No | Sí | Sí |
| Sin PowerShell | No | Sí | No |
| Sin dependencias externas | No | Sí | No |
Nadie combina las dos mitades en un sistema nativo. La detección automática siempre «sale» del motor hacia una herramienta externa; el bloqueo por trigger siempre es manual. Incluso Microsoft, en su documentación histórica, afirma que SQL Server no puede bloquear conexiones por IP de forma automática y recomienda usar firewall o IPSec. Las herramientas más usadas de la comunidad — sp_WhoIsActive, el First Responder Kit, dbatools — son de monitoreo y performance; ninguna hace bloqueo de IPs por fuerza bruta.
03 / FILOSOFÍADefensa en capas, no reemplazo
Este sistema no reemplaza al firewall. Un LOGON trigger no bloquea tráfico a nivel de red: para cuando ejecuta el ROLLBACK, el paquete ya llegó y ya se consumió un hilo. Bajo un ataque volumétrico masivo, esa carga afecta al motor aunque rechace todo.
Por eso el diseño correcto es de tres capas, donde el Auto-Blocker es la segunda — una red de seguridad, no la defensa principal:
Capa 1 — Red: Firewall / VPN / IPSec / TSplus
(bloquea el tráfico antes de llegar al SQL Server)
Capa 2 — Motor: SQL Auto-Blocker ◄── este artículo
(se defiende solo si el puerto queda expuesto)
Capa 3 — Túnel: Túnel cifrado que permite cerrar el 1433 público
(acceso solo por canal autenticado)
El valor de la Capa 2 es exactamente para los momentos en que la Capa 1 falla: la migración, la regla mal puesta, el puerto que quedó abierto. En ese hueco, el SQL Server deja de estar indefenso.
04 / ARQUITECTURAUn ciclo cerrado dentro del motor
El sistema es un ciclo de cuatro pasos, todo dentro de SQL Server:
┌─────────────────────────────┐
│ Error Log de SQL Server │
│ (intentos "Login failed") │
└──────────────┬──────────────┘
│ cada 1 min
▼
┌─────────────────────────────┐
│ SQL Agent Job │
│ EXEC sp_check_brute_force │
└──────────────┬──────────────┘
│ xp_readerrorlog
▼
┌─────────────────────────────┐
│ Detección por umbral │
│ ¿IP con N fallos en M seg?│
└──────────────┬──────────────┘
│ si supera el umbral
▼
┌─────────────────────────────┐
│ Tabla blocked_ips │
│ (INSERT de la IP) │
└──────────────┬──────────────┘
│ consultada en cada login
▼
┌─────────────────────────────┐
│ LOGON Trigger │
│ ¿IP bloqueada? → ROLLBACK │
└─────────────────────────────┘
Cuatro componentes: tablas de control (configuración, lista de bloqueos y auditoría), un procedimiento de detección, un LOGON trigger que rechaza las IPs bloqueadas, y un SQL Agent Job que ejecuta el procedimiento periódicamente.
05 / IMPLEMENTACIÓNDel log a la IP, y de la IP al bloqueo
5.1 — Detección
El corazón de la detección es xp_readerrorlog, filtrando por Login failed:
CREATE TABLE #errorlog (
LogDate DATETIME,
ProcessInfo VARCHAR(50),
LogText VARCHAR(MAX)
)
INSERT INTO #errorlog
EXEC xp_readerrorlog 0, 1, N'Login failed'
La IP del atacante viene embebida en el texto del log, entre [CLIENT: y ]. Se extrae con manipulación de cadenas — sin parsear XML, un punto de eficiencia que veremos más adelante:
SELECT
SUBSTRING(
LogText,
CHARINDEX('[CLIENT: ', LogText) + 9,
CHARINDEX(']', LogText, CHARINDEX('[CLIENT: ', LogText))
- CHARINDEX('[CLIENT: ', LogText) - 9
) AS ip_address,
COUNT(*) AS attempt_count
FROM #errorlog
WHERE LogDate >= DATEADD(SECOND, -@ventana_seg, GETDATE())
AND CHARINDEX('[CLIENT: ', LogText) > 0
GROUP BY /* misma expresión de IP */
HAVING COUNT(*) >= @max_intentos
La lógica es simple y robusta: ¿esta IP falló más de N veces en los últimos M segundos? Los valores por defecto (10 intentos en 60 segundos) se calibraron contra ataques reales.
5.2 — La calibración del umbral importa
La diferencia entre bloquear bots y no molestar usuarios reales está en el umbral:
| Origen | Comportamiento | ¿Cruza 10 en 60s? |
|---|---|---|
| Atacante automatizado | 60-900 intentos a ~1.3/seg | Sí, en ~8 segundos |
| Ser humano o equivalente | 2-4 intentos y se detiene | No |
Cualquier IP que dispare 10 intentos en 60 segundos es un bot por definición. Un humano real no alcanza esa velocidad. Por eso el bloqueo automático puede ser agresivo (incluso permanente) sin riesgo de afectar usuarios legítimos.
5.3 — El bloqueo: el LOGON trigger
El trigger se ejecuta antes de establecer la sesión. Aquí están las decisiones de diseño que lo hacen seguro y eficiente:
CREATE TRIGGER trg_autoblocker_logon
ON ALL SERVER
FOR LOGON
AS
BEGIN
BEGIN TRY
DECLARE @client_ip VARCHAR(48)
SET @client_ip = CAST(CONNECTIONPROPERTY('client_net_address') AS VARCHAR(48))
-- SIEMPRE permitir: localhost, red local, VPN
IF @client_ip IN ('127.0.0.1', '<local>', '::1')
RETURN
IF @client_ip LIKE '10.%' -- red local
RETURN
IF @client_ip LIKE '25.%' -- rango VPN (clientes legítimos)
RETURN
-- ¿Trigger activo? (permite modo pánico)
DECLARE @activo BIT
SELECT @activo = CAST(config_value AS BIT)
FROM master.dbo.autoblocker_config
WHERE config_key = 'trigger_activo'
IF ISNULL(@activo, 0) = 0
RETURN
-- ¿IP bloqueada y no expirada?
IF EXISTS (
SELECT 1 FROM master.dbo.blocked_ips
WHERE ip_address = @client_ip
AND is_active = 1
AND expires_at > GETDATE()
)
BEGIN
RAISERROR('Conexion bloqueada por Auto-Blocker.', 16, 1)
ROLLBACK
END
END TRY
BEGIN CATCH
IF ERROR_NUMBER() = 50000
BEGIN
RAISERROR('Conexion bloqueada por Auto-Blocker.', 16, 1)
ROLLBACK
END
-- Cualquier OTRO error: fail-open (permitir conexion)
END CATCH
END
Tres decisiones clave en este trigger:
1. Usa CONNECTIONPROPERTY, no EVENTDATA(). La mayoría de los ejemplos publicados extraen la IP parseando el XML de EVENTDATA() en cada login, lo que añade sobrecarga de CPU en cada conexión. CONNECTIONPROPERTY('client_net_address') es una lectura directa, mucho más liviana.
2. Bloquea por IP, no por rol. Un error común en ejemplos publicados es escribir IF IS_SRVROLEMEMBER('sysadmin') = 1 RETURN; al inicio. Eso deja pasar a cualquiera que se autentique como sysadmin antes de revisar el bloqueo — pero el ataque es justamente contra sa, que es sysadmin. Si el atacante adivina la contraseña, ese diseño lo deja entrar. Bloquear por IP cierra ese agujero.
3. Diseño fail-open. Si ocurre cualquier error inesperado dentro del trigger, el CATCH permite la conexión. Es preferible dejar pasar una IP maliciosa por error que bloquear a todos los usuarios legítimos, incluido el administrador.
06 / SEGURIDADEl riesgo más grande, resuelto
El peligro real de cualquier LOGON trigger es dejar a todos afuera, incluido el administrador. Este sistema lo mitiga en cinco niveles:
- localhost siempre pasa — el trigger nunca bloquea
127.0.0.1ni::1. - Exclusión de subredes — las redes locales (
10.x) y de VPN se excluyen tanto en el detector como en el trigger. - Diseño fail-open — un error en el trigger permite la conexión, no la bloquea.
- Modo pánico — un procedimiento desactiva todo de un solo comando.
- Conexión de emergencia vía DAC — la Dedicated Admin Connection no dispara LOGON triggers.
CREATE PROCEDURE dbo.sp_autoblocker_panic
AS
BEGIN
UPDATE dbo.autoblocker_config SET config_value = '0'
WHERE config_key = 'trigger_activo'
UPDATE dbo.blocked_ips SET is_active = 0,
unblocked_by = 'PANIC_MODE', unblocked_at = GETDATE()
WHERE is_active = 1
END
Si todo lo demás falla, siempre es posible entrar y desactivar el sistema por el canal de administración dedicado:
sqlcmd -S 127.0.0.1 -A -U sa -P tupassword
EXEC master.dbo.sp_autoblocker_panic
Este último punto es la red de seguridad definitiva: por diseño de SQL Server, el DAC ignora los LOGON triggers, así que es imposible quedar bloqueado de forma permanente.
07 / OBJECIONESRespondiendo las críticas de frente
Un sistema de seguridad serio debe anticipar sus críticas. Estas son las objeciones legítimas y cómo el diseño las atiende:
«Un LOGON trigger no para el ataque a nivel de red, consume recursos.»
Correcto. Por eso este es un sistema de Capa 2, complementario al firewall, no su reemplazo. Su valor es defender el motor en los momentos en que la capa de red falla. No está pensado para absorber un ataque volumétrico — para eso está el firewall.
«Parsear EVENTDATA() en cada login degrada el rendimiento.»
Por eso este diseño usa CONNECTIONPROPERTY('client_net_address'), una lectura directa, en lugar de parsear XML en cada conexión.
«El administrador puede quedar bloqueado y recuperar el acceso requiere modo -m con downtime.»
Por eso existen cinco capas de protección, incluido el modo pánico y el DAC, que por diseño ignora los triggers. Recuperar el acceso es inmediato, sin reiniciar el servicio.
«Publicar el código le dice al atacante cómo desarmarlo.»
Para desactivar el sistema hace falta acceso sysadmin al motor. Si el atacante ya lo tiene, no necesita fuerza bruta — ya ganó. Esto es el Principio de Kerckhoffs: un sistema debe ser seguro aunque el atacante conozca cómo funciona. fail2ban es open source y sigue siendo efectivo en millones de servidores.
08 / COMPARACIÓNFrente a todo lo demás
| Característica | Firewall | fail2ban | CodeProject | Trigger manual | Este sistema |
|---|---|---|---|---|---|
| Detección automática | — | Sí | Sí | No | Sí |
| Bloqueo en el motor | No | No | No | Sí | Sí |
Sin xp_cmdshell | Sí | Sí | No | Sí | Sí |
| Sin PowerShell | Sí | Sí | No | Sí | Sí |
| Sin dependencias externas | No | No | No | Sí | Sí |
| Detección por comportamiento | No | Sí | Sí | No | Sí |
| Auto-expiración | — | Sí | No | No | Sí |
| Modo pánico / recuperación | — | — | No | No | Sí |
| Windows y Linux | Depende | Solo Linux | Solo Win | Ambos | Ambos |
| Auditoría integrada | — | Parcial | Parcial | No | Sí |
09 / CONCLUSIÓNLa segunda capa que casi nadie tiene
Las piezas individuales de este sistema — LOGON triggers, xp_readerrorlog, SQL Agent — existen y están documentadas desde 2005-2009. La contribución de este trabajo no es inventar una característica nueva del motor, sino combinarlas en una implementación nativa y auto-contenida que no depende de ningún componente externo: ni PowerShell, ni xp_cmdshell, ni Windows Firewall, ni fail2ban.
Todas las soluciones publicadas dependen de al menos un componente externo. Este es el primer ciclo cerrado de detección y bloqueo enteramente en T-SQL: detecta, bloquea, registra y expira, sin salir nunca del SQL Server.
No reemplaza al firewall. Es la segunda capa — la que defiende al motor en el momento exacto en que la primera capa falla. Y en un mundo donde demasiados SQL Server quedan expuestos por error de configuración, esa segunda capa puede ser la diferencia entre un log lleno de intentos rechazados y una base de datos comprometida.


