Linux Preload Hook原理与实践

2018-12-12  本文已影响0人  超哥__

Preload简介

Linux常见Hook技术对比

技术类型 生效范围 生效时机 依赖注入 层级 安全性 稳定性 开发运维难度
内核模块 所有进程 加载内核模块后 最高 极高
Inline Hook 当前进程 hook后
Got Hook 当前进程模块 hook后
Preload Hook 所有进程 hook后
系统文件修改 所有进程 文件修改后

<div style="padding-left:300px;">表1 Linux常见Hook技术对比</div>

表1注:
① 依赖注入,代表在使用该技术hook第三方进程前,是否需要先进行注入
② 层级,代表hook点在整个系统调用的位置,越底层则层级越低
③ 稳定性越低,代表要通过这种技术做到稳定所需要的技术和时间成本较高
④ 安全性,代表该项技术被绕过的难易程度

函数调用类型

  按函数调用在整个系统所处层级划分,可分为系统调用和库函数调用:

  按函数调用时机划分,可分为静态调用和动态调用

内核模块Hook

  通常是从内核源码特殊位置,修改回调、修改中断表;或修改重编译内核,导出内部函数,从而跳转到自定义函数,开发内核模块实现hook。

应用层Inline Hook

  应用层内联hook,即直接修改二进制函数体的汇编指令,修改执行逻辑使其跳转到自定义函数,开发应用层模块实现hook。

Got表

  当在程序中引用某个共享库中的符号时,编译链接阶段并不知道这个符号的具体位置,只有等到动态链接器将所需要的共享库加载时进内存后,符号的地址才会最终确定。因此,需要有一个数据结构来保存符号的绝对地址。编译器在编译C/C++程序时,会根据代码中对外部库函数的静态调用情况构造成一个符号索引表,使代码中外部库函数的调用暂时指向每个索引,在程序运行时进程加载器(进程加载器的概念)会将每个索引表项指向真正的函数地址,从而完成外部库函数调用。这种方式实现了在编译期时处理(动态加载模块的)函数调用问题。
  在Windows系统中,可执行程序为PE结构,该表为import table(俗称导入表),对应.idata段;Linux/Android系统中,可执行程序为ELF结构,该表即为got(Global Offset Table)表,对应.got段;MacOS/iOS系统中,可执行程序为MachO格式,该表对应为__nl_symbol_ptr__la_symbol_ptr段。在本文中统称导入表。

应用层Got Hook

  应用层got表hook,即在运行阶段修改程序本身got表,这样调用api的逻辑,就会相应的跳转到用户自定义函数中。

