linux动态库编写与注入

2022-04-10
#linux

动态库编写

加载时、结束时运行

通过__attribute__((constructor))修饰的函数在该动态库被加载时进行调用。同样__attribute__((destructor))修饰的函数会在程序正常退出时调用。其原理是将该函数的地址加入至.init_array.fini_array段中,ld加载时就会调用该函数。

一般来说constructor会更加常用,可以在里面动态地inline hook某些函数或读取/修改某些内存的值等等。

#include <stdio.h>

__attribute__((constructor))
void constructor() {
	puts("Library Loaded...");
}

__attribute__((destructor))
void constructor() {
	puts("Library Removed...");
}

通过同名覆盖实现hook函数

这种方式只能用于LD_PRELOAD的方式加载。这边拿hook libc的函数举例,由于指定的动态库会在libc等函数之前加载,因此如果该动态库与libc中有同名函数,则会有限调用我们写的函数。然后在此函数中通过ld提供的dlsym函数加载真实的函数地址,这样就可以在调用真实的函数前后我们进行任意的修改。例如下面是一个能够检测调用libc堆操作的代码。

里面复制了一些libc源码中的宏,以便读取堆块的大小。

#define _GNU_SOURCE
#include<stdio.h>
#include<stdint.h>
#include<dlfcn.h>
#include<stdlib.h>

#define unlikely(x) __builtin_expect(!!(x),0)
#define TRY_LOAD_HOOK_FUNC(name) if(unlikely(!g_sys_##name)){ \
    g_sys_##name=(sys_##name##_t)dlsym(RTLD_NEXT,#name); \
}
#define __log(format,...) fprintf(stderr,"\33[35m\033[1m[@]"format"\33[0m",##__VA_ARGS__)

//---------------malloc.c-----------------
#ifndef INTERNAL_SIZE_T
# define INTERNAL_SIZE_T size_t
#endif
#define SIZE_SZ (sizeof (INTERNAL_SIZE_T))
#define MALLOC_ALIGN_MASK (MALLOC_ALIGNMENT - 1)
struct malloc_chunk {
  INTERNAL_SIZE_T      mchunk_prev_size;
  INTERNAL_SIZE_T      mchunk_size;
  struct malloc_chunk* fd;
  struct malloc_chunk* bk;
  struct malloc_chunk* fd_nextsize;
  struct malloc_chunk* bk_nextsize;
};
typedef struct malloc_chunk* mchunkptr;
#define chunk2mem(p)   ((void*)((char*)(p) + 2*SIZE_SZ))[菜单] 
#define mem2chunk(mem) ((mchunkptr)((char*)(mem) - 2*SIZE_SZ))
#define PREV_INUSE 0x1
#define prev_inuse(p)       ((p)->mchunk_size & PREV_INUSE)
#define IS_MMAPPED 0x2
#define chunk_is_mmapped(p) ((p)->mchunk_size & IS_MMAPPED)
#define NON_MAIN_ARENA 0x4
#define chunk_main_arena(p) (((p)->mchunk_size & NON_MAIN_ARENA) == 0)
#define SIZE_BITS (PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
#define chunksize(p) (chunksize_nomask (p) & ~(SIZE_BITS))
#define chunksize_nomask(p)         ((p)->mchunk_size)
#define next_chunk(p) ((mchunkptr) (((char *) (p)) + chunksize (p)))
#define prev_size(p) ((p)->mchunk_prev_size)
#define prev_chunk(p) ((mchunkptr) (((char *) (p)) - prev_size (p)))
//---------------end-----------------

typedef void* (*sys_malloc_t)(size_t size);
static sys_malloc_t g_sys_malloc=NULL;
void* malloc(size_t size){
    TRY_LOAD_HOOK_FUNC(malloc);
    void *p=g_sys_malloc(size);
    __log("malloc: size=0x%zX, ptr=%p\n",size,p);
    return p;
}

typedef void* (*sys_calloc_t)(size_t num,size_t size);
static sys_calloc_t g_sys_calloc=NULL;
void* calloc(size_t num,size_t size){
    TRY_LOAD_HOOK_FUNC(calloc);
    void *p=g_sys_calloc(num,size);
    __log("calloc: num=0x%zX, size=0x%zX, ptr=%p\n",num,size,p);
    return p;
}

typedef void* (*sys_realloc_t)(void *old, size_t new_size);
static sys_realloc_t g_sys_realloc=NULL;
void* realloc(void *old, size_t new_size){
    TRY_LOAD_HOOK_FUNC(realloc);
    void *p=g_sys_realloc(old,new_size);
    __log("realloc: old_ptr=%p, new_ptr=%p, new_size=0x%zX\n",old,p,new_size);
    return p;
}

typedef void (*sys_free_t)(void *ptr);
static sys_free_t g_sys_free = NULL;
void free(void *ptr){
    TRY_LOAD_HOOK_FUNC(free);
    g_sys_free(ptr);
    if(ptr){
        __log("free: size=0x%zX, ptr=%p\n",chunksize(mem2chunk(ptr)),ptr);
    }else{
        __log("free: Null pointer\n");
    }
}

动态库注入

在Linux中,让一个进程加载指定的动态链接库(.so文件)的方法比windows少得多,局限性也较大。

运行前指定

这种方式适用于在运行前加上指定环境变量或者直接修改ELF可执行文件,可以在程序运行最开始时运行某段代码,或执行函数hook等。

preload

