How the Linux Kernel Executes Programs

When you type a command such as:

ls

a lot happens behind the scenes.

The shell does not directly “become” ls by itself. Instead, it asks the Linux kernel to execute the program. The main system call involved is:

execve()

The execve system call replaces the current process image with a new program.

That means the process keeps the same process ID, but its memory, code, stack, arguments, and environment are replaced with those of the new program.

A simple view looks like this:

Before execve:

Process PID 1234
+----------------------+
| Program: shell       |
| Code: /bin/bash      |
| Args: bash           |
| Env: PATH=...        |
+----------------------+

execve("/bin/ls", ["ls"], envp)

After execve:

Process PID 1234
+----------------------+
| Program: ls          |
| Code: /bin/ls        |
| Args: ls             |
| Env: PATH=...        |
+----------------------+

The process did not get a new PID. It became a different program.

Big Picture

Program execution usually involves several layers.

User types command
        |
        v
Shell parses command
        |
        v
Shell usually calls fork()
        |
        v
Child process calls execve()
        |
        v
Kernel loads executable
        |
        v
Kernel sets up memory, stack, arguments, environment
        |
        v
Program starts running

For a normal shell command, the shell usually uses both:

  • fork() create a child process
  • execve() replace the child with the requested program

This is why the shell survives after running a command.

If the shell directly called execve("ls", ...) without forking first, the shell itself would be replaced by ls.

The execve System Call

The C function signature is:

int execve(const char *pathname, char *const argv[], char *const envp[]);

The arguments are:

  • pathname path to the program to execute
  • argv argument vector passed to the new program
  • envp environment variables passed to the new program

Example:

execve("/bin/ls", ["ls", "-l", "/tmp", NULL], envp);

This asks the kernel to run:

/bin/ls -l /tmp

The new program receives:

argv[0] = "ls"
argv[1] = "-l"
argv[2] = "/tmp"

Environment variables such as PATH, HOME, USER, and LANG can also be passed to the new program.

Important Property of execve

execve does not create a new process.

It transforms the current process.

  • fork() creates a new process.
  • execve() replaces the current process image.

This distinction is very important.

Shell
 |
 | fork()
 v
Child process
 |
 | execve("/bin/ls")
 v
ls process

The shell remains alive because it forked first. The child becomes ls.

What the Kernel Does During execve

When a process calls execve, the kernel performs several steps.

  1. Validate the executable path
  2. Check permissions
  3. Open the file
  4. Identify the binary format
  5. Load the program into memory
  6. Set up a new virtual address space
  7. Set up stack, arguments, and environment
  8. Configure registers
  9. Start execution at the program entry point

A simplified kernel-side flow looks like this:

execve()
   |
   v
do_execveat_common()
   |
   v
prepare binary parameters
   |
   v
search_binary_handler()
   |
   +--> ELF executable      -> load_elf_binary()
   |
   +--> script with #!      -> load_script()
   |
   +--> other binary format -> matching handler
   |
   v
set up memory mappings
   |
   v
set up stack and registers
   |
   v
jump to program entry point

Function names can vary slightly between kernel versions. On many modern kernels, useful functions to inspect include:

  • do_execveat_common
  • bprm_execve
  • search_binary_handler
  • load_elf_binary
  • start_thread

Observing execve with strace

The easiest way to observe program execution is with strace.

Run:

strace -e execve ls

Example output:

execve("/usr/bin/ls", ["ls"], 0x7ffc8e0a9b10 /* 45 vars */) = 0

Interpretation:

  • The program /usr/bin/ls was executed.
  • argv contained one argument: "ls".
  • The process received environment variables.
  • The return value is 0 in the trace because exec succeeded.

Normally, a successful execve does not return to the old program. If execve returns, it usually means it failed.

Example failure:

strace -e execve /no/such/program

Example output:

execve("/no/such/program", ["/no/such/program"], 0x7ffd...) = -1 ENOENT (No such file or directory)

Interpretation:

  • The kernel could not find the requested executable.
  • ENOENT means the file or path does not exist.

execve and the Shell

When you run:

ls -l

the shell usually searches your PATH to find ls.

If PATH includes:

/usr/local/bin:/usr/bin:/bin

the shell may find:

/usr/bin/ls

Then it runs something conceptually like:

fork();
execve("/usr/bin/ls", ["ls", "-l", NULL], envp);

The kernel does not search PATH for execve.

Important distinction:

  • The shell searches PATH.
  • execve needs a pathname.

