1040 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/LIGHT/Downloads/space_usb.img"
/home/LIGHT/Downloads/space_usb.img: DOS/MBR boot sector; partition 1 : ID=0xee, ...
mmls "/home/LIGHT/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/LIGHT/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/LIGHT/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/LIGHT/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/LIGHT/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/LIGHT/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/LIGHT/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/LIGHT/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/LIGHT/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/LIGHT/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://fuwari.vercel.app/posts/54/univsthreats-26-quals-ctf-bro-is-not-an-astronaut-forensics-writeup/
Author
Light
Published at
2026-02-27
License
CC BY-NC-SA 4.0