← Back to Writeups
HTBN/AReversing

cf madness

XESXOR8/23/20266 min read
#reversing#htb#n/a

cf madness

Platform: Pingctf2026 | Category: Reversing | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-19 | Status: Solved Techniques: data_flow_analysis, function_table_dispatch, return_address_rewriting, static_symbolic_solve

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: pingctf2026 | ID: 20260419_pingctf2026_cf_madness
  • Tags: function_table, elf_x86_64, control_flow_obfuscation, nop_sled, opaque_predicates, x87_long_double
  • Indicators: 89KB .text section in a simple crackme, decompilers fail or produce garbage, massive nop sled between main and validator, function table with 128 entries indexed by input byte, x87 long double used to store integer offsets
  • Source: 20260419_pingctf2026_cf_madness.md

Foothold

Vulnerability / Misconfiguration

  1. Data_flow_analysis
  2. Function_table_dispatch
  3. Return_address_rewriting
  4. Static_symbolic_solve
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • data_flow_analysis
  • function_table_dispatch
  • return_address_rewriting
  • static_symbolic_solve
  • Tags: function_table, elf_x86_64, control_flow_obfuscation, nop_sled, opaque_predicates, x87_long_double

Original Writeup

<details><summary>Click to expand original content</summary>

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{REDACTED}
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR