Understanding the Stack: Functions and Calling Conventions
Thilan Dissanayaka Exploit Development January 20, 2020

Understanding the Stack: Functions and Calling Conventions

In the previous tutorials, we reversed if/else statements and loops in GDB. We saw local variables sitting at [ebp-0x4], [ebp-0x8], etc. We saw call instructions invoke printf and puts. We saw leave and ret at the end of main.

But we never really explained why. Why does EBP matter? Why are local variables at negative offsets from EBP? What exactly happens when a call instruction executes? Where do function arguments go? What does ret actually do?

In this post, we’re going to answer all of those questions. We’ll trace the complete lifecycle of a function call — from the caller pushing arguments, through the call instruction, the prologue, the function body, the epilogue, and the return. We’ll watch the stack change at every step in GDB, and by the end, you’ll understand exactly how the stack works and why buffer overflows are so devastating.

The Stack — A Quick Refresher

The stack is a region of memory that grows downward — from high addresses to low addresses. Every time you push something, the stack pointer (ESP) decreases. Every time you pop, it increases.

High Address (e.g., 0xBFFFFFFF)
┌────────────────────────┐
│                        │
│  Stack grows downward  │
│         ↓              │
│                        │
│  ┌──────────────┐      │
│  │  Latest push │ ◄── ESP points here
│  └──────────────┘      │
│                        │
Low Address (e.g., 0xBFFFF000)

The stack has two primary uses:

  1. Saving state — Return addresses, saved registers, local variables
  2. Passing data — Function arguments (in 32-bit x86 cdecl convention)

The Calling Convention — cdecl

On 32-bit Linux, the standard calling convention is cdecl (C declaration). The rules are:

Rule Description
Arguments Pushed onto the stack right to left
Return value Placed in EAX
Caller saves EAX, ECX, EDX (caller-saved / volatile)
Callee saves EBX, ESI, EDI, EBP (callee-saved / non-volatile)
Stack cleanup The caller cleans up the arguments after the call returns
Stack alignment The stack should be 16-byte aligned before call on modern systems

Why are arguments pushed right to left? So that the first argument ends up closest to the top of the stack (lowest address). This makes it easy for functions like printf to find their first argument (the format string) at a known offset, regardless of how many additional arguments follow.

Example: A Function Call, Step by Step

Let’s work with a simple program that calls a function:

user@protostar:~$ ./func1
Result: 8

Disassembly

(gdb) disass main

Dump of assembler code for function main:
0x08048404 <+0>:     push   ebp
0x08048405 <+1>:     mov    ebp,esp
0x08048407 <+3>:     sub    esp,0x18
0x0804840a <+6>:     mov    DWORD PTR [esp+0x4],0x5
0x08048412 <+14>:    mov    DWORD PTR [esp],0x3
0x08048419 <+21>:    call   0x80483c4 <add>
0x0804841e <+26>:    mov    DWORD PTR [ebp-0x4],eax
0x08048421 <+29>:    mov    eax,DWORD PTR [ebp-0x4]
0x08048424 <+32>:    mov    DWORD PTR [esp+0x4],eax
0x08048428 <+36>:    mov    DWORD PTR [esp],0x80484e0
0x0804842f <+43>:    call   0x80482f8 <printf@plt>
0x08048434 <+48>:    mov    eax,0x0
0x08048439 <+53>:    leave
0x0804843a <+54>:    ret
End of assembler dump.
(gdb) disass add

Dump of assembler code for function add:
0x080483c4 <+0>:     push   ebp
0x080483c5 <+1>:     mov    ebp,esp
0x080483c7 <+3>:     sub    esp,0x10
0x080483ca <+6>:     mov    eax,DWORD PTR [ebp+0x8]
0x080483cd <+9>:     mov    edx,DWORD PTR [ebp+0xc]
0x080483d0 <+12>:    lea    eax,[edx+eax*1]
0x080483d3 <+15>:    mov    DWORD PTR [ebp-0x4],eax
0x080483d6 <+18>:    mov    eax,DWORD PTR [ebp-0x4]
0x080483d9 <+21>:    leave
0x080483da <+22>:    ret
End of assembler dump.

Now let’s trace the entire call, watching the stack at every stage.

Stage 1: Before the Call — Pushing Arguments

0x0804840a <+6>:     mov    DWORD PTR [esp+0x4],0x5
0x08048412 <+14>:    mov    DWORD PTR [esp],0x3
0x08048419 <+21>:    call   0x80483c4 <add>

Instead of using push instructions, the compiler placed arguments directly at stack offsets. This is an optimization — the sub esp, 0x18 in the prologue already reserved enough space, so the compiler writes to the pre-allocated slots. The effect is the same as push 5 then push 3.

Arguments are placed right to left: first 5 (second argument, at [esp+0x4]), then 3 (first argument, at [esp]).

