Reversing Loops with GDB
Thilan Dissanayaka Exploit Development January 15, 2020

Reversing Loops with GDB

In the previous tutorial, we reversed a simple binary that had variables, arithmetic, and an if/else condition. We learned how cmp + jg translates to an if statement, and how to reconstruct C source from assembly.

Now let’s tackle something more interesting — loops. Loops are everywhere in real programs. Summing values, iterating over arrays, processing strings, searching data — it’s all loops. And in assembly, loops have a very recognizable pattern once you know what to look for.

By the end of this post, you’ll be able to spot a loop in disassembly in seconds, identify its counter, condition, and body, and reconstruct the original C code.

How Loops Look in Assembly

Before we jump into GDB, let’s understand the general pattern. A for loop in C:

for (int i = 0; i < 10; i++) {
    // body
}

The compiler translates this into four parts:

  1. Initialization — Set the counter to its starting value
  2. Condition check — Compare the counter against the limit
  3. Body — The actual work
  4. Increment — Update the counter
  5. Backward jump — Go back to the condition check

In assembly, the telltale sign of a loop is a backward jump — a jump instruction that targets a lower address (earlier in the code). If/else statements jump forward (to higher addresses). Loops jump backward.

         ┌──────────────────────────────┐
         │  Initialize counter           │
         ▼                               │
    ┌─── Condition check (cmp + jXX) ────┘
    │    │
    │    ▼ (condition true — enter loop)
    │    Loop body
    │    │
    │    Increment counter
    │    │
    │    JMP back to condition ──────────► (backward jump!)
    │
    └──► (condition false — exit loop)
         Continue after loop...

Example 1: A Simple For Loop

Let’s reverse a binary that prints nothing but does some computation internally.

user@protostar:~$ ./loop1
Sum: 55

It prints “Sum: 55”. That’s 1+2+3+…+10 = 55. Smells like a loop. Let’s confirm.

Disassembly

user@protostar:~$ gdb -q ./loop1
(gdb) set disassembly-flavor intel
(gdb) disass main

Dump of assembler code for function main:
0x080483c4 <+0>:     push   ebp
0x080483c5 <+1>:     mov    ebp,esp
0x080483c7 <+3>:     sub    esp,0x18
0x080483ca <+6>:     mov    DWORD PTR [ebp-0x8],0x0
0x080483d1 <+13>:    mov    DWORD PTR [ebp-0x4],0x1
0x080483d8 <+20>:    jmp    0x80483e8 <main+36>
0x080483da <+22>:    mov    eax,DWORD PTR [ebp-0x4]
0x080483dd <+25>:    add    DWORD PTR [ebp-0x8],eax
0x080483e0 <+28>:    add    DWORD PTR [ebp-0x4],0x1
0x080483e4 <+32>:    jmp    0x80483e8 <main+36>
0x080483e6 <+34>:    jmp    0x80483f4 <main+48>
0x080483e8 <+36>:    cmp    DWORD PTR [ebp-0x4],0xa
0x080483ec <+40>:    jle    0x80483da <main+22>
0x080483ee <+42>:    jmp    0x80483f4 <main+48>
0x080483f0 <+44>:    nop
0x080483f1 <+45>:    nop
0x080483f2 <+46>:    nop
0x080483f3 <+47>:    nop
0x080483f4 <+48>:    mov    eax,DWORD PTR [ebp-0x8]
0x080483f7 <+51>:    mov    DWORD PTR [esp+0x4],eax
0x080483fb <+55>:    mov    DWORD PTR [esp],0x80484d0
0x08048402 <+62>:    call   0x80482f8 <printf@plt>
0x08048407 <+67>:    mov    eax,0x0
0x0804840c <+72>:    leave
0x0804840d <+73>:    ret
End of assembler dump.

Let’s break this down piece by piece.

The Prologue and Initialization

0x080483c4 <+0>:     push   ebp
0x080483c5 <+1>:     mov    ebp,esp
0x080483c7 <+3>:     sub    esp,0x18
0x080483ca <+6>:     mov    DWORD PTR [ebp-0x8],0x0
0x080483d1 <+13>:    mov    DWORD PTR [ebp-0x4],0x1

