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.

C Compilation Process

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:

  1. Defining the address of a symbol.
  2. 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
1
2
3
4
5
extern void (*my_puts)(char *);

int main(void) {
  my_puts("Hello, world!");
}
LD — LINKERSCRIPT.LD
INCLUDE default.ld

my_puts = 0x42069;

Defining address of a symbol

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
1
2
3
4
5
6
7
#define SECTION(x) __attribute__((section(x)))

// this section should be placed at **virtual address** `0xdeadbeefcafebabe`
SECTION(".patch")
int foo(void) {
    return 42;
};
LD — PATCH.LD
SECTIONS
{
    . = 0xdeadbeefcafebabe;
    .patch ALIGN(1) : SUBALIGN(1)
    {
        KEEP (*(.patch))
    }
}

Placing a function at an offset

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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <stdio.h>
#include <sys/cdefs.h>

volatile int i_do_nothing() {
  __asm__ volatile(".rept 500\n\t"
                   "nop\n\t"
                   ".endr\n\t");

  return 42;
}

int main(void) {
  puts("Hello! Go ahead and patch me!");
  volatile int ret = i_do_nothing();
  return ret;
}

The goal is to replace the code-cave i_do_nothing with a call to

C
1
2
puts("Hello, binary patching!");
puts("Hello, linkerscript magic!");

without recompiling the original source.

The binary has the following protections enabled:

Binary protections

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.

i do nothing

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.

puts in pl

Writing the patch

From our recon, we have the following parameters:

  1. should fit within the address range [0x1139, 0x1139+511].
  2. must call puts, which can be achieved using puts@plt.
  3. 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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <stdio.h> // for declaration of puts

#define SECTION(x) __attribute__((section(x)))

SECTION(".patch.data.string1")
const char hello_binary_patching[] = "Hello, binary patching!";

SECTION(".patch.data.string2")
const char hello_linkerscript[] = "Hello, linkerscript magic!";

SECTION(".patch.code.i_do_something")
void i_do_something() {
  puts(hello_binary_patching);
  puts(hello_linkerscript);
}
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.

Binpatch Result

Caveats

  1. If the codecave is restricted by size, it is a better idea to directly write assembly.
  2. 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:

  1. With enough compiler options, it should be possible to use languages other than C for generating object files. Example: Rust, Zig, Nim, etc.

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

  3. This method should work fairly unchanged for other binary formats, like PEs or Mach-O executables.

  4. 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, and GetProcAddress() 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.