Introduction

On the Wire

Difficulty:
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 is hanging out next to City Hall with the On the Wire robot:

image-20260101135644419
Evan Booth

Evan Booth

Hey, I’m Evan!

I like to build things.

All sorts of things.

If you aren’t failing on some front, consider adjusting your difficulty settings.

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.

Chat with Evan Booth

Congratulations! You spoke with Evan Booth!

The terminal opens up a page with the robot’s data on display:

image-20260101135821355

Each of the three tabs display different data:

image-20260101135912764 image-20260101135922984

Solution

Strategy

The goal is to get the reading from the third signal at address 0x3C. The first part of this challenge will be capturing the signals from the webpage. Then I’ll work from the first signal, using its information to read the second, which provides output to decrypt the third signal.

Capture Signals

Enumerate Site

To figure out how the robot is getting those signals, I’ll open the dev tools in the browser. At the end of the HTML source is a large inline <script> block that handles all the signal processing. It defines five wires:

        // Signal data storage for each wire
        const signalData = {
            'dq': [],
            'mosi': [],
            'sck': [],
            'sda': [],
            'scl': []
        };

Later there’s a function that associates each wire with its tab:

        function getProtocolWires(protocol) {
            const protocolWires = {
                '1wire': ['dq'],
                'spi': ['mosi', 'sck'],
                'i2c': ['sda', 'scl']
            };
            return protocolWires[protocol] || [];
        }

A lot of the code has to do with displaying the wire data. The key part is how it connects to the signal data:

// Connect to a specific wire
function connectWire(wireName, protocolName) {
    if (connections[wireName]) {
        addLog(protocolName, `Already connected to ${wireName}`);
        return;
    }

    initCanvas(wireName);

    const proto = location.protocol === 'https:' ? 'wss' : 'ws';
    const host = location.host || location.hostname; // includes port if present in current URL
    const ws = new WebSocket(`${proto}://${host}/wire/${wireName}`);
    connections[wireName] = ws;

    ws.onopen = () => {
        updateStatus(protocolName);
        addLog(protocolName, `Connected to ${wireName}`);
    };

    ws.onmessage = (event) => {
        try {
            const data = JSON.parse(event.data);

            if (data.type === 'welcome') {
                addLog(protocolName, `Server: ${data.message}`);
                return;
            }

            if (data.line && typeof data.v !== 'undefined') {
                updateSignal(data.line, data.v, data.t);
                updateFrameCount(data.line);
            }
        } catch (e) {
            console.error('Error parsing message:', e);
        }
    };

    ws.onerror = (error) => {
        console.error(`WebSocket error on ${wireName}:`, error);
        addLog(protocolName, `Error on ${wireName}`);
    };

    ws.onclose = () => {
        delete connections[wireName];
        updateStatus(protocolName);
        addLog(protocolName, `Disconnected from ${wireName}`);
    };
}

So each wire has its own websocket endpoint at /wire/{wireName}. Looking at the message handler, there’s a specific handler for a “welcome” message, and then the rest are processed by this code as long as data.v is defined:

if (data.line && typeof data.v !== 'undefined') {
    updateSignal(data.line, data.v, data.t);
}

Each message is JSON with three fields:

  • line - the wire name
  • v - the value (0 or 1)
  • t - the timestamp

In the Network tab, I can confirm this by watching the websocket connections. Each one streams a continuous flow of signal data.

image-20260101143337877

Capture Script

To work with the signals, it will help to collect the data so that I can process it offline without having to connect each time I want to try something. I’ll use Python to create a script to capture the signals and save them as JSON:

# /// script
# requires-python = ">=3.13"
# dependencies = [
#     "websockets",
# ]
# ///
import asyncio
import json
import websockets

WIRES = ['dq', 'mosi', 'sck', 'sda', 'scl']
BASE_URL = 'wss://signals.holidayhackchallenge.com/wire'
CAPTURE_SECONDS = 10

async def capture_wire(wire, data):
    url = f'{BASE_URL}/{wire}'
    async with websockets.connect(url) as ws:
        print(f'[+] Connected to {wire}')
        while True:
            msg = json.loads(await ws.recv())
            if msg.get('type') == 'welcome':
                print(f'[*] {wire}: {msg["message"]}')
                continue
            if 'v' in msg:
                data[wire].append({'v': msg['v'], 't': msg['t']})

async def main():
    data = {wire: [] for wire in WIRES}
    tasks = [asyncio.create_task(capture_wire(wire, data)) for wire in WIRES]

    print(f'[*] Capturing for {CAPTURE_SECONDS} seconds...')
    await asyncio.sleep(CAPTURE_SECONDS)

    for task in tasks:
        task.cancel()

    with open('signals.json', 'w') as f:
        json.dump(data, f)

    for wire in WIRES:
        print(f'[+] {wire}: {len(data[wire])} samples')
    print('[+] Saved to signals.json')

asyncio.run(main())

This will use async to connect to each wire, collect for 10 seconds, and then store all the data in signals.json.

oxdf@hacky$ time uv run capture.py 
[*] Capturing for 10 seconds...
[+] Connected to mosi
[*] mosi: Connected to mosi wire. Broadcasting continuously every 2000ms...
[+] Connected to scl
[*] scl: Connected to scl wire. Broadcasting continuously every 2000ms...
[+] Connected to sda
[*] sda: Connected to sda wire. Broadcasting continuously every 2000ms...
[+] Connected to sck
[*] sck: Connected to sck wire. Broadcasting continuously every 2000ms...
[+] Connected to dq
[*] dq: Connected to dq wire. Broadcasting continuously every 2000ms...
[+] dq: 4590 samples
[+] mosi: 2742 samples
[+] sck: 4803 samples
[+] sda: 1140 samples
[+] scl: 2004 samples
[+] Saved to signals.json

real    0m10.221s
user    0m0.511s
sys     0m0.173s

Because it’s using async, it only takes 10.2 seconds to get 10 seconds of all five wires. The resulting data has five wires:

oxdf@hacky$ cat signals.json | jq '. | keys'
[
  "dq",
  "mosi",
  "sck",
  "scl",
  "sda"
]

Each sample has a time and a value:

oxdf@hacky$ cat signals.json | jq '.dq | length'
4590
oxdf@hacky$ cat signals.json | jq '.dq[0] | keys'
[
  "t",
  "v"
]
oxdf@hacky$ cat signals.json | jq '.dq[0]'
{
  "v": 1,
  "t": 0
}
oxdf@hacky$ cat signals.json | jq '.dq[100]'
{
  "v": 1,
  "t": 4291
}

1-Wire

Background

1-Wire is a protocol developed by Dallas Semiconductor that uses pulse-width encoding to transmit data over a single wire. The key to decoding it is measuring how long the signal stays LOW:

  • Short LOW pulse (1-15 time units) = 1
  • Long LOW pulse (~60 time units) = 0
  • Very long LOW pulses (150+) = reset/presence signals (skip these)

Bits are transmitted LSB-first, meaning I need to reverse each group of 8 bits to get the correct byte value.

Analyzing the Data

Looking at the captured dq data, each sample has a value (0 or 1) and a timestamp. To decode the signal, I need to find the transitions from HIGH to LOW and measure how long the signal stays LOW before going HIGH again.

I’ll write a short Python script to look at this:

import json
from collections import Counter

data = json.load(open('signals.json'))['dq']

# Find LOW pulses: look for 1→0 then 0→1 transitions
pulses = []
i = 0
while i < len(data) - 1:
    if data[i]['v'] == 1 and data[i+1]['v'] == 0:
        start = data[i+1]['t']
        # Find end of LOW pulse
        j = i + 1
        while j < len(data) and data[j]['v'] == 0:
            j += 1
        if j < len(data):
            duration = data[j]['t'] - start
            pulses.append(duration)
        i = j
    else:
        i += 1

c = Counter(pulses)
for length, num in c.items():
    print(f"Found {num} pulses of length {length}.")

Running this shows there are only four lengths of low pulse:

oxdf@hacky$ uv run 1wire_analysis.py 
Found 5 pulses of length 480.
Found 5 pulses of length 150.
Found 1330 pulses of length 60.
Found 950 pulses of length 6.

Short pulses at 6 represent 1s, longer pulses of 60 represent 0s, and very long pulses (150, 480) are the reset/presence signals.

Decoding Script

I’ll write a script to:

  1. Find all LOW pulses by looking for 1→0→1 transitions
  2. Calculate the duration of each LOW pulse
  3. Convert to bits: short = 1, long = 0, skip very long pulses
  4. Group into bytes (LSB-first) and convert to ASCII
import json

data = json.load(open('signals.json'))['dq']

pulses = []
i = 0
while i < len(data) - 1:
    if data[i]['v'] == 1 and data[i+1]['v'] == 0:
        start = data[i+1]['t']
        j = i + 1
        while j < len(data) and data[j]['v'] == 0:
            j += 1
        if j < len(data):
            duration = data[j]['t'] - start
            pulses.append(duration)
        i = j
    else:
        i += 1

bits = []
for p in pulses:
    if p > 100:
        continue
    elif p < 30:
        bits.append(1)
    else:
        bits.append(0)

message = ''
for i in range(0, len(bits) - 7, 8):
    byte_bits = bits[i:i+8]
    byte_bits.reverse()
    byte_val = int(''.join(map(str, byte_bits)), 2)
    if 32 <= byte_val < 127:
        message += chr(byte_val)
    else:
        message += '\n'

print(message)

Running this outputs a clear message:

oxdf@hacky$ uv run 1wire_decode.py 

