//Vault
>Challenge Card
- Event: BackDor CTF — Reverse category
- Binary:
chal(ELF 64-bit PIE, dynamically linked, stripped) - Author: continental
- Flag format:
flag{...}
>TL;DR
The binary refuses to run on my host because it was linked against GLIBC_2.38. Instead of patching glibc, I dumped .text, reverse engineered the on-the-fly validator generator at 0x1249, and reconstructed the per-character constraints stored in .data. A short Python script (analyze_vault.py) converts virtual addresses into file offsets, decodes all 53 self-modifying stubs, and solves the linear bit constraints to recover the password:
flag{hm_she11c0d3_v4u17_cr4ck1ng_4r3_t0ugh_r1gh7!!??}
>1. Environment & Recon
- File type:
$ file chal
chal: ELF 64-bit LSB pie executable ... GLIBC_2.38 ... stripped- Runtime check:
$ ./chal
./chal: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38' not foundBecause my host only ships glibc 2.36, I decided to solve the challenge statically.
- Strings:
$ strings -n 4 chal | head
mmap
NOPE
Good job
Enter the password:
L00ks like you got some real skill issue.The presence of mmap, munmap, and the lack of obvious comparison strings hinted at runtime code generation.
>2. High-Level Binary Flow
Disassembling .text (PIE base 0x1160) revealed the following call chain:
0x1460 main
├─ greets, asks for 0x35-byte password via scanf
├─ ensures newline removal (`strcspn`)
├─ length check == 0x35
└─ calls 0x1379 with the user buffer
0x1379 driver(buf)
├─ For each byte buf[i]:
│ ├─ Calls 0x1249 with index i (build validator stub)
│ ├─ Calls mmap'ed stub with args (char, ptrs)
│ └─ On failure prints "NOPE" and exits
└─ Prints "Good job" when the buffer ends with '\\0'
The interesting part lives inside 0x1249.
>3. Understanding the JIT Factory at 0x1249
0x1249 takes the current index (saved at [rbp-0x74]), calls mmap(0, 0x8000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|ANON, -1, 0), and fills the newly mapped memory with 0x39 bytes copied from .data. Key observations:
- The source bytes come from
.data + 0x4020(file offset0x1020because.dataloads at0x4000with file offset0x3000). - Each 0x39-byte chunk is XORed with a key pulled from another table starting at
.data + 0x4C00. - A third table at
.data + 0x4CE0stores 0x20-byte per-character structures that feed the stub during execution.
Every decoded stub looks like this (example from func_05.bin):
B9 xx xx xx xx mov ecx, <start_shift>
4831F7 xor rdi, rsi
4883FA08 cmp rdx, 0x8
0F94C0 setz al
7427 jz done
... loop ...
Inside the loop the stub extracts single bits from rdi (which equals char ^ key) and compares them to bytes stored via movzx rax, byte [r8 + rdx*4]. The stub increments rcx each round and masks it with 0x7, so each stub tests exactly eight bits with a rotated order. Hence, each character has:
- A one-byte XOR key (lowest byte of the 4-byte word from the second table).
- A sequence of eight expected bits stored at offsets
(idx * 0x20) + (0, 4, 8, ... 28)in the third table. - A starting bit index encoded in the
mov ecx, imm32at the beginning of the stub.
Recovering the password reduces to reversing these three tables.
>4. Solver Code
I wrote analyze_vault.py to automate the entire process:
from pathlib import Path
import struct
BASE = Path(__file__).resolve().parent
chal_path = BASE / "chal"
data = chal_path.read_bytes()
SECTION_ADDR = 0x4000
SECTION_OFFSET = 0x3000
def to_offset(addr: int) -> int:
return SECTION_OFFSET + (addr - SECTION_ADDR)
TABLE1_OFFSET = to_offset(0x4020)
TABLE2_OFFSET = to_offset(0x4C00)
TABLE3_OFFSET = to_offset(0x4CE0)
CODE_SIZE = 0x39 # 57 bytes per generated stub
section_data = data[TABLE1_OFFSET:TABLE2_OFFSET]
keys_data = data[TABLE2_OFFSET:TABLE3_OFFSET]
table3 = data[TABLE3_OFFSET:to_offset(0x5380)]
entry_count = len(table3) // 0x20 # each entry uses 32 bytes from table3
out_dir = BASE / "decoded_funcs"
out_dir.mkdir(exist_ok=True)
print(f"table1 bytes: {len(section_data)} | keys bytes: {len(keys_data)} | entries: {entry_count}")
password_bytes = []
for idx in range(entry_count):
key_offset = idx * 4
chunk_offset = idx * CODE_SIZE
chunk = section_data[chunk_offset:chunk_offset + CODE_SIZE]
if len(chunk) < CODE_SIZE:
break
if key_offset + 4 > len(keys_data):
break
key = struct.unpack_from('<I', keys_data, key_offset)[0]
xor_byte = key & 0xFF
decoded = bytes(b ^ xor_byte for b in chunk)
out_path = out_dir / f"func_{idx:02d}.bin"
out_path.write_bytes(decoded)
entry = table3[idx * 0x20:(idx + 1) * 0x20]
if len(entry) < 0x20:
break
if decoded[:1] != b"\\xB9":
raise ValueError(f"Unexpected stub prefix at index {idx}")
start_shift = int.from_bytes(decoded[1:5], 'little') & 0x7
expected = 0
for step in range(8):
bit = entry[step * 4] & 1
shift = (start_shift + step) & 0x7
expected |= (bit << shift)
password_byte = expected ^ xor_byte
password_bytes.append(password_byte)
print(f"wrote {out_path.name} | shift {start_shift} | char: {password_byte:02x}")
flag = bytes(password_bytes)
print(f"Recovered password bytes ({len(flag)}): {flag}")
Key implementation details:
to_offsettranslates runtime addresses to file offsets (PIE.dataat0x4000, file offset0x3000).- The script also writes each decoded stub to
decoded_funcs/func_XX.binfor manual inspection.
>5. Local Success
Running the solver locally:
$ python3 analyze_vault.py
...
wrote func_52.bin | shift 6 | char: 7d
Recovered password bytes (53): b'flag{hm_she11c0d3_v4u17_cr4ck1ng_4r3_t0ugh_r1gh7!!??}'
This output provides both the flag and proof that every constraint was satisfied.