Reconstructing Functions & Control Flow
A deep dive into identifying functions, reading assembly, and understanding compiler‑generated code.
Using objdump
The objdump tool is invaluable for disassembly, especially with the -d flag. Without symbols, the output is more cryptic, but still readable. The -j option lets you specify a particular section—most often we target .text, which holds all executable code.
In the disassembly listing, the leftmost column shows hexadecimal addresses—these are the actual runtime memory locations of each instruction. The middle column gives the raw machine code (bytes), and the right column shows the human‑readable mnemonics.
Additionally, objdump -T displays all dynamically linked library functions that the program calls, which can serve as a useful cross‑reference.
The disasm.pl Script
Steve Barker originally wrote a Perl script that post‑processes objdump output to make it far more legible when symbols are missing. Later enhancements added multi‑pass analysis:
- Pass 1 – builds a symbol table of all called and jumped‑to addresses.
- Pass 2 – identifies regions between two
ret instructions and labels them as potential “unused” functions (though they may still be invoked indirectly via function pointers).
- Pass 3 – prints the annotated disassembly and generates a function call tree.
Usage is straightforward:
./disasm /path/to/binary > binary.asminfo
An optional --graph flag produces a file named call_graph in a format suitable for the Graphviz dot tool, allowing you to visualise the call hierarchy.
💡 Note: A function marked “unused” simply means it was never directly called; indirect calls (e.g., through function pointers or as a startup entry) are still possible.
Defining Your Objective
Before diving into the code, you must know what you are looking for. “Interesting” functions depend entirely on your goal:
- Are you investigating copy protection? When does it appear during execution?
- Are you auditing for security vulnerabilities? Look for sloppy string handling (
strcmp, sprintf, etc.) or unsafe memory allocations.
- Are you trying to understand network behaviour? Trace functions that invoke socket APIs.
Narrowing your search to a handful of relevant functions makes the work manageable.
Locating main()
Even without symbols, finding the entry point is often possible. Under Linux, execution starts at the _start symbol provided by the C runtime (crt0). From there, control passes to __libc_start_main, which initialises each library (calling its _init and any global constructors). Eventually, main is invoked indirectly via a call through the base pointer (ebp).
Practical techniques to pinpoint main:
- Use
ltrace -i to trace library calls with instruction pointers; then cross‑reference those addresses with your disassembly and call tree. You may need to force the program to exit early to avoid deep call stacks.
- Preload a custom shared library (using
LD_PRELOAD) that contains a constructor function; set a breakpoint on a common libc function and step until you recognise the entry of main.
- Set a breakpoint on
__libc_start_main itself (since it is a libc symbol always available), then single‑step until you reach what looks like typical main prologue code.
Even without a frame pointer, you can often obtain an address early in the execution chain that is close enough to main for further analysis.
Finding Other Interesting Functions
Several strategies help you zero in on specific functions:
- List all functions that call
exit – they may represent error or termination paths.
- Look for functions that reference GUI construction widgets (e.g., dialog boxes for serial numbers).
- Search for string references – for example, if the program prints “Already registered.”, find which function contains that string.
- Run the program under a debugger, interrupt it when an interesting operation starts, and use
stepi to slow down execution. Alternatively, set a breakpoint on a frequently called function and use continue N to skip until you reach the desired point.
- Identify functions that invoke BSD socket layer calls – useful for network‑related targets.
Mapping Program Flow
Once you have a list of candidate functions, plot the execution paths from main down to your functions of interest. The disasm.pl script with --graph can generate a call_graph file that you feed into Graphviz’s dot to produce a visual call graph – this is especially enlightening for small to medium‑sized programs.
Understanding Assembly Language
Since most reverse engineering tools emit AT&T syntax (which differs from Intel/MASM style), familiarity with that syntax is essential. Assembly is one level above machine code; to read it, you must understand the underlying hardware.
CPU Registers
The x86 architecture provides a small set of general‑purpose registers:
- EAX, EBX, ECX, EDX – the four primary integer registers. Each can be accessed as 32‑bit (
%eax), 16‑bit (%ax), or as two 8‑bit halves (%al/%ah).
- ESI, EDI – originally used for string operations, but now often used as general‑purpose registers.
- ESP – the stack pointer, which points to the top of the stack.
- EBP – the base pointer (frame pointer), used to reference function parameters and local variables. It can be omitted with
-fomit-frame-pointer to free up an extra register.
- EIP – the instruction pointer, which holds the address of the next instruction to execute; it cannot be modified directly except via jumps and calls.
The Stack
The stack is a Last‑In‑First‑Out (LIFO) memory region that exists for the entire lifetime of a process. It stores local variables, function arguments, return addresses, and saved frame pointers.
On x86, the stack grows downward – pushing a value decrements ESP by the size of the value; popping increments it. Although the stack grows downward, memory addressing within the stack is still upward (e.g., an array char b[4] at ESP=80 has b[0] at 80, b[1] at 81, etc.).
Two primary instructions manipulate the stack:
push – places a value onto the stack and decrements ESP.
pop – removes the top value and increments ESP.
pusha and popa operate on all registers at once. Arithmetic operations (like add or sub) can also adjust ESP directly to reserve or release stack space.
Function Prologue and Epilogue (GCC style)
Before a function call, arguments are pushed in reverse order. The call instruction pushes the return address (the next EIP) and jumps to the target function.
Inside the callee, a typical prologue does:
push %ebp
mov %esp, %ebp
sub $N, %esp ; allocate space for local variables (N bytes)
This sets up EBP as a fixed reference: parameters are at positive offsets from EBP, locals at negative offsets. The epilogue reverses this:
mov %ebp, %esp
pop %ebp
ret
If -fomit-frame-pointer is used, EBP becomes free and ESP is used directly for both parameters and locals, making debugging less straightforward.
Two’s Complement Representation
Most modern systems represent signed integers in two’s complement form. This has several benefits: addition works identically for positive and negative numbers, negation is easy, and the most significant bit indicates sign (0 = positive, 1 = negative).
To negate a number, invert all bits and add one. For example, –13 (binary 0000 1101) becomes 1111 0011.
In disassembly, you often see constants like 0xfffffff8. This is actually –8 in two’s complement, and it is used to decrement the stack pointer (e.g., add $0xfffffff8, %esp effectively subtracts 8).
Byte Ordering (Endianness)
Different architectures store multi‑byte values in different orders:
- Little‑endian (x86) – least significant byte first.
- Big‑endian (SPARC, PowerPC) – most significant byte first.
For example, the 32‑bit value 0x075bcd15 stored at address 0xbffff234 appears as bytes 15 cd 5b 07 on little‑endian, but as 07 5b cd 15 on big‑endian. This affects how you interpret memory dumps and cross‑platform network data (network byte order is big‑endian).
Reading Assembly Efficiently
A disciplined approach is to keep a paper record:
- Draw a table for registers (EAX, EBX, ECX, EDX, ESI, EDI) and update it with each instruction.
- Maintain a stack diagram with
ESP and EBP positions, noting each push/pop and stack‑relative access.
AT&T Syntax Basics – instructions follow the form mnemonic src, dest. Constants are prefixed with $, registers with %, and hexadecimal numbers use 0x prefix. Memory references use disp(%base, %index, scale) where the effective address is disp + %base + %index * scale. Any component may be omitted.
The Intel instruction set is well documented; the key difference is that Intel syntax uses mnemonic dest, src. The actual mnemonics are mostly the same.
Recognising Compiler‑Generated Constructs
To become fluent in assembly, you must learn to identify common high‑level structures:
- Function calls – arguments pushed, then
call; return value in %eax.
if statements – a test followed by a conditional jump; often the jump condition is the negation of the original condition.
if..else – conditional jump over one block, then an unconditional jump to skip the else block.
while loops – a conditional jump at the top, and an unconditional jump back to the top at the bottom.
for loops – similar to while, with initialisation and increment instructions placed accordingly.
do..while – the condition check is at the bottom, so the loop body executes at least once.
- Arrays on the stack – accessed via base + index * scale addressing. For multi‑dimensional arrays, the compiler flattens them into a single linear space, computing offsets as
(i * dim1 + j) * element_size.
- Structs – fields are accessed at fixed offsets from the base address of the struct instance.
- Returning structs – GCC passes a hidden pointer to the caller‑allocated struct as an extra argument; the function writes the result through that pointer and returns the pointer in
%eax.
The document provides numerous example C files and their corresponding assembly outputs for different optimisation levels (-O0, -O2, -O3 -fomit-frame-pointer) using both GCC 2.95 and 3.3.2. These exercises illustrate how the compiler transforms each control structure.
💡 Final advice: The best way to internalise these concepts is to compile small programs yourself, experiment with various optimisation flags, and study the resulting assembly. Over time, you will develop intuition for what kind of C code produced a given assembly sequence. This skill is the bedrock of effective reverse engineering.
📘 Note: The original document contained placeholders (FIXME) for some diagrams, additional examples, and further elaboration on C++ features (classes, inheritance, templates). Those have been omitted or summarised here to maintain focus on the core concepts.
This HTML rendering is based on the “Reconstructing Functions & Control Flow” document. All content is for educational purposes.