Standard prologue, then two local variables are initialized:

  • [ebp-0x8] = 0 — This will be our accumulator (the sum)
  • [ebp-0x4] = 1 — This is our loop counter, starting at 1

In C terms:

int sum = 0;
int i = 1;

The Forward Jump — Skipping to the Condition

0x080483d8 <+20>:    jmp    0x80483e8 <main+36>

Right after initialization, there’s an unconditional jump to <main+36>. Let’s see what’s there:

0x080483e8 <+36>:    cmp    DWORD PTR [ebp-0x4],0xa
0x080483ec <+40>:    jle    0x80483da <main+22>

It jumps to the condition check. This is a common compiler pattern for for loops — check the condition before the first iteration, not after. This ensures that if the loop condition is false from the start (e.g., for (i = 10; i < 5; i++)), the body never executes.

The Condition Check

0x080483e8 <+36>:    cmp    DWORD PTR [ebp-0x4],0xa
0x080483ec <+40>:    jle    0x80483da <main+22>

cmp [ebp-0x4], 0xa — Compare the loop counter with 10 (0xa).

jle 0x80483daJump if Less or Equal. If i <= 10, jump to <main+22> (the loop body). If i > 10, fall through and exit the loop.

This is the condition i <= 10. Combined with the counter starting at 1, we have: for (i = 1; i <= 10; ...).

Notice the backward jump — from address 0x080483ec back to 0x080483da. That’s lower in memory. That’s the loop.

The Loop Body

0x080483da <+22>:    mov    eax,DWORD PTR [ebp-0x4]
0x080483dd <+25>:    add    DWORD PTR [ebp-0x8],eax
0x080483e0 <+28>:    add    DWORD PTR [ebp-0x4],0x1

Line by line:

  1. Load i (from [ebp-0x4]) into EAX
  2. Add EAX to sum (at [ebp-0x8]) — this is sum += i
  3. Increment i by 1 — this is i++

After the increment, execution reaches the jmp back to the condition check at <main+36>, and the cycle repeats.

Watching It in GDB

Let’s set a breakpoint at the condition check and watch the loop iterate:

(gdb) b *0x080483e8
Breakpoint 1 at 0x80483e8

(gdb) run
Starting program: /home/user/loop1
Breakpoint 1, 0x080483e8 in main ()

(gdb) x/d $ebp-0x4
0xbffff7e4:	1
(gdb) x/d $ebp-0x8
0xbffff7e0:	0

(gdb) c
Breakpoint 1, 0x080483e8 in main ()
(gdb) x/d $ebp-0x4
0xbffff7e4:	2
(gdb) x/d $ebp-0x8
0xbffff7e0:	1

(gdb) c
Breakpoint 1, 0x080483e8 in main ()
(gdb) x/d $ebp-0x4
0xbffff7e4:	3
(gdb) x/d $ebp-0x8
0xbffff7e0:	3

(gdb) c
Breakpoint 1, 0x080483e8 in main ()
(gdb) x/d $ebp-0x4
0xbffff7e4:	4
(gdb) x/d $ebp-0x8
0xbffff7e0:	6
Iteration i sum sum after sum += i
1 1 0 1
2 2 1 3
3 3 3 6
4 4 6 10
10 10 45 55

When i reaches 11, the cmp [ebp-0x4], 0xa + jle condition fails (11 is not <= 10), and execution falls through to the printf.

After the Loop

0x080483f4 <+48>:    mov    eax,DWORD PTR [ebp-0x8]
0x080483f7 <+51>:    mov    DWORD PTR [esp+0x4],eax
0x080483fb <+55>:    mov    DWORD PTR [esp],0x80484d0
0x08048402 <+62>:    call   0x80482f8 <printf@plt>

This loads sum into EAX, pushes it as the second argument to printf, loads the format string address, and calls printf. Let’s check the format string:

(gdb) x/s 0x80484d0
0x80484d0:	"Sum: %d\n"

The Reconstructed Code

#include <stdio.h>

int main() {
    int sum = 0;
    int i;

    for (i = 1; i <= 10; i++) {
        sum += i;
    }

    printf("Sum: %d\n", sum);
    return 0;
}

We got the loop, the accumulator, the bounds, and the printf format — all from assembly.

Example 2: A While Loop

