From Symbol Hijacking to Runtime Tracing in Linux: How to Function Hook, Audit Hijacking, and Function Instrumentation?

Hello, friends!

  • Hooking using LD_PRELOAD
    • Hooking using LD_PRELOAD
    • Hooking using RTLD_NEXT
  • LD_AUDIT linker listening mechanism
  • GCC function instrumentation feature (-finstrument-functions)

In this article, we will learn about Linux from symbol hijacking to runtime tracing: function hooking (LD_PRELOAD), audit stream hijacking (LD_AUDIT), and function instrumentation, as well as how to use and leverage these tools.

Hooking using LD_PRELOAD

Hooking using LD_PRELOAD

LD_PRELOAD allows specified dynamic libraries (.so files) to be loaded preferentially at runtime, overriding the default library function implementations. The dynamic linker will first check the libraries specified by LD_PRELOAD, and if there are functions with the same name as those called by the program, it will use the implementation from that library instead of the system default version.

Function hijacking hooks, such as replacing the implementation of malloc or functions in a shared library, can be achieved using LD_PRELOAD.

Here is a related usage example:

  • Using jemalloc

In the usage of jemalloc[1], there are several methods to integrate jemalloc into an application.

The simplest method is to use the LD_PRELOAD environment variable to inject jemalloc into the application at runtime. Note that this method only works if your application does not statically link the malloc implementation.

LD_PRELOAD=`jemalloc-config --libdir`/libjemalloc.so.`jemalloc-config --revision` app

Besides this method, you can dynamically link and statically link jemalloc to your application at compile time. During development, this method can be used to find memory crash bugs, analyze memory allocation, use Heap Profiler, and resolve memory leaks.

Hooking using RTLD_NEXT

In addition to the LD_PRELOAD method, hooking can also be done using dlsym RTLD_NEXT.

If the first parameter of the dlsym or dlvsym function is set to RTLD_NEXT, the function returns the runtime address of the symbol named NAME in the next shared object, which is usually used to call the original function in the hook code.

Using this method, we can implement a custom malloc function that retrieves the pointer to the original malloc using RTLD_NEXT, allowing us to call the original malloc function within our custom malloc, achieving the purpose of hooking.

The runtime symbol resolution function is used to find the next matching symbol implementation in the loaded library chain.

Here is a related usage example:

  • Hooking in a coroutine network library

In a C++ network library based on coroutines and event loops[2], hooking is implemented using dlsym RTLD_NEXT. This includes hooking functions like read, recv, send, sleep, etc.

#define DLSYM(name) \
  name ## _f = (name ## _t)::dlsym(RTLD_NEXT, #name);
unsigned int sleep(unsigned int seconds) {
 melon::Processer* processer = melon::Processer::GetProcesserOfThisThread();
if (!melon::isHookEnabled()) {
        // Call the system function directly when not hooked, sleep_f = dlsym(RTLD_NEXT, "sleep");
return sleep_f(seconds);
 }

    // When hooked, suspend the current coroutine and continue execution after seconds
 melon::Scheduler* scheduler = processer->getScheduler();
 assert(scheduler != nullptr);
 scheduler->runAt(melon::Timestamp::now() + seconds * melon::Timestamp::kMicrosecondsPerSecond, melon::Coroutine::GetCurrentCoroutine());
 melon::Coroutine::SwapOut();
return0;
}

LD_AUDIT Linker Listening Mechanism

From the previous introduction, you are certainly familiar with LD_LIBRARY and LD_PRELOAD. LD_AUDIT is another environment variable of the glibc dynamic linker (ld.so) in the Linux system, used to specify the path of the audit library, mainly for monitoring and intercepting the loading process of dynamic libraries.

Through the LD_AUDIT linker listening mechanism, we can manipulate the dynamic linking process of glibc, such as intercepting the loading process of dynamic libraries or intercepting the symbol resolution process of dynamic libraries.

