Universal 2026 — Carrier Lock Writeup

A raw satellite downlink that refuses to auto-frame. Recognize the CCSDS telemetry structure, undo the PN9 pseudo-randomizer, reassemble the space-packet stream, and the flag falls out of a single virtual channel carrying a fragmented message.

5 min read
ibrahimsql
809 words

Universal 2026 — Carrier Lock

A space-forensics challenge. We are handed the "raw bits" a ground station pulled off a faint, repeating transmission — no framing, no metadata, just a blob. The task is to lock onto the carrier and read what it carries.

  • Event: Universal 2026
  • Category: Forensics
  • Artifact: downlink.bin
  • Flag format: uctf{...}

1. The blob

Text
$ file downlink.bin
downlink.bin: data
$ ls -l downlink.bin
-rw-r--r--  1 user  staff  22616 downlink.bin

22,616 bytes of high-entropy "data" with no recognizable container. The hint said it could not be auto-framed, which is the norm for space telemetry: an encoder syncs on a marker the analyst has to find first.

2. Finding the frame marker

CCSDS telemetry transfer frames are delimited by a 4-byte Attached Sync Marker. Scanning for it returns evenly spaced hits:

Python
data = open("downlink.bin", "rb").read()
i, offsets = 0, []
while True:
    j = data.find(b"\x1a\xcf\xfc\x1d", i)
    if j == -1:
        break
    offsets.append(j)
    i = j + 4
print(len(offsets))                 # 22
print([b - a for a, b in zip(offsets, offsets[1:])])   # [1028, 1028, ...]

1A CF FC 1D is the CCSDS Attached Sync Marker. There are 22 of them, each 1028 bytes apart: a 4-byte marker plus a 1024-byte transfer frame. That is 22 × 1028 = 22,616 bytes — the whole file, exactly.

So the capture is 22 fixed-length transfer frames. But the frame bodies are still noise, because CCSDS frames are pseudo-randomized before transmission.

3. Undoing the PN9 pseudo-randomizer

CCSDS applies a PN9 randomizer (polynomial x⁸ + x⁷ + x⁵ + x³ + 1) to avoid long runs of identical bits. It is a byte-wise XOR with a fixed sequence, and it resets at the start of every transfer frame. Reimplementing the LFSR and xor-ing each 1024-byte body undoes it:

Python
def pn9(n):
    reg, out = 0xFF, bytearray()
    for _ in range(n):
        b = 0
        for _ in range(8):
            b = (b << 1) | (reg & 1)
            fb = ((reg >> 7) ^ (reg >> 5) ^ (reg >> 3) ^ (reg >> 0)) & 1
            reg = ((reg >> 1) | (fb << 7)) & 0xFF
        out.append(b)
    return bytes(out)
 
ks = pn9(1024)
frames = [bytes(a ^ b for a, b in zip(data[o+4:o+1028], ks)) for o in offsets]

The first six bytes of each frame now decode cleanly — they are the CCSDS primary header:

frame 0  header: 0a b0 00 00 18 00
frame 1  header: 0a b0 01 01 18 0e
frame 2  header: 0a b0 02 02 18 ...

The version bits, the spacecraft ID (171), and the frame counters all increment as they should. The randomizer is gone and the frames are real.

4. Reassembling the packet stream

The 1018-byte data field of each frame (1024 minus the 6-byte primary header) is not self-contained. Parsing frame by frame overflows immediately on frame 1 — the packet stream is continuous across frame boundaries. Joining the data fields into one stream and walking it with the CCSDS Space Packet structure (6-byte header) fixes it:

Python
stream = b"".join(f[6:] for f in frames)
o, counts = 0, {}
while o + 6 <= len(stream):
    b0, b1, b2, b3, b4, b5 = stream[o:o+6]
    apid = ((b0 & 0x07) << 8) | b1
    dl = ((b4 << 8) | b5) + 1          # length field is (size - 1)
    payload = stream[o+6:o+6+dl]
    counts[apid] = counts.get(apid, 0) + 1
    o += 6 + dl
 
print(counts)
# {200: 424, 300: 424, 400: 424, 250: 9, 2047: 1}

Three application IDs dominate (200, 300, 400 with 424 packets each) — that is housekeeping telemetry, a repetitive, boring pattern. APID 2047 is a single packet of 0x90 bytes, pure padding. And APID 250 appears exactly nine times. That is the interesting one.

5. The message on APID 250

Pulling just the APID-250 payloads out and sorting them by the packet sequence count (the third header byte) reassembles the message:

msg = [(pkt_seq, payload) for ... if apid == 250]
flag = "".join(p for _, p in sorted(msg))
print(flag)
Text
# file order (interleaved with housekeeping, out of order):
# 'uctf'  '1g'  '{s1g'  'n4l_'  '1n}'  '0wn_'  '0r'  'unkn'  '0f_'
# after sorting by sequence count 0..8:
uctf{s1gn4l_0f_unkn0wn_0r1g1n}

The flag text was split into nine small packets and scattered through the telemetry stream. Sequence counts 0 through 8 put them back in order.

6. Flag

uctf{s1gn4l_0f_unkn0wn_0r1g1n}

Signal of unknown origin. The chain — sync marker, PN9 derandomization, continuous packet reassembly, sequence ordering — is the standard workflow for any CCSDS capture. The three-part trap is that the frames look like noise until you find the marker, look like garbage until you unrandomize, and look broken until you stop framing per-frame and treat the data fields as one continuous stream.

---
Share this post:

What do you think?

React to show your appreciation

Related Posts