reversefreehard

cf madness

pingctf2026

Task: ELF crackme with 89KB .text section that breaks decompilers via nop-sled + return-address-rewriting obfuscation. Solution: trace control flow through trampoline mechanism, recover per-character hash formula, extract expected values from .data, and solve symbolically.

$ ls tags/ techniques/
static_symbolic_solvereturn_address_rewritingfunction_table_dispatchdata_flow_analysis

cf madness — pingCTF 2026

Description

Ghidra had a stroke, IDA can't decompile, i don't have Binja. Only :SnowmanDecompiler: survived. (it's a joke, i wouldn't actually make you use snowman)

Files provided:

  • chall — ELF 64-bit LSB executable, x86-64, stripped

The binary is a classic flag checker: prompts for input, validates it, prints "Correct flag!" or "Wrong flag!". The twist is that the .text section is 89KB — unusually large for a simple crackme — and decompilers struggle to produce meaningful output.

Analysis

Initial recon

$ file chall
chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, stripped

$ checksec chall
No PIE, NX enabled, Partial RELRO

$ objdump -d chall | wc -l
77627

Only 4 imports: puts, printf, scanf, strlen. The entry point at 0x4003b0 calls __libc_start_main(0x4035cc, ...), so the real "main" is at 0x4035cc.

Main at 0x4035cc

The main function:

  1. Prints "Enter the flag: "
  2. Reads input with scanf("%s", 0x41c560)
  3. Stores 726 * (strlen(input)+1) as a long double at 0x41b490
  4. Falls through ~500 bytes of nop sled

The use of x87 long double to store an integer is pure obfuscation — it confuses decompilers that expect floating-point semantics.

The nop sled (0x403651 to 0x415e31)

The code between main and the trampoline is mostly nop instructions. Every ~0x41C bytes there's an opaque predicate block:

mov r10, 0x13 xor r11, r11 test r11, r11 jne junk ; never taken add r11, r10 imul r11, r11, 0x1337 xor r11, r10

These blocks only touch r10/r11 and never alter control flow — pure anti-analysis noise to break linear sweep disassembly.

The trampoline at 0x415e31

Execution falls through the nop sled and reaches the trampoline. This is the core validation loop:

// Write trampoline value to result[even_idx] int tramp_val = (int32_t)(-559038737 + *tbyte(0x41b490)); idx = [0x41b480]++; result[idx] = tramp_val; // array at 0x41a480 // Mark bitmap, save c1 c1 = [0x41b4a0]; bitmap[c1 % 128] = 1; // at 0x41b4c0 [0x41c5f0] = c1; // save for later // Dispatch to function_table[input[c1 % 128]] char ch = input[c1 % 128]; rax = function_table[ch]; // table at 0x419060 rax(); // Check if all characters processed count = popcount(bitmap[0..127]); if (count >= strlen) { validator(); // 0x403544 return; } // Compute new return address *tbyte(0x41b490) -= 726; new_ret = 0x415f9c - (int)(*tbyte(0x41b490)); [0x419040] = [0x41c5f0]; // c2 = saved_c1 *(rsp) = new_ret; ret; // jumps into nop sled, falls back to 0x415e31

The key insight: each dispatch ret lands at a different offset in the nop sled, but execution always falls through back to 0x415e31. After strlen iterations, the validator is invoked.

Function table at 0x419060

The table holds 128 function pointers, one for each ASCII code 0..127. Each function is ~97 bytes:

void func_k(void) { esi = (0xdeadbeef ^ k) ^ (c1 * 0x1337) ^ (c2 * 0xabcd); result[idx++] = esi; // writes result[odd_idx] c2 = k; // overwritten by trampoline on return! c1++; }

Function addresses:

  • func_0: 0x4004c9 (xor-with-0 optimized out)
  • func_k (k>=1): 0x400525 + (k-1)*0x61

The critical gotcha: initial c2 = 1337

The initial value of c2 is not zero — it's 1337 (0x539), stored in .data at 0x419040. Missing this detail produces wrong results for the first character.

Putting it together

Let iteration index k be 1-based. Before iteration 1: c1=0, c2=1337.

At entry of function on iteration k:

  • c1 = k-1
  • c2 = k-2 for k≥2, but c2 = 1337 for k=1

The result array is populated as:

  • result[2k-2] = trampoline value = (int32_t)(-559038737 + 726*(strlen+2-k))
  • result[2k-1] = function output = 0xdeadbeef ^ input[k-1] ^ ((k-1)*0x1337) ^ (c2*0xabcd)

Extracting strlen and expected values

The expected array is at 0x419460 (file offset 0x18460).

From expected[0] = 0xdeae5267 = -559000985:

  • Delta from -559038737 is 37752 = 726 * 52
  • So strlen+1 = 52strlen = 51

All 51 even-indexed entries match the trampoline formula.

Recovering the flag

For each k from 1 to 51:

c1 = k-1
c2 = 1337 if k==1 else k-2
input[k-1] = expected[2k-1] ^ 0xdeadbeef ^ (c1*0x1337) ^ (c2*0xabcd)

All characters come out in printable ASCII range.

Solution

#!/usr/bin/env python3 """ Solver for 'cf madness' challenge. Structure: - Main reads flag with scanf into 0x41c560 - Sets stored_value (long double) = 726*(strlen+1) at 0x41b490 - Falls through huge nop sled into a "trampoline" at 0x415e31 - Trampoline writes a value based on 0xdeadbeef + stored_value to array[even_idx] - Then indexes func_table[0x419060] by input char and calls it - Each func_k (k=0..127) computes a hash involving counter c1, "prev" c2 - After function, trampoline overrides c2 = saved_c1 (NOT input char value) - Subtracts 726 from stored_value and returns to new location in nop sled At iteration k (1-indexed): - c1 before = k-1 - c2 before function = k-2 (for k>=2), or initial 1337 for k=1 - array[2k-2] = int32( -559038737 + 726*(strlen+2-k) ) - array[2k-1] = 0xdeadbeef ^ input[k-1] ^ (k-1)*0x1337 ^ c2*0xabcd Expected array at 0x419460. """ import struct with open('chall', 'rb') as f: elf = f.read() # Find file offset for 0x419460 file_off = 0x17df8 + (0x419460 - 0x418df8) INITIAL_C2 = 1337 XOR_CONST = 0xdeadbeef # Find strlen from first trampoline value exp0 = struct.unpack('<i', elf[file_off:file_off+4])[0] delta = exp0 - (-559038737) strlen = (delta // 726) - 1 # 51 # Decode input flag = [] for k in range(1, strlen + 1): c2 = INITIAL_C2 if k == 1 else k - 2 c1 = k - 1 arr_2k_1 = struct.unpack('<I', elf[file_off + (2*k-1)*4 : file_off + (2*k-1)*4 + 4])[0] char_code = (arr_2k_1 ^ XOR_CONST ^ ((c1 * 0x1337) & 0xFFFFFFFF) ^ ((c2 * 0xabcd) & 0xFFFFFFFF)) & 0xFF flag.append(char_code) print(''.join(map(chr, flag))) # ping{n0_c0mp1l3r_w45_hur7_dur1ng_m4k1ng_7h15_ch4ll}

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups