Seguridad en Java: los fallos más comunes y cómo evitarlos

La seguridad en Java no depende de trucos raros: casi todos los fallos graves salen de las mismas siete u ocho costumbres. Aquí están, con lo que puede pasar, explicado sin recetas de ataque, y con código que se defiende, compilado y ejecutado con Java 25. Si estás aprendiendo, haz antes el curso de Java.

  1. Inyección SQL: siempre PreparedStatement
  2. Deserialización: nunca de datos ajenos, y con filtro
  3. XML: desactiva las entidades externas (XXE)
  4. Rutas de archivo: que nadie salga de su carpeta
  5. Números aleatorios: Random no es para secretos
  6. Contraseñas: nunca guardadas, solo su hash con sal
  7. Dependencias: la lección de Log4Shell
  8. Errores: la pila, para ti; el mensaje amable, para el usuario

1. Inyección SQL: siempre PreparedStatement

El error clásico es armar la consulta pegando texto que viene de fuera: "SELECT * FROM usuarios WHERE correo = '" + correo + "'". Si ese texto trae comillas y fragmentos de SQL, la base de datos los ejecuta como parte de la consulta, y quien lo escribió puede leer o borrar datos que no le tocan. La defensa no es «limpiar» el texto, sino no mezclarlo nunca con el SQL: con PreparedStatement, la consulta lleva huecos ? y los valores viajan por separado, así que la base de datos nunca los interpreta como código.

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;

class Usuarios {
    private final Connection conexion;

    Usuarios(Connection conexion) { this.conexion = conexion; }

    Optional<String> nombrePorCorreo(String correo) throws SQLException {
        String sql = "SELECT nombre FROM usuarios WHERE correo = ?";
        try (PreparedStatement ps = conexion.prepareStatement(sql)) {
            ps.setString(1, correo);                  // el valor nunca se pega al SQL
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? Optional.of(rs.getString("nombre")) : Optional.empty();
            }
        }
    }
}

Los nombres de tabla o de columna no pueden ir en un ?. Si tienen que variar (por ejemplo, ordenar por la columna que elija el usuario), compáralos con una lista cerrada de valores permitidos. Los frameworks como JPA o jOOQ usan consultas preparadas por debajo, pero puedes saltártelas sin querer concatenando texto en una consulta JPQL: la regla es la misma. Tienes un ejemplo completo con base de datos en SQLite con JDBC.

2. Deserialización: nunca de datos ajenos, y con filtro

La serialización de Java convierte objetos en bytes y al revés. El peligro está en la vuelta: ObjectInputStream.readObject() crea los objetos que digan los bytes, de cualquier clase que haya en el programa, y algunas clases de bibliotecas comunes ejecutan código al crearse. Con unos bytes preparados, un atacante puede encadenar esas clases hasta ejecutar órdenes en el servidor. Fue la causa de muchos de los fallos graves de Java de la década pasada.

Lo más seguro es no usar la serialización nativa para nada que venga de fuera: para intercambiar datos, JSON o similares, que solo crean los tipos que tú pides. Si no queda más remedio, desde Java 9 se puede poner un filtro que diga qué clases se aceptan:

import java.io.*;
import java.util.ArrayList;
import java.util.List;

record Pedido(String producto, int cantidad) implements Serializable {}

public class FiltroDeserializacion {
    static byte[] serializar(Object o) throws IOException {
        var bytes = new ByteArrayOutputStream();
        try (var out = new ObjectOutputStream(bytes)) { out.writeObject(o); }
        return bytes.toByteArray();
    }