preload参数可以让ld加载程序前加载指定动态库。有两种方式实现

  1. LD_PRELOADld.so加载程序时会看的一个环境变量。可以指定一个或多个动态库(用空格或者分号隔开)。该环境变量里的动态库会按从左到右的顺序加载。但是如果程序中可以通过检测该环境变量来反注入。
  2. 使用ld的--preload参数,这种方式可以在程序内部通过读取/proc/self/cmdline探测到。
LD_PRELOAD=./injection.so ./target
/lib64/ld-linux-x86-64.so.2 --preload ./injection.so ./target

Patchelf

使用NixOS/patchelf工具,直接修改可执行文件,可以通过--add-needed强制添加一个依赖,就不用每次启动都加上环境变量或者用ld启动。这种方式会修改文件,如果程序有签名校验等操作,就难以绕过。

patchelf --add-needed injection.so ./target

运行时注入

运行时注入大概dlsym分为以下几步:先让程序停下来,然后将shellcode写入rip指向的位置,最后让程序恢复执行。

根据这个原理,重构了MordragT/injex,并增加了一些新功能,放在ri-char/injex

让程序停下来

Ptrace

ptrace是设计给调试器使用的。因此可以通过ptrace附加到目标进程上,控制目标进程是否执行。

局限性:

  1. 需要root权限或者给/proc/sys/kernel/yama/ptrace_scope写入1
  2. 如果目标进程上已经被ptrace了,就无法使用这种方式。例如ptrace反调试,先自己启一个进程调试关键进程,这样其他进程都调不上来了

由于其局限性较大,所以一般不采用该方式,在injex中也未实现该种方式。

Signal

ptrace会在attach时给目标进程发送SIGSTOP信号,那我们也可以直接给目标进程发送该信号。当一个进程收到该信号后会进入Stopped状态,并且无法屏蔽该信号。当收到SIGCONT信号时,该进程才会继续执行。

DavidBuchanan314/dlinject中默认的方式就是这种方式。

kill -SIGSTOP $pid
kill -SIGCONT $pid

CGroups

CGroups是linux内核中给进程用于限制、统计、隔离各类系统资源的特性。他有两个不兼容的版本v1以及v2。我们可以创建一个CGroup,并将目标进程放入CGroup中,然后可以冻结这个CGroup,来实现暂停进程的功能。但是这种方式需要root权限。

DavidBuchanan314/dlinject中也实现了该方式,但是它针对的是CGroups v1。在v2中,实现的bash脚本大致如下需要(root权限):

export PID=xxx

# 暂停程序
mkdir /sys/fs/cgroup/injex_$PID # 创建名为injex_$PID的cgroup
echo $PID > /sys/fs/cgroup/injex_$PID/cgroup.procs # 将目标进程加入至injex_$PID
echo 1 > /sys/fs/cgroup/injex_$PID/cgroup.freeze # 将此cgroup冻结

# 查看是否已经处于冻结状态
cat /sys/fs/cgroup/injex_$PID/cgroup.events

# 恢复执行
echo $PID > /sys/fs/cgroup/cgroup.procs # 将目标进程放回默认cgroup
rmdir /sys/fs/cgroup/injex_$PID # 将创建的进程组删掉

写入shellcode

将程序暂停下来后可以通过/proc/pid/syscall文件来获取目标进程此时的riprsp寄存器。然后向rip指向的地址写入shellcode。然是如果在这段shellcode中直接调用_dl_open,那么加载进来的动态库构造函数可能会破坏当前进程的栈,因此先备份一遍栈上内容,然后调用加载动态库,加载后将备份的内容恢复。

那如何将备份的内容恢复呢,当然可以再次让程序停下来,但这样成本太高,通常我们把要恢复的内容、以及恢复内容的代码也写入目标进程,然后直接让目标进程执行。

所以shellcode分为两段,第一段shellcode用mmap系统调用开辟空间,然后通过读取文件或者打开共享内存的方式加载第二段shellcode。第二段shellcode里面先调用_dl_open加载动态库,然后再恢复寄存器、栈等信息,最后跳回原来的程序继续执行。具体的代码可以参考dlinject.py。在实际编写代码中还遇到一个小坑,就是解析ld.so的符号的时候没有找到_dl_open,甚至还换了一个解析ELF文件的库,后来发现是因为ld.so的调试文件是分离的,应当单独加载调试文件,然后解析符号位置,调试文件位置可以参考GDB的加载顺序

修改目标进程的内存有三种方式,他们都和ptrace需要相同的权限:有CAP_SYS_PTRACE或和目标进程是同意用户并且系统允许普通用户调试。

通过/proc/pid/mem

proc文件系统中将任意进程的内存都映射到了/proc/{pid}/mem文件,可以通过直接读写这个文件来访问、修改目标进程的内存。

通过ptrace

Ptrace通过PTRACE_PEEKDATA和PTRACE_POKEDATA功能可以向目标进程读取和写入一个字的内容。但是一次只能读写一个字,效率很低,所以一般也不采用这种方式。

process_vm_readv/writev系统调用

process_vm_readv和process_vm_writev系统调用可以不经过内核内存进行跨进程内存读写。源码位于mm/process_vm_access.c。但是实际测试中,似乎只能读、但是不能写,可能是由于想要写入的段没有写权限。但具体原因还没找到。。

总结

总结下来,想要动态注入还是需要CAP_SYS_PTRACE比较方便,但一般都是root权限直接注入就行了。还有需要改进的地方有:shellcode中调用的ld的_ld_open可能被hook,因此可以自己写如一个加载器在shellcode中。强对抗环境下,进程可以直接在/proc/self/maps看到可疑映射,可以想办法让加载出来的空间伪装成堆栈。