Shared library injection into a Linux process (part 2)
Introduction
In the last article, we saw how to retrieve a function pointer into the memory of a process as well as the theory about shared library injection and . As a reminder, here are the steps to inject a library:
- Compute the address of
__libc_dlopen_mode
into the victim process. - Attach the victim process with
ptrace
. - Modify the victim process to make it execute the
__libc_dlopen_mode
function.
So, in this article we will see how to interfere with the execution of a process in order to make it load our shared library.
Change the control flow of a victim process
In order to change the normal execution of a process, we can exploit several techniques. Among those, we will use this method:
- Allocate a new readable and writable mapping in the victim.
- Write into this new mapping the path of the lib we want to inject.
- Set the victim to make it call
__libc_dlopen_mode(our_new_mapping, RTLD_NO)
. - Wake up the victim and let the magic happen.
Let’s go!
Allocate a mapping
To allocate a mapping, we can use the syscall mmap
(man mmap). As explained in its man page:
mmap() creates a new mapping in the virtual address space of the calling process.
Now, how do we make it call this syscall? We saw in the previous article that ptrace
is a good option because it allows to arbitrarily modify the memory of a process. Thus we can modify the next instruction of the victim with an asm syscall instruction (opcode: OfO5
).
The commented code below shows how to call mmap
:
NR_MMAP = 9
PROT_READ = 1
PROT_WRITE = 2
MAP_PRIVATE = 0x02
MAP_ANONYMOUS = 0x20
SYSCALL = b'\x0f\x05\x00\x00\x00\x00\x00\x00'
# [...]
# step 1: make a backup of the current victim registers
with victim.get_regs_and_restore() as regs:
# step 2: write an asm syscall instruction into the address pointed by rip
with victim.write_mem_words_and_restore(regs.rip, SYSCALL):
# step 3: set all the registers according to the mmap parameters
# mmap(0, 8192, PROT_WRITE | PROT_READ, MAP_ANONYMOUS | MAP_PRIVATE, 0, 0)
regs.rax = NR_MMAP
regs.rdi = 0
regs.rsi = 8192 # size
regs.rdx = PROT_WRITE | PROT_READ
regs.r10 = MAP_ANONYMOUS | MAP_PRIVATE
regs.r8 = 0
regs.r9 = 0
victim.set_regs(regs)
# step 4: let the victim executes the syscall instruction (step 2)
victim.step()
# step 5: get the syscall result
victim.get_regs(regs)
mapping = regs.rax
# step 6: restore the original instruction (automatically done)
# step 7: restore all the registers (automatically done)
The write_mem_words_and_restore
method uses ptrace
to write into the process memory. It works even if the mapping has no write permission (as in this case).
Because calling a syscall is a frequent task, I made a syscall method into my proc library. All the above code can be replaced by:
prot = PROT_WRITE | PROT_READ | PROT_EXEC
flags = MAP_ANONYMOUS | MAP_PRIVATE
mapping = victim.syscall(NR_MMAP, 0, 8192, prot, flags, 0, 0)
Writing into the new mapping
We saw in the last section that ptrace
can be used to write into a process memory. Another method relies on the function process_vm_writev
(man page). It is more practical than ptrace
because it’s able to write a buffer of any size. However, it can only write into a writable mapping.
Into the excerpt below, the path of the library to inject is written at the beginning of our new mapping (write_mem_array
uses process_vm_writev
):
victim.write_mem_array(mapping, path)
Call __libc_dlopen_mode
To call a function, we can use the previous method. However, rather than a syscall
instruction, we have to put a call
instruction. The easier method is to put the function address into rax
and do a call rax
. However, once the call finished, we have to restore some data: the registers and the original instruction overridden by our call
. Without this, the victim process could crash (with a high probability). To do that, we can add an int3
instruction just after the call
. It will make the victim emit a SIGTRAP
signal then pause. During this pause, we are free to restore all the data before allowing the victim to continue.
Here is the code:
# call rax, int3
CALL_INT = b'\xff\xd0\xcc\x00\x00\x00\x00\x00'
# prepare the dlopen call
dlopen_addr = victim.get_sym_addr(libc_path, '__libc_dlopen_mode')
# make a backup of original registers values
with victim.get_regs_and_restore() as regs:
# set the registers to call __libc_dlopen_mode(our_new_mapping, RTLD_NO)
regs.rax = dlopen_addr
regs.rdi = mapping
regs.rsi = RTLD_NOW
# set the stack frame to point on the middle of the new mapping
regs.rsp = mapping + 4096
regs.rbp = regs.rsp
victim.set_regs(regs)
# write the call/int3 instructions
with victim.write_mem_words_and_restore(regs.rip, CALL_INT):
# during the continue, the victim will execute the call and the int3
victim.continue_()
Again, because calling a function is a redundant task, I made a simple method:
dlopen_addr = victim.get_sym_addr(libc_path, '__libc_dlopen_mode')
victim.call(dlopen_addr, mapping, RTLD_NOW, stack_frame_addr=mapping+4096)
Final result
Below is the full code of our dummy injector:
from deedee.proc import process
NR_MMAP = 9
PROT_READ = 1
PROT_WRITE = 2
MAP_PRIVATE = 0x02
MAP_ANONYMOUS = 0x20
RTLD_NOW = 0x02
def run(pid, libc_path, lib_path):
# attach the victim
victim = process.Process(pid)
victim.attach()
# allocate a new mapping
prot = PROT_WRITE | PROT_READ
flags = MAP_ANONYMOUS | MAP_PRIVATE
mapping = victim.syscall(NR_MMAP, 0, 8192, prot, flags, 0, 0)
# write the path lib into the beginning of the mapping
path = lib_path.encode() + b'\x00'
victim.write_mem_array(mapping, path)
# call dlopen
dlopen_addr = victim.get_sym_addr(libc_path, '__libc_dlopen_mode')
handler = victim.call(dlopen_addr, mapping, RTLD_NOW, stack_frame_addr=mapping+4096)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='Dummy Shared Library Injector')
parser.add_argument('pid', type=int, help='the pid in which the lib must be injected')
parser.add_argument('libc', type=str, help='the path of the libc')
parser.add_argument('lib', type=str, help='the path of the lib to inject')
args = parser.parse_args()
run(args.pid, args.libc, args.lib)
Demonstration
For this demonstration, we need a little shared library to inject into a process. We can make one quickly:
#include <stdio.h>
__attribute__((constructor))
static void init()
{
puts("#######################\n");
puts("I hate the bearded man.\n");
puts("#######################\n");
}
The syntax __attribute__((constructor))
is used to execute a function when the library is loaded. To compile it:
$ gcc -Wall -shared -fPIC -o libdummy.so libdummy.c
Now, we have to run a program and start our injector. In the examples below, I tried to inject our dummy lib into vlc (paths may vary):
$ $ python dummy.py $(pidof vlc) /usr/lib64/libc-2.30.so $(pwd)/libdummy/libdummy.so
Voilà! Our library is injected into the vlc process:
We can also check the maps file of vlc:
As a reminder, if you want some details about Python functions I exposed in this article, you can find the source code of the proc library on Github:
https://github.com/d33d33l4bs/proc
Improvements
This dummy injector can be greatly improved:
- Free the allocated mapping.
- Propose to
dlclose
the injected library. - Allowing to call some functions of the injected library.
- etc.
The end
That’s all folks. If you have any questions, do not hesitate.
P.S: I hate Kami.
One thought on “Shared library injection into a Linux process (part 2)”
AMAZING