    static Object leer(byte[] datos) throws Exception {
        // Solo se aceptan Pedido y las clases de java.lang; todo lo demás se rechaza («!*»).
        var filtro = ObjectInputFilter.Config.createFilter("Pedido;java.lang.*;!*");
        try (var in = new ObjectInputStream(new ByteArrayInputStream(datos))) {
            in.setObjectInputFilter(filtro);
            return in.readObject();
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.println("aceptado: " + leer(serializar(new Pedido("café", 2))));
        try {
            leer(serializar(new ArrayList<>(List.of("algo"))));
        } catch (InvalidClassException e) {
            System.out.println("rechazado: " + e.getMessage());
        }
    }
}
salidaaceptado: Pedido[producto=café, cantidad=2]
rechazado: filter status: REJECTED

El filtro también se puede poner para toda la JVM con la propiedad jdk.serialFilter, sin tocar el código, lo cual es útil para proteger una aplicación heredada mientras se migra a otro formato.

3. XML: desactiva las entidades externas (XXE)

Los analizadores de XML de Java, por compatibilidad con estándares antiguos, aceptan por omisión declaraciones DOCTYPE con entidades externas: referencias que hacen que el analizador lea un archivo del servidor o haga una petición a otra máquina. Un XML preparado puede así sacar el contenido de archivos internos o atacar servicios de la red interna (es el ataque XXE). Si tu aplicación recibe XML de fuera, configura el analizador para que rechace cualquier DOCTYPE:

import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;

DocumentBuilderFactory fabricaSegura() throws Exception {
    var f = DocumentBuilderFactory.newInstance();
    f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
    f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
    f.setXIncludeAware(false);
    f.setExpandEntityReferences(false);
    return f;
}

void leer(String xml) {
    try {
        var lector = fabricaSegura().newDocumentBuilder();
        lector.setErrorHandler(null);   // sin esto, además imprime el error por la salida de errores
        var doc = lector.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
        IO.println("leído: " + doc.getDocumentElement().getTextContent());
    } catch (Exception e) {
        IO.println("rechazado: " + e.getMessage());
    }
}

void main() {
    leer("<pedido>café</pedido>");
    leer("<!DOCTYPE pedido [<!ELEMENT pedido ANY>]><pedido>té</pedido>");
}
salidaleído: café
rechazado: DOCTYPE no está permitido cuando la función "http://apache.org/xml/features/disallow-doctype-decl" se ha definido en true.

El XML normal se lee igual que siempre; cualquier documento con DOCTYPE se rechaza antes de procesar nada. Lo mismo vale para SAXParserFactory, XMLInputFactory y TransformerFactory: cada una tiene sus opciones, y la hoja de referencia de OWASP sobre XXE las lista todas. Los ejemplos de leer XML leen archivos propios; si el XML viene de un usuario, añade esta configuración.

4. Rutas de archivo: que nadie salga de su carpeta

Cuando el nombre de un archivo viene de fuera (una descarga, una imagen subida), un nombre como ../../config/claves.properties puede apuntar fuera de la carpeta que creías. La defensa es resolver la ruta, normalizarla y comprobar que sigue dentro de la carpeta permitida:

import java.nio.file.Path;

final Path BASE = Path.of("/srv/descargas").toAbsolutePath().normalize();

Path resolver(String nombre) {
    Path ruta = BASE.resolve(nombre).normalize();
    if (!ruta.startsWith(BASE)) throw new SecurityException("ruta fuera de la carpeta: " + nombre);
    return ruta;
}

void main() {
    for (String nombre : new String[] {"factura-2026.pdf", "fotos/playa.jpg", "../../etc/passwd"}) {
        try {
            IO.println("bien: " + resolver(nombre));
        } catch (SecurityException e) {
            IO.println("rechazado: " + e.getMessage());
        }
    }
}
salidabien: /srv/descargas/factura-2026.pdf
bien: /srv/descargas/fotos/playa.jpg
rechazado: ruta fuera de la carpeta: ../../etc/passwd

normalize() resuelve los .. y startsWith de Path compara por componentes, no por texto (así /srv/descargas-viejas no pasa por empezar igual). Mejor todavía: no uses el nombre que te mandan; guarda el archivo con un identificador propio y conserva el nombre original solo como dato.

5. Números aleatorios: Random no es para secretos

java.util.Random sirve para juegos y simulaciones, pero es predecible: con la misma semilla da siempre la misma secuencia, y viendo unos pocos valores se pueden calcular los siguientes. Para contraseñas temporales, códigos de verificación o tokens de sesión, usa SecureRandom:

import java.security.SecureRandom;
import java.util.Base64;
import java.util.Random;

void main() {
    var a = new Random(2026);
    var b = new Random(2026);
    IO.println("Random con la misma semilla: " + a.nextInt(1_000_000) + " y " + b.nextInt(1_000_000));

    var seguro = new SecureRandom();
    byte[] bytes = new byte[32];
    seguro.nextBytes(bytes);
    String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    IO.println("token de " + token.length() + " caracteres, distinto en cada ejecución");
}
salidaRandom con la misma semilla: 503799 y 503799
token de 43 caracteres, distinto en cada ejecución

32 bytes de SecureRandom son 256 bits de azar: imposibles de adivinar. No imprimimos el token porque cambia en cada ejecución; la longitud, en cambio, siempre es la misma.

6. Contraseñas: nunca guardadas, solo su hash con sal

Una contraseña nunca se guarda tal cual, ni cifrada (si alguien roba la clave, las roba todas), ni con un hash rápido como SHA-256 (se prueban miles de millones por segundo). Se guarda un hash lento y con sal (unos bytes aleatorios distintos para cada usuario), pensado para contraseñas. Java trae PBKDF2 sin dependencias; Argon2 o bcrypt, con bibliotecas como Spring Security, son todavía mejores:

import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.HexFormat;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

final int ITERACIONES = 600_000;

byte[] hash(char[] clave, byte[] sal) throws Exception {
    var spec = new PBEKeySpec(clave, sal, ITERACIONES, 256);
    try {
        return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
    } finally {
        spec.clearPassword();
    }
}

void main() throws Exception {
    byte[] sal = new byte[16];
    new SecureRandom().nextBytes(sal);
    byte[] guardado = hash("Tlaxcala-2026".toCharArray(), sal);    // esto y la sal van a la base de datos
    IO.println("hash guardado: " + HexFormat.of().formatHex(guardado).length() + " dígitos hexadecimales");

    long t0 = System.nanoTime();
    boolean bien = MessageDigest.isEqual(guardado, hash("Tlaxcala-2026".toCharArray(), sal));
    long ms = (System.nanoTime() - t0) / 1_000_000;
    boolean mal = MessageDigest.isEqual(guardado, hash("tlaxcala-2026".toCharArray(), sal));
    IO.println("contraseña correcta: " + bien + ", con otra mayúscula: " + mal);
    IO.println("comprobar una contraseña tarda decenas de milisegundos: " + (ms >= 20));
}
salidahash guardado: 64 dígitos hexadecimales
contraseña correcta: true, con otra mayúscula: false
comprobar una contraseña tarda decenas de milisegundos: true

Las 600,000 iteraciones son las que recomienda OWASP para PBKDF2 con SHA-256: hacen que cada intento cueste decenas de milisegundos, nada para quien inicia sesión y una barbaridad para quien prueba millones de contraseñas robadas. MessageDigest.isEqual compara en tiempo constante, sin dar pistas por lo que tarda. Y la contraseña va en un char[], que se puede borrar de la memoria, y no en un String, que no.

7. Dependencias: la lección de Log4Shell

En diciembre de 2021 se descubrió Log4Shell (CVE-2021-44228), un fallo en Log4j 2, la biblioteca de registros más usada de Java. Bastaba con que una aplicación escribiera en el registro un texto controlado por el atacante (un nombre de usuario, la cabecera de un navegador) para que Log4j interpretara una búsqueda especial dentro del texto, descargara código de otro servidor y lo ejecutara. La Apache Software Foundation le dio la puntuación máxima de gravedad, 10.0 sobre 10. Afectaba a las versiones de la 2.0-beta9 a la 2.14.1; para Java 8 o posterior se corrigió en la 2.15.0, y las 2.16.0 y 2.17.0 cerraron los fallos relacionados que aparecieron en los días siguientes.

El problema no era el código de cada aplicación, sino una dependencia que casi nadie sabía que tenía (muchas veces llegaba como dependencia de otra dependencia). Lo que hay que hacer:

  • Saber qué usas: mvn dependency:tree o gradle dependencies listan todas las dependencias, también las indirectas.
  • Revisarlas solo: herramientas como OWASP Dependency-Check, Dependabot de GitHub o Renovate avisan cuando una dependencia tiene un fallo publicado.
  • Actualizar el JDK: las versiones LTS reciben parches de seguridad cada tres meses, en enero, abril, julio y octubre. Mira la página de versiones.
  • No registrar datos que no hacen falta, y menos aún contraseñas o tokens.

8. Errores: la pila, para ti; el mensaje amable, para el usuario

Una pila de llamadas (stack trace) dice qué bibliotecas y versiones usas, cómo se llaman tus clases y a veces trozos de consultas o rutas: información valiosa para preparar un ataque. Al usuario, un mensaje genérico con un identificador; la pila completa, al registro:

int errores = 0;

void registrar(String id, Exception e) {
    // En una aplicación real, esto va al archivo de registro (con la pila completa), no a la pantalla.
    IO.println("[registro] " + id + " " + e);
}

String atender(String pedido) {
    try {
        if (pedido.isBlank()) throw new IllegalArgumentException("pedido vacío en ServicioPedidos.procesar()");
        return "pedido recibido";
    } catch (RuntimeException e) {
        String id = "E-%04d".formatted(++errores);   // en producción, mejor un UUID
        registrar(id, e);
        return "No pudimos procesar tu pedido (referencia " + id + ").";
    }
}

void main() {
    IO.println(atender("café de olla"));
    IO.println(atender(""));
}
salidapedido recibido
[registro] E-0001 java.lang.IllegalArgumentException: pedido vacío en ServicioPedidos.procesar()
No pudimos procesar tu pedido (referencia E-0001).

Con la referencia, quien atiende al cliente encuentra el error exacto en el registro, y el usuario no ve nada que no deba.

Cada programa de esta página se compiló y se ejecutó con Java 25 LTS (Eclipse Temurin) en un contenedor limpio y sin red; las salidas están copiadas de esa ejecución. El de la consulta SQL solo se compiló, porque necesita una base de datos. Datos de Log4Shell: página de seguridad de Apache Logging.