Let’s set a breakpoint right before the call and examine the stack:

(gdb) b *0x08048419
Breakpoint 1 at 0x8048419

(gdb) run
Breakpoint 1, 0x08048419 in main ()

(gdb) x/4xw $esp
0xbffff7d0:	0x00000003	0x00000005	0xbffff898	0xb7fd7ff4
                 arg1=3      arg2=5

The stack has our arguments ready: 3 at the top, 5 right after. The call instruction is about to execute.

The Stack Before CALL

          ┌──────────────────────────┐
          │  ...main's stack frame...│
          │                          │
          │  0x00000005  (arg2)      │  ESP+4
          │  0x00000003  (arg1)      │  ◄── ESP
          └──────────────────────────┘

Stage 2: The CALL Instruction

0x08048419 <+21>:    call   0x80483c4 <add>

The call instruction does exactly two things:

  1. Pushes the return address onto the stack — the address of the instruction after the call (which is 0x0804841e)
  2. Jumps to the target function (0x080483c4)

This is equivalent to:

push 0x0804841e    ; push return address
jmp  0x080483c4    ; jump to add()

Let’s step into the call and check:

(gdb) si
0x080483c4 in add ()

(gdb) x/8xw $esp
0xbffff7cc:	0x0804841e	0x00000003	0x00000005	0xbffff898
              return addr   arg1=3      arg2=5

There it is — 0x0804841e (the return address) was pushed onto the stack by call.

The Stack After CALL

          ┌──────────────────────────┐
          │  ...main's stack frame...│
          │                          │
          │  0x00000005  (arg2)      │  ESP+C
          │  0x00000003  (arg1)      │  ESP+8
          │  0x0804841e (ret addr)   │  ◄── ESP (pushed by CALL)
          └──────────────────────────┘

This return address is what makes ret work later — and it’s what buffer overflow exploits overwrite.

Stage 3: The Function Prologue

0x080483c4 <+0>:     push   ebp
0x080483c5 <+1>:     mov    ebp,esp
0x080483c7 <+3>:     sub    esp,0x10

We’ve seen this in every function, but now let’s understand why each instruction exists.

push ebp — Save the caller’s base pointer. main was using EBP to access its own local variables. If add overwrites EBP, main won’t be able to find its variables when add returns. So we save it.

(gdb) ni
0x080483c5 in add ()

(gdb) x/8xw $esp
0xbffff7c8:	0xbffff7e8	0x0804841e	0x00000003	0x00000005
              saved EBP   return addr  arg1=3      arg2=5

0xbffff7e8 is main’s EBP value — now safely saved on the stack.

mov ebp, esp — Set up the new base pointer for add(). From now on, EBP is a fixed reference point. No matter how ESP changes (due to local allocations or pushes), EBP stays constant.

(gdb) ni
0x080483c7 in add ()

(gdb) i r ebp esp
ebp            0xbffff7c8   0xbffff7c8
esp            0xbffff7c8   0xbffff7c8

EBP and ESP now point to the same place — the top of the new stack frame.

sub esp, 0x10 — Allocate 16 bytes for local variables. ESP moves down, creating space.

(gdb) ni
0x080483ca in add ()

(gdb) i r ebp esp
ebp            0xbffff7c8   0xbffff7c8
esp            0xbffff7b8   0xbffff7b8

The Stack After the Prologue

          ┌──────────────────────────┐
          │  ...main's stack frame...│
          │                          │
          │  0x00000005  (arg2)      │  EBP+0xC
          │  0x00000003  (arg1)      │  EBP+0x8
          │  0x0804841e (ret addr)   │  EBP+0x4
          │  0xbffff7e8 (saved EBP)  │  ◄── EBP points here
          ├──────────────────────────┤
          │  [ebp-0x4]  local var    │
          │  [ebp-0x8]              │
          │  [ebp-0xc]              │
          │  [ebp-0x10]             │  ◄── ESP
          └──────────────────────────┘

This is the key diagram. Notice the pattern:

  • Arguments are at positive offsets from EBP: [ebp+0x8], [ebp+0xc], etc.
  • Local variables are at negative offsets from EBP: [ebp-0x4], [ebp-0x8], etc.
  • Saved EBP is at [ebp+0x0]
  • Return address is at [ebp+0x4]

This is why EBP matters — it gives us a stable reference point. Arguments are above it (pushed by the caller before the call), local variables are below it (allocated by the callee after the prologue).

Stage 4: Accessing Arguments

0x080483ca <+6>:     mov    eax,DWORD PTR [ebp+0x8]
0x080483cd <+9>:     mov    edx,DWORD PTR [ebp+0xc]

Now we see why arguments are at positive offsets. [ebp+0x8] is the first argument (3), and [ebp+0xc] is the second argument (5). The +0x8 skip accounts for the saved EBP (4 bytes at [ebp+0x0]) and the return address (4 bytes at [ebp+0x4]).