In the xz-sshd vulnerability, the attacker hijacked the RSA decryption function call chain through LD_AUDIT to achieve privilege escalation.

Here is a simple example:

// audit_example.c
#define _GNU_SOURCE
#include <link.h>
#include <stdio.h>

// Version check function that must be implemented
unsigned int la_version(unsigned int version) {
    printf("Audit library version: %u (supports up to version %u)\n", version, LAV_CURRENT);
    return LAV_CURRENT; // Return the supported version number
}

// Callback triggered when a library is loaded
unsigned int la_objopen(struct link_map *map, Lmid_t lmid, uintptr_t *cookie) {
    printf("Detected library load: %s (ID: %p)\n", map->l_name, (void*)*cookie);
    return LA_FLG_BINDTO | LA_FLG_BINDFROM; // Allow symbol binding tracking
}

// Callback triggered before symbol binding
uintptr_t la_symbind64(Elf64_Sym *sym, unsigned int ndx,
                      uintptr_t *refcook, uintptr_t *defcook,
                      unsigned int *flags, const char *symname) {
    printf("Symbol binding: %s (address: %#lx)\n", symname, sym->st_value);
    return sym->st_value; // Return the original address (modifiable)
}
// test_program.c
#include <stdio.h>
int main() {
    printf("hello\n");
    return 0;
}
// Compile audit library: gcc -shared -fPIC audit_example.c -o libaudit.so -ldl
// Compile test program: gcc test_program.c -o test -ldl
// Run test: LD_AUDIT=./libaudit.so ./test

Printed output:

Audit library version: 2 (supports up to version 2)
Detected library load:  (ID: 0xffff87a2c370)
Detected library load: /lib/ld-linux-aarch64.so.1 (ID: 0xffff87a2bb88)
Detected library load: linux-vdso.so.1 (ID: 0xffff87a2c950)
Detected library load: /lib/aarch64-linux-gnu/libc.so.6 (ID: 0xffff87a1d880)
Symbol binding: __libc_start_main (address: 0xffff87597434)
Symbol binding: __cxa_finalize (address: 0xffff875ad220)
Symbol binding: abort (address: 0xffff8759704c)
Symbol binding: puts (address: 0xffff875dae70)
Symbol binding: calloc (address: 0xffff875fe460)
Symbol binding: free (address: 0xffff875fdbc4)
Symbol binding: malloc (address: 0xffff875fd630)
Symbol binding: realloc (address: 0xffff875fde20)
Symbol binding: _dl_catch_exception (address: 0xffff8769d290)
Symbol binding: _dl_signal_exception (address: 0xffff8769d1e4)
Symbol binding: __tls_get_addr (address: 0xffff87a00cd0)
Symbol binding: _dl_signal_error (address: 0xffff8769d234)
Symbol binding: _dl_catch_error (address: 0xffff8769d390)
Symbol binding: __tunable_get_val (address: 0xffff87a02d40)
Symbol binding: __getauxval (address: 0xffff87655560)
Symbol binding: _dl_audit_preinit (address: 0xffff87a03774)
Symbol binding: malloc (address: 0xffff875fd630)
Test program running...

Unlike LD_PRELOAD (which forces the preloading of libraries), LD_AUDITfocuses more on event monitoring of the linking process rather than directly replacing functions. Of course, it is also possible to hijack and replace function implementations.

Application example, in the interpretation of the possible principle of the xz-sshd vulnerability——linker listening mechanism[3], the RSA_public_decrypt function was hijacked through the la_symbind64 function and replaced with a self-implemented hijack_RSA_public_decrypt function.

// Called when a symbol is bound
uintptr_t la_symbind64(Elf64_Sym *sym, unsigned int ndx, uintptr_t *refcook,
                     uintptr_t *defcook, unsigned int *flags, const char *symname) {
    printf("Symbol bound: %s\n", symname);
    // Perform any custom actions here
    if (strcmp(symname, "RSA_public_decrypt") == 0) {
        return (uintptr_t)hijack_RSA_public_decrypt;
    }
    return sym->st_value; // Return the symbol's actual address
}

