Skip to content

On the Wire⚓︎

Difficulty:
Direct link: On the Wire

Objective⚓︎

Request

Help Evan next to city hall hack this gnome and retrieve the temperature value reported by the I²C device at address 0x3C. The temperature data is XOR-encrypted, so you’ll need to work through each communication stage to uncover the necessary keys. Start with the unencrypted data being transmitted over the 1-wire protocol.

Evan Booth

So here's the deal - there are some seriously bizarre signals floating around this area.

Not your typical radio chatter or WiFi noise, but something... different.

I've been trying to make sense of the patterns, but it's like trying to build a robot hand out of a coffee maker - you need the right approach.

Think you can help me decode whatever weirdness is being transmitted out there?

You know what happens to electronics in extreme cold? They fail. All my builds, all my robots, all my weird coffee-maker contraptions—frozen solid. We can't let Frosty turn this place into a permanent deep freeze.

Hints⚓︎

Protocols

Key concept - Clock vs. Data signals:

Some protocols have separate clock and data lines (like SPI and I2C) For clocked protocols, you need to sample the data line at specific moments defined by the clock The clock signal tells you when to read the data signal For 1-Wire (no separate clock):

Information is encoded in pulse widths (how long the signal stays low or high) Different pulse widths represent different bit values Look for patterns in the timing between transitions For SPI and I2C:

Identify which line is the clock (SCL for I2C, SCK for SPI) Data is typically valid/stable when the clock is in a specific state (high or low) You need to detect clock edges (transitions) and sample data at those moments Technical approach:

Sort frames by timestamp Detect rising edges (0→1) and falling edges (1→0) on the clock line Sample the data line's value at each clock edge

Bits and Bytes

Critical detail - Bit ordering varies by protocol:

MSB-first (Most Significant Bit first):

SPI and I2C typically send the highest bit (bit 7) first When assembling bytes: byte = (byte << 1) | bit_value Start with an empty byte, shift left, add the new bit LSB-first (Least Significant Bit first):

1-Wire and UART send the lowest bit (bit 0) first When assembling bytes: byte |= bit_value << bit_position Build the byte from bit 0 to bit 7 I2C specific considerations:

Every 9th bit is an ACK (acknowledgment) bit - ignore these when decoding data The first byte in each transaction is the device address (7 bits) plus a R/W bit You may need to filter for specific device addresses Converting bytes to text:

String.fromCharCode(byte_value) // Converts byte to ASCII character

On Rails

Stage-by-stage approach

  1. Connect to the captured wire files or endpoints for the relevant wires.
  2. Collect all frames for the transmission (buffer until inactivity or loop boundary).
  3. Identify protocol from wire names (e.g., dq → 1-Wire; mosi/sck → SPI; sda/scl → I²C).
  4. Decode the raw signal:
    • Pulse-width protocols: locate falling→rising transitions and measure low-pulse width.
    • Clocked protocols: detect clock edges and sample the data line at the specified sampling phase.
  5. Assemble bits into bytes taking the correct bit order (LSB vs MSB).
  6. Convert bytes to text (printable ASCII or hex as appropriate).
  7. Extract information from the decoded output — it contains the XOR key or other hints for the next stage.
  1. Repeat Stage 1 decoding to recover raw bytes (they will appear random).
  2. Apply XOR decryption using the key obtained from the previous stage.
  3. Inspect decrypted output for next-stage keys or target device information.
  • Multiple 7-bit device addresses share the same SDA/SCL lines.
  • START condition: SDA falls while SCL is high. STOP: SDA rises while SCL is high.
  • First byte of a transaction = (7-bit address << 1) | R/W. Extract address with address = first_byte >> 1.
  • Identify and decode every device’s transactions; decrypt only the target device’s payload.
  • Print bytes in hex and as ASCII (if printable) — hex patterns reveal structure.
  • Check printable ASCII range (0x20–0x7E) to spot valid text.
  • Verify endianness: swapping LSB/MSB will quickly break readable text.
  • For XOR keys, test short candidate keys and look for common English words.
  • If you connect mid-broadcast, wait for the next loop or detect a reset/loop marker before decoding.
  • Buffering heuristic: treat the stream complete after a short inactivity window (e.g., 500 ms) or after a full broadcast loop.
  • Sort frames by timestamp per wire and collapse consecutive identical levels before decoding to align with the physical waveform.

