GaianSpace-2026-Writeup

303-gerber-grab-crack

   
Category 03 — Orbital Oops at Mission Control
Points 100

Description

The intruder did not just read the employee’s drive. They packed up the Foundation One board files, scrambled them, and shipped them off the network before anyone noticed. The corporate capture caught the upload. The clean-up crew pulled a small working disk off the staging host before it was wiped.

You have the two halves: the bytes that went out the wire, and the staging disk the intruder worked from. The upload is scrambled, but the tool that scrambled it was left on the disk, and whoever wrote that tool was not careful about how it makes its key. Put the key back together, turn the upload back into the design, and the design will tell you what it is — the fabricator leaves a note on every board.

Recover the stolen board and read the fabricator’s note.

Flag format: GAIANSPACE{...}

Attachments:

Hints

Two hints were released during the event:

  1. The tool tells on itself — The disk carries the program that scrambled the file. Read it. Its key comes straight from a phrase it keeps in the open. There is more than one job on the wire and more than one key on the disk, including one the listing does not show; an older job opens under an older phrase but is not the board you were sent, so match the key to the job before you spend your one attempt.
  2. Rebuild, then open — The board files did not go out in one piece. Two uploads of that job carry an offset header; put the pieces together in offset order, not the order they were sent, before you undo anything. The status call afterwards gives the finished length and hash so you can check the join. Then base64-decode, decrypt with the key the tool uses, unzip, and read the note in the silkscreen file.

Solution

Analysis

Two artifacts are needed: the captured exfiltration (exfil_upload.pcap) and the attacker’s FAT12 working disk (attacker_disk.img). Neither artifact alone contains a readable board archive.

  1. Recover the encoder: parse the FAT12 boot sector, root directory, and 12-bit FAT entries to extract ENCRYPTO.PY (the FAT 8.3 name is ENCRYPTOPY ). The recovered code contains PASSPHRASE = ... and derives its AES key by computing SHA256(PASSPHRASE)[:16], yielding a 16-byte AES-128 key.
  2. Reassemble the upload: use TCP sequence numbers to recover HTTP requests. The two POST /upload/foundation_one_gerbers.zip.enc chunks advertise X-Upload-Offset and X-Upload-Length; place each body at its declared offset. Verify there are no gaps and compare the SHA-256 digest against the captured status response.
  3. Undo encoding and encryption: Base64-decode the reassembled bytes. The first 16 bytes are the CBC IV; decrypt the remainder with the derived AES-128 key and remove PKCS#7 padding.
  4. Inspect the Gerber archive: validate the resulting ZIP, save foundation_one_gerbers_recovered.zip, then search its silkscreen Gerber comment lines for the fabricator’s FAB-NOTE flag.

The relevant key derivation is:

key = hashlib.sha256(passphrase).digest()[:16]

The passphrase, key, fragment offsets, ciphertext, and flag are derived from the supplied disk and PCAP. They are not preloaded as solution constants.

Reproduction

The source requires the third-party cryptography package:

python3 -m pip install cryptography
python3 303.py

Place exfil_upload.pcap and attacker_disk.img next to the script first. Expected relevant output:

Passphrase: g3rb3r-h31st-2026
AES-128 key: ff6c6ae021c3d3f700d5ab05313f49d0
Upload offset 3000: 2952 bytes
Upload offset 0: 3000 bytes
Reassembled upload: 5952 encoded bytes
Status SHA256 verified
Recovered ZIP: .../foundation_one_gerbers_recovered.zip (4441 bytes)
Fabricator note: G04 FAB-NOTE foundation_one rev C : GAIANSPACE{s1lk_scr33n_le4k} do-not-pop*
FLAG: GAIANSPACE{s1lk_scr33n_le4k}

The output ZIP is the input Gerber collection for challenge 304; no external Gerber utility is required for this recovery step.

Solver Code

#!/usr/bin/env python3
"""Reconstruct GaianSpace 303 exfiltration and recover the Gerber FAB-NOTE."""

# Dependencies:
#   python3 -m pip install cryptography

import base64
import hashlib
import io
import re
import struct
import zipfile
from collections import defaultdict
from pathlib import Path

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7

BASE = Path(__file__).resolve().parent


