root@kali:~$ cat hackthebox/challenges/hardware/sneak-peek.md
Hack The Box Challenge — Sneak peek
A custom Modbus/TCP service exposes PLC memory writes, allowing a stored MD5 password digest to be replaced and authentication bypassed safely.

- Platform
- Hack The Box
- Type
- challenge
- Difficulty
- Easy
- Category
- Hardware
- Solved
- 19 Sept 2026
- Published
- 19 September 2026
Authorized-lab notice: This write-up documents the retired Hack The Box Challenge Sneak peek. Its retired status was explicitly confirmed before publication on 19 September 2026. The real flag, ephemeral target address, personal test values and unrelated sensitive information have been removed. The original challenge package is not redistributed.
More retired Hack The Box material is available in the write-ups archive and the focused Challenges archive.
Executive summary
Sneak peek presents a network-accessible PLC service that implements a small proprietary protocol inside Modbus/TCP. The supplied protocol diagram documents operations for reading a 16 KiB memory block, writing arbitrary bytes into that block and requesting a protected secret. Authentication is based on an MD5 password digest stored inside the same writable memory.
The important weakness is not MD5 cracking. The service gives an unauthenticated client both the read primitive needed to locate the stored 16-byte digest and the write primitive needed to replace it. A full memory read identified one isolated 16-byte non-zero entry at offset 0x0400. Replacing that value with the MD5 digest of a known password made the secret request succeed. The solver then restored the original digest and verified the restoration byte for byte.
This is an authentication-bypass challenge built around insecure PLC memory access, custom protocol analysis and safe state restoration.
Challenge information
| Item | Value |
|---|---|
| Name | Sneak peek |
| Platform | Hack The Box |
| HTB category | ICS |
| Portfolio category | Hardware |
| Difficulty | Easy |
| Status | Retired, confirmed 19 September 2026 |
| Core weakness | Unauthenticated read/write access to authentication state |
| Transport | Custom function inside Modbus/TCP |
Hack The Box categorizes the challenge as ICS. This portfolio currently groups it under Hardware, the closest supported category for direct interaction with PLC memory and industrial protocols.
Provided material and evidence boundaries
The challenge package supplied two files:
client.py
protocol_information.png
The original files remain local and are not included in this repository. Their SHA-256 values identify the evidence used during the solve:
a2d2733843c2e48aaa794c0bce9aff7d8308735e2b3232c1d0b7e09236479c63 client.py
76fb0674b5c32d6c4665162e248175214b8a12cea0ea072b567a63b67a60acae protocol_information.png
client.py was a partially configured pymodbus client. It supplied request and response class templates but deliberately left the host, port, custom function code and request data incomplete. Its comments recorded the expected environment as Python 3.9.18 with pymodbus==3.5.4.
The protocol image contained the information required to complete the client:
| Field | Value |
|---|---|
| Outer custom Modbus function | 0x64 |
| Memory size | 16 × 1024 bytes |
| Read memory operation | 0x20 |
| Write memory operation | 0x21 |
| Get secret operation | 0x22 |
| Session | 0x00 |
| Invalid-password error | 0xE009 |
The operation-specific request data was documented as:
read: [address byte 0][address byte 1][address byte 2][length]
write: [address byte 0][address byte 1][address byte 2][data...]
get_secret: [password...]
The public solver linked later is our own sanitized derivative. It contains no flag, fixed target, personal path or original HTB asset.
Initial protocol analysis
Confirming the service
The target was an ephemeral HTB instance, so its real address is replaced with placeholders. A TCP connection check confirmed that the assigned port was reachable:
nc -vz -w 5 <TARGET> <PORT>
<TARGET> <PORT> open
The expected pymodbus dependency was not installed in the Kali environment:
python3 - <<'PY'
import pymodbus
print(pymodbus.__version__)
PY
ModuleNotFoundError: No module named 'pymodbus'
Installing another dependency was unnecessary. Modbus/TCP framing is small enough to reproduce with Python’s standard socket and struct modules.
Reconstructing a request
A Modbus/TCP message begins with a seven-byte MBAP header:
[transaction id:2][protocol id:2][length:2][unit id:1]
The Protocol Data Unit used by this challenge then begins with the outer function 0x64, followed by the documented session, inner operation and operation data:
[0x64][session][operation][operation data...]
The first read-only probe requested 32 bytes from address zero:
payload = bytes([
0x00, # session
0x20, # read_memory_block
0x00, 0x00, 0x00,
0x20, # 32 bytes
])
The response was:
MBAP tid=1 protocol=0 length=37 unit=1
PDU 64 00 20 ff 01 5a e7 25 9b 7b 14 b2 00 96 13 36 34 de d5 fa
59 55 c9 00 12 bf 00 0a df 94 00 e1 1e 5a 34 9e
The first four PDU bytes have clear meanings:
64 outer custom function
00 session
20 read operation
ff success status
The remaining 32 bytes are the requested memory. This proved both the packet layout and the ability to read arbitrary PLC memory without authentication.
Dumping and classifying PLC memory
The complete memory block was read in 0xFF-byte chunks. A one-byte length field made 255 the natural maximum per documented read request:
memory = bytearray()
for address in range(0, 16 * 1024, 0xFF):
length = min(0xFF, 16 * 1024 - address)
request_data = address.to_bytes(3, "big") + bytes([length])
memory.extend(read_block(request_data))
print(f"Dumped {len(memory)} bytes")
Dumped 16384 bytes
MD5-like ASCII candidates:
Printable strings (length >= 8):
The empty searches mattered. The password digest was not stored as a 32-character hexadecimal MD5 string, and the memory did not contain a helpful plaintext label. A raw MD5 digest is 16 bytes, so the next step was to inspect the binary record structure rather than printable strings.
The zero bytes visible in the first probe acted as delimiters. Searching for non-zero runs of at least 16 bytes produced two relevant regions:
for match in re.finditer(rb"[^\x00]{16,}", memory):
print(hex(match.start()), len(match.group()), match.group().hex())
The useful portion of the output was:
offset 0x0400, length 16: 3f9b2b9e6e7885a1068076dbdaf76d15
offset 0x0807, length 14329: ff ff ff ff ...
The second region was erased or unused 0xFF filler. The isolated entry at 0x0400 was exactly 16 bytes long and therefore matched the expected size of the raw password digest.
Failed approaches and course corrections
Looking for printable MD5 text
The first full-memory analysis searched for [0-9a-f]{32} and printable strings. That would have found a hexadecimal MD5 representation, but both searches returned nothing. The result redirected the analysis toward a raw 16-byte digest.
Writing to address zero
Before the correct entry was localized, a controlled test temporarily placed the digest of a known password at address 0x000000. The write succeeded, but get_secret returned the documented authorization error:
[+] Known MD5 written temporarily
operation 0x22 failed, status/data: f0 e0 09
[+] Original memory restored
The trailing bytes e0 09 encode error 0xE009, confirming that authentication still failed. More importantly, the original 16 bytes were restored in a finally block. This disproved the hypothesis that the server searched the whole memory block for any matching digest; it used a specific location.
Rejecting a broad memory spray
A possible brute-force method was to fill the memory with repeated copies of a known digest under all 16 byte alignments. That would have caused more than one thousand writes and unnecessarily modified nearly the entire PLC memory. It was not executed. Locating the isolated 16-byte record reduced the final method to one targeted write and one restoration write.
External assistance disclosure
After the initial raw dump and failed address-zero test, a public community write-up was consulted. It suggested splitting the memory at zero bytes and examining entries of at least 16 bytes. The local dump was then reanalyzed independently, producing the verified 0x0400 offset and the exact 16-byte candidate shown above. No official write-up text or code was copied.
Exploiting the authentication design
Cracking the original MD5 was not required. The attacker controls both sides of the comparison:
- Choose any known password.
- Calculate its raw 16-byte MD5 digest.
- Back up the original digest at
0x0400. - Write the known digest to that address with operation
0x21. - Send the known plaintext password with operation
0x22. - Receive the protected secret.
- Restore and verify the original digest.
The published solver uses the neutral lab value known_password. The actual solve used another locally chosen test string; changing that string does not change the vulnerability or the result because the solver writes the corresponding digest before authenticating.
The decisive logic is:
known_password = b"known_password"
known_digest = hashlib.md5(known_password).digest()
memory = read_memory(client)
address, original_digest = find_hash_entry(memory)
try:
client.write(address, known_digest)
secret = client.get_secret(known_password)
finally:
client.write(address, original_digest)
assert client.read(address, 16) == original_digest
This is password-hash replacement, not password recovery.
Verified solver
The final solver uses only Python’s standard library and performs four safety checks:
- validates Modbus/TCP transaction IDs and protocol IDs;
- requires exactly one plausible isolated 16-byte hash entry;
- restores the original digest inside a
finallyblock; - reads the digest back and compares it byte for byte after restoration.
Download the sanitized Sneak peek solver.
Run it against a fresh retired-challenge instance:
python3 solve_sneak_peek.py <TARGET> <PORT>
The sanitized form of the verified output is:
[*] Candidate MD5 entry: 0x0400
[+] Secret: HTB{<REDACTED_FLAG>}
[+] Original hash restored and verified
The remote solve returned an HTB-formatted secret. The value is intentionally redacted from this public article and the downloadable script.
Validation
The exploit was validated through observable protocol and state evidence:
- The full read returned exactly
16384bytes, matching the supplied memory size. - The candidate at
0x0400was exactly 16 bytes, matching a raw MD5 digest. - A wrong-location replacement produced error
0xE009rather than a secret. - Replacing the candidate digest made the same password succeed immediately.
- The response contained a correctly bounded
HTB{...}token. - The original 16-byte value was written back and a fresh read confirmed an exact match.
The final run therefore verified both exploitation and cleanup. It did not leave the known password digest installed in the PLC memory.
Why the attack works
The system treats an MD5 digest as an authentication boundary while exposing the storage that contains that digest through unauthenticated read and write operations. The strength or crack resistance of the original password is irrelevant once an attacker can replace the verifier.
Conceptually, the check is equivalent to:
if md5(user_password).digest() == memory[PASSWORD_OFFSET:PASSWORD_OFFSET + 16]:
return secret
Operation 0x21 lets an unauthenticated client control the right-hand side of that comparison. The attacker can therefore choose a password, install its digest and satisfy the check without learning the original password.
MD5 is unsuitable for password storage because it is fast and unsalted, but replacing it with a modern password hash would not fix this challenge’s primary flaw. Any verifier becomes ineffective if an unauthenticated network client can overwrite it.
Defensive perspective
| Weakness | Defensive control |
|---|---|
| Unauthenticated Modbus access | Restrict PLC access through segmentation, allow-lists and authenticated gateways |
| Read access to credential material | Keep secrets outside general-purpose process memory and deny protocol-level reads |
| Arbitrary memory writes | Expose narrowly scoped operations rather than raw address-based writes |
| Password verifier stored in writable memory | Protect authentication state with hardware or OS-backed access controls |
| MD5 password hashing | Use a salted password KDF such as Argon2id, scrypt or bcrypt |
| No integrity protection | Use a secure transport or gateway that authenticates commands and detects tampering |
| Limited operational visibility | Alert on custom Function 0x64, especially inner operation 0x21 followed by 0x22 |
In a real operational environment, arbitrary writes can affect availability or physical processes. Testing should begin with passive capture and read-only enumeration, minimize writes, back up original values and include a verified restoration plan.
Key takeaways
- Reverse engineer both the outer Modbus/TCP frame and the inner proprietary protocol before sending state-changing requests.
- Treat binary structure as evidence when string searches fail; a raw MD5 digest is 16 bytes, not 32 ASCII characters.
- A strong password cannot compensate for an attacker-writable password verifier.
- Prefer targeted writes over broad memory modification, especially in ICS and OT environments.
- Build restoration and read-back verification into the exploit rather than treating cleanup as a manual afterthought.
- Document failed hypotheses because they establish what the service does and does not check.
References
- Hack The Box — Sneak peek
- Supplied
protocol_information.pngandclient.pyartifacts (not redistributed) - X3ric — HackTheBox Sneak peek Challenge — consulted after the initial failed offset hypothesis to identify the zero-delimited entry strategy