Solution⚓︎

Initial access and signal source⚓︎

After clicking the objective terminal, a webpage shows the signal dashboard with the protocol and the wires used by the protocol. The default one shows the waves of the DQ wire for the 1-Wire protocol.

Screenshot 2026-01-19 173458

I found in the website source code that the data of every wire's graph comes from a websocket.

Screenshot 2026-01-20 113845

1-Wire stage⚓︎

1-Wire uses a single data line (DQ) with no separate clock, so bits are encoded by the width of low pulses. I noticed that short low pulses are 6 µs and represent a bit 1 while long low pulses are 60 µs and represent a bit 0.

A voltage v reading 0 corresponds to "High" idle state, and 1 means the line is low.

Under the message part of the devtools network section for the dq request, the data format is as follows:

Screenshot 2026-01-19 180936

In DevTools, the websocket request header shows a secure websocket (wss) endpoint in use for live data.

Screenshot 2026-01-20 113942

So I wrote this python script to help decode the 1-Wire DQ stream.

Notes on how this works:

  • The 1-Wire DQ line encodes bits by pulse width; short low pulses commonly mean '1' and long low pulses mean '0'.
  • Each WebSocket message carries a voltage level V and a timestamp t.
  • Pulse duration = t - previous_t; 6 µs → bit 1, 60 µs → bit 0, anything else is discarded
  • On {"Marker": "stop"}, the WebSocket closes and the collected bits are processed.
  • Bits are assembled LSB-first into bytes: the first observed bit becomes bit 0 of the byte.
  • The resulting bytes are decoded as ASCII to produce the XOR key for the next stage.

Decoding script:

WebSocket stream
    │
    ▼
timestamp delta → pulse width
    │
    ▼
6 µs → bit 1,  60 µs → bit 0   (pulse-width decoding)
    │
    ▼
data_list  (stream of bits, others discarded)
    │
    ▼
bits_to_bytes() — 8 bits at a time, LSB-first
(byte |= bit << bit_index)
    │
    ▼
bytes → ASCII string  →  XOR key for next stage
Decode 1-Wire
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import websocket
import json

pulse_list = []
time_list = []
data_list = []
bytes_list = []
previous_t = 0
short = 6
long = 60

def bits_to_bytes(bits):
    bytes = []
    for i in range(0, len(bits), 8):
        chunk = bits[i:i+8]
        if len(chunk) < 8:
            break

        byte = 0
        for bit_index, bit in enumerate(chunk):
            byte |= (bit << bit_index)
        bytes.append(byte)
    return bytes


def on_message(ws, message):
    global previous_t
    data = json.loads(message)
    v = data.get('v')
    t = data.get('t')
    m = data.get('marker')
    if m == "stop":
        print("Stop marker received. Closing WebSocket.")
        ws.close()
    if v is not None and m is None:
        time = t - previous_t
        if v ==0:
            pulse_list.append({"t": time, "v": "High"})
        else:
            pulse_list.append({"t": time, "v": "Low"})
        time_list.append(time)
        if time == short:
            data_list.append(1)
        elif time == long:
            data_list.append(0)
        previous_t = t


def on_error(ws, error):
    print(f"Error: {error}")

def on_close(ws, close_status_code, close_msg):
    print("WebSocket closed")

def on_open(ws):
    print("WebSocket connection opened")

ws_url = "wss://signals.holidayhackchallenge.com/wire/dq"
ws = websocket.WebSocketApp(ws_url,
                            on_open=on_open,
                            on_message=on_message,
                            on_error=on_error,
                            on_close=on_close)

ws.run_forever()

bytes_list = bits_to_bytes(data_list)

# print("Collected values:", pulse_list,)
# print("Collected times:", time_list,)
# print("Data list :", data_list)
print("Bytes list :", bytes_list)
ascii_output =  bytes(bytes_list).decode('ascii', errors='ignore')
print("ASCII Output:", ascii_output)

The script helped to decrypt the sent data and get the XOR key icy.

Screenshot 2026-01-24 014617

