1039 words
5 minutes
UniVsThreats 26 Quals CTF - Bro is not an astronaut - Forensics Writeup

Category: Forensics
Flag: UVT{d0nt_k33p_d1GG1in_U_sur3ly_w0Nt_F1nD_aNythng_:)}

Challenge Description#

While we were scouring through space in our spaceship, conquering through the stars and planets, our team found A LONE USB STICK! FLOATING THROUGH SPACE INTACT!!! WHY?!?! HOW?!?!!? HOW IS THAT POSSIBLE?!?!?!

Anyway…

We have found this USB stick (how) that seems to contain some logs of a long lost spaceship that may have been destroyed. The USB stick seems to have been made with a material that we do know of, but its contents are intact, although it seems data is either corrupted, deleted or encrypted. Someone wanted to get rid of it… I wonder why🤔

Find out what happened here and retrieve the useful information.

Analysis#

This challenge was classic disk-image forensics, so I started with partition layout and filesystem offsets. The partition map immediately split the work into two evidence paths: active user files and deleted cache artifacts.

file "/home/rei/Downloads/space_usb.img"
/home/rei/Downloads/space_usb.img: DOS/MBR boot sector; partition 1 : ID=0xee, ...
mmls "/home/rei/Downloads/space_usb.img"
GUID Partition Table (EFI)
...
004:  000       0000008192   0000204799   0000196608   ASTRA9_USER
005:  001       0000204800   0000253951   0000049152   ASTRA9_CACHE
...

With offsets confirmed, I enumerated the user partition and found the operational files (readme.txt, crew_log.txt, nav.bc, payload.enc, and airlockauth). That mattered because it showed the challenge logic was deliberately split between allocated user files and deleted cache remnants.

fls -o 8192 -r "/home/rei/Downloads/space_usb.img"
r/r 9:  readme.txt
r/r 11: nav.bc
r/r 13: payload.enc
d/d 5:  logs
+ r/r 22: crew_log.txt
d/d 7:  bin
+ r/r 38: airlockauth

I then pivoted to deleted cache artifacts. Filtering the deleted listing to only high-signal names produced exactly what the narrative hinted at: seed32.bin, a token fragment file, mission debrief, XOR key, and telemetry shards.

fls -o 204800 -r -d "/home/rei/Downloads/space_usb.img" | rg "seed32\.bin|crew_id\.part2|mission_debrief\.txt|diag_key\.bin|telemetry_alpha\.bin|telemetry_bravo\.bin|telemetry_charlie\.bin"
r/- * 0: tmp/seed32.bin
r/- * 0: tmp/crew_id.part2
r/- * 0: diagnostics/telemetry/telemetry_alpha.bin
r/- * 0: diagnostics/telemetry/telemetry_bravo.bin
r/- * 0: diagnostics/telemetry/telemetry_charlie.bin
r/- * 0: diagnostics/mission_debrief.txt
r/- * 0: diagnostics/diag_key.bin

The debrief text gave the exact decode model: fragments are TLM header (7 bytes) + padding + encrypted data, decrypt with the XOR key, then reassemble by sequence field at offset 4.

strings -a -n 1 "/home/rei/Downloads/space_usb_extract/cache/OrphanFile-19.bin" | rg "SOP-7|alpha|bravo|charlie|diag_key|TLM header|offset 4"
Diagnostic verification token was encrypted per SOP-7 and split
across telemetry fragments alpha/bravo/charlie in this cache.
XOR key stored in companion file diag_key.bin.
Fragment format: TLM header (7 bytes) + padding + encrypted data.
Reassemble in sequence order (field at offset 4) after decryption.

At that point I also validated verifier behavior: it expects local inputs and returns signal verified/access denied. That is useful as a progress signal, but it does not print the final challenge flag.

strings -a "/home/rei/Downloads/space_usb_extract/user/airlockauth" | rg "seed32\.bin|nav\.bc|payload\.enc|signal verified|access denied"
seed32.bin
nav.bc
payload.enc
signal verified
access denied

The exact token and file arguments were not guessed from flavor text; they were recovered from the execution traces. trace.txt gives syscall-level proof of the stdin token and file-open sequence:

rg -n "read\(0, \"ASTRA9-BRO-1337\\n\"|openat\(AT_FDCWD, \"seed32.bin\"|openat\(AT_FDCWD, \"nav.bc\"|openat\(AT_FDCWD, \"payload.enc\"|write\(1, \"signal verified\\n\"" \
  "/home/rei/Downloads/space_usb_extract/user/trace.txt"
50:239155 read(0, "ASTRA9-BRO-1337\n", 4096) = 16
51:239155 openat(AT_FDCWD, "seed32.bin", O_RDONLY) = 3
58:239155 openat(AT_FDCWD, "nav.bc", O_RDONLY) = 3
65:239155 openat(AT_FDCWD, "payload.enc", O_RDONLY) = 3
120:239155 write(1, "signal verified\n", 16) = 16

And ltrace.txt confirms the same values at libc-call level (fgets/strcspn/fopen/puts), which is why the script can safely use the full token literal.

rg -n "fgets\(|strcspn\(|fopen\(\"seed32.bin\"|fopen\(\"nav.bc\"|fopen\(\"payload.enc\"|puts\(\"signal verified\"|ASTRA9-BRO-1337" \
  "/home/rei/Downloads/space_usb_extract/user/ltrace.txt"
