HTB: Signal from Space Challenge

Signal from Space - HackTheBox Challenge Writeup

Challenge Information

FieldValue
NameSignal from Space
CategoryMisc
DifficultyMedium
Authord3vn0mi

Description

A brief transmission was captured during a satellite overpass before the signal disappeared below the horizon. All that’s left is the receiver’s audio recording. Demodulate the AFSK1200 signal and recover the hidden message.

The challenge ships a single audio recording of a captured satellite pass and asks the player to demodulate an AFSK1200 (Audio Frequency-Shift Keying, 1200 baud) signal to recover a hidden flag.

Solution

1. Recovering the real artifact

The staged challenge file, flag.wav, initially showed up as 0 bytes — a red herring caused by the staging step silently emptying the artifact. Listing the actual distributed zip showed the real payload:

Terminal window
# The staged flag.wav was empty; check the source zip instead
ls -la /out/
unzip -l /out/a26ccb44-1571-4710-9809-568cc5e223b9.zip
# -> shows a genuine 38,444-byte entry, not 0 bytes

The archive was password-protected with the standard HTB default password:

Terminal window
unzip -o -P hackthebox /out/a26ccb44-1571-4710-9809-568cc5e223b9.zip
# recovers the real flag.wav

2. Fingerprinting the signal

Inspecting the recovered WAV showed it was tiny: 48 kHz, 16-bit, stereo, only 9,600 frames — just 0.2 seconds of audio, or 240 symbol periods at 1200 baud.

import wave, numpy as np
w = wave.open('flag.wav')
d = np.frombuffer(w.readframes(w.getnframes()), dtype='<i2')
print(w.getframerate(), w.getnchannels(), w.getnframes())
# 48000 Hz, 2 channels, 9600 frames -> 0.2s

A quick FFT over the whole 0.2 s clip put spectral peaks around ~1100 Hz and ~2300 Hz, which at first glance looks like a nonstandard tone pair. That turned out to be a measurement artifact, not a real deviation: with only 0.2 s of data the FFT bin resolution is ~5 Hz, and FM sidebands smear the peaks further. Measuring the instantaneous frequency instead (via a manual Hilbert transform) resolved this cleanly to 1200 Hz / 2200 Hz — standard Bell 202 tones, confirming AFSK1200 as stated in the challenge description.

3. Demodulation

The demodulation pipeline:

import wave, numpy as np
w = wave.open('flag.wav')
sr = w.getframerate()
raw = np.frombuffer(w.readframes(w.getnframes()), dtype='<i2')
raw = raw.reshape(-1, w.getnchannels()) # separate stereo channels
def demod_channel(sig):
sig = sig.astype(float)
# 1. Build the analytic signal (manual Hilbert transform via FFT)
N = len(sig)
spec = np.fft.fft(sig)
h = np.zeros(N)
h[0] = 1
h[1:N//2] = 2
h[N//2] = 1 if N % 2 == 0 else 2
analytic = np.fft.ifft(spec * h)
# 2. Instantaneous frequency = derivative of the unwrapped phase
phase = np.unwrap(np.angle(analytic))
inst_freq = np.diff(phase) / (2 * np.pi) * sr
# 3. Smooth with a boxcar filter, then slice against the midpoint
# between the two Bell 202 tones (1200 Hz and 2200 Hz)
kernel = np.ones(5) / 5
smoothed = np.convolve(inst_freq, kernel, mode='same')
bits = (smoothed > 1700).astype(int)
# 4. Clock recovery: 40 samples/symbol at 48kHz/1200baud.
# Pick the sample offset whose symbol centers sit farthest
# from the decision threshold (i.e., the most confident phase).
sps = sr // 1200 # 40 samples per symbol
best_offset, best_score = 0, -1
for offset in range(sps):
centers = smoothed[offset::sps]
score = np.mean(np.abs(centers - 1700))
if score > best_score:
best_offset, best_score = offset, score
symbols = bits[best_offset::sps]
return symbols
left_bits = demod_channel(raw[:, 0])
right_bits = demod_channel(raw[:, 1])

Clock recovery picked offset 13 on the left channel and 24 on the right — consistent with a ~6-sample skew between the two channels, exactly what you’d expect from two independently-tapped receiver channels of the same underlying transmission.

4. The one real decision point: skip AX.25 framing

Everything about the prompt (“satellite,” “AFSK1200”) screams AX.25 packet radio, which would normally mean: differential-decode with NRZI, hunt for HDLC flag bytes (0x7E), undo bit-stuffing, and strip a trailing FCS. That was the natural first guess — and it produced garbage.

Rather than iterating one variant at a time, the recovered 240-bit stream was decoded against every reasonable combination at once — raw vs. inverted vs. NRZI-decoded bits, each read MSB-first and LSB-first:

def try_all_variants(bits):
variants = {}
variants['raw'] = bits
variants['inverted'] = 1 - bits
# NRZI decode: a 0 = transition, a 1 = no transition
nrzi = np.zeros_like(bits)
prev = bits[0]
for i, b in enumerate(bits):
nrzi[i] = 0 if b != prev else 1
prev = b
variants['nrzi'] = nrzi
results = {}
for name, v in variants.items():
for order in ('msb', 'lsb'):
byts = []
for i in range(0, len(v) - 7, 8):
chunk = v[i:i+8]
if order == 'lsb':
chunk = chunk[::-1]
byte = int(''.join(map(str, chunk)), 2)
byts.append(byte)
text = bytes(byts)
results[f'{name}-{order}'] = text
return results
for name, text in try_all_variants(left_bits).items():
print(name, text)

Plain NRZ, MSB-first ASCII was immediately readable — no HDLC sync flags, no bit-stuffing, no frame check sequence. The 240 recovered bits split evenly into exactly 30 bytes: the flag plus a trailing newline.

Both stereo channels — despite their ~6-sample clock offset — decoded independently to the identical string, confirming the result:

HTB{REDACTED}

Key Steps

  1. Discover the staged flag.wav was a 0-byte artifact; recover the real file from the distributed zip.
  2. Unzip with the default HTB archive password hackthebox.
  3. Load the WAV (48 kHz, stereo, 0.2 s / 9,600 frames) and confirm Bell 202 tones (1200 Hz / 2200 Hz) via instantaneous-frequency analysis rather than a low-resolution FFT.
  4. Build the analytic signal, take the phase derivative to get instantaneous frequency, smooth, and threshold at 1700 Hz (the midpoint between mark/space tones).
  5. Recover the symbol clock (40 samples/symbol at 1200 baud) by picking the sampling offset that maximizes distance from the decision threshold.
  6. Skip the AX.25/HDLC framing rabbit hole once it produces garbage; brute-force the raw/inverted/NRZI × MSB/LSB matrix to find the readable variant immediately.
  7. Decode 240 bits as 30 raw ASCII bytes to recover the flag, cross-validated against the second stereo channel.

Tools Used

  • Python 3 (wave, numpy)
  • Manual Hilbert transform / instantaneous frequency analysis for AFSK demodulation
  • unzip (password-protected archive extraction)

Key Learnings

  • Staged 0-byte artifacts aren’t necessarily broken challenges — check the original distributed zip for the real payload before assuming the challenge is unsolvable.
  • Low-resolution FFTs on short audio clips lie about tone frequencies. With only 0.2 s of samples, bin resolution is ~5 Hz and FM sidebands distort peak locations; instantaneous-frequency analysis via the analytic signal (Hilbert transform → phase derivative) is far more reliable for confirming FSK tone pairs.
  • Don’t assume the “obvious” protocol layer. AFSK1200 + “satellite” strongly implies AX.25/HDLC packet radio framing, but this challenge used raw NRZ ASCII with no framing at all. When a standard framing assumption produces garbage, brute-force the small space of bit-polarity/encoding/bit-order variants rather than iterating guesses one at a time.
  • Cross-channel validation is free confirmation. Since the recording was stereo, decoding both channels independently and getting an identical result served as a built-in correctness check on the demodulation pipeline.