应用层Preload Hook

  对于Preload的解释,详见[Preload Hook原理](#Preload Hook原理)。Preload Hook是指利用系统支持的preload能力,将模块自动注入进程实现hook。可以通过以下手段使用Preload技术:一种是环境变量配置(LD_PRELOAD);另一种是文件配置:(/etc/ld.so.preload)。

Preload Hook原理

进程模块表的链状结构

  下面是从源码中摘录的部分定义,可以看到常见系统均(至少)使用单链表或双链表方式存储模块表,甚至MacOS和Windows操作系统也是采用类似方式。使用链表方式存储,便于增删查改(一般程序加载模块数在百级别以内)。

// from glibc/elf/link.h                for Linux
struct link_map     
{
    /* These first few members are part of the protocol with the debugger.
       This is the same format used in SVR4.  */
    ElfW(Addr) l_addr;        /* Difference between the address in the ELF
                   file and the addresses in memory.  */
    char *l_name;        /* Absolute file name object was found in.  */
    ElfW(Dyn) *l_ld;        /* Dynamic section of the shared object.  */
    struct link_map *l_next, *l_prev; /* 这里为链表结构.  */
};  
struct link_map _dl_loaded; // 全局模块表头
// from bionic/linker/linker_soinfo.h            for Android 8
struct soinfo {
public:
      const ElfW(Phdr)* phdr;
      size_t phnum;
      ElfW(Addr) base;
      size_t size;
      ElfW(Dyn)* dynamic;
      soinfo* next;  /* 这里为链表结构  */
      ....;
};
struct soinfo solist; // 全局模块表头
// from bionic/linker/linker.h                for Android 4-7
struct soinfo
{
    const char name[SOINFO_NAME_LEN];
    Elf32_Phdr *phdr;
    int phnum;
    unsigned entry;
    unsigned base;
    unsigned size;
    int unused;  // DO NOT USE, maintained for compatibility.
    unsigned *dynamic;
    unsigned wrprotect_start;
    unsigned wrprotect_end;
    soinfo *next;
    ...
};  
struct soinfo solist; // 全局模块表头
// from dyld/src/ImageLoader.h                    for Macos/iOS
class ImageLoader {
public:
    typedef uint32_t DefinitionFlags;
    static const DefinitionFlags kNoDefinitionOptions = 0;
    static const DefinitionFlags kWeakDefinition = 1;
    ......
    struct DynamicReference {                    /* 这里为链表结构 */
      ImageLoader* from;                            
      ImageLoader* to;
    };
    ......
};
static std::vector<ImageLoader*>    sAllImages; // 全局模块表数组
// from wrk/public/sdk/inc/ntpsapi.h            for Windows
typedef struct _PEB_LDR_DATA {
    ULONG Length;
    BOOLEAN Initialized;
    HANDLE SsHandle;
    LIST_ENTRY InLoadOrderModuleList;            /* 这里为链表结构 */
    LIST_ENTRY InMemoryOrderModuleList;        /* 这里为链表结构 */
    LIST_ENTRY InInitializationOrderModuleList;    /* 这里为链表结构 */
    PVOID EntryInProgress;
} PEB_LDR_DATA, *PPEB_LDR_DATA;
// NtCurrentPeb()->Ldr       全局模块表头

Linux系统Preload Hook

进程加载器的概念

  先来介绍几个名词

Bit Linux Android MacOS/iOS Windows
64 /lib64/ld-linux-x86-64.so.2 /system/bin/linker64 /usr/lib/dyld %SYSTEMROOT%\system32\ntdll.dll
32 /lib/ld-linux.so.2 /system/bin/linker /usr/lib/dyld %SYSTEMROOT%\system32\ntdll.dll

<div style="padding-left:300px;">表2 多个平台的进程加载器</div>

  如前述,Linux/Android系统支持环境变量和文件方式的Preload;MacOS/iOS系统则提供了环境变量DYLD_INSERT_LIBRARIES用于Preload,同时提供__interpose结构用于Hook;Windows系统未提供环境变量方式的Preload,而提供了注册表方式注入。

Preload特点

  Preload技术有自身的作用范围,如果将Preload环境变量到全局环境(/etc/profile)或使用Preload文件时,Preload便在所有在此(配置)时间节点之后运行的进程生效,而无法影响已经在运行的进程;若将Preload环境变量作为启动进程的环境变量(如shell中执行LD_PREOAD=/lib64/inject.so ./myprocess),则Preload只对当前进程生效。默认情况下进程创建的子进程也会继承Preload环境变量,而父进程可以在子进程初始化前修改子进程的环境变量,从而避免往子代传播。Preload具体使用方式如下:

  进程在启动后,会按照一定顺序加载动态库:

  如前述,模块通过Preload技术添加到模块链后,进程加载器在解析导入表时,会按照链表顺序依次查找符号,因此如果在Preload动态库中实现并导出了同名(而不是C++修饰名)库函数,即可实现Hook。下面是一个简单例子,捕获所有socket函数:

// inject.c
#include <stdio.h>
#include <sys/socket.h>
 __attribute__ ((visibility("default"))) int socket(int family, int type, int protocol) {
    printf("detect socket call\n");
    return -1;
}
__attribute__((constructor)) void main() {
    printf("module inject success\n");
}
/* 
    gcc inject.c --shared -fPIC -o inject.so
    LD_PRELOAD=$PWD/inject.so ping 127.0.0.1
    module inject success
    detect socket call
*/

应用Preload Hook存在的问题和解决方案

Preload是否能作用于动态加载(dlopen/dlsym)的函数?

  这里和静态引用函数相比较,如前述,若preload模块已经导出同名函数,进程启动时需要借助进程加载器处理静态引用函数,从模块链中按模块加载顺序查找,直到遇到匹配的函数指针,这个函数此时位于preload模块中。而在动态调用过程中,指定动态库名及符号名的动态调用会在查找时匹配动态库名,再在该动态库符号表中查找符号名,因此会返回原始函数指针。

Preload是否能作用于子进程(fork/vfork/clone)(execve/system/popen/...)?

  由于preload是进程加载器的行为,因此如果能触发进程加载器就能触发preload。对于第一类函数(fork/vfork/clone),由于没有对进程进行重新初始化的操作,而仅仅是对进程空间的复制行为,因此不能触发preload;对于第二类函数(execve/system/popen/...),其首先要调用fork产生一个子进程,然后需要使用新的可执行文件对子进程的进程空间重新初始化,这个过程势必会触发preload过程。

  另外一个重要的考虑因素是,Preload只在部署之后对新进程生效,因此需要作为一个独立服务的可启动项,考虑系统其他服务启动顺序和时机。

对Docker / Chroot的支持?

  Paas容器技术提供了和宿主隔离的独立环境,在这种环境下文件系统是相互隔离的,而Preload技术则基于文件系统(ld.so.preload),因此不存在一个绝对完美的方法提供对容器类场景的支持。下面提供一些特殊方式来解决该需求,对于Docker 分2种情况讨论:

  对于类似于使用chroot机制进行文件隔离的场景,需要针对性的修改chroot环境中对应的Preload配置文件及部署拦截模块进行Preload。

如何拦截动态加载的符号?

  如前述,Preload是进程加载器的行为,因此对于静态引用的符号生效;这里暂且将动态调用限制在仅使用dlopen/dlsym函数上,对于明确指定了动态库路径和符号名的dlopen/dlsym的动态调用,必然要对dlsym本身进行hook,这样引发的问题是怎样得到原始的dlopen/dlsym及其他符号名,因为通常我们会在hook函数执行前/后,执行一次原始函数,而这个原始函数指针按常规方式需要由dlsym来获取。如果静态使用dlopen/dlsym则会调用hook模块中的自定义dlopen/dlsym中去,从而产生死循环。要解开这个循环必须要使用更底层的方式来做。一种选择是手动解析/proc/pid/map中的模块导出表,从而获取函数基址。更通用的方式则是使用GNU提供的dl_iterate_phdr,此函数存在于进程加载器linker中。所以一个典型的preload hook过程如下:

如何更安全的部署preload模块?

  由于preload函数会影响所有新启动的进程,若preload模块存在缺陷,最差的情况是无法执行任何命令(execve/system/popen/...);此种情况下若进行重启,重启所创建的任何进程均会无法正确执行而导致系统无法使用,从而陷入死循环,这种情况极难处理。这里提供的解决方式是使用mount命令,其特点是,在删除源文件或重启后失效,利用这个特点,首先将ld.preload.so拷贝为ld.preload.so.new,然后使用mount将ld.preload.so.new绑定到ld.preload.so上。此时若preload模块存在缺陷,只需要重启即可去除preload。

Preload Hook应用之——网络访问日志

  由前述可知,Preload技术并不是一种安全的技术,此技术多用于非安全应用的API拦截、日志记录、流量监控或打补丁等场景。在Preload技术上层即使构造比较复杂的系统,如果该系统和Preload本身是串联的,这种情况同样也是非安全,因此这里我们只讨论非安全对抗的应用模型,以网络通信监控为例,具体应用需要视具体业务而定。

策略在so中

  如上图所示,每个主机的代理进程(Agent)负责维护策略,通过RPC从远程服务器获取监控策略,并下发给每个被管理(通过Preload Hook注入inject.so)的进程(Client)。这些进程加载和解析由代理进程下发的策略,在触发网络通信时在inject.so中对五元组(协议/源IP/源PORT/目的IP/目的PORT)自行做判决,这种模型存在以下特点:

策略在代理进程中

  如上图所示,每个主机的代理进程负责维护策略,同时对被管理的进程发送的五元组数据进行判决和响应,这些Client在触发网络通信时会通过inject.so向代理进程发送IPC五元组数据,根据结果选择拦截或允许。这种模型存在以下特点:

策略在远程主机上

  如上图所示,和前一个模型不同的是,每个主机的代理进程通过RPC转发判决请求到服务器并返回结果。这种模型存在以下特点:

拦截函数

  需要拦截的点如下:

  在上述函数中,需要获取三元组标识一次通信过程,包括(协议,本地端,远端),本地端和远端均由一个地址和一个端口构成。获取这些信息的方式一般如下:

总结

  本篇文章详细介绍了Preload原理、使用方式及适用场景,最后通过网络策略拦截案例来例证该技术的适用场景。希望对大家有帮助。

参考文档

man手册

上一篇 下一篇

猜你喜欢

热点阅读