Some higher-level functions, such as execvp, do search PATH, but they are library functions. They eventually call execve.

Executing Scripts

Not every executable file is a compiled binary.

A script may begin with a shebang line:

#!/bin/sh

Example script:

#!/bin/sh
echo "Hello from script"

When the kernel sees the #! line, it uses the interpreter.

Script file:
./hello.sh

First line:
#!/bin/sh

Kernel executes:
/bin/sh ./hello.sh

The flow looks like this:

execve("./hello.sh")
        |
        v
Kernel sees #!/bin/sh
        |
        v
Kernel executes interpreter
        |
        v
execve("/bin/sh", ["/bin/sh", "./hello.sh"], envp)

This is why the interpreter path must be valid.

If the shebang points to a missing interpreter, the script fails even if the script itself exists.

Executable Permissions

For execve to work, permissions matter.

Check a file:

ls -l ./hello

Example:

-rwxr-xr-x 1 user user 16000 Jun 1 12:00 hello

The x bits mean the file is executable.

If a file is not executable:

-rw-r--r-- 1 user user 16000 Jun 1 12:00 hello

running it may fail:

./hello

Example output:

bash: ./hello: Permission denied

Fix:

chmod +x ./hello

Binary Formats

Linux can execute several types of files through binary format handlers.

Common examples:

  • ELF binaries
  • scripts with shebang lines
  • miscellaneous registered binary formats through binfmt_misc

Most compiled Linux programs use the ELF format.

ELF stands for Executable and Linkable Format.

To inspect a binary:

file /bin/ls

Example output:

/bin/ls: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2

Interpretation:

  • /bin/ls is an ELF executable.
  • It is for x86-64.
  • It is dynamically linked.
  • It uses a dynamic linker.

ELF Loading

For ELF binaries, the kernel uses the ELF loader.

A simplified ELF loading flow:

Kernel opens executable
        |
        v
Reads ELF header
        |
        v
Checks architecture and format
        |
        v
Maps program segments into memory
        |
        v
Sets up stack with argv, envp, auxiliary vector
        |
        v
Sets instruction pointer to entry point
        |
        v
Program begins execution

To inspect the ELF header:

readelf -h /bin/ls

Example output:

ELF Header:
  Class:                             ELF64
  Machine:                           Advanced Micro Devices X86-64
  Entry point address:               0x61d0

Interpretation:

  • The binary is a 64-bit ELF file.
  • It targets x86-64.
  • The entry point is the address where execution begins.

Static and Dynamic Linking

Programs can be statically linked or dynamically linked.

  • Static linking: library code is included inside the executable

  • Dynamic linking: the executable depends on shared libraries at runtime

Static Linking

A statically linked binary contains the library code it needs.

Compile statically:

gcc -static -o hello-static hello.c

Check it:

file hello-static

Example output:

hello-static: ELF 64-bit LSB executable, x86-64, statically linked

Run:

ldd hello-static

Example output:

not a dynamic executable

Interpretation:

  • The binary does not need the dynamic linker to load shared libraries.
  • Most required code is inside the executable.

Static binaries are useful for initramfs environments because they do not need many external libraries.

Dynamic Linking

Most normal Linux programs are dynamically linked.

A dynamically linked program depends on shared libraries such as:

  • libc.so.6
  • libpthread.so
  • libm.so

The kernel loads the executable and notices that it needs an interpreter, commonly:

/lib64/ld-linux-x86-64.so.2

or on Debian/Ubuntu systems:

/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

Then the dynamic linker loads the shared libraries.

Executable
    |
    v
Kernel sees dynamic linker path
    |
    v
Dynamic linker starts
    |
    v
Shared libraries are loaded
    |
    v
Program main() begins

Diagram:

+-------------------+       +-------------------+       +-------------------+
| Executable        | ----> | Dynamic Linker    | ----> | Shared Libraries  |
| ./myapp           |       | ld-linux...       |       | libc.so.6, etc.   |
+-------------------+       +-------------------+       +-------------------+

Inspecting Shared Libraries with ldd

Use:

ldd /usr/bin/ls

Example output:

linux-vdso.so.1 (0x00007ffc12345000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2

Interpretation:

  • /usr/bin/ls depends on shared libraries.
  • The dynamic linker found the libraries on the system.

If a library is missing, ldd may show:

libexample.so => not found

That means the program may fail to start.

Library Search Paths

The dynamic linker searches for libraries using several sources.

Common places include:

  • RPATH or RUNPATH embedded in the executable
  • LD_LIBRARY_PATH environment variable
  • /etc/ld.so.cache
  • /lib
  • /usr/lib
  • /lib64
  • /usr/lib64
  • distribution-specific library directories

The flow is roughly:

Program needs libexample.so
        |
        v
Dynamic linker checks configured search paths
        |
        v
If found, library is loaded
        |
        v
If not found, program fails

ldconfig

The ldconfig command updates the shared library cache.

The cache is stored at:

/etc/ld.so.cache

To update it:

sudo ldconfig

To add a custom library directory:

echo "/opt/myapp/lib" | sudo tee /etc/ld.so.conf.d/myapp.conf
sudo ldconfig

Check whether a library is known:

ldconfig -p | grep mylib

LD_LIBRARY_PATH

LD_LIBRARY_PATH temporarily adds directories to the library search path.

Example:

export LD_LIBRARY_PATH=/opt/myapp/lib:$LD_LIBRARY_PATH
./myapp

This is useful for testing and development.

However, it can cause version conflicts if used carelessly.

A good rule is:

  • Use LD_LIBRARY_PATH for temporary testing.
  • Use ldconfig or proper packaging for stable system-wide configuration.

Building a Custom Kernel for Learning

Building a custom kernel is a useful way to understand how Linux executes programs internally.

This is a lab activity. It should be done in a virtual machine, containerized build environment, or disposable test system.

Do not replace the kernel on an important machine unless you understand kernel recovery.

Kernel Source

Clone the kernel source:

git clone https://github.com/torvalds/linux.git
cd linux

Create a default configuration:

make defconfig

This creates a .config file for your architecture.

Debug-Friendly Kernel Configuration

For kernel debugging, useful settings include:

  • debug symbols
  • GDB scripts
  • kernel debugging options
  • disabled KASLR
  • simplified feature set

Open the configuration menu:

make menuconfig

Useful options to consider:

  • Enable debug info
  • Enable GDB scripts
  • Disable KASLR
  • Enable kernel debugger options if needed
  • Reduce unneeded drivers or features for faster builds

KASLR means Kernel Address Space Layout Randomization. It randomizes where the kernel is loaded in memory.

For debugging, disabling KASLR makes addresses easier to understand.

Compiling the Kernel

Build with all CPU cores:

make -j$(nproc)

Build GDB helper scripts:

make scripts_gdb

The uncompressed debug kernel image is usually:

vmlinux

The bootable compressed kernel image on x86 is usually:

arch/x86/boot/bzImage

Initramfs

An initramfs is an initial RAM filesystem loaded by the kernel during boot.

It provides a minimal user-space environment before the real root filesystem is mounted.

For a kernel execution lab, an initramfs can contain:

  • a shell
  • basic utilities
  • test programs
  • an init script

The kernel starts the first user-space process, usually:

/init

inside the initramfs.

BusyBox

BusyBox provides many small Unix utilities in one binary.

It is commonly used in initramfs labs.

Download and extract:

wget https://busybox.net/downloads/busybox-1.35.0.tar.bz2
tar -xjf busybox-1.35.0.tar.bz2
cd busybox-1.35.0

Configure:

make defconfig
make menuconfig

Enable:

  • Build BusyBox as a static binary

Build:

make -j$(nproc)
make install

This creates:

_install/

containing BusyBox and utility symlinks.

Creating a Minimal Initramfs

Create directories:

mkdir -p ../initramfs/{bin,sbin,etc,proc,sys,usr/{bin,sbin}}

Copy BusyBox files:

cp -r _install/* ../initramfs/

Create /init:

cat > ../initramfs/init << 'EOF'
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
echo "Welcome to the custom initramfs shell!"
exec /bin/sh
EOF

Make it executable:

chmod +x ../initramfs/init

Important note:

  • /init must be executable.
  • If /init is missing or not executable, the kernel may panic because it cannot start user space.

Adding a Test Program

Create a small C program:

cat > ../initramfs/hello.c << 'EOF'
#include <stdio.h>

int main(void) {
    printf("Hello from the custom kernel!\n");
    return 0;
}
EOF

Compile it statically:

gcc -static -o ../initramfs/bin/hello ../initramfs/hello.c

Check:

file ../initramfs/bin/hello

Expected output includes:

statically linked

This matters because the minimal initramfs may not contain shared libraries.

Creating the Initramfs Archive

From inside the initramfs directory:

cd ../initramfs
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz
cd ..

This creates:

initramfs.cpio.gz

The kernel can load this archive as its initial filesystem.

Booting with QEMU

QEMU can boot the custom kernel without replacing your real system kernel.

Run:

qemu-system-x86_64 \
  -kernel linux/arch/x86/boot/bzImage \
  -initrd initramfs.cpio.gz \
  -append "console=ttyS0" \
  -nographic \
  -s -S

Option meanings:

  • -kernel kernel image to boot
  • -initrd initramfs archive
  • -append kernel command-line arguments
  • console=ttyS0 sends output to serial console
  • -nographic uses terminal instead of graphical display
  • -s starts a GDB server on TCP port 1234
  • -S pauses CPU at startup until GDB continues execution

The -s -S options are useful for debugging.

Debugging with GDB

In another terminal:

gdb linux/vmlinux

Connect to QEMU:

target remote :1234

The kernel is paused.

Useful setup:

set pagination off

Set a breakpoint.

Depending on kernel version, try:

break do_execveat_common

or:

break bprm_execve

or search for relevant symbols:

info functions execve
info functions search_binary_handler

Continue:

continue

When you run a program inside QEMU, the breakpoint should trigger during execution.

Binary Handler Selection

The kernel uses binary handlers to decide how to execute a file.

The important function is commonly:

search_binary_handler

It checks available handlers and selects the right one.

Examples:

  • ELF binary -> load_elf_binary
  • script with #! -> script handler
  • other formats -> other registered handlers

Set a breakpoint:

break search_binary_handler
continue

When it stops, inspect the execution path.

For ELF binaries, another useful breakpoint is:

break load_elf_binary
continue

Program Entry Point

After loading the binary, the kernel prepares CPU registers so execution begins at the program entry point.

On x86-64, the instruction pointer register controls the next instruction to execute.

In kernel debugging, you may inspect something like:

print/x regs->ip

The exact register structure and symbol names can vary by kernel version and architecture.

Compare with:

readelf -h hello

Look for:

Entry point address

Interpretation:

  • The ELF entry point is where execution begins.
  • The kernel sets the process state so the CPU starts there.

Scenario 1: Observe execve for a Normal Command

See the system call used to execute a program.

Simulate

strace -e execve ls

Example Output

execve("/usr/bin/ls", ["ls"], 0x7ffc8e0a9b10 /* 45 vars */) = 0

