DEV Community

Higor Diego
Higor Diego

Posted on

From file to flag: reversing "kindling" (Malware Space Village) with nothing but GDB

Recon, disassembly reading and live debugging on a warm-up challenge from the Malware Space Village at Ekoparty

Knowing how to use a tool is one thing. Knowing how to run an analysis is another.

In reverse engineering, the debugger solves half the problem. The other half is method: what to look at first, what to rule out and where to set the breakpoint.

To show this in practice, I picked kindling, a warm-up challenge I solved in the Malware Space Village CTF at Ekoparty. The Malware Space Village is the Ekoparty village dedicated to malware analysis and reverse engineering. The flag is simple. What matters here is the path to it, which is the same one I use on any binary:

recon → understand the logic → isolate the transformation → invert it → validate

All of it with basic tools: file, readelf, strings and GDB. Commands and outputs are below for anyone who wants to follow along.

Get the challenge

Want to follow along on your own machine? Grab the binary here:

👉 Download kindling

chmod +x kindling
./kindling
Enter fullscreen mode Exit fullscreen mode

Tip: run it inside a VM or sandbox. It's a harmless warm-up, but treating every unknown binary as hostile is a good habit.

Recon: know the terrain before you dig

Rule number one: don't open the debugger before you know what you're looking at.

$ file kindling
kindling: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=cba4..., for GNU/Linux 3.2.0, not stripped
Enter fullscreen mode Exit fullscreen mode

Three conclusions right away:

  • ELF 64-bit x86-64: Intel architecture, so we'll be reading x86-64 assembly.
  • PIE: addresses are relative and change on every run. This changes how we set breakpoints.
  • not stripped: symbols are still in the binary. In a CTF, that's gold.

Now protections and symbols:

$ readelf -d kindling | grep -Ei 'bind_now|flag'
 0x000000000000001e (FLAGS)    BIND_NOW
 0x000000006ffffffb (FLAGS_1)  Flags: NOW PIE

$ readelf -sW kindling | grep -Ei 'main|kFlagEnc'
    37: 0000000000001100   213 FUNC    GLOBAL DEFAULT  16 main
     4: 0000000000002040    36 OBJECT  LOCAL  DEFAULT  18 kFlagEnc
Enter fullscreen mode Exit fullscreen mode

Two gifts:

  • main at 0x1100, only 213 bytes long. A small target.
  • kFlagEnc, a 36-byte object at 0x2040. The name gives it away: it's an encoded flag.

And strings wraps up the recon:

