Previously, in the article on easily hooking Linux kernel functions using Ftrace, we introduced how to use ftrace (function trace) to hook kernel functions. Through the ftrace mechanism, we can specify a callback function that we write for the kernel function we want to hook. When the target function is called, the kernel automatically invokes our specified callback function and passes the parameters of the hooked function to the callback function via the ftrace_regs structure. This way, we effectively intercept the parameters of the hooked function and perform operations we desire, such as checking or modifying the parameters.In this article, we will continue to introduce ftrace, examining the principles of ftrace hook kernel functions from the perspective of assembly language. We will observe the changes in the instructions of a kernel function in memory before and after it is hooked using gdb.In the previous article, we wrote a simple ftrace hook kernel module (ftrace_test.ko) that hooks the kernel function do_filp_open to obtain the name of the file that the function is trying to open. Below is the disassembled assembly code of the original do_filp_open function obtained using gdb before installing this kernel module. Remember how this part looks, as it will change slightly later.
Next, after we install the ftrace_test.ko module, here is the log output from this module. Each time the hooked function do_filp_open is called, this module outputs 3 lines of log. The first line outputs the address of the hook point in the do_filp_open function, the second line outputs the return address of the function do_filp_open in its parent function do_sys_openat2, and the third line outputs which file is being opened in this do_filp_open call.
Now, we will again use gdb to disassemble the function do_filp_open, and the result is as follows. Comparing the assembly code of do_filp_open before installing ftrace_test.ko, we can see that the content of one instruction has changed. The address of the changed instruction is 0xffffffff8149d5e4, and the instruction content has changed from a 5-byte nop instruction to a call instruction:
nopl 0x0(%rax,%rax,1) -> callq 0xffffffffc000a000
The address of this changed instruction is the same as the address of the first log output from the kernel module ftrace_test.ko each time the do_filp_open function is called, which is the hook point of ftrace in do_filp_open. This location has a specific name: call __fentry__. This is a nop instruction reserved by the compiler for ftrace dynamic instrumentation. Normally, when the CPU executes to this location, it will slide over these nop instructions, which almost has no impact on the execution of the current function. Once ftrace hooks this function, the kernel will replace this call __fentry__ with “call a special address”. In the above example, it is call 0xffffffffc000a000, and this special address is known as the ftrace trampoline. Once the execution flow enters the do_filp_open function, it will first call this special address (jump to this “trampoline”).Now, let’s take a look at the assembly code of this trampoline. At a glance, this code contains only one call instruction, which calls the callback function specified for the do_filp_open function in ftrace_ops. Before this call instruction, there are a series of operations that save the registers onto the stack. This is actually preparing the ftrace_regs parameter for the ftrace_ops callback function, saving the parameters passed to the target function into the ftrace_regs structure, so that in the callback function, we can access the parameters of the hooked function through ftrace_regs. After the ftrace callback function returns, the trampoline code will restore the previously saved values from ftrace_regs back to the corresponding registers because the next step is to call the hooked function itself. We need to restore the current CPU’s execution state to what it was when entering the target function, so that the hook does not affect the original function’s execution.
Based on the situation we observed through disassembly, the principles of how ftrace implements kernel function hooking can be summarized as follows:
Preparation at the compilation stage: The compiler reserves an instrumentation point (call __fentry__) at the beginning of each function.
Dynamic modification at runtime: Once ftrace hook is enabled, the kernel replaces the instrumentation instruction at the beginning of the target function with a call instruction that jumps to the ftrace trampoline code.
Triggering the execution of the callback function: When the hooked function is executed, it first jumps to the ftrace trampoline code, which saves the current register values and passes them to the callback function. After the callback function completes its tasks and returns, the ftrace trampoline code restores the previously saved register values and jumps back (ret) to execute the hooked function.
Resuming execution of the hooked function: When the execution flow returns from the trampoline code, the hooked function continues executing the original flow as if nothing had happened.
We previously introduced the basic principles of kprobe, which is also a form of dynamic instrumentation technology. It works by inserting an int3 (breakpoint) instruction at the target address. When the kernel executes to the breakpoint, the CPU triggers a trap, entering the kernel’s breakpoint handler, which then calls your callback function. This method is more versatile (it can be instrumented at almost any instruction), but it incurs relatively high overhead due to interrupt handling, context saving/restoration, etc. In contrast, the principles of ftrace introduced in this article are more sophisticated. It utilizes the cooperation of the compiler to avoid breakpoint interruptions, and its jumps are direct, resulting in relatively low overhead and being performance-friendly. However, its limitation is that it usually can only hook at the function entry point (because it relies on the __fentry__ point). In fact, the optimizations that Linux developers previously mentioned for kprobe leverage the principles of ftrace hooking at the function entry point. This way, the kprobe added at the function entry point behaves like ftrace, directly jumping to a segment of trampoline code, and then calling the user-provided callback function, thus avoiding the additional overhead caused by handling the int3 instruction.