Vulnerabilidades web (OWASP Top 10)
"Toda input es maliciosa hasta que se demuestre lo contrario." — regla de oro del desarrollo web seguro.
Qué vas a aprender en este capítulo
OWASP (Open Web Application Security Project) publica cada pocos años el Top 10 de las vulnerabilidades más críticas en aplicaciones web. Este capítulo cubre cada una con ejemplos reales de ataque y cómo prevenirla.
3.1 OWASP Top 10 (2021)
📐 Fundamento
| # | Categoría | Ejemplos |
|---|---|---|
| A01 | Broken Access Control | IDOR, bypass de autorización |
| A02 | Cryptographic Failures | Datos sensibles sin cifrar, TLS débil |
| A03 | Injection | SQLi, XSS, command injection |
| A04 | Insecure Design | Falla en el diseño, no en la implementación |
| A05 | Security Misconfiguration | Config por defecto, pantallas de error verbosas |
| A06 | Vulnerable Components | Librerías con CVEs conocidas (log4j) |
| A07 | Identification & Authentication Failures | Credential stuffing, sesiones débiles |
| A08 | Software & Data Integrity Failures | Dependencias sin verificar, deserialización insegura |
| A09 | Security Logging & Monitoring | No detectar ataques, logs ausentes |
| A10 | Server-Side Request Forgery (SSRF) | El servidor hace requests no validados |
3.2 SQL Injection (A03)
💡 Intuición
Si concatenás input del usuario en una query SQL, el atacante puede "escapar" del input y agregar SQL propio.
SELECT * FROM users WHERE email = 'ana@example.com'
↓ atacante escribe:
SELECT * FROM users WHERE email = '' OR '1'='1' --'
→ siempre verdadero → devuelve TODOS los usuarios
📐 Fundamento
Ataque clásico — bypass de login:
# Código vulnerable
def login(email, password):
query = f"SELECT * FROM users WHERE email='{email}' AND password='{password}'"
return db.execute(query)
# Atacante ingresa:
# email: admin@example.com'--
# password: cualquier_cosa
#
# Query resultante:
# SELECT * FROM users WHERE email='admin@example.com'--' AND password='cualquier_cosa'
# ↑ comentario, ignora el resto
# → entra como admin sin contraseña
Ataque de extracción de datos (UNION):
GET /platillos?categoria=pupusa' UNION SELECT email, password_hash FROM users--
→ devuelve los emails y hashes de todos los usuarios mezclados con los platillos
Solución 1 — Prepared Statements (la única solución correcta):
# BIEN — el motor de BD trata los parámetros como datos, nunca como código
def login(email, password):
query = "SELECT * FROM users WHERE email = %s AND password = %s"
return db.execute(query, (email, password))
# Aunque el atacante envíe "admin'--", se busca literalmente ese texto en la BD
Solución 2 — ORM (también seguro si se usa correctamente):
from sqlalchemy.orm import Session
def login(email, password):
user = session.query(User).filter(User.email == email).first()
# SQLAlchemy parametriza automáticamente
Cuidado — el ORM puede ser inseguro si usás raw SQL:
# MAL — concatenación dentro del ORM
session.execute(f"SELECT * FROM users WHERE email = '{email}'") # vulnerable
# BIEN
session.execute("SELECT * FROM users WHERE email = :email", {"email": email})
Defensa en profundidad:
- Validar inputs (formato esperado).
- Mínimo privilegio en BD: el usuario de la app no debe tener permisos de DROP TABLE.
- WAF (Web Application Firewall) detecta patrones de SQLi conocidos.
3.3 Cross-Site Scripting (XSS)
💡 Intuición
XSS: inyectar JavaScript en una página que será ejecutado por otros usuarios.
Si el sistema permite que un usuario malicioso publique en el chat: <script>fetch('/api/borrar-cuenta', {method:'POST'})</script> y otros usuarios ven ese mensaje, su navegador ejecuta ese JavaScript y borra su propia cuenta.
📐 Fundamento
Tipos de XSS:
Reflected XSS: el script viene en la URL/request y se refleja en la respuesta.
URL: https://la-esquina.com/buscar?q=<script>alert('XSS')</script>
Si la página muestra: "Resultados para: <script>alert('XSS')</script>"
→ el navegador ejecuta el script
Stored XSS: el script se guarda en la BD y se ejecuta cada vez que alguien lo ve.
Comentario en una reseña de pupusa:
<img src=x onerror="fetch('/api/admin/borrar-todo', {method:'POST'})">
→ Si un admin ve el comentario, se borra todo
DOM-based XSS: el script se ejecuta por código JavaScript del cliente sin pasar por el servidor.
// Código vulnerable en el cliente
document.getElementById('saludo').innerHTML = "Hola " + getParameterByName('nombre');
// Si la URL es ?nombre=<img src=x onerror=alert(1)> → XSS
Prevención: codificar (escape) outputs según el contexto:
# Backend Python con Jinja2 (Flask, Django) — escape automático
{{ comentario.texto }} # automáticamente escapa <, >, ", ', &
# Si necesitás HTML raw, ser muy explícito:
{{ comentario.texto | safe }} # PELIGROSO si el contenido viene de usuarios
// Frontend — usar APIs seguras
element.textContent = userInput; // SEGURO
element.innerHTML = userInput; // PELIGROSO
// Si necesitás HTML, sanitizar con DOMPurify
const limpio = DOMPurify.sanitize(userInput);
element.innerHTML = limpio;
Content Security Policy (CSP): instrucción al navegador de qué scripts puede ejecutar.
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.la-esquina.com
Esto le dice al navegador: "Solo ejecutá scripts del mismo origen o del CDN. Bloqueá todo inline." → la mayoría de XSS ya no funciona aunque exista la inyección.
3.4 CSRF — Cross-Site Request Forgery
💡 Intuición
CSRF: hacer que el navegador del usuario haga una acción sin que él lo sepa, aprovechando que está logueado.
Estás logueado en la-esquina.com. Visitás un sitio malicioso que tiene oculto:
<img src="https://la-esquina.com/api/transferir?monto=10000&destino=atacante">
Tu navegador hace el request, incluyendo tu cookie de sesión → la transferencia se ejecuta como si fueras vos.
📐 Fundamento
Defensas contra CSRF:
1. Tokens CSRF:
El servidor genera un token aleatorio por sesión. Cada formulario debe incluirlo. El servidor verifica que el token coincida.
<form action="/transferir" method="POST">
<input type="hidden" name="csrf_token" value="aleatorio_único_por_sesión">
<input name="monto"><input name="destino">
<button>Transferir</button>
</form>
# Django/Flask manejan esto automáticamente
@csrf_protect
def transferir(request):
# Verifica el token automáticamente; rechaza si no coincide
pass
El sitio malicioso no conoce el token (no puede leer la cookie por la same-origin policy) → no puede falsificar el form.
2. Cookies SameSite:
# En el backend al setear la cookie
response.set_cookie(
'sesion_id', token,
httponly=True, # JS no puede leerla → previene XSS robarla
secure=True, # solo HTTPS
samesite='Strict' # NO se envía en requests cross-origin → previene CSRF
)
SameSite=Strict significa: la cookie solo se envía si el request viene del mismo sitio. El sitio malicioso no puede usar tu sesión.
SameSite=Lax (default moderno): se envía en navegación de nivel superior (clics en links) pero no en requests automáticos (img, fetch).
3. Verificar Origin o Referer header:
@app.before_request
def check_origin():
if request.method == 'POST':
origin = request.headers.get('Origin') or request.headers.get('Referer', '')
if not origin.startswith('https://la-esquina.com'):
abort(403)
3.5 SSRF — Server-Side Request Forgery (A10)
📐 Fundamento
SSRF: el atacante hace que el servidor haga requests a URLs que el atacante elige.
# Endpoint vulnerable: descargar imagen de URL
@app.route('/descargar')
def descargar():
url = request.args.get('url')
return requests.get(url).content
El atacante puede usar esto para:
?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
→ AWS metadata endpoint, devuelve credenciales del servidor
?url=http://localhost:8080/admin
→ acceder a servicios internos no expuestos a internet
?url=file:///etc/passwd
→ leer archivos del servidor
Prevención:
import ipaddress
from urllib.parse import urlparse
import socket
ALLOWED_HOSTS = ['cdn.la-esquina.com', 'images.example.com']
def url_segura(url: str) -> bool:
parsed = urlparse(url)
# 1. Solo HTTPS
if parsed.scheme not in ('https',):
return False
# 2. Whitelist de hosts permitidos
if parsed.hostname not in ALLOWED_HOSTS:
return False
# 3. Resolver el hostname y verificar que NO sea IP privada
try:
ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
if ip.is_private or ip.is_loopback or ip.is_link_local:
return False
except (socket.gaierror, ValueError):
return False
return True
@app.route('/descargar')
def descargar():
url = request.args.get('url')
if not url_segura(url):
abort(400, 'URL no permitida')
return requests.get(url, timeout=5).content
3.6 IDOR — Insecure Direct Object Reference (A01)
📐 Fundamento
IDOR: acceder a recursos cambiando un ID en la URL sin verificación de autorización.
Usuario ana visita: /api/factura/12345 ← su factura
Atacante prueba: /api/factura/12346 ← factura de otro cliente
/api/factura/12347
...
Si el endpoint solo verifica login pero no que el recurso pertenezca al usuario: el atacante descarga todas las facturas.
Prevención:
@app.route('/api/factura/<int:id>')
@requiere_login
def ver_factura(id):
factura = db.get_factura(id)
# SIEMPRE verificar que el recurso pertenezca al usuario actual
if factura.cliente_id != g.user.id and not g.user.is_admin:
abort(403) # NO 404 — eso confirmaría que existe; mejor 403 genérico
return factura
Mejor aún — IDs no enumerables (UUIDs):
# En lugar de IDs secuenciales (1, 2, 3...) que se pueden adivinar:
factura.id = uuid.uuid4() # ej: f47ac10b-58cc-4372-a567-0e02b2c3d479
# El atacante no puede simplemente probar /api/factura/1, /api/factura/2...
Pero atención: UUIDs no reemplazan la verificación de autorización. Son una capa adicional de defensa, no la principal.
3.7 Otros ataques importantes
📐 Fundamento
Command Injection:
# MAL
import os
def imprimir_archivo(nombre):
os.system(f"lp {nombre}")
# Atacante: nombre = "factura.pdf; rm -rf /"
# → ejecuta el comando malicioso
# BIEN — pasar args como lista, sin shell
import subprocess
subprocess.run(['lp', nombre]) # nombre tratado literalmente
Path Traversal:
# MAL — el atacante puede usar "../" para salir del directorio
def descargar_factura(nombre):
with open(f"/var/facturas/{nombre}") as f:
return f.read()
# Atacante: nombre = "../../etc/passwd"
# BIEN — validar que el path resuelto esté dentro del directorio permitido
import os
def descargar_factura(nombre):
base = "/var/facturas"
path = os.path.normpath(os.path.join(base, nombre))
if not path.startswith(base + os.sep):
abort(400)
with open(path) as f:
return f.read()
Insecure Deserialization (A08):
Pickle, YAML, etc. pueden ejecutar código arbitrario al deserializar.
import pickle
# MAL — pickle ejecuta código durante load
data = pickle.loads(input_del_usuario) # ¡EJECUCIÓN DE CÓDIGO ARBITRARIO!
# BIEN — usar formatos seguros
import json
data = json.loads(input_del_usuario)
Vulnerable Components (A06):
Log4Shell (CVE-2021-44228), 2021: una vulnerabilidad en log4j (librería de logging de Java) permitía ejecución remota de código. Afectó miles de empresas.
Mitigación:
- Mantener dependencias actualizadas.
- Escanear vulnerabilidades automáticamente:
npm audit,pip-audit, Snyk, Dependabot. - Tener un proceso de respuesta rápida cuando aparece un CVE crítico.
# Python
pip install pip-audit
pip-audit
# JavaScript
npm audit
npm audit fix
3.8 Ejercicios
✏️ Ejercicio 3.1 — Encontrar vulnerabilidades
Para cada fragmento de código, identificá la vulnerabilidad OWASP y propone la corrección:
# A
@app.route('/buscar')
def buscar():
q = request.args.get('q')
return render_template('resultados.html', termino=q)
# En el template: <h1>Resultados para: {{ termino|safe }}</h1>
# B
@app.route('/api/usuario/<int:id>')
@requiere_login
def get_usuario(id):
return jsonify(db.get_user(id))
# C
@app.route('/login', methods=['POST'])
def login():
email = request.form['email']
pwd = request.form['password']
user = db.execute(f"SELECT * FROM users WHERE email='{email}' AND pwd='{pwd}'")
if user:
session['user_id'] = user.id
Solución
A — Stored/Reflected XSS (A03): El template usa |safe que desactiva el escape automático. Si el usuario busca <script>...</script>, se ejecuta. Fix: quitar |safe y dejar que Jinja escape automáticamente.
B — IDOR (A01): Cualquier usuario logueado puede ver datos de cualquier otro pasando su ID. Fix:
def get_usuario(id):
if id != g.user.id and not g.user.is_admin:
abort(403)
return jsonify(db.get_user(id))
C — SQL Injection (A03): Concatenación directa de inputs. Fix:
user = db.execute("SELECT * FROM users WHERE email=%s AND pwd=%s", (email, pwd))
# Y además: hashear contraseñas con bcrypt, no guardarlas planas
3.9 Para profundizar
- OWASP Top 10 (owasp.org/Top10) — lista oficial actualizada.
- OWASP Cheat Sheet Series — guías concretas por tema.
- PortSwigger Web Security Academy (free, incluye labs interactivos).
- Siguiente: Seguridad de redes y sistemas.
3.X Errores comunes
⚠️ Trampa común
Escapar HTML donde el contexto no es HTML. htmlspecialchars() o escape() previene XSS solo en contexto HTML body. Dentro de un <script>, dentro de un atributo onerror=, o dentro de una URL en href=javascript:, escapar HTML no es suficiente: necesitás escape específico del contexto (JS string escape, URL encode).
Tip: la regla es "output encoding by context". Para frontend, no escapes a mano: usá frameworks (React, Vue) que lo hacen por vos. Si tenés que generar HTML server-side, usá un templating engine que sepa el contexto (Jinja2 con autoescape, etc.).
⚠️ Trampa común
Sanitizar input en lugar de validar/escapar al output. "Voy a remover los <script> del input para prevenir XSS." Mal: el atacante usa <scr<script>ipt> (después de remover el inner, queda <script>). O codifica: <script> que después de un htmlentities_decode vuelve a ser activo.
Tip: valid input, escape output — guardá el input tal cual (después de validar tipo y largo), y escapá al renderizar según el contexto. Sanitizar es válido para Rich Text (DOMPurify), no para evitar inyecciones.
Definiciones nuevas: OWASP Top 10, SQL Injection, Prepared Statement, XSS (reflected/stored/DOM), CSP, CSRF, SameSite cookies, SSRF, IDOR, Command Injection, Path Traversal, Insecure Deserialization, CVE, Log4Shell, dependency scanning.