SPI stage⚓︎

SPI has a dedicated clock (SCK) and data line (MOSI). Data is sampled at specific clock signal. SPI transmits data MSB-first: the most significant bit (bit 7) of each byte is sent first, so bytes must be assembled by shifting left and OR-ing each new bit in.

Screenshot 2026-01-20 220944

The SCK wire request in the network section of the DevTools shows how the clock data is formatted.

Screenshot 2026-01-20 221035

The MOSI wire request in the network section of the DevTools shows how the sample data is formatted.

Screenshot 2026-01-20 221116

I wrote this new python script to use the previously found xor key icy to decode the SPI data.

Notes on how SPI decoding works:

  • Two WebSocket streams are collected: MOSI (data-bit) and SCK (data-sample)
  • Streams close when the "idle-low" marker is seen twice with t=0, indicating a full broadcast.
  • For each SCK sample event, the latest MOSI value with t <= SCK timestamp is picked one bit each clock edge.
  • Bits are assembled MSB-first into bytes: shift left and OR the sampled bit.
  • Bytes are XOR-decrypted with the key from the 1-Wire stage to reveal the next key.

Decoding script:

Two WebSocket streams (MOSI and SCK)
    │
    ▼
Buffer until "idle-low" marker seen twice
    │
    ▼
For each SCK sample event:
  find latest MOSI value with t ≤ SCK timestamp
    │
    ▼
combined_samples  (one bit per clock edge)
    │
    ▼
bits_to_bytes_msb() — 8 bits at a time, MSB-first
(byte = (byte << 1) | bit)
    │
    ▼
XOR decrypt with key from 1-Wire stage
    │
    ▼
bytes → ASCII string  →  XOR key for next stage
Decode SPI
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import websocket
import json

class WebSocketCollector:
    def __init__(self, url, name=""):
        self.url = url
        self.name = name
        self.data_list = []
        self.idle_count = 0
        self.ws = websocket.WebSocketApp(url,
                                        on_message=self.on_message,
                                        on_error=self.on_error,
                                        on_close=self.on_close,
                                        on_open=self.on_open)


    def on_message(self, ws, message):
        data = json.loads(message)
        marker = data.get('marker')
        if marker == "idle-low" and data.get('t') == 0:
            self.idle_count += 1

        if self.idle_count >= 2:
            print("Idle-low marker received 2 times with t=0. Closing WebSocket.")
            ws.close()
            self.idle_count = 0
            return

        v = data.get('v')
        t= data.get('t')
        marker = data.get('marker')

        if self.name == "mosi" and v is not None and marker == "data-bit":
            self.data_list.append({"t": t, "v": v})
            #print(f"Received mosi value: {v}")

        if self.name == "sck" and marker == "sample" and v is not None:
            self.data_list.append({"t": t, "v": v})
            #print(f"Received value: {v}")

    def on_error(self, ws, error):
        print(f"{self.name} Error: {error}")

    def on_close(self, ws, close_status_code, close_msg):
        print(f"{self.name} WebSocket closed")

    def on_open(self, ws):
        print(f"{self.name} WebSocket connection opened")

    def run(self):
        self.ws.run_forever()

def sample_data(mosi_data, sck_data):
    combined_samples = []
    for i in sck_data:
        for j in mosi_data:
            if j['t'] <= i['t']:
                m_sample = j['v']
            else:
                break
        combined_samples.append(m_sample)
    return combined_samples

def bits_to_bytes_msb(bits):
    out = []
    for i in range(0, len(bits), 8):
        chunk = bits[i:i+8]
        if len(chunk) < 8:
            break
        byte = 0
        for bit in chunk:
            byte = (byte << 1) | bit
        out.append(byte)
    return out

def xor_repeat_key(data, key=b"icy"):
    if isinstance(key, str):
        key = key.encode("ascii")
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))

mosi_ws_url = "wss://signals.holidayhackchallenge.com/wire/mosi"
sck_ws_url = "wss://signals.holidayhackchallenge.com/wire/sck"

mosi_collector= WebSocketCollector(mosi_ws_url, name="mosi")
sck_collector = WebSocketCollector(sck_ws_url, name="sck")