(gdb) x/d $ebp+0x8
0xbffff7d0:	3

(gdb) x/d $ebp+0xc
0xbffff7d4:	5

The arguments are right where we expected them.

Stage 5: The Function Body

0x080483d0 <+12>:    lea    eax,[edx+eax*1]
0x080483d3 <+15>:    mov    DWORD PTR [ebp-0x4],eax
0x080483d6 <+18>:    mov    eax,DWORD PTR [ebp-0x4]

This adds the two arguments (3 + 5 = 8), stores the result in the local variable at [ebp-0x4], then loads it back into EAX.

Why store to a local and immediately load it back? The original C code likely has int result = a + b; return result;. The compiler stored the result in result (on the stack), then loaded it into EAX to return it. With optimizations, this would collapse into a single lea eax, [edx+eax].

The return value is in EAX — that’s the cdecl convention. The caller will read EAX to get the result.

Stage 6: The Function Epilogue

0x080483d9 <+21>:    leave
0x080483da <+22>:    ret

leave is shorthand for two instructions:

mov esp, ebp    ; Deallocate local variables (ESP = EBP, discarding everything below)
pop ebp         ; Restore the caller's EBP from the stack

After leave:

(gdb) ni
0x080483da in add ()

(gdb) i r ebp esp
ebp            0xbffff7e8   0xbffff7e8    ← main's EBP restored!
esp            0xbffff7cc   0xbffff7cc

(gdb) x/4xw $esp
0xbffff7cc:	0x0804841e	0x00000003	0x00000005	0xbffff898
              return addr   arg1        arg2

EBP is restored to main’s value. ESP points to the return address — exactly where it needs to be for ret.

ret pops the return address from the stack and jumps to it:

pop eip         ; EIP = 0x0804841e (the instruction after the call in main)
(gdb) ni
0x0804841e in main ()

(gdb) i r eax
eax            0x8     8

We’re back in main, and EAX holds 8 — the return value from add(3, 5).

Stage 7: Back in the Caller

0x0804841e <+26>:    mov    DWORD PTR [ebp-0x4],eax

main stores the return value (EAX = 8) into its own local variable at [ebp-0x4]. This is int result = add(3, 5);.

The rest of main passes this value to printf:

(gdb) x/s 0x80484e0
0x80484e0:	"Result: %d\n"

The Reconstructed Code

#include <stdio.h>

int add(int a, int b) {
    int result = a + b;
    return result;
}

int main() {
    int result = add(3, 5);
    printf("Result: %d\n", result);
    return 0;
}

Nested Function Calls

What happens when functions call other functions? Each call creates a new stack frame, and they chain together like links in a chain.

Consider: main calls foo, and foo calls bar.

          ┌──────────────────────────┐
          │  main's stack frame       │
          │  ┌────────────────────┐  │
          │  │ local variables    │  │
          │  │ saved EBP ─────────┼──┼──► (previous frame)
          │  │ return addr        │  │
          │  └────────────────────┘  │
          │  foo's arguments         │
          ├──────────────────────────┤
          │  foo's stack frame        │
          │  ┌────────────────────┐  │
          │  │ local variables    │  │
          │  │ saved EBP ─────────┼──┼──► main's EBP
          │  │ return addr ───────┼──┼──► back to main
          │  └────────────────────┘  │
          │  bar's arguments         │
          ├──────────────────────────┤
          │  bar's stack frame        │
          │  ┌────────────────────┐  │
          │  │ local variables    │  │
          │  │ saved EBP ─────────┼──┼──► foo's EBP
          │  │ return addr ───────┼──┼──► back to foo
          │  └────────────────────┘  │  ◄── ESP
          └──────────────────────────┘

Each saved EBP points to the previous frame’s EBP, forming a linked list of stack frames. This is how GDB’s backtrace command works — it follows this chain to show you the call stack:

(gdb) bt
#0  bar () at prog.c:4
#1  0x08048420 in foo () at prog.c:9
#2  0x08048450 in main () at prog.c:14

GDB starts at the current EBP, reads the saved EBP to find the previous frame, reads its saved EBP for the one before that, and so on until it reaches the bottom of the stack. Each return address along the way tells GDB which function the frame belongs to.

The EBP Chain in GDB

You can walk the frame chain manually:

(gdb) i r ebp
ebp            0xbffff798   0xbffff798    ← bar's frame

(gdb) x/xw $ebp
0xbffff798:	0xbffff7b8                    ← foo's EBP (saved by bar's prologue)

(gdb) x/xw 0xbffff7b8
0xbffff7b8:	0xbffff7e8                    ← main's EBP (saved by foo's prologue)

