Patching ELFs with Assembly C, or abusing the linker for fun and profit
Using a little bit of linkerscript magic and C to patch binaries the toolchain-intended way - instead of manually patching assembly instructions like a madman.
In this post, we’ll explore an intuitive way to patch program behaviour in ELF binaries - by using a method that involves NO assembly.
TL;DR
Feel free to skip to Simple demonstration section if you are familiar with C compilation process and linkerscript.
Compiling C - a quick refresher
Compiling C, in the most simplest form, is as simle as a single command like gcc main.c -o main.
But behind the scenes, there’s three main components involved in converting a C source file to an executable - the compiler, the assembler, the linker.

Abusing the linker for fun and patching binaries
In the compilation process, the linker is used to resolve relocations in object files.
The GNU Linker (ld) provides two features that can be used for binary patching:
- Defining the address of a symbol.
- Placing a certain section of code at a certain memory offset.
Defining the address of a symbol
In C, symbols may be referenced before they are defined. This allows code to refer to objects or functions whose final definition is provided later-either by another object file or by the linker itself.
Symbols defined in linkerscript can be referenced using the extern C keyword (It’s not necessary for functions).
The below example shows a linkerscript which defines the address of the symbol my_puts to be 0x42069.
It can be verified by looking at corresponding disassembly.
C — MAIN.C
| |
LD — LINKERSCRIPT.LD
INCLUDE default.ld
my_puts = 0x42069;
Feel free to download and compile the contents of example/defining_external_symbols/export.zip to verify.
Placing a certain section of code at a certain memory offset
By modifying the location counter (the .) of a linkerscript, we can force certain sections to be placed in specific virtual addresses.
For example, the below code places the function foo at the virtual address 0xdeadbeefcafebabe.
C — PATCH.C
| |
LD — PATCH.LD
SECTIONS
{
. = 0xdeadbeefcafebabe;
.patch ALIGN(1) : SUBALIGN(1)
{
KEEP (*(.patch))
}
}
The source for this example is provided at: example/placing_section_at_address/export.zip.
Simple demonstration
For a practical example, we will patch a binary produced by the following source:
C — PATCHME.C
| |
The goal is to replace the code-cave i_do_nothing with a call to
C
| |
without recompiling the original source.
The binary has the following protections enabled:

The most notable one, for our concerns, is PIE (Position Independent Executable).
Since it is enabled, we cannot call to absolute addresses - we have to make the compiler emit the rel32 counterparts of the call / jmp instructions by setting a flag.
Notation
Moving on, the word “target” refers to the binary being patched.
Recon
Before we start writing the patch, we need to identify code caves and enumerate the environment.
We need a safe-to-patch code cave.
When patching a code cave, it should not cause un-wanted side-effects (like triggering improper memory access).
The i_do_nothing function provides a perfect code cave, with 500 nop instructions.
It starts at physical address 0x01139 and has a length of 511 bytes.

We need to call external library functions
Our goal is to call puts.
Since the target is dynamically linked, we can find a reference to puts in the .plt section,
at the physical offset 0x1030.

Writing the patch
From our recon, we have the following parameters:
- should fit within the address range
[0x1139, 0x1139+511]. - must call
puts, which can be achieved usingputs@plt. - must also embed two null-terminated strings (the parameters to
puts@plt).
With the above in mind, one can write a patch - something similar to this:
C — BINPATCH.C
| |
LD — BINPATCH.LD
patch_at = 0x01139;
patch_size = 511;
puts = 0x1030; /* plt entry of puts */
SECTIONS
{
. = patch_at;
.patch ALIGN(1) : SUBALIGN(1)
{
KEEP(*(.patch.code.*))
KEEP(*(.patch.data.*))
}
ASSERT(SIZEOF(.patch) <= patch_size, "patch is too big")
}Once the patch is compiled and extracted, it can be applied to the target using the dd command.

Caveats
- If the codecave is restricted by size, it is a better idea to directly write assembly.
- If the target function’s calling convention differs from the C compiler’s ABI, it’s preferable to use a small assembly “stub” to adapt the arguments and invoke the C function correctly.
Conclusion
The original goal of this method of binary patching was to increase productivity by working with a high level tool like the C compiler instead of an assembler. That said, it also comes with a few pretty useful side effects. I’ll revisit the points outlined below in more detail in future posts:
With enough compiler options, it should be possible to use languages other than C for generating object files. Example: Rust, Zig, Nim, etc.
The patch itself gains a degree of platform independence: as long as the target and the patching language share the same (or similar) ABI, the patch logic remains unchanged. The only platform-specific component is the linker script, which defines the required symbols, addresses, and offsets.
This method should work fairly unchanged for other binary formats, like PEs or Mach-O executables.
With a large enough code caves, it should be possible to embed a dynamic symbol-resolver, which uses platform dependent methods to load addresses of symbols from libraries (like
dlsym()on linux, andGetProcAddress()on NT).
Overall, this method shifts binary patching away from instruction-level edits to a more structured, maintainable and versionable workflow. It mirrors how decompilers let us think in C while reverse-engineering: the low-level complexity of assembly still exists, but the compiler handles it for us, allowing us to focus on intent rather than instructions.