Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
ReverseMedium

Vault

CTF writeup for Vault from Backdoor

//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

  1. File type:
bash
$ file chal

chal: ELF 64-bit LSB pie executable ... GLIBC_2.38 ... stripped
  1. Runtime check:
bash
$ ./chal

./chal: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38' not found

Because my host only ships glibc 2.36, I decided to solve the challenge statically.

  1. Strings:
bash
$ 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 offset 0x1020 because .data loads at 0x4000 with file offset 0x3000).
  • Each 0x39-byte chunk is XORed with a key pulled from another table starting at .data + 0x4C00.
  • A third table at .data + 0x4CE0 stores 0x20-byte per-character structures that feed the stub during execution.

Every decoded stub looks like this (example from func_05.bin):

nasm

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:

  1. A one-byte XOR key (lowest byte of the 4-byte word from the second table).
  2. A sequence of eight expected bits stored at offsets (idx * 0x20) + (0, 4, 8, ... 28) in the third table.
  3. A starting bit index encoded in the mov ecx, imm32 at 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:

python

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_offset translates runtime addresses to file offsets (PIE .data at 0x4000, file offset 0x3000).
  • The script also writes each decoded stub to decoded_funcs/func_XX.bin for manual inspection.

>5. Local Success

Running the solver locally:

bash

$ 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.