(gdb) x/xw 0xbffff7e8
0xbffff7e8:	0x00000000                    ← bottom of the chain (no previous frame)

And the return addresses are always at saved_ebp + 4:

(gdb) x/xw 0xbffff798+4
0xbffff79c:	0x08048420                    ← return to foo

(gdb) x/xw 0xbffff7b8+4
0xbffff7bc:	0x08048450                    ← return to main

Why Arguments Are Pushed Right to Left

Consider printf("x=%d y=%d", 10, 20). The arguments are pushed in reverse order:

push 20         ; third argument (rightmost)
push 10         ; second argument
push fmt_str    ; first argument (leftmost) — pushed last, so it's on top

After pushing, the stack looks like:

ESP ──►  fmt_str  (first argument — always at [esp])
         10       (second argument — at [esp+4])
         20       (third argument — at [esp+8])

The first argument is always at the same position relative to ESP, regardless of how many arguments follow. This is critical for variadic functions like printf — the function reads the format string first, then uses it to determine how many more arguments to read from the stack.

If arguments were pushed left to right, the format string would be buried under a variable number of arguments, and printf wouldn’t know where to find it.

Why This Matters for Exploitation

Now that you understand the stack layout, the classic buffer overflow attack should make perfect sense.

Look at the stack inside a function:

          ┌──────────────────────────┐
          │  function arguments       │
          │  RETURN ADDRESS           │  ◄── TARGET
          │  saved EBP                │
          │  ────────────────────     │
          │  char buffer[64]          │  ◄── overflow starts here
          │                           │
          │                           │  ◄── ESP
          └──────────────────────────┘

If the function has a local char buffer[64] and uses an unsafe function like gets(buffer) or strcpy(buffer, input), an attacker can write more than 64 bytes. The extra bytes overflow past the buffer, past the saved EBP, and overwrite the return address.

When the function executes ret, it pops the attacker-controlled return address into EIP — and the CPU jumps wherever the attacker wants. If that address points to shellcode the attacker injected into the buffer, it’s game over.

Normal stack:                     After overflow:
┌──────────────┐                 ┌──────────────────┐
│ ret: 0x0804XX │ (legit)        │ ret: 0xBFFFF7D0  │ (attacker's address!)
│ saved EBP     │                │ AAAA (overwritten)│
│ buffer[64]    │                │ AAAAAAAAAAAAAAA..│
│               │                │ [shellcode here]  │ ◄── 0xBFFFF7D0
└──────────────┘                 └──────────────────┘

This is why stack canaries, ASLR, and DEP exist — to make this exact attack harder. But understanding the attack starts with understanding the stack.

Key Takeaways

Here’s a summary of everything we covered:

The function call lifecycle:

1. Caller pushes arguments (right to left)
2. CALL pushes return address, jumps to function
3. Callee prologue: push ebp / mov ebp,esp / sub esp,N
4. Function body executes (args at [ebp+8], [ebp+C], locals at [ebp-4], [ebp-8])
5. Return value goes in EAX
6. Callee epilogue: leave (mov esp,ebp + pop ebp) / ret
7. Execution returns to caller at the saved return address

The stack frame layout:

Offset from EBP Contents
[ebp+0xC] Second argument
[ebp+0x8] First argument
[ebp+0x4] Return address (pushed by call)
[ebp+0x0] Saved EBP (pushed by prologue)
[ebp-0x4] First local variable
[ebp-0x8] Second local variable

Essential GDB commands for stack analysis:

x/20xw $esp        — Examine 20 words at the stack pointer
x/xw $ebp          — See the saved EBP (previous frame)
x/xw $ebp+4        — See the return address
x/xw $ebp+8        — See the first argument
bt                  — Full backtrace (walk the EBP chain)
info frame          — Detailed info about the current frame

Final Thoughts

The stack is the backbone of program execution. Every function call, every local variable, every return — it all lives on the stack. And once you understand how it works at the assembly level, a huge number of things click into place:

  • Why buffer overflows overwrite return addresses
  • Why stack canaries are placed between local variables and the return address
  • Why ASLR randomizes the stack location
  • Why leave and ret appear at the end of every function
  • Why GDB’s bt command can show you the call chain

This knowledge is fundamental to exploit development, reverse engineering, and understanding how computers actually run your code. Everything else builds on top of this.

Happy reversing!

ALSO READ
Writing a Shell Code for Linux
Apr 21, 2020 Exploit Development

Shellcode is a small piece of machine code used as the payload in exploit development. In this post, we write Linux shellcode from scratch — starting with a simple exit, building up to spawning a shell, and explaining every decision along the way.

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,...

Common Web Application Attacks
Feb 05, 2020 Application Security

Web applications are one of the most targeted surfaces by attackers. This is primarily because they are accessible over the internet, making them exposed and potentially vulnerable. Since these...

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.

> > >