Tema 19: Seguridad, hardening y cifrado — la muralla del altépetl
#PF-019 | XP: 320 | Insignia: Centinela del Calpulli | Tier: Senior | Tiempo: 4.5 h

Objetivo
Protegerás tu base de datos contra los vectores de ataque más comunes: inyección SQL, conexiones no cifradas, contraseñas débiles, exposición a internet, y datos en reposo sin cifrar. Aplicarás el checklist de CIS Benchmarks a MySQL y PostgreSQL, configurarás TLS, y aprenderás a responder a incidentes de seguridad.
Mapa del tema
Visión mexica — Por Citlalli
El altépetl mexica no era solo una ciudad: era una fortaleza. Tenía murallas (tlalchixco), canales defensivos, guerreros águila y jaguar en los calpixqui de guardia, y un sistema de vigilancia de 24 horas. Pero lo más importante: sabía dónde estaba cada entrada, quién podía cruzarla, y qué había del otro lado.
En bases de datos modernas, el equivalente es la defensa en profundidad: no una sola muralla sino varias capas (red, autenticación, autorización, cifrado, auditoría, respaldos). Si una falla, las demás mantienen al atacante afuera.
Este tema es la muralla. Los temas anteriores te dieron las piedras (usuarios, permisos), este te enseña a colocarlas correctamente.
Bloque 1 — Anatomía de un ataque: inyección SQL y otros vectores
Inyección SQL: el ataque #1
La inyección SQL ocurre cuando la aplicación concatena input del usuario directamente en la query sin sanitizar.
Código vulnerable (PHP):
$nombre = $_GET['nombre'];
$query = "SELECT FROM clientes WHERE nombre = '$nombre'";
$result = $pdo->query($query);Si el atacante envía `?nombre=' OR '1'='1`, la query se convierte en:
SELECT FROM clientes WHERE nombre = '' OR '1'='1'Devuelve todos los clientes. Peor: `?nombre='; DROP TABLE clientes; --` puede borrar la tabla (depende del driver).
Código seguro (con prepared statements):
$stmt = $pdo->prepare("SELECT FROM clientes WHERE nombre = ?");
$stmt->execute([$nombre]);Regla de oro: nunca concatenes input del usuario en SQL. Siempre usa prepared statements (PDO, mysqli prepared, psycopg, etc.).
Vector 2: contraseñas en la cadena de conexión
# Mal
conn = psycopg2.connect("host=db user=app password=MiClaveSecreta2026! dbname=tienda_tlalli")
Si el código se filtra, la contraseña queda visible
Seguro:
import os
conn = psycopg2.connect(
host=os.environ["DB_HOST"],
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
dbname="tienda_tlalli"
)Y en producción, las variables de entorno se cargan desde un vault (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault).
Vector 3: exposición a internet
MySQL y PostgreSQL escuchan por defecto en `0.0.0.0:3306` y `0.0.0.0:5432` respectivamente. Si tu servidor tiene IP pública, está expuesto.
Checklist inmediato:
# ¿Está MySQL escuchando en todas las interfaces?
sudo ss -tlnp | grep 3306
Si dice 0.0.0.0:3306, está expuesto
¿Está PostgreSQL?
sudo ss -tlnp | grep 5432Fix MySQL: editar `my.cnf` y enlazar a localhost:
[mysqld]
bind-address = 127.0.0.1O a la IP interna del servidor:
bind-address = 10.0.0.5Fix PostgreSQL: editar `postgresql.conf`:
listen_addresses = 'localhost' # o '10.0.0.5'Y `pg_hba.conf` para limitar hosts (Tema 18).
Vector 4: backups sin cifrar expuestos
Si tu respaldo se almacena en S3 sin cifrar y el bucket se hace público, todos los datos personales quedan expuestos. Tema 16 ya cubre esto: GPG/openssl en el archivo, cifrado en S3 (SSE-KMS o SSE-S3), bucket con acceso restringido.
Vector 5: dependencias vulnerables
Drivers desactualizados, ORMs con CVEs conocidos, versiones de MySQL/PostgreSQL sin parches. Suscribirse a:
- https://www.cvedetails.com para CVEs de MySQL/PostgreSQL.
- https://www.postgresql.org/support/security/ para parches oficiales.
- https://www.mysql.com/support/security/ para parches de MySQL.
Bloque 2 — TLS/SSL: cifrado en tránsito
Por qué TLS es obligatorio
Sin TLS, la contraseña de tu usuario viaja en texto plano por la red. Un atacante con `tcpdump` o Wireshark en el mismo segmento de red la captura en segundos.
# Captura sin TLS (peligro)
sudo tcpdump -i any -A port 3306 | grep -i password
En 30 segundos ves la contraseña en texto plano
Configurar TLS en MySQL
Generar o obtener certificados:
# Opción 1: Certificados autofirmados (para desarrollo)
openssl req -newkey rsa:2048 -nodes -keyout ca.key -x509 -days 3650 -out ca.pem
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out server.pem -days 3650Opción 2: certificados de Let's Encrypt (gratis) o de una CA interna (producción).
Configurar MySQL (`my.cnf`):
[mysqld]
ssl_ca = /etc/mysql/ssl/ca.pem
ssl_cert = /etc/mysql/ssl/server.pem
ssl_key = /etc/mysql/ssl/server.key
require_secure_transport = ON`require_secure_transport = ON` rechaza cualquier conexión no TLS.
Forzar TLS para un usuario específico:
ALTER USER 'app_tienda'@'localhost' REQUIRE SSL;Verificar desde el cliente:
mysql -u app_tienda -p --ssl-mode=REQUIRED -h localhost
mysql> SHOW STATUS LIKE 'Ssl_cipher';
-- Esperado: una cifra como TLS_AES_256_GCM_SHA384Configurar TLS en PostgreSQL
`postgresql.conf`:
ssl = on
ssl_cert_file = '/etc/postgresql/ssl/server.crt'
ssl_key_file = '/etc/postgresql/ssl/server.key'
ssl_ca_file = '/etc/postgresql/ssl/ca.crt'`pg_hba.conf` (cambiar método):
hostssl tienda_tlalli all 0.0.0.0/0 scram-sha-256
hostssl fuerza TLS para esta regla
Verificar:
psql "host=localhost dbname=tienda_tlalli user=app_tienda sslmode=require"
postgres=# SHOW ssl;
-- Esperado: onBloque 3 — Cifrado en reposo
Cifrado en reposo protege los datos si el disco es robado, el servidor es decomisado, o el backup se filtra.
Opciones por motor
| Motor | Cifrado nativo | Cifrado a nivel OS | Cifrado en cloud | |---|---|---|---| | MySQL 8 Community | No (TDE es Enterprise) | LUKS, BitLocker, eCryptfs | AWS RDS encryption, Azure TDE | | MySQL 8 Enterprise | TDE (tablespace) | LUKS, BitLocker | Igual | | MariaDB | Tablespace encryption (open source) | LUKS, BitLocker | Igual | | PostgreSQL | pgcrypto (datos específicos), TDE en forks | LUKS, BitLocker | AWS RDS encryption, Azure TDE |
Cifrado a nivel OS con LUKS (Linux)
# Verificar si la partición está cifrada
lsblk -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE
Si TYPE dice "crypt", está cifrada
Crear volumen cifrado (ejemplo con /dev/sdb)
sudo cryptsetup luksFormat /dev/sdb
sudo cryptsetup open /dev/sdb data_crypt
sudo mkfs.ext4 /dev/mapper/data_crypt
sudo mount /dev/mapper/data_crypt /var/lib/mysqlCifrado de columnas específicas con pgcrypto (PostgreSQL)
-- Instalar extensión
CREATE EXTENSION IF NOT EXISTS pgcrypto;-- Crear tabla con columna cifrada
CREATE TABLE clientes_seguros (
cliente_id SERIAL PRIMARY KEY,
nombre TEXT,
email BYTEA, -- Cifrado
telefono BYTEA
);
-- Insertar con cifrado
INSERT INTO clientes_seguros (nombre, email, telefono) VALUES
('Xochitl Hernández Ruiz',
pgp_sym_encrypt('xochitl@example.com', 'ClaveMaestra_2026!'),
pgp_sym_encrypt('+52 55 1234 5678', 'ClaveMaestra_2026!'));
-- Leer descifrando
SELECT nombre, pgp_sym_decrypt(email, 'ClaveMaestra_2026!') AS email
FROM clientes_seguros;
Trade-off: el cifrado por columna impide hacer `WHERE email = 'xochitl@example.com'` directamente. Se cifra en la app o se usan técnicas como `pgp_sym_encrypt` con índice sobre un hash determinístico.
Cifrado en AWS RDS
Si tu base está en RDS, activa el cifrado en reposo al crear la instancia:
# Terraform
resource "aws_db_instance" "tienda" {
identifier = "tienda-tlalli"
engine = "postgres"
instance_class = "db.t3.micro"
storage_encrypted = true # ← Cifrado en reposo
kms_key_id = aws_kms_key.tienda.arn
# ...
}Bloque 4 — Hardening del servidor: CIS Benchmarks
El CIS (Center for Internet Security) publica benchmarks de configuración segura para MySQL y PostgreSQL. No son obligatorios pero son la línea base que auditan PCI-DSS, ISO 27001 y SOC 2.
CIS MySQL 8.0 Benchmark (resumen de los 30+ controles clave)
sudo cp /etc/mysql/my.cnf /etc/mysql/my.cnf.bak
sudo chown root:root /etc/mysql/my.cnf.bak
sudo chmod 600 /etc/mysql/my.cnf.bak
```2.1 — Deshabilitar el uso de `LOCAL INFILE`:
```ini
[mysqld]
local_infile = 0
```
Previene `LOAD DATA LOCAL INFILE`, vector de exfiltración.2.2 — Limitar `secure_file_priv` (vacío = cualquier path):
```ini
[mysqld]
secure_file_priv = /var/lib/mysql-files/
```2.3 — Deshabilitar `skip-grant-tables` (debe estar comentado o en 0):
```ini
# skip-grant-tables
```
Si está activo, cualquiera entra sin contraseña.3.1 — Eliminar usuarios anónimos:
```sql
DELETE FROM mysql.user WHERE User = '';
FLUSH PRIVILEGES;
```3.2 — Deshabilitar login remoto como root:
```sql
DELETE FROM mysql.user WHERE User = 'root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');
FLUSH PRIVILEGES;
```4.1 — Forzar contraseñas con plugin seguro:
```ini
[mysqld]
default_authentication_plugin = caching_sha2_password
```4.4 — Política de contraseñas (validar longitud y complejidad):
```ini
[mysqld]
validate_password_policy = STRONG
validate_password_length = 12
validate_password_mixed_case_count = 1
validate_password_number_count = 1
validate_password_special_char_count = 1
```5.1 — Limitar `max_connections`:
```ini
[mysqld]
max_connections = 100
```
Previene ataques de agotamiento de conexiones.6.1 — Habilitar log de errores:
```ini
[mysqld]
log_error = /var/log/mysql/error.log
```CIS PostgreSQL 16 Benchmark (resumen)
1.1 — Permisos de los archivos de configuración:
```bash
sudo chown postgres:postgres /etc/postgresql/16/main/postgresql.conf
sudo chmod 600 /etc/postgresql/16/main/postgresql.conf
```1.2 — Permisos del directorio de datos:
```bash
sudo chown postgres:postgres /var/lib/postgresql/16/main
sudo chmod 700 /var/lib/postgresql/16/main
```3.1 — `listen_addresses` solo en local o red interna:
```ini
listen_addresses = 'localhost' # o '10.0.0.5'
```4.1 — `log_connections = on`:
```ini
log_connections = on
log_disconnections = on
log_statement = 'ddl'
```4.2 — `log_min_duration_statement` (log de queries lentas):
```ini
log_min_duration_statement = 1000 # ms
```6.1 — `password_encryption = scram-sha-256` (Tema 18).
6.2 — `pg_hba.conf` estricto (Tema 18).
Bloque 5 — Respuesta a incidentes
Si te atacan: el protocolo de las 6 horas
Hora 0 — Detección: una alerta salta (log de errores inusual, caída de la base, reporte de usuario).
Hora 0:15 — Contención:
Aislar la base: bloquear IPs sospechosas en el firewall.
Cambiar contraseñas de todos los usuarios con permisos altos.
Si hay ransomware: desconectar de la red, no apagar.
Hacer un snapshot forense del estado actual.
Hora 1 — Evaluación:¿Qué datos fueron accedidos? Revisar logs de conexión, log de queries.
¿Qué cuentas fueron comprometidas?
¿Hay persistencia? (usuarios nuevos creados, tareas programadas, triggers sospechosos).
¿Hay exfiltración? (logs de red saliente grandes, archivos .sql o .zip subidos a servicios externos).
Hora 3 — Erradicación:Revocar permisos de cuentas comprometidas.
Eliminar usuarios y backdoors.
Parchar la vulnerabilidad explotada.
Restaurar desde respaldo limpio si hay duda de integridad.
Hora 6 — Recuperación:Restaurar en entorno limpio.
Monitoreo intensificado las siguientes 72 horas.
Verificación de integridad de los datos.
Post-incidente:Documentar timeline detallado.
Análisis de causa raíz.
Acciones correctivas: ¿qué control faltaba?
Comunicado a usuarios si hubo filtración de datos (LFPDPPP en México: aviso en 72 horas).
Recursos forenses
- MySQL: `general_log` (si estaba activo), `slow_query_log`, `audit_log` (Enterprise), `binlog`.
- PostgreSQL: `log_statement`, `pg_stat_statements`, `pgAudit`, `log_min_duration_statement = 0` durante el incidente.
AI Mission
Pídele a tu IA que evalúe la configuración actual de tu base. Prompt sugerido:
Pega aquí tu my.cnf (o postgresql.conf) y pg_hba.conf y hazme
un análisis de seguridad contra los CIS Benchmarks.Dame:
Revisa cada sugerencia. La IA no conoce tu entorno completo (réplicas, otras apps que dependen de la base, etc.).
Errores típicos
Confiar en `localhost` para "no necesita TLS" → dentro de la misma red, un atacante con ARP spoofing puede interceptar.
`require_secure_transport = ON` solo en producción → en desarrollo se filtra la contraseña y la usan en producción.
TLS con certificados autofirmados sin verificar en el cliente → vulnerable a MITM.
Cifrado en reposo sin cifrado de backups → el backup se filtra aunque el disco esté cifrado.
Hardening solo del motor, no del SO → MySQL seguro sobre un Windows sin updates es inseguro.
Confiar en que el ORM te protege de SQL injection → si concatenas strings en algún lugar del código, sigue siendo vulnerable.
LAB práctico — Aplicar CIS Benchmarks a tienda_tlalli
Objetivo: dejar tu MySQL y PostgreSQL con los 10 controles CIS más críticos aplicados, documentados, y verificados.
Paso 1 — Backup de la configuración
bash
sudo cp /etc/mysql/my.cnf /etc/mysql/my.cnf.bak.$(date +%Y%m%d)
sudo cp /etc/postgresql/16/main/postgresql.conf /etc/postgresql/16/main/postgresql.conf.bak.$(date +%Y%m%d)
sudo cp /etc/postgresql/16/main/pg_hba.conf /etc/postgresql/16/main/pg_hba.conf.bak.$(date +%Y%m%d)
sudo chmod 600 /etc//.bak
Paso 2 — Aplicar CIS MySQL
ini
/etc/mysql/my.cnf
[mysqld]1. Deshabilitar local_infile
local_infile = 02. Limitar secure_file_priv
secure_file_priv = /var/lib/mysql-files/3. Autenticación fuerte
default_authentication_plugin = caching_sha2_password4. Validación de contraseñas
validate_password_policy = STRONG validate_password_length = 125. Limitar conexiones
max_connections = 1006. TLS
ssl_ca = /etc/mysql/ssl/ca.pem ssl_cert = /etc/mysql/ssl/server.pem ssl_key = /etc/mysql/ssl/server.key require_secure_transport = ON7. Logs
log_error = /var/log/mysql/error.log slow_query_log = ON long_query_time = 2bash
sudo systemctl restart mysql
Verificar:
sql
SHOW VARIABLES LIKE 'local_infile'; -- 0
SHOW VARIABLES LIKE 'secure_file_priv'; -- /var/lib/mysql-files/
SHOW VARIABLES LIKE 'default_authentication_plugin'; -- caching_sha2_password
SHOW VARIABLES LIKE 'require_secure_transport'; -- ON
SHOW STATUS LIKE 'Ssl_cipher'; -- alguna cifra TLS
Paso 3 — Limpiar usuarios anónimos y root remoto
sql
SELECT user, host FROM mysql.user;
-- Eliminar usuarios anónimos
DELETE FROM mysql.user WHERE User = '';
-- Restringir root a localhost
DELETE FROM mysql.user WHERE User = 'root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');
FLUSH PRIVILEGES;
Paso 4 — Aplicar CIS PostgreSQL
`/etc/postgresql/16/main/postgresql.conf`:
ini
1. Limitar listen_addresses
listen_addresses = 'localhost'2. TLS
ssl = on ssl_cert_file = '/etc/postgresql/ssl/server.crt' ssl_key_file = '/etc/postgresql/ssl/server.key'3. Logging
log_connections = on log_disconnections = on log_statement = 'ddl' log_min_duration_statement = 10004. Passwords
password_encryption = scram-sha-256bash
sudo systemctl restart postgresql
Verificar:
sql
SHOW listen_addresses; -- localhost
SHOW ssl; -- on
SHOW log_connections; -- on
Paso 5 — Forzar TLS en la conexión de la app
PHP:
php
$pdo = new PDO(
"mysql:host=localhost;dbname=tienda_tlalli;charset=utf8mb4",
"app_tienda",
$pwd,
[
PDO::MYSQL_ATTR_SSL_CA => '/etc/mysql/ssl/ca.pem',
PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => true,
]
);
Python:
python
import os
import pymysql
conn = pymysql.connect(
host=os.environ["DB_HOST"],
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
database="tienda_tlalli",
ssl={"ca": "/etc/mysql/ssl/ca.pem"}
)
```Entregables del LAB
- [ ] Backup de todas las configuraciones previas.
- [ ] 10 controles CIS aplicados en MySQL.
- [ ] 6 controles CIS aplicados en PostgreSQL.
- [ ] Conexión de la app verificada con TLS.
- [ ] Documento `HARDENING.md` con la lista de cambios y el antes/después.
Quest
Preguntas de opción múltiple (10)
1. ¿Qué es inyección SQL?
A. Un tipo de índice. B. Inserción de código SQL malicioso en una query a través de input del usuario. C. Una técnica de respaldo. D. Un comando de PostgreSQL.
2. ¿Cómo se previene la inyección SQL?
A. Validando el charset. B. Usando prepared statements. C. Cambiando el puerto de la base. D. Aumentando `max_connections`.
3. ¿Qué hace `require_secure_transport = ON` en MySQL?
A. Requiere autenticación con LDAP. B. Rechaza conexiones que no usen TLS. C. Activa el cifrado en reposo. D. Cambia la contraseña.
4. ¿Qué hace `local_infile = 0`?
A. Deshabilita la carga de archivos locales desde el cliente. B. Activa el modo de respaldo. C. Deshabilita la red. D. Activa la compresión.
5. ¿Qué es LUKS?
A. Un motor de base de datos. B. Una herramienta de cifrado de discos en Linux. C. Un protocolo de red. D. Un ORM.
6. ¿Qué organización publica los CIS Benchmarks?
A. ISO. B. Center for Internet Security. C. IEEE. D. W3C.
7. ¿Qué archivo PostgreSQL controla desde qué hosts se puede conectar un usuario?
A. `postgresql.conf` B. `pg_hba.conf` C. `pg_ident.conf` D. `recovery.conf`
8. ¿Qué ley mexicana obliga a avisar a los usuarios en caso de filtración de datos?
A. Ley Federal del Trabajo. B. Ley Federal de Protección de Datos Personales en Posesión de los Particulares (LFPDPPP). C. Ley de Telecomunicaciones. D. Ley Federal de Derechos de Autor.
9. ¿Cuál es la primera acción en respuesta a un incidente de seguridad?
A. Restaurar el respaldo. B. Cambiar todas las contraseñas. C. Contener: aislar la base y bloquear IPs sospechosas. D. Llamar a la policía.
10. ¿Qué extensión PostgreSQL permite cifrar columnas específicas?
A. `pgcrypto` B. `pg_stat_statements` C. `uuid-ossp` D. `postgis`
Preguntas abiertas (3)
A. Un auditor de PCI-DSS te pide evidencia de que tu base cumple con los CIS Benchmarks. Lista 5 configuraciones que tendrías que mostrarle y cómo las verificas.
B. Tienes una base PostgreSQL en AWS RDS. ¿Cómo activas el cifrado en tránsito y en reposo? ¿Es posible cambiar el cifrado en una instancia existente?
C. Descubres que un usuario con `app_tienda` ejecutó `DROP TABLE clientes` por una inyección SQL. Describe las 4 acciones inmediatas (hora 0 a hora 1) que tomarías.
Respuestas modelo
1. B — Inyección SQL es insertar SQL malicioso en input del usuario. La A, C, D son inventos.
2. B — Prepared statements separan código de datos, haciendo imposible la inyección. Validar charset no evita SQLi. Cambiar puerto es seguridad por oscuridad. `max_connections` no tiene relación.
3. B — `require_secure_transport` rechaza conexiones no TLS. La A es LDAP (no existe built-in). La C es diferente (cifrado en reposo). La D no es eso.
4. A — `local_infile = 0` deshabilita `LOAD DATA LOCAL INFILE`, vector de exfiltración. La B, C, D no son eso.
5. B — LUKS (Linux Unified Key Setup) cifra discos/particiones en Linux. La A, C, D son inventos.
6. B — Center for Internet Security. La A es ISO. La C es IEEE. La D es W3C.
7. B — `pg_hba.conf` (Tema 18). La A es configuración general. La C es mapeo de usuarios. La D ya no se usa.
8. B — LFPDPPP. La A es laboral. La C es telecom. La D es propiedad intelectual.
9. C — Contención: aislar la base, bloquear IPs, cambiar contraseñas. Restaurar es posterior. La D no es una acción técnica.
10. A — `pgcrypto` ofrece `pgp_sym_encrypt` y `pgp_sym_decrypt`. Las otras son para otras funciones.
Tras las huellas — Por Citlalli
Los guerreros águila y jaguar del ejército mexica no solo peleaban: vigilaban. Patrullaban los muros del altépetl, reconocían陌生人 (desconocidos), y reportaban cualquier movimiento inusual al calpixqui de guardia. Su trabajo no era la batalla: era la disuasión. Si los atacantes sabían que había vigilancia continua, preferían atacar otro altépetl.
La seguridad de bases de datos funciona igual: la auditoría constante, el monitoreo, el hardening visible disuaden al atacante promedio. El 80% de los ataques a bases son automatizados, contra blancos fáciles. Si tu configuración tiene TLS, contraseñas fuertes, sin puertos expuestos, sin usuarios anónimos, sin `local_infile`, el script automatizado pasa de largo y va al siguiente servidor.
Sabiduría mexica: la mejor batalla es la que no peleas. La mejor intrusión es la que nunca ocurre.