All Articles

Tracing Library Function Calls Through GOT/PLT

This page has been machine-translated from the original page.

This article explores the overview of GOT and PLT along with practical verification.

The motivation for writing this article was that I became confused about GOT while researching Position Independent Code (PIC).

Table of Contents

Shared Libraries and Dynamic Linking

Most ELF binaries have library functions (predefined, frequently used convenient functions) linked via dynamic linking.

Dynamic linking is a mechanism that links the actual body of library functions and other elements required for program execution at runtime.

The counterpart to dynamic linking is static linking, where all necessary library functions and other components are linked into a single program beforehand.

By using dynamic linking for functions and modules commonly used by multiple programs, such as library functions, there are benefits such as reducing the file size of programs and improving memory usage efficiency at runtime.

You can check which shared libraries an ELF binary depends on using the ldd command.

$ ldd test.o
linux-vdso.so.1 (0x00007ffdb417f000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f02af5ca000)
/lib64/ld-linux-x86-64.so.2 (0x00007f02af7d7000)

Reference: Man page of LDD

This article summarizes the mechanism for linking dynamically linked shared libraries at program runtime.

Flow of Shared Library Function Calls

When executing an ELF binary with dynamically linked library functions, the library functions are not bound until they are actually called during processing.

This mechanism is called lazy binding and is supported by the PLT (Procedure Linkage Table).

When a shared library function is called during program execution, the initially called address is not the actual shared library function, but an entry in the .plt section.

The invoked PLT entry then jumps to an area called the GOT (Global Offset Table).

Reference: Detailed Security Contest

GOT

The GOT (Global Offset Table) refers to a section of computer program memory (executable files and shared libraries) used to ensure that programs compiled as ELF files can execute correctly.

Reference: Global Offset Table - Wikipedia

Simply put, the GOT is an area for maintaining a list of library function addresses.

This area is populated with the addresses of library functions used during program execution.

The GOT makes it easy to relocate library functions within the process memory space.

PLT

The PLT (Procedure Linkage Table) is a collection of small code snippets for calling library functions.

The PLT contains the same number of code entries as the library functions held by the GOT.

The behavior of the code in the PLT is to jump to the value set in the GOT.

When PLT code is called and the function address has not yet been set in the GOT, it sets the address in the GOT before jumping.

Reference: What are PLT and GOT? · Keichi Takahashi

Tracing GOT and PLT Operations

From here, we’ll use GDB to examine the memory map in practice.

Source Code and Build

The code used for verification is as follows.

#include <stdio.h>
#include <stdlib.h>

int test()
{
    return 0;
}

int main()
{
    int a = test();
    int b = rand();
    int c = rand();
    return a * b;
}

Compile with the following command and launch with gdb.

gcc -fcf-protection=none -no-pie -g test.c -o test.o
gdb ./test.o

Virtual Memory Output

Linux provides the /proc directory as a pseudo-directory for currently running processes.

Directly under the /proc directory, there are numeric directories corresponding to the PIDs of running processes, with process control tables mapped inside them.

Reference: About Process Memory Maps (Linux)

Reference: embedded - Understanding Linux /proc/pid/maps or /proc/self/maps - Stack Overflow

To check the process memory map, I used the following command.

$ cat /proc/`pidof test.o`/maps
address           permission offset   device inode      pathname
00400000-00401000 r--p 00000000 fd:00 786517                             /home/ubuntu/gottest/test.o
00401000-00402000 r-xp 00001000 fd:00 786517                             /home/ubuntu/gottest/test.o
00402000-00403000 r--p 00002000 fd:00 786517                             /home/ubuntu/gottest/test.o
00403000-00404000 r--p 00002000 fd:00 786517                             /home/ubuntu/gottest/test.o
00404000-00405000 rw-p 00003000 fd:00 786517                             /home/ubuntu/gottest/test.o
7ffff7dc3000-7ffff7de8000 r--p 00000000 fd:00 945235                     /lib/x86_64-linux-gnu/libc-2.31.so
7ffff7de8000-7ffff7f60000 r-xp 00025000 fd:00 945235                     /lib/x86_64-linux-gnu/libc-2.31.so
7ffff7f60000-7ffff7faa000 r--p 0019d000 fd:00 945235                     /lib/x86_64-linux-gnu/libc-2.31.so
7ffff7faa000-7ffff7fab000 ---p 001e7000 fd:00 945235                     /lib/x86_64-linux-gnu/libc-2.31.so
7ffff7fab000-7ffff7fae000 r--p 001e7000 fd:00 945235                     /lib/x86_64-linux-gnu/libc-2.31.so
7ffff7fae000-7ffff7fb1000 rw-p 001ea000 fd:00 945235                     /lib/x86_64-linux-gnu/libc-2.31.so
7ffff7fb1000-7ffff7fb7000 rw-p 00000000 00:00 0 
7ffff7fcb000-7ffff7fce000 r--p 00000000 00:00 0                          [vvar]
7ffff7fce000-7ffff7fcf000 r-xp 00000000 00:00 0                          [vdso]
7ffff7fcf000-7ffff7fd0000 r--p 00000000 fd:00 945231                     /lib/x86_64-linux-gnu/ld-2.31.so
7ffff7fd0000-7ffff7ff3000 r-xp 00001000 fd:00 945231                     /lib/x86_64-linux-gnu/ld-2.31.so
7ffff7ff3000-7ffff7ffb000 r--p 00024000 fd:00 945231                     /lib/x86_64-linux-gnu/ld-2.31.so
7ffff7ffc000-7ffff7ffd000 r--p 0002c000 fd:00 945231                     /lib/x86_64-linux-gnu/ld-2.31.so
7ffff7ffd000-7ffff7ffe000 rw-p 0002d000 fd:00 945231                     /lib/x86_64-linux-gnu/ld-2.31.so
7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0 
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                          [stack]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

