Indirect Control Flow (ICF)
What is Indirect Control Flow (ICF)?
In the context of binary analysis, indirect control flow occurs when a branching instruction (such as jmp, call, etc.) has a variable destination (memory, register). A common term for this is indirection.
Taxonomy of ICF
Indirection from compilers are usually introduced from function pointers or jump tables. Other sources of indirection are usually some variations of this (e.g. vtable).
Programming Constructs
Selector Function
Selector functions are generic functions that point to one of multiple functions depending on the runtime circumstances. Programmers often use this mechanism not only because it simplifies the client code, but also because it optimizes the space required by the program.
void *call_chunkfun(struct obstack *h, size_t size) {
if (h->use_extra_arg)
return h->chunkfun.extra(h->extra_arg, size);
else
return h->chunkfun.plain(size);
}
In this example, the selector function decides which version of the allocation routine to call based on the flag use_extra_arg.
Callbacks
Callbacks are functions passed as parameters to other functions. The pointer to the callback function is stored and later used to transfer control. This pattern is frequently used in event-driven programming and asynchronous processing. The function that receives the callback does not know which function it will eventually call, only that it meets the required signature.
Anonymous Functions
You can also explicitly declare anonymous functions utilize function pointers directly.
typedef int (*compute_func)(int, int);
The above example is one example of an anonymous function. You can imagine that this function could call add(int a, int b) or multiply(int a, int b) for example.
Compiler Constructs
Switch Statements
For switch statements with many cases, compilers often generate jump tables to optimize branch selection. Instead of evaluating each condition sequentially, the compiler creates an array of branch targets, and the control flow is redirected by indexing into this array
The decision to use a jump table versus a series of conditional branches depends on factors such as the density of case values and the target architecture.
For example, consider this switch statement compiled on x86-64 GCC 14.2:
void process_case(int value) {
switch (value) {
case 0: printf("Case 0\n"); break;
case 1: printf("Case 1\n"); break;
case 2: printf("Case 2\n"); break;
case 3: printf("Case 3\n"); break;
case 4: printf("Case 4\n"); break;
default: printf("Default case\n"); break;
}
}
In this case, having 6 cases is enough for the compiler to conclude a jmp rax is necessary. x86-64 clang 19.1.0 on the other hand is okay with just 4 cases, including the default case.
Virtual Tables
In object-oriented programming, virtual functions are implemented using virtual tables (vtables). A virtual table is essentially an array of pointers to virtual functions. This table allows the program to invoke the correct function for an object. This enables polymorphism where the actual function executed is determined at runtime:
class Base {
public:
virtual void doWork() { /* base work */ }
};
class Derived : public Base {
public:
void doWork() override { /* derived work */ }
};
Here, when a Base* pointer points to a Derived object, the doWork() call is resolved via the vtable at runtime.
Lambda Expressions
Lambda expressions in C++ allow for inline anonymous function definitions:
auto multiply = [](int a, int b) { return a * b; };
int result = multiply(3, 4);
The reason this is here instead of in programming constructs is because this is
Computed Gotos and Trampolines
Some compilers for C provide an extension called computed goto, which allows jumping to a label whose address is computed at runtime. This construct can yield more efficient state machines in certain cases. Similarly, trampolines—small pieces of code that redirect control flow—are used in some environments to perform lazy resolution of function addresses, particularly in dynamically loaded libraries.
Linker
In many binary formats, such as the ELF format on Linux, the linker and dynamic loader work together to resolve external function. This is often implemented using a Global Offset Table (GOT) and a Procedure Linkage Table (PLT). Initially, a call to a shared library function goes through a PLT entry, which then resolves the function’s actual address via the GOT. Once resolved, subsequent calls bypass the indirection overhead.
Reproduce
You can reproduce the discovery of some of the results using this Github gist.