Interpretation:

  • The ls command was executed using execve.
  • The path was /usr/bin/ls.
  • The argument list contained "ls".
  • The process received environment variables.

Real Use

Use this when you want to confirm which executable is actually being run.

Scenario 2: Show That execve Replaces the Current Process

Demonstrate that execve does not create a new PID.

Create Program

cat > exec-demo.c << 'EOF'
#include <stdio.h>
#include <unistd.h>

int main(void) {
    printf("Before execve: PID=%d\n", getpid());

    char *argv[] = {"/bin/echo", "Hello after execve", NULL};
    char *envp[] = {NULL};

    execve("/bin/echo", argv, envp);

    perror("execve failed");
    return 1;
}
EOF

Compile:

gcc -o exec-demo exec-demo.c

Run:

./exec-demo

Example Output

Before execve: PID=8123
Hello after execve

Interpretation:

  • The program printed its PID before execve.
  • Then it was replaced by /bin/echo.
  • The original program did not continue after successful execve.

The line after execve only runs if execve fails.

Scenario 3: Observe a Script Shebang

See how executing a script causes the interpreter to run.

Create Script

cat > hello-script.sh << 'EOF'
#!/bin/sh
echo "hello from script"
EOF

chmod +x hello-script.sh

Trace it:

strace -e execve ./hello-script.sh

Example Output

execve("./hello-script.sh", ["./hello-script.sh"], 0x7ffc...) = 0
hello from script

Depending on tracing options and shell behavior, you may also observe /bin/sh being involved.

Interpretation:

  • The kernel sees the shebang line.
  • The script is executed through /bin/sh.
  • The script itself is not native machine code.

Failure Case

Change the shebang to a missing interpreter:

cat > bad-script.sh << 'EOF'
#!/no/such/interpreter
echo "this will not run"
EOF

chmod +x bad-script.sh
./bad-script.sh

Example output:

bash: ./bad-script.sh: cannot execute: required file not found

Interpretation:

  • The script exists.
  • The interpreter in the shebang does not exist.
  • Execution fails because the kernel cannot start the interpreter.

Scenario 4: Compare Static and Dynamic Binaries

Understand the difference between static and dynamic linking.

Create Program

cat > hello.c << 'EOF'
#include <stdio.h>