def fat12_file(image: bytes, filename: bytes) -> bytes:
    bps = int(struct.unpack_from('<H', image, 11)[0])
    sectors_per_cluster = int(image[13])
    reserved = int(struct.unpack_from('<H', image, 14)[0])
    fat_count = int(image[16])
    root_entries = int(struct.unpack_from('<H', image, 17)[0])
    sectors_per_fat = int(struct.unpack_from('<H', image, 22)[0])
    root_sectors = (root_entries * 32 + bps - 1) // bps
    root_start = (reserved + fat_count * sectors_per_fat) * bps
    data_start = (reserved + fat_count * sectors_per_fat + root_sectors) * bps
    fat = image[reserved * bps:(reserved + sectors_per_fat) * bps]

    for off in range(root_start, root_start + root_entries * 32, 32):
        entry = image[off:off + 32]
        if entry[0] == 0:
            break
        if entry[0] == 0xE5 or entry[11] == 0x0F:
            continue
        if entry[:11].upper() != filename.upper():
            continue
        cluster = int(struct.unpack_from('<H', entry, 26)[0])
        size = int(struct.unpack_from('<I', entry, 28)[0])
        result = bytearray()
        seen: set[int] = set()
        while 2 <= cluster < 0xFF8:
            if cluster in seen:
                raise ValueError('FAT cluster loop')
            seen.add(cluster)
            start = data_start + (cluster - 2) * sectors_per_cluster * bps
            result.extend(image[start:start + sectors_per_cluster * bps])
            entry_off = cluster + cluster // 2
            packed = int.from_bytes(fat[entry_off:entry_off + 2], 'little')
            cluster = (packed >> 4) & 0xFFF if cluster & 1 else packed & 0xFFF
        return bytes(result[:size])
    raise ValueError(f'FAT file not found: {filename!r}')


def tcp_streams(pcap: bytes) -> dict[tuple[bytes, bytes, int, int], bytes]:
    magic = pcap[:4]
    if magic == b'\xd4\xc3\xb2\xa1':
        endian = '<'
    elif magic == b'\xa1\xb2\xc3\xd4':
        endian = '>'
    else:
        raise ValueError('Expected classic PCAP')
    linktype = int(struct.unpack_from(endian + 'I', pcap, 20)[0])
    if linktype != 1:
        raise ValueError('Expected Ethernet link type')
    segments: dict[tuple[bytes, bytes, int, int], list[tuple[int, bytes]]] = defaultdict(list)
    offset = 24
    while offset + 16 <= len(pcap):
        _, _, captured, _ = struct.unpack_from(endian + 'IIII', pcap, offset)
        offset += 16
        frame = pcap[offset:offset + captured]
        offset += captured
        if len(frame) < 14 or frame[12:14] != b'\x08\x00':
            continue
        ip = frame[14:]
        if len(ip) < 20 or ip[9] != 6:
            continue
        ihl = (ip[0] & 15) * 4
        ip_len = int.from_bytes(ip[2:4], 'big')
        tcp = ip[ihl:ip_len]
        if len(tcp) < 20:
            continue
        hdr_len = (tcp[12] >> 4) * 4
        payload = tcp[hdr_len:]
        if not payload:
            continue
        sport = int.from_bytes(tcp[:2], 'big')
        dport = int.from_bytes(tcp[2:4], 'big')
        seq = int.from_bytes(tcp[4:8], 'big')
        key = (ip[12:16], ip[16:20], sport, dport)
        segments[key].append((seq, payload))

    output = {}
    for key, chunks in segments.items():
        chunks.sort(key=lambda item: item[0])
        initial = chunks[0][0]
        assembled = bytearray()
        for seq, payload in chunks:
            pos = seq - initial
            if pos > len(assembled):
                raise ValueError(f'Missing TCP segment on flow {key}')
            skip = max(0, len(assembled) - pos)
            assembled.extend(payload[skip:])
        output[key] = bytes(assembled)
    return output