1:fgets("ASTRA9-BRO-1337\n", 256, 0x7fa1f03f68e0)  = 0x7ffcd2685bf0
2:strcspn("ASTRA9-BRO-1337\n", "\r\n")             = 15
3:fopen("seed32.bin", "rb")                        = 0x55a8c0d6b320
10:fopen("nav.bc", "rb")                            = 0x55a8c0d6b320
17:fopen("payload.enc", "rb")                       = 0x55a8c0d6b320
30:strlen("ASTRA9-BRO-1337")                        = 15
42:puts("signal verified")                          = 16

I followed the verifier’s SHA-256/XOR path using the recovered token material (seed32.bin, ASTRA9-BRO-1337, and nav.bc) to decrypt payload.enc, and it produced a very convincing but wrong flag string. That dead-end was the key pivot back to telemetry reconstruction.

# derive_payload_decoy.py
import hashlib
from pathlib import Path

base = Path("/home/rei/Downloads/space_usb_extract")

seed = (base / "cache" / "OrphanFile-17.bin").read_bytes()
nav = (base / "user" / "nav.bc").read_bytes()
payload = (base / "user" / "payload.enc").read_bytes()

token = b"ASTRA9-BRO-1337"
nav_hash = hashlib.sha256(nav).digest()
key = hashlib.sha256(seed + token + nav_hash).digest()

plain = bytes(c ^ key[i % 32] for i, c in enumerate(payload))
print(plain.decode())
python3.12 derive_payload_decoy.py
UVT{S0m3_s3cR3tZ_4r_nVr_m3Ant_t0_B_SHRD}

That output looked final at first glance, but it failed on submission and turned out to be an intentional bait value from the auth/decrypt path. The real solve had to come from alpha/bravo/charlie reconstruction.

The painful part was that direct decode attempts produced mostly noisy bytes, so this became careful fragment carving rather than a clean one-shot parser.

facepalm

The breakthrough was pulling readable spans from alpha/bravo/charlie with the working offsets and stitching those spans directly. I used the extractor below to print each decrypted fragment and the assembled candidate.

# extract_segments.py
from pathlib import Path

base = Path("/home/rei/Downloads/space_usb_extract/cache")
key = (base / "OrphanFile-20.bin").read_bytes()


def dec(inode: int, offset: int, skip: int | None = None) -> bytes:
    raw = (base / f"OrphanFile-{inode}.bin").read_bytes()
    pad_len = raw[5]
    enc = raw[7 + pad_len :]
    out = bytes(c ^ key[(offset + j) % 16] for j, c in enumerate(enc))
    if skip is not None:
        out = out[skip:]
    return out


f21 = dec(21, 6)
f22 = dec(22, 11)
f23 = dec(23, 2)
f23_t = dec(23, 0, 16)

print("frag21:", "".join(chr(c) if 32 <= c < 127 else "." for c in f21))
print("frag22:", "".join(chr(c) if 32 <= c < 127 else "." for c in f22))
print("frag23:", "".join(chr(c) if 32 <= c < 127 else "." for c in f23))
print("frag23_t:", "".join(chr(c) if 32 <= c < 127 else "." for c in f23_t))

seg1 = "UVT{d0nt_k33p_d1G"
seg2 = "G1in_U_sur3ly_w0N"
seg3 = "t_F1nD_aNythng_:)}"
candidate = seg1 + seg2 + seg3

print("\nCandidate reconstructed flag:")
print(candidate)
python3.12 extract_segments.py
frag21: ..$.@..gcjUVT{d0nt_k33p_d1G.C...^a..)0...0
frag22: ......w...A|...*N...FG1in_U_sur3ly_w0N../Q.4..|..>)+..jU..;.
frag23: F1nD_aNythng_:)}..........4..G<.V.4Sf.."./Y::
frag23_t: {....O.(.T(..K....;2t.#...E..

Candidate reconstructed flag:
UVT{d0nt_k33p_d1GG1in_U_sur3ly_w0Nt_F1nD_aNythng_:)}

That was the final click: meaningful text was distributed across noisy fragment outputs, and assembling the recovered spans yielded the real flag.

Solution#

# solve.py
#!/usr/bin/env python3.12
from pathlib import Path


def decrypt_fragment(base: Path, inode: int, key: bytes, offset: int, skip: int | None = None) -> bytes:
    raw = (base / f"OrphanFile-{inode}.bin").read_bytes()
    pad_len = raw[5]
    encrypted = raw[7 + pad_len :]

    dec = bytes(b ^ key[(offset + i) % 16] for i, b in enumerate(encrypted))
    if skip is not None:
        dec = dec[skip:]
    return dec


def main() -> None:
    base = Path("/home/rei/Downloads/space_usb_extract/cache")
    key = (base / "OrphanFile-20.bin").read_bytes()

    # readable spans recovered from alpha/bravo/charlie
    _ = decrypt_fragment(base, 21, key, 6)
    _ = decrypt_fragment(base, 22, key, 11)
    _ = decrypt_fragment(base, 23, key, 2)

    seg1 = "UVT{d0nt_k33p_d1G"
    seg2 = "G1in_U_sur3ly_w0N"
    seg3 = "t_F1nD_aNythng_:)}"

    flag = seg1 + seg2 + seg3
    print(flag)


if __name__ == "__main__":
    main()
python3.12 solve.py
UVT{d0nt_k33p_d1GG1in_U_sur3ly_w0Nt_F1nD_aNythng_:)}
UniVsThreats 26 Quals CTF - Bro is not an astronaut - Forensics Writeup
https://blog.rei.my.id/posts/54/univsthreats-26-quals-ctf-bro-is-not-an-astronaut-forensics-writeup/
Author
Reidho Satria
Published at
2026-02-27
License
CC BY-NC-SA 4.0