GCC Function Instrumentation Feature (-finstrument-functions)

The -finstrument-functions option in GCC is a powerful compilation option that automatically inserts hook functions at the entry and exit of functions, mainly for performance analysis and call tracing.

When this option is added at compile time, GCC inserts cyg_profile_func_enter at the start of each function and cyg_profile_func_exit before the function returns. The parameters of these two hook functions include the current function address and the caller address.

With this feature, we can count function execution time, locate functions with execution anomalies, analyze performance bottlenecks, and of course, we can also use ebpf to capture directly. It records function call paths to assist in debugging complex call relationships.

// instrument.c
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <time.h>

// Hook functions must be prevented from being instrumented
void __attribute__((no_instrument_function))
__cyg_profile_func_enter(void *func, void *caller) {
    Dl_info info;
    dladdr(func, &info);
    printf("▶ ENTER: %s [%p]\n", info.dli_sname ? info.dli_sname : "unknown", func);
}

void __attribute__((no_instrument_function))
__cyg_profile_func_exit(void *func, void *caller) {
    Dl_info info;
    dladdr(func, &info);
    printf("◀ EXIT: %s [%p]\n", info.dli_sname ? info.dli_sname : "unknown", func);
}
// main.c
#include <stdio.h>

void test_func() {
    sleep(1); // Simulate time-consuming operation
}

int main() {
    printf("Start tracing...\n");
    test_func();
    printf("End tracing\n");
    return0;
}
// gcc -finstrument-functions main.c instrument.c -ldl -rdynamic -o demo
// ./demo

Output:

▶ ENTER: main [0xaaaadcf80bf4]
Start tracing...
▶ ENTER: test_func [0xaaaadcf80b94]
◀ EXIT: test_func [0xaaaadcf80b94]
End tracing
◀ EXIT: main [0xaaaadcf80bf4]

Using attribute((no_instrument_function)) to avoid instrumenting the hook functions themselves. Custom hook functions need to be defined, usually combined with dladdr to resolve function names and file names.

Using the addr2line tool to convert addresses to source code line numbers, combined with tools like perf to analyze performance.

How is this feature specifically used? Here are two examples:

  • The open-source tool uftrace[4] uses this feature to obtain data; uftrace is a function call tracing tool for C, C++, Rust, and Python programs.

User space C/C++/Rust functions, by either dynamically patching functions using -P., or else selective NOP patching using code compiled with -pg, -finstrument-functions or -fpatchable-function-entry=N.

  • Using GCC function instrumentation to find time-consuming anomalous functions[5]

This article uses function instrumentation to automatically insert hook functions at the entry and exit of functions to count function execution time.

In cyg_profile_func_enter, the start ticks of the function are recorded, and in cyg_profile_func_exit, the end ticks of the function are recorded. The difference between the two gives the ticks consumed by the function, which is then divided by the CPU frequency (g_cs_hz) to obtain the execution time. The RDTSC (Read Time Stamp Counter) instruction in the x86 instruction set architecture is used to read the processor’s clock cycle counter.

If the execution time exceeds 5ms, the function pointer and execution time are pushed onto a stack for later printing.

References

[1]

In the usage of jemalloc: https://github.com/jemalloc/jemalloc/wiki/Getting-Started

[2]

In a C++ network library based on coroutines and event loops: https://github.com/gatsbyd/melon/blob/master/src/Hook.cpp

[3]

Interpretation of the possible principle of the xz-sshd vulnerability——linker listening mechanism: https://zhuanlan.zhihu.com/p/689983608

[4]

uftrace: https://github.com/namhyung/uftrace

[5]

Using GCC function instrumentation to find time-consuming anomalous functions: https://zhuanlan.zhihu.com/p/706025483

Leave a Comment