Let’s reverse another binary:

user@protostar:~$ ./loop2
Countdown: 10 9 8 7 6 5 4 3 2 1 Liftoff!

Disassembly

(gdb) disass main

Dump of assembler code for function main:
0x080483c4 <+0>:     push   ebp
0x080483c5 <+1>:     mov    ebp,esp
0x080483c7 <+3>:     sub    esp,0x18
0x080483ca <+6>:     mov    DWORD PTR [ebp-0x4],0xa
0x080483d1 <+13>:    jmp    0x80483ea <main+38>
0x080483d3 <+15>:    mov    eax,DWORD PTR [ebp-0x4]
0x080483d6 <+18>:    mov    DWORD PTR [esp+0x4],eax
0x080483da <+22>:    mov    DWORD PTR [esp],0x80484d0
0x080483e1 <+29>:    call   0x80482f8 <printf@plt>
0x080483e6 <+34>:    sub    DWORD PTR [ebp-0x4],0x1
0x080483ea <+38>:    cmp    DWORD PTR [ebp-0x4],0x0
0x080483ee <+42>:    jg     0x80483d3 <main+15>
0x080483f0 <+44>:    mov    DWORD PTR [esp],0x80484d4
0x080483f7 <+51>:    call   0x80482f8 <puts@plt>
0x080483fc <+56>:    mov    eax,0x0
0x08048401 <+61>:    leave
0x08048402 <+62>:    ret
End of assembler dump.

Breaking It Down

Initialization:

0x080483ca <+6>:     mov    DWORD PTR [ebp-0x4],0xa

One variable at [ebp-0x4] initialized to 10 (0xa). This is our countdown counter.

Jump to condition:

0x080483d1 <+13>:    jmp    0x80483ea <main+38>

Same pattern as the for loop — jump to the condition check before the first iteration.

Condition check:

0x080483ea <+38>:    cmp    DWORD PTR [ebp-0x4],0x0
0x080483ee <+42>:    jg     0x80483d3 <main+15>

Compare counter with 0. JGJump if Greater. While counter > 0, jump back to the loop body. This is while (n > 0).

Loop body:

0x080483d3 <+15>:    mov    eax,DWORD PTR [ebp-0x4]
0x080483d6 <+18>:    mov    DWORD PTR [esp+0x4],eax
0x080483da <+22>:    mov    DWORD PTR [esp],0x80484d0
0x080483e1 <+29>:    call   0x80482f8 <printf@plt>
0x080483e6 <+34>:    sub    DWORD PTR [ebp-0x4],0x1

Print the counter value, then decrement by 1 (sub ... 0x1 is n--).

Let’s check the format string:

(gdb) x/s 0x80484d0
0x80484d0:	"%d "

After the loop:

0x080483f0 <+44>:    mov    DWORD PTR [esp],0x80484d4
0x080483f7 <+51>:    call   0x80482f8 <puts@plt>
(gdb) x/s 0x80484d4
0x80484d4:	"Liftoff!"

How While Loops Differ from For Loops

In this disassembly, the structure is nearly identical to the for loop. That’s because, at the assembly level, for and while loops compile to the same pattern:

// These two are equivalent in assembly:

// For loop
for (int i = 10; i > 0; i--) { ... }

// While loop
int i = 10;
while (i > 0) { ... i--; }

The compiler generates the same initialization → condition check → body → update → backward jump pattern for both. You often can’t tell whether the original was a for or while just from the assembly. Context and coding conventions help, but it’s sometimes ambiguous.

The Reconstructed Code

#include <stdio.h>

int main() {
    int n = 10;

    while (n > 0) {
        printf("%d ", n);
        n--;
    }

    printf("Liftoff!\n");
    return 0;
}

Example 3: Nested Loops

This one prints a triangle pattern:

user@protostar:~$ ./loop3
*
**
***
****
*****

Disassembly

(gdb) disass main