mosi_collector.run()
sck_collector.run()

sampled_data = sample_data(mosi_collector.data_list, sck_collector.data_list)
bytes_list = bits_to_bytes_msb(sampled_data)
decrypted_bytes = xor_repeat_key(bytes_list, key="icy")

print("Sampled data:", sampled_data)
print("Bytes list :", bytes_list)

print("XOR decrypted (hex):", decrypted_bytes.hex())
print("XOR decrypted (ASCII):", decrypted_bytes.decode('latin-1', errors='ignore'))

After sampling MOSI on SCK edges and assembling MSB-first bytes, the SPI payload decodes into the next XOR key bananza.

Screenshot 2026-01-25 003218

I2C stage⚓︎

I2C uses two lines: SCL for the clock and SDA for data. Multiple devices share the same bus, each identified by a 7-bit address. The bus signals bus activity through special conditions: a START condition is when SDA falls while SCL is high, and a STOP condition is when SDA rises while SCL is high.

The first byte of every I2C transaction carries the target device's 7-bit address in the upper 7 bits, plus a Read/Write flag in the lowest bit. Therefore, the raw address byte must be shifted right by one (first_byte >> 1) to recover the 7-bit address. After every byte of data the bus expects an ACK bit (a 9th clock pulse), which is a handshake and not part of the payload. Those ACK bits must be stripped out before assembling bytes.

Screenshot 2026-01-21 003700

The SDA capture shows data changes aligned to SCL high periods.

Screenshot 2026-01-25 182131

The SCL capture provides the clock edges for sampling SDA and counting ACK bits.

Screenshot 2026-01-25 183036

I wrote this python script to use the previously found XOR key bananza to decode the I2C data and found the temperature value 32.84.

Notes on how I2C decoding works (explanatory, non-code guidance):

  • Two WebSocket streams are collected: SDA ("data-bit" or "address-bit") and SCL ("data-sample" or "address-sample")
  • Streams close when the "bus-idle" marker is seen twice with t=0, indicating a full broadcast.
  • For each SCL sample event, the latest SDA value with t <= SCL timestamp is picked one bit each clock edge.
  • Address and data samples are kept separate during collection based on their marker types
  • Address bits are decoded to locate the target device 0x3c
  • Data bits belonging to that transaction are extracted and assembled MSB-first into bytes
  • Bytes are XOR-decrypted with the key from the SPI stage to reveal the temperature value.

Decoding script:

Two WebSocket streams (SDA and SCL)
    │
    ▼
Buffer until "bus-idle" marker seen twice
    │
    ▼
For each SCL sample event:
  find latest SDA value with t ≤ SCL timestamp
  separate into address_samples vs data_samples by marker type
    │
    ▼
decode_address() → identify transaction for device 0x3C
    │
    ▼
extract data bits belonging to that transaction
    │
    ▼
bits_to_bytes_msb() — 8 bits at a time, MSB-first
(byte = (byte << 1) | bit)
    │
    ▼
XOR decrypt with key from SPI stage ("bananza")
    │
    ▼
bytes → ASCII string  →  temperature value
Decode I2C
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
import websocket
import json

class WebSocketCollector:
    def __init__(self, url, name=""):
        self.url = url
        self.name = name
        self.data_list = []
        self.idle_count = 0
        self.ws = websocket.WebSocketApp(url,
                                        on_message=self.on_message,
                                        on_error=self.on_error,
                                        on_close=self.on_close,
                                        on_open=self.on_open)


    def on_message(self, ws, message):
        data = json.loads(message)
        marker = data.get('marker')
        if marker == "bus-idle" and data.get('t') == 0:
            self.idle_count += 1

        if self.idle_count >= 2:
            print("Idle-low marker received 2 times with t=0. Closing WebSocket.")
            ws.close()
            self.idle_count = 0
            return

        v = data.get('v')
        t= data.get('t')
        marker = data.get('marker')

        if self.name == "sda" and (marker == "data-bit" or marker == "address-bit"):
            self.data_list.append(data)
            #print(f"Received mosi value: {v}")

        if self.name == "scl" and (marker == "data-sample" or marker == "address-sample"):
            self.data_list.append(data)
            #print(f"Received value: {v}")

    def on_error(self, ws, error):
        print(f"{self.name} Error: {error}")

    def on_close(self, ws, close_status_code, close_msg):
        print(f"{self.name} WebSocket closed")

    def on_open(self, ws):
        print(f"{self.name} WebSocket connection opened")

    def run(self):
        self.ws.run_forever()