def main() -> None:
    disk = (BASE / 'attacker_disk.img').read_bytes()
    source = fat12_file(disk, b'ENCRYPTOPY ')
    match = re.search(rb'^PASSPHRASE\s*=\s*["\']([^"\']+)["\']', source, re.M)
    if match is None or b'sha256' not in source or b'[:16]' not in source:
        raise ValueError('Unexpected key derivation in ENCRYPTO.PY')
    passphrase = match.group(1)
    key = hashlib.sha256(passphrase).digest()[:16]
    print(f'Passphrase: {passphrase.decode("ascii")}')
    print(f'AES-128 key: {key.hex()}')

    streams = tcp_streams((BASE / 'exfil_upload.pcap').read_bytes())
    parts: dict[int, bytes] = {}
    upload_length = None
    expected_sha = None
    for flow, stream in streams.items():
        if flow[3] == 8080 and b'POST /upload/foundation_one_gerbers.zip.enc ' in stream:
            sep = stream.find(b'\r\n\r\n')
            if sep < 0:
                raise ValueError('HTTP header incomplete')
            head, body = stream[:sep], stream[sep + 4:]
            if not head.startswith(b'POST /upload/foundation_one_gerbers.zip.enc '):
                continue
            def header(name: bytes) -> int:
                found = re.search(rb'(?im)^' + re.escape(name) + rb':\s*(\d+)\s*$', head)
                if not found:
                    raise ValueError(f'Missing HTTP header {name!r}')
                return int(found.group(1))
            part_off = header(b'X-Upload-Offset')
            upload_length = header(b'X-Upload-Length')
            content_length = header(b'Content-Length')
            if len(body) < content_length:
                raise ValueError('Truncated HTTP upload body')
            parts[part_off] = body[:content_length]
            print(f'Upload offset {part_off}: {content_length} bytes')
        elif flow[2] == 8080:
            match_sha = re.search(rb'"sha256"\s*:\s*"([0-9a-f]{64})"', stream)
            if match_sha is not None and b'foundation_one_gerbers.zip.enc' in stream:
                expected_sha = match_sha.group(1).decode('ascii')

    if upload_length is None or not parts:
        raise ValueError('Foundation One upload not found')
    encoded = bytearray(upload_length)
    coverage = bytearray(upload_length)
    for off, payload in parts.items():
        if off + len(payload) > upload_length:
            raise ValueError('Upload fragment out of bounds')
        encoded[off:off + len(payload)] = payload
        coverage[off:off + len(payload)] = b'\x01' * len(payload)
    if not all(coverage):
        raise ValueError('Upload contains gaps')

    encrypted = base64.b64decode(bytes(encoded), validate=True)
    actual_sha = hashlib.sha256(bytes(encoded)).hexdigest()
    print(f'Reassembled upload: {len(encoded)} encoded bytes')
    print(f'Upload SHA256: {actual_sha}')
    if expected_sha is not None:
        if actual_sha != expected_sha:
            print('Note: status hash differs from Base64 body; checking decoded ciphertext')
            if hashlib.sha256(encrypted).hexdigest() != expected_sha:
                raise ValueError('Status SHA256 mismatch')
        print('Status SHA256 verified')

    iv, ciphertext = encrypted[:16], encrypted[16:]
    decryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
    padded = decryptor.update(ciphertext) + decryptor.finalize()
    unpadder = PKCS7(128).unpadder()
    plaintext = unpadder.update(padded) + unpadder.finalize()
    if not zipfile.is_zipfile(io.BytesIO(plaintext)):
        raise ValueError('Decrypted data is not a ZIP archive')
    output = BASE / 'foundation_one_gerbers_recovered.zip'
    output.write_bytes(plaintext)
    print(f'Recovered ZIP: {output} ({len(plaintext)} bytes)')

    with zipfile.ZipFile(io.BytesIO(plaintext)) as archive:
        for name in archive.namelist():
            print(f'ZIP: {name}')
            if 'Silkscreen' in name:
                layer = archive.read(name)
                for note in re.findall(rb'G04[^\r\n]*FAB-NOTE[^\r\n]*', layer):
                    print('Fabricator note:', note.decode('ascii'))
                    flag = re.search(rb'GAIANSPACE\{[^}\r\n]+\}', note)
                    if flag:
                        print('FLAG:', flag.group().decode('ascii'))
                        return
    raise ValueError('Flag not found in silkscreen')


if __name__ == '__main__':
    main()

Flag

GAIANSPACE{s1lk_scr33n_le4k}