maps indicates contiguous virtual memory regions within a process or thread.

The address records the start and end addresses of the region in the process address space, and permission records the permissions for that region.

From the above results, we can see that two shared libraries are being used: /lib/x86_64-linux-gnu/libc-2.31.so and /lib/x86_64-linux-gnu/ld-2.31.so.

Here, the shared library /lib/x86_64-linux-gnu/ld-2.31.so is mapped to 7ffff7fcf000, but this is not a fixed address.

The memory address where a shared library is deployed is determined at program runtime and may be deployed to different addresses depending on the circumstances.

The mechanism that investigates which memory address a shared library is actually deployed to at runtime and calls it is PLT and GOT.

Call Instruction

Here’s an excerpt of the call instruction locations from the assembly source.

$ disas main
Dump of assembler code for function main:
   0x000000000040113e <+13>:call   0x401126 <test>
   0x0000000000401146 <+21>:call   0x401030 <rand@plt>
   0x000000000040114e <+29>:call   0x401030 <rand@plt>
End of assembler dump.

The call instruction executes a combination of the following two processes:

  • Push the address next to the call instruction (the instruction to execute after the function returns) onto the stack
  • Jump to the address of the function being called

Reference: Function Calls in x86 Assembly Language

Here, let’s output the assembly for each of the two call instructions.

# 0x401126 <test>
$ disas 0x401126
Dump of assembler code for function test:
   0x0000000000401126 <+0>:push   rbp
   0x0000000000401127 <+1>:mov    rbp,rsp
   0x000000000040112a <+4>:mov    eax,0x0
   0x000000000040112f <+9>:pop    rbp
   0x0000000000401130 <+10>:ret    
End of assembler dump.
# 0x401030 <rand@plt>
$ disas 0x401030
Dump of assembler code for function rand@plt:
   0x0000000000401030 <+0>:jmp    QWORD PTR [rip+0x2fe2]        # 0x404018 <rand@got.plt>
   0x0000000000401036 <+6>:push   0x0
   0x000000000040103b <+11>:jmp    0x401020
End of assembler dump.

When comparing these, we can see that while the user function test is directly called, when calling rand, rand@plt is called instead.

When rand@plt is called, the first JMP instruction calls rand@got.plt.

This is because the destination address has not yet been set in the GOT.

Calling Shared Library Functions

Next, we’ll set breakpoints at the first and second rand function call points respectively, and observe the changes in the GOT before and after the shared library function is bound through the PLT and GOT.

$ b *0x401146
$ b *0x40114e
$ run

Now we’ve reached the first rand function call point.

From the disassembly result of rand@plt, we know that the corresponding GOT address is 0x404018.

In other words, ultimately the address of the rand function body should be stored at 0x404018.

However, at this point, the rand function has not been called once during program execution, so the GOT contains the address of rand@plt+6.

$ telescope 0x404018
0000| 0x404018 --> 0x401036 (<rand@plt+6>:push   0x0)

Reference: Command dereference - GEF - GDB Enhanced Features documentation

The processing at address rand@plt+6 pushes a value (0x0) onto the stack and then jumps to 0x401020.

$ disas 0x401030
Dump of assembler code for function rand@plt:
   0x0000000000401030 <+0>:jmp    QWORD PTR [rip+0x2fe2]        # 0x404018 <rand@got.plt>
   0x0000000000401036 <+6>:push   0x0
   0x000000000040103b <+11>:jmp    0x401020
End of assembler dump.

The subsequent processing stores another value on the stack and then continues with processing that jumps to the address stored at 0x404010.

$ x/16 0x401020
   0x401020:push   QWORD PTR [rip+0x2fe2]        # 0x404008
   0x401026:jmp    QWORD PTR [rip+0x2fe4]        # 0x404010

Setting a breakpoint at 0x401036 and tracing the subsequent processing yielded the following results.

=> 0x401026:jmp    QWORD PTR [rip+0x2fe4]        # 0x404010
 | 0x40102c:nop    DWORD PTR [rax+0x0]
 | 0x401030 <rand@plt>:jmp    QWORD PTR [rip+0x2fe2]        # 0x404018 <rand@got.plt>
 | 0x401036 <rand@plt+6>:push   0x0
 | 0x40103b <rand@plt+11>:jmp    0x401020
 |->   0x7ffff7fe7bb0:endbr64 
       0x7ffff7fe7bb4:push   rbx
       0x7ffff7fe7bb5:mov    rbx,rsp
       0x7ffff7fe7bb8:and    rsp,0xffffffffffffffc0
       0x7ffff7fe7bbc:sub    rsp,QWORD PTR [rip+0x14b45]        # 0x7ffff7ffc708 <_rtld_global_ro+232>

The function being called here is _dl_runtime_resolve.

The following reference was very helpful for details.

Reference: Ret2dl_resolve x64

This function resolves the address of the target rand function and calls it.

Since the GOT is updated at this point, subsequent calls to the rand function will directly call the resolved rand function address from the GOT.

When we actually stop at the second rand function call point and check the contents of the GOT referenced by rand@plt as before, we find that it contains the address of the rand function body.

$ telescope 0x404018
0000| 0x404018 --> 0x7ffff7e0de90 (<rand>:endbr64)

Therefore, on the second execution, _dl_runtime_resolve is not called, and the rand function is executed directly.

Summary

I started by reading UNIX code but ended up investigating GOT and PLT.

Since Detailed Security Contest contains more detailed information, I plan to dig deeper while also trying things like GOT Overwrite.

References