def sample_data(sda_data, scl_data):
    data_samples = []
    address_samples = []
    for scl in scl_data:
        is_data = scl['marker'] == "data-sample"
        for sda in sda_data:
            if scl['marker'] == "data-sample" and sda['t'] <= scl['t']:
                data_sample = sda['v']
            elif scl['marker'] == "address-sample" and sda['t'] <= scl['t']:
                address_sample = sda['v']
            else:
                break
        if is_data: 
            data_samples.append(data_sample)
            # print(f"Data Sample Added: {data_sample}")
        if not is_data:
            address_samples.append(address_sample)
            # print(f"Address Sample Added: {address_sample}")
    return data_samples, address_samples

def bits_to_bytes_msb(bits):
    out = []
    for i in range(0, len(bits), 8):
        chunk = bits[i:i+8]
        if len(chunk) < 8:
            break
        byte = 0
        for bit in chunk:
            byte = (byte << 1) | bit
        out.append(byte)
    return out

def decode_address(bits):
    out = []
    hex= []
    for i in range(0, len(bits), 8):
        chunk = bits[i:i+8]
        if len(chunk) < 8:
            break
        byte = 0
        for i in range(7):
            byte = (byte << 1) | chunk[i]
        out.append(byte)
        h = bytes([byte]).hex()
        hex.append(h)

    return out, hex

def get_data_at_addresses(data_bits, address_bits):
    # print("Data bits length:", len(data_bits))
    address, address_hex = decode_address(address_bits)
    # print("Decoded addresses (hex):", address_hex)
    data_start = 0
    data_end = 0
    out = []
    for i in range(len(address_hex)):
        if address_hex[i] == '3c':
            data_start = i*24
            print(f"Found address 0x3c at index {i}, data starts at bit index {data_start}")
            data_end = data_start + 25
            break
    for i in range(data_start, len(data_bits)):
        # print(f"Data bit at index {i}: {data_bits[i]}")
        out.append(data_bits[i])
    return out

def xor_repeat_key(data, key=b"bananza"):
    if isinstance(key, str):
        key = key.encode("ascii")
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))

sda_ws_url = "wss://signals.holidayhackchallenge.com/wire/sda"
scl_ws_url = "wss://signals.holidayhackchallenge.com/wire/scl"

sda_collector= WebSocketCollector(sda_ws_url, name="sda")
scl_collector = WebSocketCollector(scl_ws_url, name="scl")

sda_collector.run()
scl_collector.run()

# print("Collected values for sda:", sda_collector.data_list)
# print("Collected values for scl:", scl_collector.data_list)

sampled_data, address_data = sample_data(sda_collector.data_list, scl_collector.data_list)
temp_add_data = get_data_at_addresses(sampled_data, address_data)
data_bytes_list = bits_to_bytes_msb(temp_add_data)
address_bytes_list, address_hex_list = decode_address(address_data)
data_ascii = bytes(data_bytes_list).decode('ascii', errors='ignore')
decrypted_bytes = xor_repeat_key(data_bytes_list, key="bananza")

#print("Sampled data:", sampled_data)
print("Bytes list :", data_bytes_list)
print("Address Bytes list :", address_bytes_list)
print("Address hex Output:", address_hex_list)
print("XOR decrypted (hex):", decrypted_bytes.hex())
print("XOR decrypted (ASCII):", decrypted_bytes.decode('ascii', errors='ignore'))

Once the I2C transaction for address 0x3C was decoded and XOR-decrypted, the temperature value appeared.

Screenshot 2026-01-25 235430

Wrap-up and completion⚓︎

The objective completion screen confirms the decoded I2C temperature result was accepted.

Screenshot 2026-01-25 235700

Answer

32.84

Response⚓︎

Evan Booth

Nice work! You cracked that signal encoding like a pro.

Turns out the weirdness had a method to it after all - just like most of my builds!