Dump of assembler code for function main:
0x080483c4 <+0>:     push   ebp
0x080483c5 <+1>:     mov    ebp,esp
0x080483c7 <+3>:     sub    esp,0x18
0x080483ca <+6>:     mov    DWORD PTR [ebp-0x8],0x1
0x080483d1 <+13>:    jmp    0x8048401 <main+61>
0x080483d3 <+15>:    mov    DWORD PTR [ebp-0x4],0x0
0x080483da <+22>:    jmp    0x80483ea <main+38>
0x080483dc <+24>:    mov    DWORD PTR [esp],0x2a
0x080483e3 <+31>:    call   0x8048308 <putchar@plt>
0x080483e8 <+36>:    add    DWORD PTR [ebp-0x4],0x1
0x080483ec <+40>:    mov    eax,DWORD PTR [ebp-0x4]
0x080483ef <+43>:    cmp    eax,DWORD PTR [ebp-0x8]
0x080483f2 <+46>:    jl     0x80483dc <main+24>
0x080483f4 <+48>:    mov    DWORD PTR [esp],0xa
0x080483fb <+55>:    call   0x8048308 <putchar@plt>
0x08048400 <+60>:    add    DWORD PTR [ebp-0x8],0x1
0x08048404 <+64>:    cmp    DWORD PTR [ebp-0x8],0x5
0x08048408 <+68>:    jle    0x80483d3 <main+15>
0x0804840a <+70>:    mov    eax,0x0
0x0804840f <+75>:    leave
0x08048410 <+76>:    ret
End of assembler dump.

This is longer, but now that we know the loop pattern, we can identify the two loops quickly.

Spotting the Two Loops

Look for backward jumps:

  1. 0x080483f2 <+46>: jl 0x80483dc <main+24> — jumps backward from +46 to +24. Inner loop.
  2. 0x08048408 <+68>: jle 0x80483d3 <main+15> — jumps backward from +68 to +15. Outer loop.

The outer loop’s body (from +15 to +60) contains the inner loop (from +24 to +46). That’s how nested loops look — a backward jump inside another backward jump.

The Outer Loop

0x080483ca <+6>:     mov    DWORD PTR [ebp-0x8],0x1      ; i = 1
...
0x08048400 <+60>:    add    DWORD PTR [ebp-0x8],0x1      ; i++
0x08048404 <+64>:    cmp    DWORD PTR [ebp-0x8],0x5      ; i <= 5?
0x08048408 <+68>:    jle    0x80483d3 <main+15>           ; if yes, loop

Counter [ebp-0x8] starts at 1, increments by 1 each iteration, and loops while <= 5. That’s for (i = 1; i <= 5; i++).

The Inner Loop

0x080483d3 <+15>:    mov    DWORD PTR [ebp-0x4],0x0      ; j = 0
...
0x080483dc <+24>:    mov    DWORD PTR [esp],0x2a          ; putchar('*')
0x080483e3 <+31>:    call   0x8048308 <putchar@plt>
0x080483e8 <+36>:    add    DWORD PTR [ebp-0x4],0x1      ; j++
0x080483ec <+40>:    mov    eax,DWORD PTR [ebp-0x4]
0x080483ef <+43>:    cmp    eax,DWORD PTR [ebp-0x8]      ; j < i?
0x080483f2 <+46>:    jl     0x80483dc <main+24>           ; if yes, loop

Counter [ebp-0x4] starts at 0 each time the outer loop iterates (it’s re-initialized inside the outer loop). It loops while j < i. The body calls putchar(0x2a) — and 0x2a in ASCII is *.

Between the inner loop and the outer loop’s increment:

0x080483f4 <+48>:    mov    DWORD PTR [esp],0xa
0x080483fb <+55>:    call   0x8048308 <putchar@plt>

putchar(0xa)0xa is the newline character \n. This prints a newline after each row of asterisks.

The Reconstructed Code

#include <stdio.h>

int main() {
    int i, j;

    for (i = 1; i <= 5; i++) {
        for (j = 0; j < i; j++) {
            putchar('*');
        }
        putchar('\n');
    }

    return 0;
}

Iteration by iteration:

  • i=1: prints 1 asterisk, then newline → *
  • i=2: prints 2 asterisks, then newline → **
  • i=3: prints 3 → ***
  • i=4: prints 4 → ****
  • i=5: prints 5 → *****

Matches the output perfectly.

Understanding Jump Conditions

The conditional jump instruction after cmp tells you the loop’s condition. Here’s the complete mapping:

Instruction Meaning Signed? C Equivalent
je / jz Jump if Equal / Zero ==
jne / jnz Jump if Not Equal / Not Zero !=
jl / jnge Jump if Less Signed <
jle / jng Jump if Less or Equal Signed <=
jg / jnle Jump if Greater Signed >
jge / jnl Jump if Greater or Equal Signed >=
jb / jnae Jump if Below Unsigned <
jbe / jna Jump if Below or Equal Unsigned <=
ja / jnbe Jump if Above Unsigned >
jae / jnb Jump if Above or Equal Unsigned >=

The signed versions (jl, jle, jg, jge) are used for regular int comparisons. The unsigned versions (jb, jbe, ja, jae) are used for unsigned int, pointer comparisons, and size comparisons.

When the jump is backward (to a lower address), the jump condition tells you the loop continuation condition — the loop keeps running while this condition is true.

Do-While Loops

There’s one more loop type worth mentioning. A do-while loop checks the condition after the body, not before:

do {
    // body
} while (condition);

In assembly, the do-while is actually the simplest loop — no initial forward jump needed:

loop_body:          ; ← body executes at least once
    ...
    cmp ...
    jle loop_body   ; backward jump — check condition after body

Compare this to for/while, which always have a forward jmp to the condition check before the first iteration. If you see a loop with no initial forward jump — it’s a do-while.

Loop Patterns Cheat Sheet

Here’s how to quickly identify loops in disassembly:

Spotting a loop:

  • Look for backward jumps (conditional jump to a lower address)
  • The target of the backward jump is the start of the loop body (or the condition check)

Identifying the counter:

  • Look for a mov [ebp-X], immediate before the loop — that’s the initialization
  • Look for add [ebp-X], 1 or sub [ebp-X], 1 inside the loop — that’s the increment/decrement
  • The same [ebp-X] in the cmp instruction is the counter

Identifying the condition:

  • cmp followed by a backward conditional jump — the jump condition is the continuation condition
  • cmp [ebp-X], 0xa + jle backward = while (i <= 10)
  • cmp [ebp-X], 0x0 + jg backward = while (n > 0)

For/while vs do-while:

  • for/while — Forward jmp to condition check before the body (condition checked first)
  • do-while — No forward jump; condition check is at the bottom (body executes at least once)

Nested loops:

  • Multiple backward jumps, with one jump’s range contained within another’s
  • The inner loop’s backward jump has a smaller range (fewer addresses between jump and target)

Infinite loops:

  • jmp backward with no condition — while (true) or for (;;)

Final Thoughts

Once you recognize the loop pattern — initialization, condition, body, increment, backward jump — you’ll start seeing it everywhere in disassembly. It becomes automatic. And that’s the whole point of these tutorials: building pattern recognition that lets you read assembly as fluently as C.

The key insight is that all loops (for, while, do-while) compile to essentially the same structure. The only differences are where the condition check happens (before or after the body) and how the counter is managed. The backward jump is universal.

Next up, we’ll look at how function calls work — how arguments are passed on the stack, how return values come back in EAX, and how multiple stack frames chain together. That’s where things start connecting to exploitation.

Happy reversing!

ALSO READ
Exploiting a Stack Buffer Overflow on Windows
Apr 12, 2020 Exploit Development

In a previous tutorial we discusses how we can exploit a buffer overflow vulnerability on a Linux machine. I wen through all theories in depth and explained each step. Now today we are going to jump...

Exploiting a  Stack Buffer Overflow  on Linux
Apr 01, 2020 Exploit Development

Have you ever wondered how attackers gain control over remote servers? How do they just run some exploit and compromise a computer? If we dive into the actual context, there is no magic happening....

Basic concepts of Cryptography
Mar 01, 2020 Cryptography

Ever notice that little padlock icon in your browser's address bar? That's cryptography working silently in the background, protecting everything you do online. Whether you're sending an email,...

Remote Code Execution (RCE)
Jan 02, 2020 Application Security

Remote Code Execution (RCE) is the holy grail of application security vulnerabilities. It allows an attacker to execute arbitrary code on a remote server — and the consequences are as bad as it sounds. In this post, we'll go deep into RCE across multiple languages, including PHP, Java, Python, and Node.js.

> > >