cf madness
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Data_flow_analysis
- Function_table_dispatch
- Return_address_rewriting
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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:
- Prints
"Enter the flag: " - Reads input with
scanf("%s", 0x41c560) - Stores
726 * (strlen(input)+1)as a long double at0x41b490 - 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-1c2 = k-2for k≥2, butc2 = 1337for 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
-559038737is37752 = 726 * 52 - So
strlen+1 = 52→ strlen = 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-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR