“The Descent” Crackme — Solve Notes

Quick reference for the two-stage crackme (serial_of gate + XOR-encrypted vault).

Overview

The binary has two gates:

  1. Floor 1 – Sponsorship Gate: prompts for a Crawler ID and a Sponsor Serial. The serial must equal a hash of the ID.
  2. Floor 2 – Sealed Vault: prompts for a phrase. If correct, prints a flag that was XOR-decrypted from an embedded blob at runtime.

Floor 1 — serial_of()

undefined8 serial_of(byte *param_1, char *param_2)
{
  uint local_14 = 0x1505;
  for (byte *p = param_1; *p != 0; p++)
      local_14 = (uint)*p ^ (local_14 * 0x21);   // 0x21 = 33
  sprintf(param_2, "%08X", (ulong)local_14);
  return 0;
}
  • This is a djb2-style hash (seed 0x1505, multiplier 33), formatted as an 8-digit uppercase hex string.
  • There’s no secret — the “serial” is just the hash of whatever ID you type.
  • Solve strategy: pick any Crawler ID, compute its hash the same way, and supply that hash as the serial.
def serial_of(s: str) -> str:
    h = 0x1505
    for c in s.encode():
        h = (c ^ (h * 0x21)) & 0xFFFFFFFF
    return "%08X" % h
 
print(serial_of("test"))  # -> 7C73AF33

Example that passes Floor 1:

Crawler ID: test
Sponsor Serial: 7C73AF33

Floor 2 — XOR-encrypted vault

Key derivation

for (i = 0; i < 8; i++)
    real_key[i] = obf_key[i] ^ 0x5A;

obf_key bytes (from binary): C0 66 2B BF 52 E7 1C 75 → XOR with 0x5A

real_key = 9A 3C 71 E5 08 BD 46 2F

Decryption routine — unpack()

void unpack(long src, int len, long key, int keylen, long dst) {
    for (int i = 0; i < len; i++)
        dst[i] = src[i] ^ key[i % keylen];
}

Simple repeating-key XOR. Since XOR is self-inverse, decrypting = re-applying the same key.

Decrypting enc_pass (10 bytes → 9-char passphrase + NUL)

Ciphertext: D8 53 03 84 66 C9 05 40 E8 4C

XOR each byte with real_key[i % 8]:

icipherkeyplain
0D89AB
1533Co
20371r
384E5a
46608n
5C9BDt
60546C
7402Fo
8E89Ar
94C3Cp

Passphrase = BorantCorp

Decrypting enc_flag (48 bytes → flag string)

Same key, same technique, applied to the longer enc_flag blob.

Flag: DBFCTF{the_desperado_club_always_pays_its_debts}


General takeaways / reusable technique

  1. Always separate the two problems: a “which input passes the gate” check (Floor 1, hash-based) vs. “what secret is hidden in the binary” (Floor 2, XOR-encrypted data). They can use completely unrelated mechanisms even in the same binary.
  2. Repeating-key XOR is trivially reversible — if you can dump the ciphertext and the key (or how the key is derived, e.g. obf_key ^ constant), you can decrypt entirely offline without ever running the binary.
  3. Custom hash-check gates (djb2/FNV-style loops with a multiply-XOR pattern) usually don’t need to be “cracked” — you just need to replicate the same arithmetic in Python and pick any input, then compute its correct output.
  4. Ghidra/decompiler workflow used here:
    • Decompile main to find the overall flow and prompts.
    • Decompile helper functions (serial_of, unpack) referenced from main.
    • Dump raw bytes of relevant global data (obf_key, enc_pass, enc_flag) via the disassembly listing or gdb/objdump.
    • Reimplement the logic in Python to compute expected inputs / decrypt secrets without needing to brute-force anything at runtime.
# Full reusable solve script
def serial_of(s: str) -> str:
    h = 0x1505
    for c in s.encode():
        h = (c ^ (h * 0x21)) & 0xFFFFFFFF
    return "%08X" % h
 
def unpack(cipher: bytes, key: bytes) -> bytes:
    return bytes(c ^ key[i % len(key)] for i, c in enumerate(cipher))
 
obf_key = bytes.fromhex("C0662BBF52E71C75")
real_key = bytes(b ^ 0x5A for b in obf_key)
 
enc_pass = bytes.fromhex("D8530384 66C9 0540 E84C".replace(" ", ""))
enc_flag = bytes.fromhex("<fill in all 48 bytes here>")
 
print(unpack(enc_pass, real_key).rstrip(b"\x00").decode())
print(unpack(enc_flag, real_key).rstrip(b"\x00").decode())

Transclude of the-darkest-descent.7z