int main(void) {
    puts("hello");
    return 0;
}
EOF

Compile dynamically:

gcc -o hello-dynamic hello.c

Compile statically:

gcc -static -o hello-static hello.c

Check:

file hello-dynamic hello-static

Example Output

hello-dynamic: ELF 64-bit LSB pie executable, x86-64, dynamically linked
hello-static:  ELF 64-bit LSB executable, x86-64, statically linked

Run ldd:

ldd hello-dynamic
ldd hello-static

Example Output

linux-vdso.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2

not a dynamic executable

Interpretation:

  • The dynamic binary depends on shared libraries.
  • The static binary contains the needed library code.
  • The static binary is more suitable for minimal initramfs labs.

Scenario 5: Diagnose a Missing Shared Library

Practice identifying why a dynamically linked program fails to start.

Simulate Conceptually

If a program depends on a missing library, running ldd may show:

ldd ./myapp

Example output:

libexample.so => not found
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6

Interpretation:

  • The program requires libexample.so.
  • The dynamic linker cannot find it.
  • The program will likely fail at startup.

Fix Options

Temporary development fix:

export LD_LIBRARY_PATH=/opt/myapp/lib:$LD_LIBRARY_PATH
./myapp

System-wide fix:

echo "/opt/myapp/lib" | sudo tee /etc/ld.so.conf.d/myapp.conf
sudo ldconfig

Verify:

ldconfig -p | grep libexample

Scenario 6: Boot a Minimal Initramfs and Run a Static Program

Verify that a custom kernel can start a minimal user space and execute a test program.

Simulate

Boot QEMU:

qemu-system-x86_64 \
  -kernel linux/arch/x86/boot/bzImage \
  -initrd initramfs.cpio.gz \
  -append "console=ttyS0" \
  -nographic

Inside QEMU, run:

/bin/hello

Example Output

Welcome to the custom initramfs shell!
/ # /bin/hello
Hello from the custom kernel!

Interpretation:

  • The kernel booted successfully.
  • The initramfs /init script ran.
  • The shell started.
  • The test program executed successfully.

This confirms that the kernel can load and execute a user-space ELF program.

Scenario 7: Break on Kernel Exec Path with GDB

Observe the kernel execution path when a program runs.

Start QEMU Paused

qemu-system-x86_64 \
  -kernel linux/arch/x86/boot/bzImage \
  -initrd initramfs.cpio.gz \
  -append "console=ttyS0" \
  -nographic \
  -s -S

Connect GDB

gdb linux/vmlinux

Inside GDB:

target remote :1234
set pagination off
info functions execve
break do_execveat_common
continue

If the symbol is unavailable, try:

break bprm_execve
break search_binary_handler
break load_elf_binary

Trigger Exec

Inside QEMU:

/bin/hello

Example GDB Output

Breakpoint 1, do_execveat_common (...)

Interpretation:

  • The kernel reached the exec path.
  • The program execution request entered kernel code.
  • You can now step through the loading process.

Scenario 8: Observe ELF Handler Selection

Watch the kernel choose the correct binary format handler.

GDB Breakpoints

break search_binary_handler
break load_elf_binary
continue

Run inside QEMU:

/bin/hello

Example Interpretation

  • search_binary_handler is called.
  • The kernel checks available binary handlers.
  • For the hello program, the ELF handler is selected.
  • load_elf_binary handles the executable.

This shows how Linux supports multiple executable formats.

Scenario 9: Compare ELF Entry Point with Kernel Register Setup

Connect the ELF entry point to where execution begins.

Check Entry Point

Inside the build host:

readelf -h initramfs/bin/hello

Example output:

Entry point address:               0x401530

Inspect in GDB

Near the end of ELF loading, inspect the instruction pointer setup.

Example:

print/x regs->ip

Example output:

$1 = 0x401530

Interpretation:

  • The ELF header says execution begins at 0x401530.
  • The kernel prepared the process so the CPU begins at that address.

Exact addresses and structures may vary depending on architecture, compiler, static vs dynamic linking, and kernel version.

Scenario 10: Trace Dynamic Linker Involvement

Understand that dynamically linked programs involve the dynamic linker.

Check a Normal Binary

file /bin/ls

Example:

/bin/ls: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2

Inspect program headers:

readelf -l /bin/ls | grep interpreter

Example output:

[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

Interpretation:

  • The executable asks the kernel to start the dynamic linker.
  • The dynamic linker loads required shared libraries.
  • Then the program begins normal execution.

This is why dynamically linked binaries need the correct linker and libraries to exist in the runtime environment.

Common Problems and Fixes

Problem: Permission Denied

Example:

bash: ./hello: Permission denied

Check:

ls -l ./hello

Fix:

chmod +x ./hello

Interpretation:

The file exists, but it is not executable.

Problem: No Such File or Directory

Example:

bash: ./hello: No such file or directory

Possible causes:

  • file path is wrong
  • interpreter is missing
  • dynamic linker is missing
  • required runtime path is missing

Check:

ls -l ./hello
file ./hello
readelf -l ./hello | grep interpreter
ldd ./hello

If file shows a dynamic interpreter that does not exist, the binary cannot start.

Problem: Missing Shared Library

Example:

error while loading shared libraries: libexample.so: cannot open shared object file

Check:

ldd ./myapp

Fix:

export LD_LIBRARY_PATH=/path/to/lib:$LD_LIBRARY_PATH

or:

echo "/path/to/lib" | sudo tee /etc/ld.so.conf.d/myapp.conf
sudo ldconfig

Problem: Initramfs Kernel Panic

Example:

Kernel panic - not syncing: No working init found

Possible causes:

  • /init missing
  • /init not executable
  • /init has wrong shebang
  • shell interpreter missing
  • BusyBox not copied correctly
  • initramfs archive created incorrectly

Check initramfs contents:

mkdir /tmp/initramfs-check
cd /tmp/initramfs-check
zcat /path/to/initramfs.cpio.gz | cpio -idmv
ls -l init
file bin/busybox

Fix:

chmod +x init

Make sure /bin/sh exists.

Problem: GDB Breakpoint Does Not Work

Possible causes:

  • function name changed in this kernel version
  • debug symbols are missing
  • KASLR is enabled
  • wrong vmlinux file is loaded
  • kernel was optimized in a way that changes symbol visibility

Check symbols:

info functions execve
info functions binary_handler
info functions load_elf

Make sure GDB uses:

linux/vmlinux

not only the compressed bzImage.

Useful Command Summary

Observe execution:

strace -e execve ls
strace -f -e execve bash -c 'ls | wc -l'

Inspect binaries:

file ./program
readelf -h ./program
readelf -l ./program
ldd ./program

Compile test programs:

gcc -o hello hello.c
gcc -static -o hello-static hello.c

Library cache:

sudo ldconfig
ldconfig -p | grep library

Kernel build:

git clone https://github.com/torvalds/linux.git
cd linux
make defconfig
make menuconfig
make -j$(nproc)
make scripts_gdb

Initramfs archive:

find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz

QEMU boot:

qemu-system-x86_64 \
  -kernel linux/arch/x86/boot/bzImage \
  -initrd initramfs.cpio.gz \
  -append "console=ttyS0" \
  -nographic

QEMU with GDB:

qemu-system-x86_64 \
  -kernel linux/arch/x86/boot/bzImage \
  -initrd initramfs.cpio.gz \
  -append "console=ttyS0" \
  -nographic \
  -s -S

GDB:

target remote :1234
break do_execveat_common
break search_binary_handler
break load_elf_binary
continue

Safe Lab Rules

Kernel and QEMU labs can be complex. Use a safe environment.

  • Use a virtual machine or disposable lab system.
  • Do not replace your main system kernel.
  • Keep build directories organized.
  • Use static binaries in minimal initramfs environments.
  • Disable KASLR for easier debugging.
  • Expect kernel function names to vary by version.
  • Document the kernel version you are using.

Practical Challenges

  1. Run strace -e execve ls and explain the output.
  2. Write a small C program that calls execve("/bin/echo", ...). Confirm that the original program does not continue after a successful execve.
  3. Create a script with a valid shebang and trace its execution. Then create a script with a broken shebang and explain the error.
  4. Compile the same hello.c program dynamically and statically. Compare file and ldd output.
  5. Use readelf -h to find the entry point of a compiled program.
  6. Use readelf -l to find the dynamic linker requested by a dynamically linked binary.
  7. Build a minimal initramfs with BusyBox and a static hello program.
  8. Boot a custom kernel and initramfs with QEMU.
  9. Attach GDB to QEMU and set breakpoints in the exec path.
  10. Write a short report explaining the path from typing a command in the shell to the kernel loading and starting the executable.

Once QEMU is waiting for a debugger connection, open another terminal, navigate to your kernel source, and run gdb vmlinux. In GDB, do target remote :1234 and then continue. Describe what you see on the QEMU console and why you’re dropped into your initramfs shell.