read and decrypt the SPI bus data using the XOR key: icy
read and decrypt the SPI bus data using the XOR key: icy
read and decrypt the SPI bus data using the XOR key: icy
read and decrypt the SPI bus data using the XOR key: icy
read and decrypt the SPI bus data using the XOR key: icy

The message instructs me to decrypt the SPI bus data using the XOR key icy.

SPI

Background

SPI (Serial Peripheral Interface) uses separate clock and data lines. For this challenge, I have:

  • sck - the clock signal
  • mosi - the data signal (Master Out, Slave In)

To decode SPI:

  • Sample the MOSI data line on each rising edge of SCK (when clock goes 0→1)
  • Bits are MSB-first (most significant bit first)
  • Then XOR decrypt with the key icy from the 1-Wire message

Decoding Script

I’ll write a script to:

  1. Find all SCK rising edges (0→1 transitions)
  2. Sample the MOSI value at each rising edge timestamp
  3. Group bits into bytes (MSB-first)
  4. XOR decrypt with key icy
import json

data = json.load(open('signals.json'))
sck = sorted(data['sck'], key=lambda x: x['t'])
mosi = sorted(data['mosi'], key=lambda x: x['t'])

def get_mosi_at_time(t):
    val = 0
    for m in mosi:
        if m['t'] <= t:
            val = m['v']
        else:
            break
    return val

bits = []
for i in range(len(sck) - 1):
    if sck[i]['v'] == 0 and sck[i+1]['v'] == 1:  
        t = sck[i+1]['t']
        bits.append(get_mosi_at_time(t))

raw_bytes = []
for i in range(0, len(bits) - 7, 8):
    byte_bits = bits[i:i+8]
    byte_val = int(''.join(map(str, byte_bits)), 2)
    raw_bytes.append(byte_val)

key = b'icy'
decrypted = ''
for i, b in enumerate(raw_bytes):
    decrypted += chr(b ^ key[i % len(key)])

print(decrypted)

I need the get_mosi_at_time function because the timestamps for the two signals don’t always line up. So when I get that edge in the clock, I’ll want to find the last value of the signal.

Running this outputs another message:

oxdf@hacky$ uv run spi.py 
read and decrypt the I2C bus data using the XOR key: bananza. the temperature sensor address is 0x3C

The message gives me the XOR key bananza and the I2C address 0x3C for the final stage.

I2C

Background

I2C (Inter-Integrated Circuit) is another clock-and-data protocol, using:

  • scl - the clock signal
  • sda - the data signal

Like SPI, I sample SDA on the rising edge of SCL, and bits are MSB-first. However, I2C has additional complexity:

  • The first byte of each transaction is the address byte: (7-bit address << 1) | R/W bit
  • Every 9th bit is an ACK bit that must be skipped when extracting data
  • The target address is 0x3C, which becomes 0x78 (write) or 0x79 (read) when shifted

For address 0x3C:

0x3C = 0011 1100
0x3C << 1 = 0111 1000 = 0x78

So I need to find 01111000 in the bit stream to locate the start of the relevant data.

Decoding Script

I’ll write a script to:

  1. Find SCL rising edges and sample SDA
  2. Search for the address byte 0x78 in the bit stream
  3. Extract data bits after the address, skipping every 9th bit (ACK)
  4. XOR decrypt with key bananza
import json

data = json.load(open('signals.json'))
scl = sorted(data['scl'], key=lambda x: x['t'])
sda = sorted(data['sda'], key=lambda x: x['t'])

def get_sda_at_time(t):
    val = 0
    for s in sda:
        if s['t'] <= t:
            val = s['v']
        else:
            break
    return val

bits = []
for i in range(len(scl) - 1):
    if scl[i]['v'] == 0 and scl[i+1]['v'] == 1:
        t = scl[i+1]['t']
        bits.append(get_sda_at_time(t))

addr_pattern = [0, 1, 1, 1, 1, 0, 0, 0]
start_idx = None
for i in range(len(bits) - 8):
    if bits[i:i+8] == addr_pattern:
        start_idx = i + 9
        break

data_bits = []
idx = start_idx
while idx < len(bits):
    for _ in range(8):
        if idx < len(bits):
            data_bits.append(bits[idx])
            idx += 1
    idx += 1

raw_bytes = []
for i in range(0, len(data_bits) - 7, 8):
    byte_bits = data_bits[i:i+8]
    byte_val = int(''.join(map(str, byte_bits)), 2)
    raw_bytes.append(byte_val)

key = b'bananza'
decrypted = ''
for i, b in enumerate(raw_bytes):
    decrypted += chr(b ^ key[i % len(key)])

print(decrypted)

Running this produces the temperature (plus some junk):

oxdf@hacky$ uv run i2c.py 
32.84«HJNGFgb`6t{v~úbä

The temperature value is 32.84, which is the answer to the challenge.

Outro

Signals

Congratulations! You have completed the Signals challenge!

Evan is pumped:

Evan Booth

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!