$ strings -a -n 4 kindling | head
Enter flag:
Wrong
Correct
kindling.c
kFlagEnc
GCC: (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
Enter fullscreen mode Exit fullscreen mode

The plot is clear: the program reads input, compares it against something and prints Wrong or Correct. And we already know kFlagEnc is the reference for that comparison.

In under a minute I already have a map of the challenge. Recon isn't bureaucracy. It's what keeps you from debugging blind later.

Read the code in GDB before running it

Not many people use GDB this way, but it works really well as a disassembler and for inspecting static data, without even starting the process.

$ gdb -q ./kindling
(gdb) info functions
...
0x0000000000001100  main
0x00000000000011e0  _start

(gdb) info address kFlagEnc
Symbol "kFlagEnc" is at 0x2040 in a file compiled without debugging.
Enter fullscreen mode Exit fullscreen mode

GDB uses AT&T syntax by default. I prefer Intel:

(gdb) set disassembly-flavor intel
(gdb) disassemble main
Enter fullscreen mode Exit fullscreen mode

The lines that matter:

0x113a <+58>:  mov    esi,0x100          ; fgets(buf, 256, stdin)
0x1142 <+66>:  call   0x10e0 <fgets@plt>
0x1156 <+86>:  call   0x10d0 <strcspn@plt>
0x1162 <+98>:  call   0x10b0 <strlen@plt>
0x1167 <+103>: cmp    rax,0x24           ; requires 36 bytes
0x116b <+107>: je     0x119a <main+154>

0x119a <+154>: xor    eax,eax
0x119c <+156>: lea    rcx,[rip+0xe9d]    ; rcx = &kFlagEnc
0x11b2 <+178>: movzx  edx,BYTE PTR [rbx+rax*1]
0x11b6 <+182>: xor    edx,0x5a
0x11b9 <+185>: cmp    dl,BYTE PTR [rcx+rax*1]
0x11bc <+188>: je     0x11a8 <main+168>
0x11be <+190>: jmp    0x116d <main+109>  ; mismatch -> "Wrong"
Enter fullscreen mode Exit fullscreen mode

The logic shows up on its own:

  1. fgets reads up to 256 bytes of input.
  2. strcspn + strlen measure the length, which must be 36 (0x24).
  3. A loop takes each buf[i], applies xor 0x5a and compares it with kFlagEnc[i].

The whole challenge in one line: every input byte, XORed with 0x5a, must equal kFlagEnc.

⚠️ The trap I fell into: the offsets <+103> and <+185> are in decimal. main+103 is 0x1167, not 0x1203. Mixing up decimal and hex when addressing by symbol is a classic mistake that costs time.

You can even read the constant before running anything:

(gdb) x/36bx &kFlagEnc
0x2040 <kFlagEnc>:    0x3c 0x6b 0x28 0x29 0x2e 0x05 0x6f 0x2a
0x2048 <kFlagEnc+8>:  0x3b 0x28 0x31 0x05 0x2d 0x3b 0x28 0x37
0x2050 <kFlagEnc+16>: 0x2f 0x2a 0x7b 0x1a 0x37 0x3b 0x36 0x2d
0x2058 <kFlagEnc+24>: 0x3b 0x28 0x3f 0x29 0x2a 0x3b 0x39 0x3f
0x2060 <kFlagEnc+32>: 0x74 0x35 0x28 0x3d
Enter fullscreen mode Exit fullscreen mode

At this point the challenge is already solved on paper: flag[i] = kFlagEnc[i] ^ 0x5a. But solving is not the same as understanding. So let's confirm every deduction with the program running.

Confirm the hypothesis live

Stop right after strlen

Since the binary is PIE, absolute addresses won't work. The robust way is to anchor the breakpoint on symbol + offset:

(gdb) start < flag.txt
(gdb) break *(&main+103)
(gdb) continue
(gdb) printf "rax (strlen) = %d\n", (int)$rax
(gdb) x/s $rbx
Enter fullscreen mode Exit fullscreen mode
Breakpoint 2, 0x0000555555555167 in main ()
rax (strlen) = 36
0x7fffffffb830: "f1rst_5park_warmup!@malwarespace.org"
Enter fullscreen mode Exit fullscreen mode
  • rax = 36: passes the cmp rax, 0x24.
  • rbx points to the input buffer (main does mov rbx, rsp at the start).

fgets stored exactly what we sent, and the length checks out.

Follow the loop, one iteration at a time

Now the breakpoint goes on cmp dl,[rcx+rax] (main+185), and I ask GDB to print the state on every iteration:

(gdb) break *(&main+185)
(gdb) commands
> silent
> printf "i=%d buf[i]=%#x buf[i]^0x5a=%#x kFlagEnc[i]=%#x\n", (int)$rax, *(unsigned char*)($rbx+$rax), (unsigned char)$dl, *(unsigned char*)($rcx+$rax)
> continue
> end
(gdb) continue
Enter fullscreen mode Exit fullscreen mode

The first iterations:

i=0  buf[i]=0x66  buf[i]^0x5a=0x3c  kFlagEnc[i]=0x3c
i=1  buf[i]=0x31  buf[i]^0x5a=0x6b  kFlagEnc[i]=0x6b
i=2  buf[i]=0x72  buf[i]^0x5a=0x28  kFlagEnc[i]=0x28
Enter fullscreen mode Exit fullscreen mode

There's the heart of the challenge, proven at runtime: buf[i] ^ 0x5a == kFlagEnc[i].

Sanity check: the failure path

Input with the wrong length has to land on Wrong:

(gdb) start < short_flag.txt
(gdb) break *(&main+103)
(gdb) continue
(gdb) printf "strlen = %d -> the 'je' is not taken\n", (int)$rax
Enter fullscreen mode Exit fullscreen mode
strlen = 1 -> the 'je' is not taken
Enter fullscreen mode Exit fullscreen mode

Hypothesis confirmed from both sides.

Reversing the XOR

XOR is its own inverse: to undo it, just apply the same XOR again.

You can do this inside GDB itself. One tip: while/end passed inline with -ex doesn't work well, so use a command file (decode.gdb):

set debuginfod enabled off
file ./kindling
start < flag.txt
break *(&main+185)
continue
printf "Flag = "
set $i = 0
while $i < 36
  printf "%c", (*(unsigned char*)($rcx+$i) ^ 0x5a)
  set $i = $i + 1
end
printf "\n"
quit
Enter fullscreen mode Exit fullscreen mode
$ gdb -q -batch -x decode.gdb
...
Flag = f1rst_5park_warmup!@malwarespace.org
Enter fullscreen mode Exit fullscreen mode

And the final proof, running the binary:

$ printf 'f1rst_5park_warmup!@malwarespace.org\n' | ./kindling
Enter flag: Correct
Enter fullscreen mode Exit fullscreen mode

🚩 Flag: f1rst_5park_warmup!@malwarespace.org

For those who prefer to keep it in Python:

#!/usr/bin/env python3
enc = bytes.fromhex(
    "3c6b28292e056f2a3b2831052d3b28372f2a7b1a373b362d"
    "3b283f292a3b393f7435283d"
)
print(bytes(b ^ 0x5A for b in enc).decode())
Enter fullscreen mode Exit fullscreen mode

File to do

What a warm-up teaches

kindling is easy on purpose. But what you take from it applies to much bigger targets:

  1. Recon first, always. file, readelf and strings answer most questions before any debugger.
  2. Read before you run. disassemble and x/ in GDB already give you all the static logic.
  3. PIE changes how you break. Anchor on symbol + offset, not on absolute addresses.
  4. Watch the base of your offsets. main+103 is decimal. It was my first mistake, and the one that taught me the most.
  5. XOR undoes XOR.
  6. Validate. Predicting the flag is nice. Seeing Correct on screen is the proof.

Tools change from challenge to challenge. Method is what scales.

Happy hacking! 🔥


Challenge: kindling, Malware Space Village CTF at Ekoparty. ELF x86-64 PIE, not stripped. Tools: GDB 17.x, readelf, strings and Python 3.

Top comments (0)