• [免费专栏] ATTACK安全之车机(Android)设备中监控命令执行的一些想法【概念篇】



    欢迎新同学的光临
    … …
    人若无名,便可专心练剑


    我不是一条咸鱼,而是一条死鱼啊!


    0x01 前言

    【本篇纯理论搬的,暂时不具备这块的知识体系,看的有点槽,准备后面慢慢补充一些实验结果以及实操记录,感兴趣的可以关注该专栏:https://blog.csdn.net/ananas_orangey/category_11673665.html】

    :【最后的最后,真的五体投地佩服文中里的大佬,是真的大佬,向大佬们看起】

    • 前面理论多,不想看的可以直接跳过0x01 前言的内容,直接到0x02 车机(Android)设备中监控命令执行的一些想法

    入侵检测的过程中,进程创建监控是必不可少的一点,因为攻击者的绝大多数攻击行为都是以进程的方式呈现,例如进程发起网络连接请求传输数据,并可能产生文件读写行为。所以从进程的角度出发关联出命令执行、网络连接、读写文件,可以快速分析出大量安全攻击场景,还原出入侵行为攻击的链路脑图

    切记,不管是事前、还是事中、甚至事后的攻击行为,都可以依赖进程数据为基础进行入侵行为的基础数据分析,并结合不同的攻击向量,多维度快速高效的分析出攻击行为或异常攻击行为

    命令行终端:实际是一个叫bash/sh的端终程序提供的功能,该程序底层的实质还是调用一些操作系统提供的函数

    再开始之前之前先来了解内核的一些东西和实例,本篇主要还是理论篇,无实操

    Ring 权限关系图:

    • 使用这种权限控制的好处在于计算机用户的软件不会危及系统的安全,只有稳定的系统软件才能够操作系统的关键内存等硬件设备。从而最大程度保证系统运行的稳定

    在这里插入图片描述

    从内到外依次使用0-3标识,这些环(ring)。越内部的圈代表的权限越大。内圈可以访问,修改外圈的资源;外圈不可以访问,修改内圈的资源

    • 为什么会有4个ring?因为x86的CPU,在Data segment selector中使用了2个bits来描述权限。

    我们最常见的是ring 0(内核态),和ring 3(用户态)。因为例如Windows和Unix这些常见的操作系统,只提供了两种权限模式,所以并没有完全使用整个ring架构。所以,一般情况下,完全可以使用ring 0 表示内核态,ring 3表示用户态

    Linux进程监控,通常使用hook技术,而hook大概分为两类:

    • 应用级
    • 内核级

    1.1 Linux系统调用的基本原理

    系统调用:是内核提供给应用程序使用的功能函数,由于应用程序一般运行在 用户态,处于用户态的进程有诸多限制(如不能进行 I/O 操作),所以有些功能必须由内核代劳完成。而内核就是通过向应用层提供 系统调用,来完成一些在用户态不能完成的工作

    在系统中真正被所有进程都使用的内核通信方式是系统调用。例如当进程请求内核服务时,就使用的是系统调用。一般情况下,进程是不能够存取系统内核的。它不能存取内核使用的内存段,也不能调用内核函数,CPU的硬件结构保证了这一点。只有系统调用是一个例外。进程使用寄存器中适当的值跳转到内核中事先定义好的代码中执行

    linux的系统调用形式与POSIX兼容,也是一套C语言函数名的集合。然而,linux系统调用的内部实现方式却与DOC的INT 21H相似,它是经过INT 0X80H软中断进入后,再根据系统调用号分门别类地服务

    进程可以跳转到的内核中的位置叫做system_call。在此位置的过程检查系统调用号,它将告诉内核进程请求的服务是什么。然后,它再查找系统调用表sys_call_table,找到希望调用的内核函数的地址,并调用此函数,最后返回

    所以,如果希望改变一个系统调用的函数,需要做的是编写一个自己的函数,然后改变sys_call_table中的指针指向该函数,最后再使用cleanup_module将系统调用表恢复到原来的状态

    用通俗易懂的话来说:Linux里面的每个系统调用是靠一些宏,一张系统调用表,一个系统调用入口来完成的,且系统调用其实就是函数调用,只不过调用的是内核态的函数。但与普通的函数调用不同,系统调用不能使用 call 指令来调用,而是需要使用 软中断 来调用。在 Linux 系统中,系统调用一般使用int 0x80 指令(x86)或者syscall 指令(x64)来调用

    1.1.1 宏

    宏就是_syscallN(type,name,x...),N是系统调用所需的参数数目,type是返回类型,name即面向用户的系统调用函数名,x…是调用参数,个数即为N

    例如:

    #define _syscall3(type,name,type1,arg1,type2,arg2,type3,arg3) \
    type name(type1 arg1,type2 arg2,type3 arg3) \
    { \
    long __res; \
    __asm__ volatile ("int $0x80" \
            : "=a" (__res) \
            : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
                      "d" ((long)(arg3))); \
    if (__res>=0) \
            return (type) __res; \
    errno=-__res; \
    return -1; \
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    这些宏定义于include\asm\Unistd.h,这就是为什么你在程序中要包含这个头文件的原因。该文件中还以__NR_name的形式定义了164个常数,这些常数就是系统调用函数name的函数指针在系统调用表中的偏移量

    1.1.2 系统调用表

    系统调用表定义于entry.s的最后。这个表按系统调用号(即前面提到的__NR_name)排列了所有系统调用函数的指针,以供系统调用入口函数查找。从这张表看得出,linux给它所支持的系统调用函数取名叫sys_name

    1.1.3 系统调用入口函数

    系统调用入口函数定义于entry.s

    ENTRY(system_call)
            pushl %eax                      # save orig_eax
            SAVE_ALL
    #ifdef __SMP__
            ENTER_KERNEL
    #endif
            movl $-ENOSYS,EAX(%esp)
            cmpl $(NR_syscalls),%eax
            jae ret_from_sys_call
            movl SYMBOL_NAME(sys_call_table)(,%eax,4),%eax
            testl %eax,%eax
            je ret_from_sys_call
    #ifdef __SMP__
            GET_PROCESSOR_OFFSET(%edx)
            movl SYMBOL_NAME(current_set)(,%edx),%ebx
    #else
            movl SYMBOL_NAME(current_set),%ebx
    #endif
            andl $~CF_MASK,EFLAGS(%esp)
            movl %db6,%edx
            movl %edx,dbgreg6(%ebx)
            testb $0x20,flags(%ebx)
            jne 1f
            call *%eax
            movl %eax,EAX(%esp)
            jmp ret_from_sys_call
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    这段代码现保存所有的寄存器值,然后检查调用号(__NR_name)是否合法(在系统调用表中查找),找到正确的函数指针后,就调用该函数(即你真正希望内核帮你运行的函数)。运行返回后,将调用ret_from_sys_call,这里就是著名的进程调度时机之一

    当在程序代码中用到系统调用时,编译器会将上面提到的宏展开,展开后的代码实际上是将系统调用号放入ax后移用int 0x80使处理器转向系统调用入口,然后查找系统调用表,进而由内核调用真正的功能函数

    自己添加过系统调用的人可能知道,要在程序中使用自己的系统调用,必须显示地应用宏_syscallN。而对于linux预定义的系统调用,编译器在预处理时自动加入宏_syscall3int,ioctl,arg1,arg2,arg3)并将其展开。所以,并不是ioctl本身是宏替换符,而是编译器自动用宏声明了ioctl这个函数。

    从系统分析的角度,linux的系统调用涉及4个方面的问题:

    • 与系统调用有关的数据结构和函数
    • 进程的系统调用命令转换为INT 0x80中断的过程
    • 系统调用功能模块的初始化
    • 内核如何为各种系统调用服务

    1.1.3.1 与系统调用有关的数据结构和函数

    函数名以“sys_”开头,后跟该系统调用的名字。例如,系统调用fork()的响应函数是sys_fork()(见Kernel/fork.c),exit()的响应函数是sys_exit()(见kernel/fork.c)

    文件include/asm/unisted.h为每个系统调用规定了唯一的编号。假设用name表示系统调用的名称,那么系统调用号与系统调用响应函数的关系是:以系统调用号_NR_name作为下标,可找出系统调用表sys_call_table(见arch/i386/kernel/entry.S)中对应表项的内容,它正好 是该系统调用的响应函数sys_name的入口地址。系统调用表sys_call_table记录了各sys_name函数在表中的位 置,共190项。有了这张表,就很容易根据特定系统调用在表中的偏移量,找到对应的系统调用响应函数的入口地址。系统调用表共256项,余下的项是可供用户自己添加的系统调用空间

    1.1.3.2 进程的系统调用命令转换为INT 0x80中断的过程

    宏定义_syscallN()见include/asm/unisted.h)用于系统调用的格式转换和参数的传递。N取0~5之间的整数。参数个数为N的系统调用由_syscallN()负责格式转换和参数传递。系统调用号放入EAX寄存器,启动INT 0x80后,规定返回值送EAX寄存器。

    1.1.3.3 系统调用功能模块的初始化

    对系统调用的初始化也就是对INT 0x80的初始化。系统启动时,汇编子程序setup_idt(见arch/i386/kernel/head.S)准备了1张256项的idt表,由start_kernel()(见 init/main.c),trap_init()(见arch/i386/kernel/traps.c)调用的C语言宏定义set_system_gate(0x80,&system_call)(见include/asm/system.h)设置0x80号软中断的服务程序为system_call(见arch/i386/kernel/entry.S),system.call就是所有系统调用的总入口

    1.1.3.4 内核如何为各种系统调用服务

    当进程需要进行系统调用时,必须以C语言函数的形式写一句系统调用命令。该命令如果已在某个头文件中由相应的_syscallN()展开,则用户程序必须包含该文 件。当进程执行到用户程序的系统调用命令时,实际上执行了由宏命令_syscallN()展开的函数。系统调用的参数 由各通用寄存器传递,然后执行INT 0x80,以内核态进入入口地址system_call

    ENTRY(system_call)
            pushl %eax                        # save orig_eax
            SAVE_ALL
    #ifdef __SMP__
            ENTER_KERNEL
    #endif
            movl $-ENOSYS,EAX(%esp)
            cmpl $(NR_syscalls),%eax
            jae ret_from_sys_call
            movl SYMBOL_NAME(sys_call_table)(,%eax,4),%eax
            testl %eax,%eax
            je ret_from_sys_call
    #ifdef __SMP__
            GET_PROCESSOR_OFFSET(%edx)
            movl SYMBOL_NAME(current_set)(,%edx),%ebx
    #else
            movl SYMBOL_NAME(current_set),%ebx
    #endif
            andl $~CF_MASK,EFLAGS(%esp)        # clear carry - assume no errors
            movl %db6,%edx
            movl %edx,dbgreg6(%ebx)  # save current hardware debugging status
            testb $0x20,flags(%ebx)                # PF_TRACESYS
            jne 1f
            call *%eax
            movl %eax,EAX(%esp)                # save the return value
            jmp ret_from_sys_call
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    system_call入口的汇编程序的主要功能是:

    • 保存寄存器当前值(SAVE_ALL)
    • 检验是否为合法的系统调用
    • 根据系统调用表_sys_call_table和EAX持有的系统调用号找出并转入系统调用响应函数
    • 从该响应函数返回后,让EAX寄存器保存函数返回值,跳转至ret_from_sys_call(arch/i386/kernel/entry.S)
    • 最后,在执行位于用户程序中系统调用命令后面余下的指令之前,若INT 0X80的返回值非负,则直接按类型type返回;否则,将INT 0X80的返回值取绝对值,保留在errno变量中,返回-1

    1.2 ret_from_sys_call

    ret_from_sys_call入口的汇编程序段在linux进程管理中起到了十分重要的作用。所有系统调用结束前以及大部分中断服务返回前,都会跳转至此处入口地址。 该段程序不仅仅为系统调用服务,它还处理中断嵌套、CPU调度、信号等事务

    1.3 系统调用实现过程

    1.3.1 函数名约定

    系统调用响应函数的函数名约定,函数名以“sys_”开头,后跟该系统调用的名字,由此构成164个形似sys_name()的函数名,因此,系统调用ptrace()的响应函数是sys_ptrace() (kernel/ptrace.c)

    1.3.2 系统调用号

    系统调用号,文件include/asm/unistd.h为每个系统调用规定了唯一的编号:

    #define __NR_setup                  0
    #define __NR_exit                            1
    #define __NR_fork                  2
    …        …
    #define __NR_ptrace                  26
    
    • 1
    • 2
    • 3
    • 4
    • 5

    以系统调用号__NR_name作为下标,找出系统调用表sys_call_table (arch/i386/kernel/entry.S)中对应表项的内容,正好就是该系统调用的响应函数sys_name的入口地址

    1.3.3 系统调用表

    系统调用表sys_call_table (arch/i386/kernel/entry.S)形如:

    ENTRY(sys_call_table)
            .long SYMBOL_NAME(sys_setup)                /* 0 */
            .long SYMBOL_NAME(sys_exit)
            .long SYMBOL_NAME(sys_fork)
                    …        …
                    .long SYMBOL_NAME(sys_stime)                /* 25 */
            .long SYMBOL_NAME(sys_ptrace)
                    …        …
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    sys_call_table记录了各sys_name函数(共166项,其中2项无效)在表中的位子。有了这张表,很容易根据特定系统调用在表中的偏移量,找到对应的系统调用响应函数的入口地址。NR_syscalls(即256)表示最多可容纳的系统调用个数。这样,余下的90项就是可供用户自己添加的系统调用空间

    1.3.4 从ptrace系统调用命令到INT 0X80中断请求的转换

    从ptrace系统调用命令到INT 0X80中断请求的转换,宏定义syscallN()(include/asm/unistd.h)用于系统调用的格式转换和参数的传递

    #define _syscall4(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4) \
    type name (type1 arg1, type2 arg2, type3 arg3, type4 arg4) \
    { \
    long __res; \
    __asm__ volatile ("int $0x80" \
            : "=a" (__res) \
            : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
              "d" ((long)(arg3)),"S" ((long)(arg4))); \
    __syscall_return(type,__res); \
    }
    
        N取05之间任意整数。参数个数为N的系统调用由syscallN负责格式转换和参数传递。例如,ptrace()有四个参数,它对应的格式转换宏就是syscall4()syscallN()第一个参数说明响应函数返回值的类型,第二个参数为系统调用的名称(即name),其余的参数依次为系统调用参数的类型和名称。例如_syscall4(int, ptrace, long request, long pid, long addr, long data)说明了系统调用命令
    
    int sys_ptrace(long request, long pid, long addr, long data)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    宏定义的余下部分描述了启动INT 0X80和接收、判断返回值的过程。也就是说,以系统调用号对EAX寄存器赋值,启动INT 0X80。规定返回值送EAX寄存器。函数的参数压栈,压栈顺序见下表:

    参数参数在堆栈的位置传递参数的寄存器
    arg100(%esp)ebx
    arg204(%esp)ecx
    arg308(%esp)edx
    arg40c(%esp)esi
    arg510(%esp)edi

    INT 0X80的返回值非负,则直接按类型type返回;否则,将INT 0X80的返回值取绝对值,保留在errno变量中,返回-1

    1.3.5 系统调用功能模块的初始化

    系统调用功能模块的初始化,对系统调用的初始化也即对INT 0X80的初始化。系统启动时,汇编子程序setup_idt(arch/i386/kernel/head.S)准备了张256项的idt 表,由start_kernel()(init/main.c)、trap_init()(arch/i386/kernel/traps.c)调用的C语言宏定义set_system_gate(0x80, &system_call)(include/asm/system.h)设置0X80号软中断的服务程序为system_call。system_call(arch/i386/kernel/entry.S)就是所有系统调用的总入口

    更多内容请前往:https://cloud.tencent.com/developer/article/1939486

    1.4 Ring3中Hook技术

    如下内容来自:https://www.cnblogs.com/LittleHann/p/3854977.html

    Hook技术是一个相对较宽的话题,因为操作系统从ring3到ring0是分层次的结构,在每一个层次上都可以进行相应的Hook,它们使用的技术方法以及取得的效果也是不尽相同的

    Hook 系统调用原理:内核系统调用hook主要是在内核模块加载的时候,通过修改替换内核系统调用表sys_call_table的系统调用号地址来实现目的

    内核源码默认配置修改支持系统调用挂钩功能:

    • 为了通过insmod命令加载内核模块,需要增加Android内核可加载内核模块配置的支持

    • 为了获取sys_call_table导出全局内核符号表的地址需要增加内核符号表导出配置功能

    • 为了向内核内存空间读写数据,需要禁用Android内核的内存保护。需要关闭CONFIG_STRICT_MEMORY_RWX这个选项来编译内核

    • root权限下,关闭symbol符号屏蔽,并将/proc/sys/kernel/kptr_restrict重置为0,就可以打印显示出来

    adb shell "su -c echo 0 > /proc/sys/kernel/kptr_restrict"
    
    • 1
    • 获取sys_call_table的内存地址
    adb shell cat /proc/kallsyms | grep sys_call_table
    
    • 1

    sys_call_table=xxxx4即为需要找到的Android系统调用表的内存基址,后面很多Android系统调用的系统函数调用地址都需要通过这个基址加函数的偏移计算出来

    一般的系统调用都是调用0x80号中断,所有的系统调用都是通过int 0x80,接下我们来看看int 0x80是如何执行的,这是一个系统中断,操作系统对于中断处理流程一般为:

    • 关中断:CPU关闭中段响应,即不再接受其它外部中断请求
    • 保存断点:将发生中断处的指令地址压入堆栈,以使中断处理完后能正确地返回
    • 识别中断源:CPU识别中断的来源,确定中断类型号,从而找到相应的中断服务程序的入口地址
    • 保护现场所:将发生中断处理有关寄存器(中断服务程序中要使用的寄存器)以及标志寄存器的内存压入堆栈
    • 执行中断服务程序:转到中断服务程序入口开始执行,可在适当时刻重新开放中断,以便允许响应较高优先级的外部中断
    • 恢复现场并返回:把“保护现场”时压入堆栈的信息弹回原寄存器,然后执行中断返回指令(IRET),从而返回主程序继续运行

    1.4.1 LD_PRELOAD动态连接.so函数劫持

    LD_PRELOAD hook技术属于so依赖劫持技术的一种实现,可以使用LD_PRELOAD挂载一个自定义动态库直接hook比如在glibc.so中的函数,大致包括:malloc、read、write等函数。接下来我们讨论一下LD_PRELOAD hook技术的技术原理,先来看一下linux操作系统加载so的底层原理

    包括Linux系统在内的很多开源系统都是基于Glibc的,动态链接的ELF可执行文件在启动时同时会启动动态链接器(/lib/ld-linux.so.X),程序所依赖的共享对象全部由动态链接器负责装载和初始化,所以这里所谓的共享库的查找过程,本质上就是动态链接器(/lib/ld-linux.so.X)对共享库路径的搜索过程,搜索过程如下:

    • /etc/ld.so.cache:Linux为了加速LD_PRELOAD的搜索过程,在系统中建立了一个ldconfig程序,这个程序负责
      • 将共享库下的各个共享库维护一个SO-NAME(一一对应的符号链接),这样每个共享库的SO-NAME就能够指向正确的共享库文件
      • 将全部SO-NAME收集起来,集中放到/etc/ld.so.cache文件里面,并建立一个SO-NAME的缓存
      • 当动态链接器要查找共享库时,它可以直接从/etc/ld.so.cache里面查找。所以,如果我们在系统指定的共享库目录下添加、删除或更新任何一个共享库,或者我们更改了/etc/ld.so.conf、/etc/ld.preload的配置,都应该运行一次ldconfig这个程序,以便更新SO-NAME和/etc/ld.so.cache。很多软件包的安装程序在结束共享库安装以后都会调用ldconfig
    • 根据/etc/ld.so.preload中的配置进行搜索(LD_PRELOAD):这个配置文件中保存了需要搜索的共享库路径,Linux动态共享库加载器根据顺序进行逐行广度搜索
    • 根据环境变量LD_LIBRARY_PATH指定的动态库搜索路径:
    • 根据ELF文件中的配置信息:任何一个动态链接的模块所依赖的模块路径保存在".dynamic"段中,由DT_NEED类型的项表示,动态链接器会按照这个路径去查找DT_RPATH所指定的路径,编译目标代码时,可以对gcc加入链接参数"-Wl,-rpath"指定动态库搜索路径
      • DT_NEED段中保存的是绝对路径,则动态链接器直接按照这个路径进行直接加载
      • DT_NEED段中保存的是相对路径,动态链接器会在按照一个约定的顺序进行库文件查找下列路径
        • ```/lib``````
        • /usr/lib
        • /etc/ld.so.conf中配置指定的搜索路径

    可以看到,LD_PRELOAD是Linux系统中启动新进程首先要加载so的搜索路径,所以它可以影响程序的运行时的链接(Runtime linker),它允许你定义在程序运行前"优先加载"的动态链接库。

    我们只要在通过LD_PRELOAD加载的.so中编写我们需要hook的同名函数,根据Linux对外部动态共享库的符号引入全局符号表的处理,后引入的符号会被省略,即系统原始的.so(/lib64/libc.so.6)中的符号会被省略。

    通过strace program也可以看到,Linux是优先加载LD_PRELOAD指明的.so,然后再加载系统默认的.so的:

    在这里插入图片描述

    1.4.1.1 通过自写.so文件劫持LD_PRELOAD

    1)demo例子

    正常程序main.c:

    #include 
    #include 
    
    int main(int argc, char *argv[])
    {
        if( strcmp(argv[1], "test") )
        {
            printf("Incorrect password\n");
        }
        else
        {
            printf("Correct password\n");
        }
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    用于劫持函数的.so代码hook.c

    #include 
    #include 
    #include 
    /*
    hook的目标是strcmp,所以typedef了一个STRCMP函数指针
    hook的目的是要控制函数行为,从原库libc.so.6中拿到strcmp指针,保存成old_strcmp以备调用
    */
    typedef int(*STRCMP)(const char*, const char*);
    
    int strcmp(const char *s1, const char *s2)
    {
        static void *handle = NULL;
        static STRCMP old_strcmp = NULL;
    
        if( !handle )
        {
            handle = dlopen("libc.so.6", RTLD_LAZY);
            old_strcmp = (STRCMP)dlsym(handle, "strcmp");
        }
        printf("oops!!! hack function invoked. s1=<%s> s2=<%s>\n", s1, s2);
        return old_strcmp(s1, s2);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    编译:

    gcc -o test main.c
    gcc -fPIC -shared -o hook.so hook.c -ldl
    
    • 1
    • 2

    运行:

    LD_PRELOAD=./hook.so ./test 123
    
    • 1

    在这里插入图片描述

    2)hook function注意事项

    在编写用于function hook的.so文件的时候,要考虑以下几个因素

    1. Hook函数的覆盖完备性
    对于Linux下的指令执行来说,有7个Glibc API都可是实现指令执行功能,对这些API对要进行Hook
    /*
    #include 
    int execl(const char *pathname, const char *arg0, ... /* (char *)0 */ );
    int execv(const char *pathname, char *const argv[]);
    int execle(const char *pathname, const char *arg0, .../* (char *)0, char *const envp[] */ );
    int execve(const char *pathname, char *const argv[]char *const envp[]);
    int execlp(const char *filename, const char *arg0, ... /* (char *)0 */ );
    int execvp(const char *filename, char *const argv[]);
    int fexecve(int fd, char *const argv[]char *const envp[]);
    http://www.2cto.com/os/201410/342362.html
    */
    
    2. 当前系统中存在function hook的重名覆盖问题
        1) /etc/ld.so.preload中填写了多条.so加载条目
        2) 其他程序通过"export LD_PRELOAD=.."临时指定了待加载so的路径
    在很多情况下,出于系统管理或者集群系统日志收集的目的,运维人员会向系统中注入.so文件,对特定function函数进行hook,这个时候,当我们注入的.so文件中的hook function和原有的hook function存在同名的情况,Linux会自动忽略之后载入了hook function,这种情况我们称之为"共享对象全局符号介入"
    
    3. 注入.so对特定function函数进行hook要保持原始业务的兼容性
    典型的hook的做法应该是
    hook_function()
    {
        save ori_function_address;
        /*
        do something in here
        span some time delay
        */
        call ori_function;
    }
    hook函数在执行完自己的逻辑后,应该要及时调用被hook前的"原始函数",保持对原有业务逻辑的透明
    
    4. 尽量减小hook函数对原有调用逻辑的延时
    hook_function()
    {
        save ori_function_address;
        /*
        do something in here
        span some time delay
        */
        call ori_function;
    }
    hook这个操作是一定会对原有的代码调用执行逻辑产生延时的,我们需要尽量减少从函数入口到"call ori_function"这块的代码逻辑,让代码逻辑尽可能早的去"call ori_function"
    在一些极端特殊的场景下,存在对单次API调用延时极其严格的情况,如果延时过长可能会导致原始业务逻辑代码执行失败
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44

    如果需要不仅仅是替换掉原有库函数,而且还希望最终将函数逻辑传递到原有系统函数,实现透明hook(完成业务逻辑的同时不影响正常的系统行为)、维持调用链,那么需要用到RTLD_NEXT

    当调用dlsym的时候传入RTLD_NEXT参数,gcc的共享库加载器会按照"装载顺序(load order)(即先来后到的顺序)"获取"下一个共享库"中的符号地址
    /*
    Specifies the next object after this one that defines name. This one refers to the object containing the invocation of dlsym(). The next object is the one found upon the application of a load order symbol resolution algorithm (see dlopen()). The next object is either one of global scope (because it was introduced as part of the original process image or because it was added with a dlopen() operation including the RTLD_GLOBAL flag), or is an object that was included in the same dlopen() operation that loaded this one.
    The RTLD_NEXT flag is useful to navigate an intentionally created hierarchy of multiply-defined symbols created through interposition. For example, if a program wished to create an implementation of malloc() that embedded some statistics gathering about memory allocations, such an implementation could use the real malloc() definition to perform the memory allocation-and itself only embed the necessary logic to implement the statistics gathering function.
    http://pubs.opengroup.org/onlinepubs/009695399/functions/dlsym.html
    http://www.newsmth.net/nForum/#!article/KernelTech/413
    */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    code example

    // used for getting the orginal exported function address
    #if defined(RTLD_NEXT)
    #  define REAL_LIBC RTLD_NEXT
    #else
    #  define REAL_LIBC ((void *) -1L)
    #endif
    
    //REAL_LIBC代表当前调用链中紧接着下一个共享库,从调用方链接映射列表中的下一个关联目标文件获取符号
    #define FN(ptr,type,name,args)  ptr = (type (*)args)dlsym (REAL_LIBC, name)
    
    ...
    FN(func,int,"execve",(const char *, char **const, char **const));
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    我们知道,如果当前进程空间中已经存在某个同名的符号,则后载入的so的同名函数符号会被忽略,但是不影响so的载入,先后载入的so会形成一个链式的依赖关系,通过RTLD_NEXT可以遍历这个链

    3)SO功能代码编写

    这个小节我们来完成一个基本的进程、网络、模块加载监控的小demo

    1. 指令执行
        1) execve
        2) execv
    2. 网络连接
        1) connect
    3. LKM模块加载
        1) init_modulec
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    hook.c

    #include 
    #include 
    #include 
    
    #include 
    #include   
    #include 
    #include 
    #include 
    
    #include  
    #include 
    #include 
     
    #if defined(RTLD_NEXT)
    #  define REAL_LIBC RTLD_NEXT
    #else
    #  define REAL_LIBC ((void *) -1L)
    #endif
    
    #define FN(ptr, type, name, args)  ptr = (type (*)args)dlsym (REAL_LIBC, name)
     
    int execve(const char *filename, char *const argv[], char *const envp[])
    {
        static int (*func)(const char *, char **, char **);
        FN(func,int,"execve",(const char *, char **const, char **const)); 
    
        //print the log
        printf("filename: %s, argv[0]: %s, envp:%s\n", filename, argv[0], envp);
    
        return (*func) (filename, (char**) argv, (char **) envp);
    } 
    
    int execv(const char *filename, char *const argv[]) 
    {
        static int (*func)(const char *, char **);
        FN(func,int,"execv", (const char *, char **const)); 
    
        //print the log
        printf("filename: %s, argv[0]: %s\n", filename, argv[0]);
    
        return (*func) (filename, (char **) argv);
    }  
      
    int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) 
    { 
        static int (*func)(int, const struct sockaddr *, socklen_t);
        FN(func,int,"connect", (int, const struct sockaddr *, socklen_t)); 
    
        /*
        print the log
        获取、打印参数信息的时候需要注意
        1. 加锁
        2. 拷贝到本地栈区变量中
        3. 然后再打印
        调试的时候发现直接获取打印会导致core dump
        */
        printf("socket connect hooked!!\n");
    
        //return (*func) (sockfd, (const struct sockaddr *) addr, (socklen_t)addrlen);
        return (*func) (sockfd, addr, addrlen);
    }  
    
    int init_module(void *module_image, unsigned long len, const char *param_values) 
    { 
        static int (*func)(void *, unsigned long, const char *);
        FN(func,int,"init_module",(void *, unsigned long, const char *)); 
    
        /*
        print the log
        lkm的加载不需要取参数,只需要捕获事件本身即可
        */
        printf("lkm load hooked!!\n");
    
        return (*func) ((void *)module_image, (unsigned long)len, (const char *)param_values);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76

    编译,并装载

    //编译出一个so文件
    gcc -fPIC -shared -o hook.so hook.c -ldl
    
    • 1
    • 2

    添加LD_PRELOAD有很多种方式

    1. 临时一次性添加(当条指令有效)
    LD_PRELOAD=./hook.so nc www.baidu.com 80  
    /*
    LD_PRELOAD后面接的是具体的库文件全路径,可以连接多个路径
    程序加载时,LD_PRELOAD加载路径优先级高于/etc/ld.so.preload
    */
    
    2. 添加到环境变量LD_PRELOAD中(当前会话SESSION有效)
    export LD_PRELOAD=/zhenghan/snoopylog/hook.so
    //"/zhenghan/snoopylog/"是编译.so文件的目录
    unset LD_PRELOAD
    
    3. 添加到环境变量LD_LIBRARY_PATH中
    假如现在需要在已有的环境变量上添加新的路径名,则采用如下方式
    LD_LIBRARY_PATH=/zhenghan/snoopylog/hook.so:$LD_LIBRARY_PATH.(newdirs是新的路径串)
    /*
    LD_LIBRARY_PATH指定查找路径,这个路径优先级别高于系统预设的路径
    */
    
    4. 添加到系统配置文件中
    vim /etc/ld.so.preload
    add /zhenghan/snoopylog/hook.so
    
    5. 添加到配置文件目录中
    cat /etc/ld.so.conf
    //include ld.so.conf.d/*.conf
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    效果测试

    1. 指令执行
    在代码中手动调用: execve(argv[1], newargv, newenviron);
    
    2. 网络连接
    执行: nc www.baidu.com 80
    
    3. LKM模块加载
    编写测试LKM模块,执行: insmod hello.ko
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    在真实的环境中,socket的网络连接存在大量的连接失败,非阻塞等待等等情况,这些都会触发connect的hook调用,对于connect的hook来说,我们需要对以下的事情进行过滤

    1. 区分IPv4、IPv6
    根据connect参数中的(struct sockaddr *addr)->sa_family进行判断
    
    2. 区分执行成功、执行失败
    如果本次connect调用执行失败,则不应该继续进行参数获取
    int ret_code = (*func) (sockfd, addr, addrlen);
    int tmp_errno = errno;
    if (ret_code == -1 && tmp_errno != EINPROGRESS)
    {
        return ret_code;
    }
    
    3. 区分TCP、UDP连接
    对于TCP和UDP来说,它们都可以发起connect请求,我们需要从中过滤出TCP Connect请求
    #include 
    #include 
    
    int getsockopt(int sock, int level, int optname, void *optval, socklen_t *optlen);
    int setsockopt(int sock, int level, int optname, const void *optval, socklen_t optlen);
    /*
    #include 
    #include 
    main()
    {
       int s;
       int optval;
       int optlen = sizeof(int);
       if((s = socket(AF_INET, SOCK_STREAM, 0)) < 0)
       perror("socket");
       getsockopt(s, SOL_SOCKET, SO_TYPE, &optval, &optlen);
       printf("optval = %d\n", optval);
       close(s);
    }
    */
    执行:
    optval = 1 //SOCK_STREAM 的定义正是此值
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    Relevant Link:

    • http://m.blog.csdn.net/blog/cdhql/42081029
    • http://blog.csdn.net/xioahw/article/details/4056514
    • http://c.biancheng.net/cpp/html/357.html
    • http://m.blog.csdn.net/blog/cdhql/42081029

    4)劫持效果测试

    1. 指令执行监控

    execve.c

    #include 
    #include 
    #include 
    
    int main(int argc, char *argv[])
    {
       char *newargv[] = { NULL, "hello", "world", NULL };
       char *newenviron[] = { NULL };
    
       if (argc != 2) 
       {
           fprintf(stderr, "Usage: %s \n", argv[0]);
           exit(EXIT_FAILURE);
       }
    
       newargv[0] = argv[1];
    
       execve(argv[1], newargv, newenviron);
       perror("execve");   /* execve() only returns on error */
       exit(EXIT_FAILURE);
    }
    //gcc -o execve execve.c
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    myecho.c

    #include 
    #include 
    
    int main(int argc, char *argv[])
    {
        int j;
    
        for (j = 0; j < argc; j++)
            printf("argv[%d]: %s\n", j, argv[j]);
    
        exit(EXIT_SUCCESS);
    }
    //gcc -o myecho myecho.c
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    在这里插入图片描述

    可以看到,LD_PRELOAD在所有程序代码库加载前优先加载,对glibc中的导出函数进行了hook

    1. 网络连接监控
    nc www.baidu.com 80
    
    • 1

    在这里插入图片描述

    1. 模块加载监控

    hello.c

    #include     // included for all kernel modules
    #include     // included for KERN_INFO
    #include         // included for __init and __exit macros
    #include 
    #include 
     
    static int __init hello_init(void)
    { 
        struct cred *currentCred;
        currentCred = current->cred;    
        printk(KERN_INFO "uid = %d\n", currentCred->uid);
        printk(KERN_INFO "gid = %d\n", currentCred->gid);
        printk(KERN_INFO "suid = %d\n", currentCred->suid);
        printk(KERN_INFO "sgid = %d\n", currentCred->sgid);
        printk(KERN_INFO "euid = %d\n", currentCred->euid);
        printk(KERN_INFO "egid = %d\n", currentCred->egid);  
    
        printk(KERN_INFO "Hello world!\n"); 
        return 0;    // Non-zero return means that the module couldn't be loaded.
    }
     
    static void __exit hello_cleanup(void)
    {
        printk(KERN_INFO "Cleaning up module.\n");
    }
     
    module_init(hello_init);
    module_exit(hello_cleanup);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    Makefile

    obj-m := hello.o
    KDIR := /lib/modules/$(shell uname -r)/build
    PWD := $(shell pwd)
     
    all:
        $(MAKE) -C $(KDIR) M=$(PWD) modules
     
    clean:
        $(MAKE) -C $(KDIR) M=$(PWD) clean
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    加载模块:insmod hello.ko

    在这里插入图片描述

    1.4.1.2 使用snoopy进行execve/execv、connect、init_module hook

    snoopy会监控服务器上的命令执行,并记录到syslog

    本质上,snoopy是利用ld_preload技术实现so依赖劫持的,只是它的工程化完善度更高,日志采集和日志整理传输这方面已经帮助我们完成了

    #cat /etc/ld.so.preload
    /usr/local/snoopy/lib/snoopy.so
    
    • 1
    • 2

    1.4.2 绕过基于Linux消息队列(Message Queue)通信的Hook模块

    消息队列提供了一种在两个不相关的进程之间传递数据的相当简单且有效的方法,但是对于消息队列的使用,很容易产生几点安全风险:

    • 在创建消息队列的时候对message queue的权限控制没有严格控制,让任意非root用户也可以从消息队列中读取消息
    • 在用户态标识消息队列的MSGID很容易通过"ipcs"指令得到,从而攻击者可以获取到和Hook模块相同的消息队列,从中读取消息
    • Linux下的消息队列是内核态维护的一个消息队列,每个消息只能被"取出"一次
    • 当系统中存在多个进程同时在从同一个消息队列中"消费"消息的时候,对消息队列中消息的获取的顺序是一个"竞态条件",谁先获取到消息取决进程的内核调度优先级、以及接收进程自身的接收逻辑,为了提高"竞态条件"的"获胜率",可以使用nice(-20);提高进程的静态优先级,从而间接影响到内核调度优先级

    综合以上4点就可以得出一种,这本质上一种攻击”监控软件“本身的一种技术方式,如果监控软件使用消息队列进行日志的通信,则攻击者可以通过这种方式强制取出队列中的消息,从而使监控软件的监控失效

    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    #define MSG_FILE "/etc/fstab"
    
    #define BUF_SZ_63        63
    #define BUF_SZ_255      255
    #define BUF_SZ_511      511
    #define BUF_SZ_1023    1023
    #define BUF_SZ_10_KB  10239
    
    
    #define OPERATION_PERMISSION 0666
    
    #define MAGIC_NUMBER_1 (~0xDEADBEEF)
    #define MAGIC_NUMBER_2 (~0xABABABAB)
    
    #define AGX_SO_VER               7
    
    #define GET_MSG_PROT(x) ((unsigned int)( (x & 0xFFFF0000) >> 16 ))
    #define GET_MSG_TYPE(x) ((unsigned int)(  x & 0x0000FFFF) )
    
    struct syscall_event 
    {
        long msg_category;
        char msg_body[BUF_SZ_10_KB+1];
    };
    
    int main()
    {
        int msg_id;
        int newpri; 
    
        if((msg_id = msgget((key_t)MAGIC_NUMBER_1+AGX_SO_VER, OPERATION_PERMISSION|IPC_CREAT|IPC_EXCL)) == -1)
        {
            if (EEXIST == errno)
            {
                printf("msqg already exist: %d\n", errno);
                if ((msg_id = msgget((key_t)MAGIC_NUMBER_1+AGX_SO_VER, OPERATION_PERMISSION|IPC_CREAT)) == -1)
                {
                    printf("Unhandled error: %d\n", errno);
                    exit(1);
                }
            }
            else
            {
                printf("Unhandled error: %d\n", errno);
                exit(1);
            }
        }
    
        //调整用户态的nice值。即内核态的静态优先级
        newpri = nice(-20);
        printf("New priority = %d\n", newpri);
        
        while(1)
        {
            struct syscall_event msg = {0, {0}}; 
    
            size_t count = msgrcv(msg_id, &msg, BUF_SZ_10_KB, 0, MSG_NOERROR);
            if (count == -1)
            {
                // error handling
                break;
            }
    
            printf("Server Receive: %lx, %lx\n%s\n", GET_MSG_PROT(msg.msg_category), GET_MSG_TYPE(msg.msg_category), msg.msg_body);
            
        }
    
        struct msqid_ds buf;
        int ret = msgctl(msg_id, IPC_RMID, &buf);
        if (ret ==  -1)
        {
            printf("rm msgq failed: %d\n", errno);
        }
    
        exit(0);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86

    1.4.3 基于PD_PRELOAD、LD_LIBRARY_PATH环境变量劫持绕过Hook模块

    已知snoopy监控服务器上的指令执行,是通过修改系统的共享库预加载配置文件(/etc/ld.so.preload)实现,但是这种方式存在一个被黑客绕过的可能

    在这里插入图片描述

    LD_PRELOAD的加载顺序优先于/etc/ld.so.preload的配置项,黑客可以利用这点来强制覆盖共享库的加载顺序

    1. 强制指定LD_PRELOAD的环境变量    
    export LD_PRELOAD=/lib64/libc.so.6
    bash
    /*
    新启动的bash终端默认会使用LD_PRELOAD的共享库路径
    */
    
    2. LD_PRELOAD="/lib64/libc.so.6" bash 
    /*
    重新开启一个加载了默认libc.so.6共享库的bash session
    因为对于libc.so.6来说,它没有使用dlsym去动态获取API Function调用链条的RTL_NEXT函数,即调用链是断开的
    */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    在这个新的Bash下执行的指令,因为都不会调用到snoopy的hook函数,所以也不会被记录下来

    Relevant Link:

    • http://coolex.info/blog/445.html

    1.4.4 基于ptrace()调试技术进行API Hook

    在Linux下,除了使用LD_PRELOAD这种被动Glibc API注入方式,还可以使用基于调试器(Debuger)思想的ptrace()主动注入方式,总体思路如下

    • 使用Linux Module、或者LSM挂载点对进程的启动动作进行实时的监控,并通过Ring0-Ring3通信,通知到Ring3程序有新进程启动的动作
    • 用ptrace函数attach上目标进程
    • 让目标进程的执行流程跳转到mmap函数来分配一小段内存空间
    • 把一段机器码拷贝到目标进程中刚分配的内存中去
    • 最后让目标进程的执行流程跳转到注入的代码执行

    Relevant Link:

    • http://blog.sina.com.cn/s/blog_dae890d10101f00d.html
    • https://code.google.com/p/linux-hook-api/source/browse/trunk/injector_api_x86/ptrace.cc?r=30
    • http://www.cnblogs.com/guaiguai/archive/2010/06/11/1756427.html

    1.4.5 通过静态编码绕过LD_PRELOAD机制监控

    通过静态链接方式编译so模块:

    gcc -o test test.c -static 
    
    • 1

    在静态链接的模式下,程序不会去搜索系统中的so文件(不管是系统默认的、还是第三方加入的),所以也就不会调用到Hook SO模块

    1.4.6 通过内联汇编的方式绕过LD_PRELOAD机制监控

    使用内嵌汇编的形式直接通过syscall指令使用系统调用功能,同样也不会调用到Glibc提供的API

    asm("movq $2, %%rax\n\t syscal:"=a"(ret));
    
    • 1

    Relevant Link:

    • http://blog.cloud-sec.org/uncategorized/%E7%BB%95%E8%BF%87c%E5%BA%93ld_preload%E6%9C%BA%E5%88%B6%E7%9A%84%E5%87%A0%E7%A7%8D%E6%96%B9%E6%B3%95/

    1.4.7 基于PLT劫持、PLT重定向技术实现Hook

    PLT HOOK,可以通过ptrace注入和加壳的形式

    • http://www.cnblogs.com/LittleHann/p/4594641.html

    1.5 Ring0中Hook技术

    • 补充知识:

    BIOS信息篡改的难易程度(如下),详细内容请移步:https://stackoverflow.com/questions/35883313/dmidecode-product-uuid-and-product-serial-what-is-the-difference

    BIOS信息存在与DMI-TABLE表中,是一块随BIOS代码在硬件出厂时FLASH进去的永久数据,不可更改。但由于是存在于DMI-TABLE中,其数据是由系统调用表中函数来进行读取,在针对Ring0级的Hook还是存在一定的风险。除此之外在Linux若需要修改BIOS序列值,需要厂商协助重刷BIOS数据

    应用层/内核层Hook:

    • 针对Ring3层Hook 1、2、3、的攻击方式,检测LD_PRELOAD的挂载。对于inline hook,通过对原有指令返回位置的汇编代码作污点标记,通过查找jmp,push ret等指令校验
    • 针对Ring0层(系统调用表的底层)的Hook,在应用层无法做到检测,详细如何检测直接跳到文章0x02 车机(Android)设备中监控命令执行的一些想法

    在这里插入图片描述

    检测思路:

    • 提取设备指纹时,每提取一个指纹后都单独先进行存储,再得到最终的设备指纹时再进行计算,
    • 在提取指纹模块中,内置了多个变量,在每提取一个指纹维度的情况下都会单独先进行存储(分散存储),在得到最终指纹时再进行计算,提取的过程并非是线性的叠加,防止应用层的Hook和存储逻辑的泄漏

    如下内容来自:https://www.cnblogs.com/LittleHann/p/3854977.html

    1.5.1 Kernel Inline Hook

    传统的kernel inline hook技术就是修改内核函数的opcode,通过写入jmp或push ret等指令跳转到新的内核函数中,从何达到劫持的目的

    其实就是在运行时替换函数指令,原理是每个函数的调用前几个字节指令是固定的,可以通过类似jump等指令执行其他指令

    在这里插入图片描述

    • 我们知道实现一个系统调用的函数中一定会递归的嵌套有很多的子函数,即它必定要调用它的下层函数
    • 从汇编的角度来说,对一个子函数的调用是采用"段内相对短跳转 jmp offset"来实现的,即CPU根据offset来进行一个偏移量的跳转
    • 如果我们把下层函数在上层函数中的offset替换成我们"Hook函数"的offset,这样上层函数调用下层函数时,就会跳到我们的"Hook函数"中
    • 我们就可以在"Hook函数"中做过滤和劫持内容的工作

    以sys_read作为例子:\linux-2.6.32.63\fs\read_write.c

    asmlinkage ssize_t sys_read(unsigned int fd, char __user * buf, size_t count)
    {
            struct file *file;
            ssize_t ret = -EBADF;
            int fput_needed;
    
            file = fget_light(fd, &fput_needed);
            if (file) 
        {
                    loff_t pos = file_pos_read(file);
                    ret = vfs_read(file, buf, count, &pos);
                    file_pos_write(file, pos);
                    fput_light(file, fput_needed);
            }
    
            return ret;
    }
    EXPORT_SYMBOL_GPL(sys_read);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    在sys_read()中,调用了子函数vfs_read()来完成读取数据的操作,在sys_read()中调用子函数vfs_read()的汇编命令是:
    call 0xc106d75c

    等同于:
    jmp offset(相对于sys_read()的基址偏移)
    所以,我们的思路很明确,找到call 0xc106d75c 这条汇编,把其中的offset改成我们的Hook函数对应的offset,就可以实现劫持目的了

    1. 搜索sys_read的opcode
    2. 如果发现是call指令,根据call后面的offset计算要跳转的地址是不是我们要hook的函数地址
        1) 如果"不是"就重新计算Hook函数的offset,用Hook函数的offset替换原来的offset
        2) 如果"已经是"Hook函数的offset,则说明函数已经处于被劫持状态了,我们的Hook引擎应该直接忽略跳过,避免重复劫持
    
    • 1
    • 2
    • 3
    • 4

    poc:

    /*
    参数:
    1. handler是上层函数的地址,这里就是sys_read的地址
    2. old_func是要替换的函数地址,这里就是vfs_read
    3. new_func是新函数的地址,这里就是new_vfs_read的地址
    */
    unsigned int patch_kernel_func(unsigned int handler, unsigned int old_func, 
            unsigned int new_func)
    {
        unsigned char *p = (unsigned char *)handler;
        unsigned char buf[4] = "\x00\x00\x00\x00";
        unsigned int offset = 0;
        unsigned int orig = 0;
        int i = 0;
    
        DbgPrint("\n*** hook engine: start patch func at: 0x%08x\n", old_func);
    
        while (1) {
            if (i > 512)
                return 0;
    
            if (p[0] == 0xe8) {
                DbgPrint("*** hook engine: found opcode 0x%02x\n", p[0]);
                
                DbgPrint("*** hook engine: call addr: 0x%08x\n", 
                    (unsigned int)p);
                buf[0] = p[1];
                buf[1] = p[2];
                buf[2] = p[3];
                buf[3] = p[4];
    
                DbgPrint("*** hook engine: 0x%02x 0x%02x 0x%02x 0x%02x\n", 
                    p[1], p[2], p[3], p[4]);
    
                    offset = *(unsigned int *)buf;
                    DbgPrint("*** hook engine: offset: 0x%08x\n", offset);
    
                    orig = offset + (unsigned int)p + 5;
                    DbgPrint("*** hook engine: original func: 0x%08x\n", orig);
    
                if (orig == old_func) {
                    DbgPrint("*** hook engine: found old func at"
                        " 0x%08x\n", 
                        old_func);
    
                    DbgPrint("%d\n", i);
                    break;
                }
            }
            p++;
            i++;
        }
    
        offset = new_func - (unsigned int)p - 5;
        DbgPrint("*** hook engine: new func offset: 0x%08x\n", offset);
    
        p[1] = (offset & 0x000000ff);
        p[2] = (offset & 0x0000ff00) >> 8;
        p[3] = (offset & 0x00ff0000) >> 16;
        p[4] = (offset & 0xff000000) >> 24;
    
        DbgPrint("*** hook engine: pachted new func offset.\n");
    
        return orig;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65

    对于这类劫持攻击,目前常见的做法是fireeye的"函数返回地址污点检测",通过对原有指令返回位置的汇编代码作污点标记,通过查找jmp,push ret等指令来进行防御

    1.5.1.1 利用0x80中断劫持system_call->sys_call_table进行系统调用Hook

    我们知道,要对系统调用(sys_call_table)进行替换,却必须要获取该地址后才可以进行替换。但是Linux 2.6版的内核出于安全的考虑没有将系统调用列表基地址的符号sys_call_table导出,但是我们可以采取一些hacking的方式进行获取

    因为系统调用都是通过0x80中断来进行的,故可以通过查找0x80中断的处理程序来获得sys_call_table的地址。其基本步骤:

    • 获取中断描述符表(IDT)的地址(使用C ASM汇编)
    • 从中查找0x80中断(系统调用中断)的服务例程(8*0x80偏移)
    • 搜索该例程的内存空间,从其中获取sys_call_table(保存所有系统调用例程的入口地址)的地址

    编程示例

    find_sys_call_table.c

    #include 
    #include 
    
    // 中断描述符表寄存器结构
    struct 
    {
        unsigned short limit;
        unsigned int base;
    } __attribute__((packed)) idtr;
    
    
    // 中断描述符表结构
    struct 
    {
        unsigned short off1;
        unsigned short sel;
        unsigned char none, flags;
        unsigned short off2;
    } __attribute__((packed)) idt;
    
    // 查找sys_call_table的地址
    void disp_sys_call_table(void)
    {
        unsigned int sys_call_off;
        unsigned int sys_call_table;
        char* p;
        int i;
    
        // 获取中断描述符表寄存器的地址
        asm("sidt %0":"=m"(idtr));
        printk("addr of idtr: %x\n", &idtr);
    
        // 获取0x80中断处理程序的地址
        memcpy(&idt, idtr.base+8*0x80, sizeof(idt));
        sys_call_off=((idt.off2<<16)|idt.off1);
        printk("addr of idt 0x80: %x\n", sys_call_off);
    
        // 从0x80中断服务例程中搜索sys_call_table的地址
        p=sys_call_off;
        for (i=0; i<100; i++)
        {
            if (p=='\xff' && p[i+1]=='\x14' && p[i+2]=='\x85')
            {
                sys_call_table=*(unsigned int*)(p+i+3);
                printk("addr of sys_call_table: %x\n", sys_call_table);
                return ;
            }
        }
    }
    
    // 模块载入时被调用
    static int __init init_get_sys_call_table(void)
    {
        disp_sys_call_table();
        return 0;
    }
    
    module_init(init_get_sys_call_table);
    
    // 模块卸载时被调用
    static void __exit exit_get_sys_call_table(void)
    {
    }
    
    module_exit(exit_get_sys_call_table);
    
    // 模块信息
    MODULE_LICENSE("GPL2.0");
    MODULE_AUTHOR("LittleHann");
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69

    Makefile

    obj-m := find_sys_call_table.o  
    
    • 1

    编译

    make -C /usr/src/kernels/2.6.32-358.el6.i686 M=$(pwd) modules
    
    • 1

    测试效果:dmesg| tail

    在这里插入图片描述

    获取到了sys_call_table的基地址之后,我们就可以修改指定offset对应的系统调用了,从而达到劫持系统调用的目的

    Relevant Link:

    • http://www.elliotbradbury.com/linux-syscall-hooking-interrupt-descriptor-table/

    1.5.1.2 获取sys_call_table的常用方法

    1、通过dump获取绝对地址

    模拟出一个call *sys_call_table(,%eax,4),然后看其机器码,然后在system_call的附近基于这个特征进行寻找

    #include 
    void fun1()
    {
            printf("fun1/n");
    }
    void fun2()
    {
            printf("fun2/n");
    }
    unsigned int sys_call_table[2] = {fun1, fun2};
    int main(int argc, char **argv)
    {
            asm("call *sys_call_table(%eax,4");
    }
    
    编译
    gcc test.c -o test
    
    objdump进行dump
    objdump -D ./test | grep sys_call_table
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    2、通过/boot/System.map-2.6.32-358.el6.i686文件查找

    cd /boot
    grep sys_call_table System.map-2.6.32-358.el6.i686
    
    • 1
    • 2

    3、通过读取/dev/kmem虚拟内存全镜像设备文件获得sys_call_table地址

    Linux下/dev/mem和/dev/kmem的区别:

    1. /dev/mem: 
    物理内存的全镜像。可以用来访问物理内存。比如:
        1) X用来访问显卡的物理内存,
        2) 嵌入式中访问GPIO。用法一般就是open,然后mmap,接着可以使用map之后的地址来访问物理内存。这其实就是实现用户空间驱动的一种方法。
    
    2. /dev/kmem: 
    kernel看到的虚拟内存的全镜像。可以用来:
        1) 访问kernel的内容,查看kernel的变量,
        2) 用作rootkit之类的
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    code

    #include 
    #include 
    #include 
    #include 
    
    int kfd; 
    struct
    {
        unsigned short limit;
        unsigned int base;
    } __attribute__ ((packed)) idtr;
    
    struct
    {
        unsigned short off1;
        unsigned short sel;
        unsigned char none, flags;
        unsigned short off2;
    } __attribute__ ((packed)) idt;
    
    int readkmem (unsigned char *mem,  unsigned off,  int bytes)
    {
        if (lseek64 (kfd, (unsigned long long) off,  SEEK_SET) != off)
        {
            return -1;
        } 
        if (read (kfd, mem, bytes) != bytes) 
        {
            return -1;
        } 
    }
    
    int main (void)
    {
        unsigned long sct_off;
        unsigned long sct;
        unsigned char *p, code[255];
        int i; 
        /* request IDT and fill struct */ 
    
        asm ("sidt %0":"=m" (idtr));
    
        if ((kfd = open ("/dev/kmem", O_RDONLY)) == -1)
        {
            perror("open");
            exit(-1);
        } 
        if (readkmem ((unsigned char *)&idt, idtr.base + 8 * 0x80, sizeof (idt)) == -1)
        {
            printf("Failed to read from /dev/kmem\n");
            exit(-1);
        } 
        
        sct_off = (idt.off2 << 16) | idt.off1; 
        
        if (readkmem (code, sct_off, 0x100) == -1)
        {
            printf("Failed to read from /dev/kmem\n");
            exit(-1);
        }
    
        /* find the code sequence that calls SCT */ 
    
        sct = 0;
        for (i = 0; i < 255; i++)
        {
            if (code[i] == 0xff && code[i+1] == 0x14 &&  code[i+2] == 0x85) 
            {
                sct = code[i+3] + (code[i+4] << 8) +  (code[i+5] << 16) + (code[i+6] << 24);
            }
        }
        if (sct)
        {
            printf ("sys_call_table: 0x%x\n", sct);
        }
        
        close (kfd);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78

    4、通过函数特征码循环搜索获取sys_call_table地址 (64 bit)

    unsigned long **find_sys_call_table() 
    { 
        unsigned long ptr;
        unsigned long *p;
    
        for (ptr = (unsigned long)sys_close; ptr < (unsigned long)&loops_per_jiffy; ptr += sizeof(void *)) 
        {    
            p = (unsigned long *)ptr;
    
            if (p[__NR_close] == (unsigned long)sys_close) 
        {
                printk(KERN_DEBUG "Found the sys_call_table!!!\n");
                return (unsigned long **)p;
            }
        }
        
        return NULL;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    要特别注意的是代码中进行函数地址搜索的代码:if (p[__NR_close] == (unsigned long)sys_close)

    在64bit Linux下,函数的地址是8字节的,所以要使用unsigned long

    我们可以在linux下执行以下两条指令

    grep sys_close System.map-2.6.32-358.el6.i686
    grep loops_per_jiffy System.map-2.6.32-358.el6.i686
    
    • 1
    • 2

    在这里插入图片描述

    可以看到,系统调用表sys_call_table中的函数地址都落在这个地址区间中,因此我们可以使用loop搜索的方法去获取sys_call_table的基地址

    5、通过kprobe方式动态获取kallsyms_lookup_name,然后利用kallsyms_lookup_name获取sys_call_table的地址

    通过kprobe的函数hook挂钩机制,可以获取内核中任意函数的入口地址,我们可以先获取"kallsyms_lookup_name"函数的入口地址

    //get symbol name by  "kprobe.addr"
    //when register a kprobe on succefully return,the structure of kprobe save the symbol address at "kprobe.addr"
    //just return this value
    static void* aquire_symbol_by_kprobe(char* symbol_name)
    {
        void *symbol_addr=NULL;
        struct kprobe kp;
    
        do
        {
           memset(&kp,0,sizeof(kp));
           kp.symbol_name=symbol_name;
           kp.pre_handler=kprobe_pre;
           if(register_kprobe(&kp)!=0)
           {
                break;
           }
           //this is the address of  "symbol_name"
           symbol_addr=(void*)kp.addr;
    
           //now kprobe is not used any more,so unregister it
           unregister_kprobe(&kp);
    
        }while(false);
    
        return symbol_addr;
    }
    
    //调用之
    tmp_lookup_func = aquire_symbol_by_kprobe("kallsyms_lookup_name");
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    kallsyms_lookup_name()可以用于获取内核导出符号表中的符号地址,而sys_call_table的地址也存在于内核导出符号表中,我么可以使用kallsyms_lookup_name()获取到sys_call_table的基地址

    (void**)kallsyms_lookup_name("sys_call_table");
    
    • 1

    Relevant Link:

    • http://www.rootkitanalytics.com/kernelland/IDT-dev-kmem-method.php
    • http://www.gilgalab.com.br/hacking/programming/linux/2013/01/11/Hooking-Linux-3-syscalls

    1.5.1.3 利用Linux内核机制kprobe机制(kprobes, jprobe和kretprobe)进行系统调用Hook

    kprobe简介

    kprobe是一个动态地收集调试和性能信息的工具,它从Dprobe项目派生而来,它几乎可以跟踪任何函数或被执行的指令以及一些异步事件。它的基本工作机制是:

    • 用户指定一个探测点,并把一个用户定义的处理函数关联到该探测点
    • 在注册探测点的时候,对被探测函数的指令码进行替换,替换为int 3的指令码
    • 在执行int 3的异常执行中,通过通知链的方式调用kprobe的异常处理函数
    • 在kprobe的异常出来函数中,判断是否存在pre_handler钩子,存在则执行
    • 执行完后,准备进入单步调试,通过设置EFLAGS中的TF标志位,并且把异常返回的地址修改为保存的原指令码
    • 代码返回,执行原有指令,执行结束后触发单步异常
    • 在单步异常的处理中,清除单步标志,执行post_handler流程,并最终返回

    从原理上来说,kprobe的这种机制属于系统提供的"回调订阅",和netfilter是类似的,linux内核通过在某些代码执行流程中给出回调函数接口供程序员订阅,内核开发人员可以在这些回调点上注册(订阅)自定义的处理函数,同时还可以获取到相应的状态信息,方便进行过滤、分析

    kprobe实现了三种类型的探测点:

    • kprobes
      • kprobes是可以被插入到内核的任何指令位置的探测点,kprobe允许在同一地址注册多个kprobes,但是不能同时在该地址上有多个jprobes
    • jprobe
      • jprobe则只能被插入到一个内核函数的入口
    • kretprobe(也叫返回探测点)
      • 而kretprobe则是在指定的内核函数返回时才被执行

    在本文中,我们可以使用kprobe的程序实现作一个内核模块,模块的初始化函数来负责安装探测点,退出函数卸载那些被安装的探测点。kprobe提供了接口函数(APIs)来安装或卸载探测点。目前kprobe支持如下架构:i386、x86_64、ppc64、ia64(不支持对slot1指令的探测)、sparc64 (返回探测还没有实现)
    kprobe实现原理

    值得注意的是,这位说的kprobe指的是kprobe机制,它由kprobes, jprobe和kretprobe三种技术共同组成

    1、kprobes

    /*
    kprobes执行流程
    */
    1. 当安装一个kprobes探测点时,kprobe首先备份被探测的指令
    2. 使用断点指令(int 3指令)来取代被探测指令的头一个或几个字节(这点和OD很像)
    3. CPU执行到探测点时,将因运行断点指令而执行trap操作,那将导致保存CPU的寄存器,调用相应的trap处理函数
    4. trap处理函数将调用相应的notifier_call_chain(内核中一种异步工作机制)中注册的所有notifier函数
    5. kprobe正是通过向trap对应的notifier_call_chain注册关联到探测点的处理函数来实现探测处理的
    6. 当kprobe注册的notifier被执行时
        6.1 它首先执行关联到探测点的pre_handler函数,并把相应的kprobe struct和保存的寄存器作为该函数的参数
        6.2 然后,kprobe单步执行被探测指令的备份(原始函数)
        6.3 最后,kprobe执行post_handler
    7. 等所有这些运行完毕后,紧跟在被探测指令后的指令流将被正常执行
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    在使用kprobes技术进行编程的时候,基本代码框架如下

    #include linux/kprobes.h
    ...
    /*
    探测点处理函数pre_handler的原型如下
    用户必须按照该原型参数格式定义自己的pre_handler(函数名可以任意定)
        1) 参数p
        就是指向该处理函数关联到的kprobes探测点的指针,可以在该函数内部引用该结构的任何字段,就如同在使用调用register_kprobe时传递的那个参数
        2) 参数regs
        指向运行到探测点时保存的寄存器内容
    kprobe负责在调用pre_handler时会自动传递这些参数,用户不必关心,只是要知道在该函数内你能访问这些内容
    */
    int pre_handler(struct kprobe *p, struct pt_regs *regs);
    
    /*
    探测点处理函数post_handler的原型如下
        1) 参数p
        与pre_handler相同
        2) 参数regs
        与pre_handler相同
        3) 参数flags
        最后一个参数flags总是0。
    */
    void post_handler(struct kprobe *p, struct pt_regs *regs, unsigned long flags);
    
    /*
    错误处理函数fault_handler的原刑如下
        1) 参数p
        与pre_handler相同
        2) 参数regs
        与pre_handler相同
        3) trapnr
        trapnr是与错误处理相关的架构依赖的trap号(例如,对于i386,通常的保护错误是13,而页失效错误是14)
    如果成功地处理了异常,它应当返回1
    */
    int fault_handler(struct kprobe *p, struct pt_regs *regs, int trapnr);
    
    /*
    值得注意的是: 在注册kprobes之前,程序员必须先设置好struct kprobe的这些字段(包括各个回调函数)
    注册一个kprobes类型的探测点,其函数原型为
    params: struct kprobe类型的指针
    struct kprobe 
    {
        struct hlist_node hlist; 
        /* list of kprobes for multi-handler support */
        struct list_head list; 
        /*count the number of times this probe was temporarily disarmed */
        unsigned long nmissed;  
    
        /* location of the probe point */
        kprobe_opcode_t *addr; 
    
        /* Allow user to indicate symbol name of the probe point(如果在不知道需要监控的系统调用的地址的情况下,可以直接通过内核导出符号连接指定监控点)*/
        const char *symbol_name;
        /* Offset into the symbol */
        unsigned int offset;
    
        /* Called before addr is executed. */
        kprobe_pre_handler_t pre_handler;
    
        /* Called after addr is executed, unless... */
        kprobe_post_handler_t post_handler;
    
        /*
        called if executing addr causes a fault (eg. page fault).
        Return 1 if it handled fault, otherwise kernel will see it.
        */
        kprobe_fault_handler_t fault_handler;
    
        /*
        called if breakpoint trap occurs in probe handler.
        Return 1 if it handled break, otherwise kernel will see it.
        */
        kprobe_break_handler_t break_handler;
    
        /* Saved opcode (which has been replaced with breakpoint) */
        kprobe_opcode_t opcode;
        /* copy of the original instruction */
        struct arch_specific_insn ainsn;
        /*
        Indicates various status flags.
        Protected by kprobe_mutex after this kprobe is registered.
        */
        u32 flags;
    };
    */
    int register_kprobe(struct kprobe *kp);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86

    整个编码顺序为:

    声明pre_handler()->声明post_handler()->声明fault_handler()->设置struct kprobe->调用register_kprobe()进行内核回调机制注册
    
    • 1

    注册了回调函数之后,我们就相当于劫持了指定的内核系统调用函数,则新的系统调用执行流程为:

    pre_handler->被Hook原函数->post_handler
    
    • 1

    2、jprobe

    值得注意的是,jprobe是建立在kprobes的基础上的监控机制,jprobe对kprobes的代码进行了封装,简化了编程的同时,还将接口变得更加"干净",我们在jprobe的回调处理函数中看到的所有参数都和原始内核系统调用的原始函数的参数一模一样,从某种程序上来说,jprobe比kprobes更加"好用"(前提是你仅仅想hook系统调用)

    /*
    jprobe执行流程
    */
    1. jprobe通过注册kprobes在被探测函数入口的来实现,它能无缝地访问被探测函数的参数
    2. jprobe处理函数应当和被探测函数有同样的原型,而且该处理函数在函数末必须调用kprobe提供的函数jprobe_return()
    3. 当执行到该探测点时,kprobe备份CPU寄存器和栈的一些部分,然后修改指令寄存器指向jprobe处理函数
    4. 当执行该jprobe处理函数时,寄存器和栈内容与执行真正的被探测函数一模一样,因此它不需要任何特别的处理就能访问函数参数, 在该处理函数执行到最后时,它调用jprobe_return(),那导致寄存器和栈恢复到执行探测点时的状态,因此被探测函数能被正常运行
    5. 需要注意,被探测函数的参数可能通过栈传递,也可能通过寄存器传递,但是jprobe对于两种情况都能工作,因为它既备份了栈,又备份了寄存器,当然,前提是jprobe处理函数原型必须与被探测函数完全一样
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    在使用jprobe技术进行编程的时候,基本代码框架如下

    #include linux/kprobes.h
    ...
    /*
    ..
    声明entry中指定的探测点的处理回调函数该处理函数的参数表和返回类型应当与被探测函数完全相同(重要)
    声明kp中指定的错误处理函数
    ..
    */
    
    /*
    register_jprobe()函数用于注册jprobes类型的探测点,它的原型如下:
    struct jprobe 
    { 
        /*    
        对于jprobe技术来说,我们在struct kprobe里面设置:    
        1) kp.addr: 指定探测点的位置(即你要hook的点)       
        2) kp.symbol_name: 直接指定探测点的导出名       
        3) kp.fault_handler: 指定监控出错时的处理函数
        */
        struct kprobe kp;
        
        /* 
        probe handling code to jump to 
        entry指定探测点的处理回调函数
            1) 该处理函数的参数表和返回类型应当与被探测函数完全相同
            2) 而且它必须正好在返回前调用jprobe_return()
        */
        kprobe_opcode_t *entry; 
    };
    */
    int register_jprobe(struct jprobe *jp);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    整个编码顺序为:

    声明注册回调函数()->声明出错处理函数()->设置struct jprobe->调用register_jprobe()进行内核回调机制注册
    
    • 1

    注册了回调函数之后,我们就相当于劫持了指定的内核系统调用函数,则新的系统调用执行流程为:

    注册回调劫持函数->jprobe_return()恢复现场->被Hook原函数
    
    • 1

    3、kretprobe

    /*
    kretprobe执行流程
    */
    1. kretprobe也使用了kprobes来实现2
    2. 当用户调用register_kretprobe()时,kprobe在被探测函数的入口建立了一个探测点
    3. 当执行到探测点时,kprobe保存了被探测函数的返回地址并取代返回地址为一个trampoline的地址,kprobe在初始化时定义了该trampoline并且为该trampoline注册了一个kprobe
    4. 当被探测函数执行它的返回指令时,控制传递到该trampoline,因此kprobe已经注册的对应于trampoline的处理函数将被执行,而该处理函数会调用用户关联到该kretprobe上的处理函数
    5. 处理完毕后,设置指令寄存器指向已经备份的函数返回地址,因而原来的函数返回被正常执行。
    6. 被探测函数的返回地址保存在类型为kretprobe_instance的变量中,结构kretprobe的maxactive字段指定了被探测函数可以被同时探测的实例数
    7. 函数register_kretprobe()将预分配指定数量的kretprobe_instance:
        7.1 如果被探测函数是非递归的并且调用时已经保持了自旋锁(spinlock),那么maxactive为1就足够了
        7.2 如果被探测函数是非递归的且运行时是抢占失效的,那么maxactive为NR_CPUS就可以了
        7.3 如果maxactive被设置为小于等于0, 它被设置到缺省值(如果抢占使能, 即配置了 CONFIG_PREEMPT,缺省值为102*NR_CPUS中的最大值,否则缺省值为NR_CPUS)
        7.4 如果maxactive被设置的太小了,一些探测点的执行可能被丢失,但是不影响系统的正常运行,在结构kretprobe中nmissed字段将记录被丢失的探测点执行数,它在返回探测点被注册时设置为0,每次当执行探测函数而没有kretprobe_instance可用时,它就加1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    在使用kretprobe技术进行编程的时候,基本代码框架如下

    #include linux/kprobes.h
    ..
    /*
    kretprobe_handler是kretprobe机制下的回调处理函数,它的原型如下:
    param:
        1) kretprobe_instance ri
        指向类型为struct kretprobe_instance的变量
        struct kretprobe_instance 
        {
            struct hlist_node hlist;
            struct kretprobe *rp;        //指向相应的kretprobe_instance变量(就是我们在register_kretprobe时传入的参数) 
            kprobe_opcode_t *ret_addr;    //返回地址
            struct task_struct *task;    //指向相应的task_struct
            char data[0];
        }; 
        结构struct kretprobe_instance是注册函数register_kretprobe根据用户指定的maxactive值来分配的,kprobe负责在调用kretprobe处理函数时传递相应的kretprobe_instance
        2) 参数regs
        指向保存的寄存器  
    */
    int kretprobe_handler(struct kretprobe_instance *ri, struct pt_regs *regs);
    
    /*
    ..
    声明错误处理函数
    ..
    */
    
    /*
    该函数用于注册类型为kretprobes的探测点,它的原型如下:
    param:
        1) struct kretprobe rp
        struct kretprobe 
        {
            /*
            kretprobe同样是复用了kprobes的机制
            和jprobe一样,一般情况下,我们需要在kp中设置:
                1) kp.addr: 指定探测点的位置(即你要hook的点)
                2) kp.symbol_name: 直接指定探测点的导出名
                3) kp.fault_handler: 指定监控出错时的处理函数
            */
            struct kprobe kp; 
    
            //注册的回调函数,handler指定探测点的处理函数
            kretprobe_handler_t handler;
            //注册的预处理回调函数,类似于kprobes中的pre_handler()
            kretprobe_handler_t entry_handler;  
    
            //maxactive指定可以同时运行的最大处理函数实例数,它应当被恰当设置,否则可能丢失探测点的某些运行
            int maxactive;    
            int nmissed;
            //指示kretprobe需要为回调监控预留多少内存空间
            size_t data_size;
            struct hlist_head free_instances;
            raw_spinlock_t lock;
        }; 
    该注册函数在地址rp->kp.addr注册一个kretprobe类型的探测点,当被探测函数返回时,rp->handler会被调用
    如果成功,它返回0,否则返回负的错误码
    */
    int register_kretprobe(struct kretprobe *rp);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59

    整个编码顺序为:

    声明kretprobe_handler()->声明出错处理函数()->设置struct kretprobe->调用register_kretprobe()进行内核回调机制注册
    
    • 1

    注册了回调函数之后,我们就相当于劫持了指定的内核系统调用函数,则新的系统调用执行流程为:

    被Hook原函数会照常先执行->当原始函数的返回点位置会执行一次我们注册的kretprobe_handler()->恢复现场继续原始的系统调用
    
    • 1

    了解了kprobe的基本原理之后,我们要回到我们本文的主题,系统调用的Hook上来,由于kprobe是linux提供的稳定的回调注册机制,linux天生就稳定地支持在我们指定的某个函数的执行流上进行注册回调,我们很方便地使用它来进行系统调用(例如sys_execv()、网络连接等)的执行Hook,从而劫持linux系统的系统调用流程,为下一步的恶意入侵行为分析作准备

    下面我们分别学习kprobe的3种机制: kprobes、jprobe、kretprobe

    kprobes编程示例

    do_fork.c

    /*
     * * You will see the trace data in /var/log/messages and on the console
     * * whenever do_fork() is invoked to create a new process.
     * */
    
    #include 
    #include 
    #include 
    
    //定义要Hook的函数,本例中do_fork
    static struct kprobe kp = 
    {
        .symbol_name = "do_fork",
    };
    
    static int handler_pre(struct kprobe *p, struct pt_regs *regs)
    {
        struct thread_info *thread = current_thread_info();
    
        printk(KERN_INFO "pre-handler thread info: flags = %x, status = %d, cpu = %d, task->pid = %d\n",
        thread->flags, thread->status, thread->cpu, thread->task->pid);
    
        return 0;
    }
    
    static void handler_post(struct kprobe *p, struct pt_regs *regs, unsigned long flags)
    {  
        struct thread_info *thread = current_thread_info();
    
        printk(KERN_INFO "post-handler thread info: flags = %x, status = %d, cpu = %d, task->pid = %d\n",
        thread->flags, thread->status, thread->cpu, thread->task->pid);
    }
    
    static int handler_fault(struct kprobe *p, struct pt_regs *regs, int trapnr)
    {
        printk(KERN_INFO "fault_handler: p->addr = 0x%p, trap #%dn",
        p->addr, trapnr);
        return 0;
    }
    
    /*
    内核模块加载初始化,这个过程和windows下的内核驱动注册分发例程很类似
    */
    static int __init kprobe_init(void)
    {
        int ret;
        kp.pre_handler = handler_pre;
        kp.post_handler = handler_post;
        kp.fault_handler = handler_fault;
    
        ret = register_kprobe(&kp);
        if (ret < 0) 
        {
            printk(KERN_INFO "register_kprobe failed, returned %d\n", ret);
            return ret;
        }
        printk(KERN_INFO "Planted kprobe at %p\n", kp.addr);
        return 0;
    }
    
    static void __exit kprobe_exit(void)
    {
        unregister_kprobe(&kp);
        printk(KERN_INFO "kprobe at %p unregistered\n", kp.addr);
    }
    
    module_init(kprobe_init)
    module_exit(kprobe_exit)
    MODULE_LICENSE("GPL");
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69

    Makefile

    obj-m := do_fork.o
    
    • 1

    编译:

    make -C /usr/src/kernels/2.6.32-358.el6.i686 M=$(pwd) modules
    
    • 1

    加载内核模块:

    insmod do_fork.ko
    
    • 1

    测试效果:

    • dmesg| tail

    在这里插入图片描述

    • cat /proc/kallsyms | grep do_fork

    在这里插入图片描述

    do_fork的地址与kprobe注册的地址一致,可见,在kprobe调试模块在内核停留期间,我们编写的内核监控模块劫持并记录了系统fork出了新的进程信息

    jprobe编程示例

    **kretprobe编程示例 **

    Relevant Link:

    • http://m.blog.csdn.net/blog/panfengyun12345/19480567
    • http://www.redhat.com/magazine/005mar05/features/kprobes/
    • http://www.ibm.com/developerworks/library/l-kprobes/index.html
    • http://lwn.net/Articles/132196/
    • https://www.kernel.org/doc/Documentation/kprobes.txt

    1.5.1.4 LSM(linux security module) Security钩子技术(linux原生机制)

    Linux安全模块(LSM)是Linux内核的一个轻量级通用访问控制框架。它使得各种不同的安全访问控制模型能够以Linux可加载内核模块的形式实现出来,用户可以根据其需求选择适合的安全模块加载到Linux内核中,从而大大提高了Linux安全访问控制机制的灵活性和易用性

    目前已经有很多著名的增强访问控制系统移植到Linux安全模块(LSM)上实现,包括如下:

    • POSIX.1e capabilities
    • 安全增强Linux(SELinux)
    • 域和类型增强(DTE)
    • Linux入侵检测系统(LIDS)

    Linux安全模块(LSM)有如下特点:

    • 真正的通用,当使用一个不同的安全模型的时候,只需要加载一个不同的内核模块
    • 概念上简单,对Linux内核影响最小,高效,并且能够支持现存的POSIX.1e capabilities逻辑,作为一个可选的安全模块
    • 能够允许他们以可加载内核模块的形式重新实现其安全功能,并且不会在安全性方面带来明显的损失,也不会带来额外的系统开销

    为了满足这些设计目标,Linux安全模块(LSM)采用了通过在内核源代码中放置钩子的方法,来"仲裁"对内核内部对象进行的访问,这些对象如下:

    • 任务
    • inode结点
    • 打开的文件
    • 用户进程执行系统调用
    • API的监控
    • 进程/进程间通讯
    • 网络系统

    在LSM机制,Linux执行系统调用的流程如下:

    • 用户进程执行系统调用
    • 首先遍历Linux内核原有的逻辑找到并分配资源,进行错误检查,并经过经典的UNIX自主访问控制
    • 恰好就在Linux内核试图对内部对象进行访问之前,一个Linux安全模块(LSM)的钩子对安全模块所必须提供的函数进行一个调用(一个Hook的过程)
    • 从而对安全模块提出这样的问题"是否允许访问执行?"
    • 安全模块根据其安全策略进行决策,作出回答:允许,或者拒绝进而返回一个错误
      • 值得注意的是:Linux安全模块(LSM)目前作为一个Linux内核补丁的形式实现。其本身不提供任何具体的安全策略,而是提供了一个通用的基础体系给安全模块,由安全模块来实现具体的安全策略(即安全控制的决策算法由程序员自己来指定)
    • 通过LSM决策流程之后,原始的系统调用程序流将继续执行

    LSM主要在五个方面对Linux内核进行了修改

    1. 在特定的内核数据结构中加入了安全域
    安全域是一个void*类型的指针,它使得安全模块把安全信息和内核内部对象联系起来。下面列出被修改加入了安全域的内核数据结构,以及各自所代表的内核内部对象:
        1) task_struct结构: 任务(进程)
        2) linux_binprm结构: 程序
        3) super_block结构: 文件系统
        4) inode结构: 管道、文件、Socket套接字
        5) file结构:打开的文件
        6) sk_buff结构: 网络缓冲区()
        7) net_device结构: 网络设备
        8) kern_ipc_perm结构: Semaphore信号、共享内存段、消息队列
        9) msg_msg: 单个的消息
    
    2. 在内核源代码中不同的关键点插入了对安全钩子函数的调用
    Linux安全模块(LSM)提供了两类对安全钩子函数的调用
        1) 管理内核对象的安全域
        2) 仲裁对这些内核对象的访问
    对安全钩子函数的调用通过钩子来实现,钩子是全局表security_ops中的函数指针,这个全局表的类型是security_operations结构
    \linux-2.6.32.63\include\linux\security.h
    关于struct security_operations的相关知识,请参阅另一篇文章
    http://i.cnblogs.com/EditPosts.aspx?postid=3865490
    (搜索0x1: struct security_operations)
    
    3. 加入了一个通用的安全系统调用
    Linux安全模块(LSM)提供了一个通用的安全系统调用,允许安全模块为安全相关的应用编写新的系统调用,其风格类似于原有的Linux系统调用socketcall(),是一个多路的系统调用
    这个系统调用为security(),其参数为(unsigned int id, unsigned int call, unsigned long *args)
        1) id
        代表模块描述符
        2) call
        代表调用描述符
        3) args
        代表参数列表
    这个系统调用缺省的提供了一个sys_security()入口函数:其简单的以参数调用sys_security()钩子函数。如果安全模块不提供新的系统调用,就可以定义返回-ENOSYS的sys_security()钩子函数,但是大多数安全模块都可以自己定义这个系统调用的实现
    
    4. 提供了函数允许内核模块注册为安全模块或者注销
    在内核引导的过程中,Linux安全模块(LSM)框架被初始化为一系列的虚拟钩子函数,以实现传统的UNIX超级用户机制
        1) register_security()
        当加载一个安全模块时,必须使用register_security()函数向Linux安全模块(LSM)框架注册这个安全模块
            1.1) 这个函数将设置全局表security_ops,使其指向这个安全模块的钩子函数指针
            1.2) 从而使内核向这个安全模块询问访问控制决策 
        
        2) unregister_security()
        一旦一个安全模块被加载,就成为系统的安全策略决策中心,而不会被后面的register_security()函数覆盖,直到这个安全模块被使用unregister_security()函数向框架注销:
            2.1) 这简单的将钩子函数替换为缺省值
            2.2) 系统回到UNIX超级用户机制  
    
    5. 将capabilities逻辑的大部分移植为一个可选的安全模块
    Linux内核现在对POSIX.1e capabilities的一个子集提供支持。Linux安全模块(LSM)设计的一个需求就是把这个功能移植为一个可选的安全模块。POSIX.1e capabilities提供了划分传统超级用户特权并赋给特定的进程的功能
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47

    code

    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int test_file_permission(struct file *file, int mask)
    {
        char *name = file->f_path.dentry->d_name.name;
        if(!strcmp(name, "test.txt"))
        {
            file->f_flags |= O_RDONLY;
            printk("you can have your control code here!\n");
        }
        return 0;
    } 
    
    /*
    一般的做法是:定义你自己的struct security_operations,实现你自己的hook函数,具体有哪些hook函数可以查询
    include/linux/security.h文件 
    */
    static struct security_operations test_security_ops = 
    {
            .name =                 "test", 
            .file_permission =      test_file_permission,
    };
    
    static __init int test_init(void)
    {
            printk("enter test init!\n"); 
            printk(KERN_INFO "Test: becoming......\n")
    
        //调用register_security来用你的test_security_ops初始化全局的security_ops指针
            if (register_security(&test_security_ops))
        {
            panic("Test: kernel registration failed.\n");
        }  
            return 0;
    } 
    
    security_initcall(test_init);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    将该文件以模块的形式放到security/下编译进内核,启用新的内核后,当你操作文件test.txt时,通过dmesg命令就能再终端看到"you can have your control code here!"

    Relevant Link:

    • http://blog.aliyun.com/948
    • http://www.cnblogs.com/cslunatic/p/3709356.html
    • http://www.ibm.com/developerworks/cn/linux/l-lsm/part1
    • http://blog.sina.com.cn/s/blog_858820890101eb3c.html
    • http://www.ubuntukylin.com/ukylin/forum.php?mod=viewthread&tid=3048

    1.5.2 LSM Function Replace Hook劫持技术

    LSM模块在所有验证函数中都调用了security_ops的函数指针,如sys_mmap函数:

    ..
    error = security_file_mmap(file, reqprot, prot, flags);
    ...
    static inline int security_file_mmap (struct file *file, unsigned long reqprot, unsigned long prot, unsigned long flags)
    {
        return security_ops->file_mmap (file, reqprot, prot, flags);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这样,security_ops被定义为一个全局变量的话, rootkit很容易就可以将security_ops变量导出,然后替换为自己的fake函数,LSM框架很容易就被摧毁掉

    code

    #include
    #include
    #include
    #include
    #include
    #include
    #include
    
    MODULE_LICENSE("GPL");
    MODULE_AUTHOR("wzt");
    
    extern struct security_operations *security_ops;
    struct security_operations *fake_security_ops;
    
    int fake_file_mmap(struct file *file, unsigned long reqprot, unsigned long prot, unsigned long flags)
    {
        printk("in fake_file_mmap.\n"); 
        return 0;
    }
    
    static int rootkit_init(void)
    {
        printk("loading LSM rootkit demo module.\n");
        fake_security_ops = security_ops;
        printk("orig file_mmap address: 0xx, 0xx\n", (unsigned int)fake_security_ops->file_mmap, (unsigned int)security_ops->file_mmap);
    
        fake_security_ops->file_mmap = fake_file_mmap;
        security_ops = fake_security_ops;
        printk("new file_mmap address: 0xx, 0xx\n", (unsigned int)fake_security_ops->file_mmap, (unsigned int)security_ops->file_mmap);
            
        security_ops->file_mmap(NULL, 0, 0, 0);
    
        return 0;
    }
    
    static void rootkit_exit(void)
    {
        printk("unload LSM rootkit demo module.\n"); 
    }
    
    module_init(rootkit_init);
    module_exit(rootkit_exit);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    Relevant Link:

    • http://blog.sina.com.cn/s/blog_858820890101eb3c.html

    1.5.2.1 int 80中断劫持技术

    传统的hook劫持方法通过替换sys_call_table[]数组中的函数地址,来截获系统调用,但是如果要监控所有的API, 那么需要重新编写所有API的替代函数(需要为每一个Hook单独编写一个hooded_handler函数),而linux kernel 2.6.18中大概有300多个系统调用函数

    为了解决这个问题,我们可以通过这样一种思维模式模式去思考

    • 如果你需要劫持的控制流是"多路"的,除了分别对"每一路"进行hook之外,还可以将hook点"上移"
    • 即找到所有系统调用的总的调度的入口点,在一个控制流相对较集中的节点位置部署hook逻辑
    • 这也是一种底层统一防御的思想(在cms的漏洞防御中也可以得到应用)

    关于int 80中断劫持的相关知识,请参阅另一篇文章

    • http://www.cnblogs.com/LittleHann/p/3879961.html
      • (搜索: void set_idt_handler(void *system_call))

    1.5.2.2 利用从PAGE_OFFSET起始位置搜索特征码劫持system_call_sys_call_table进行系统调用hook

    和通过int 0x80中断获取sys_call_table的方法类似,这种技术的区别是获取sys_call_table的方式不同,而针对sys_call_table进行replace hook才是关键点

    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    MODULE_AUTHOR("test");
    MODULE_DESCRIPTION("test");
    MODULE_LICENSE("GPL");
    
    typedef asmlinkage int (*mkdir_t)(const char* name);
    typedef asmlinkage int (*open_t)(const char *filename, int flags, int mode);
    void** sys_call_table = NULL;
    asmlinkage open_t old_open_func=NULL;
    asmlinkage mkdir_t  old_mkdir_func=NULL;
    
    static int wpoff_cr0(void)
    {
        unsigned int cr0 = 0;
        unsigned int ret;
    
        asm volatile ("movl %%cr0, %%eax":"=a"(cr0)); //汇编代码,用于取出CR0寄存器的值
        ret = cr0;
        cr0 &= 0xfffeffff;                            
        asm volatile ("movl %%eax, %%cr0": :"a"(cr0));//汇编代码,将修改后的CR0值写入CR0寄存器
        return ret;               
    }
    
    /*改回原CR0寄存器的值*/
    static void set_cr0(int val)
    {
        asm volatile ("movl %%eax, %%cr0": :"a"(val)); 
        return;
    }
    
    asmlinkage int fake_sys_mkdir(const char *name)
    {
        printk("sys_mkdir(%s)\n",name);
        
        if(old_mkdir_func)
        {
            if(strstr(name,"test_zr")) 
            {
                return -1;
            }
            else
            {
                return old_mkdir_func(name);
            }
        }
        return -1;
    }
    
    asmlinkage long fake_sys_open(const char *filename, int flags, int mode)
    {
        printk("sys_open(%s)\n",filename);
        if(old_open_func)
        {
            if(strstr(filename,"test_zr")) 
            {
                return -1;
            }
            else
            {
                return old_open_func(filename,flags,mode);
            }
        }
        return -1;
    }
    
    static void* aquire_sys_call_table(void* start_addr)
    {
        unsigned long int offset = 0;
    
        unsigned long int end = VMALLOC_START < ULLONG_MAX ? VMALLOC_START : ULLONG_MAX;
    
        void *table_addr=NULL;
    
        void** tmp_table=NULL;
           
        *(void**)&offset = start_addr;
    
        while (offset < end) 
            {
    
            tmp_table=(void**)offset;
    
            if (tmp_table[__NR_close] == (void*)sys_close) 
            {
                    table_addr=(void*)tmp_table;
    
                    break;
            }
    
            offset += sizeof(void *);
        }
        
        return table_addr;
    }
     
    
    static int patch_init(void)
    {
        int ret=0;
        //sys_call_table=(void**)kallsyms_lookup_name("sys_call_table");
        //get_sysentry_addr();
        sys_call_table=(void**)aquire_sys_call_table((void*)PAGE_OFFSET);
        printk("sys_call_table addr:%p\n",sys_call_table);
        if(sys_call_table)
        {
            int cr0 = 0;
            old_open_func=(open_t)sys_call_table[__NR_open];
            
            old_mkdir_func=(mkdir_t)sys_call_table[__NR_mkdir];
    
            if(!old_open_func || ((int)old_open_func % sizeof(void*)))
            {
                printk("!sys_open\n");
                ret=-1;
            }else
            {
                cr0=wpoff_cr0();
                sys_call_table[__NR_open]=(open_t)fake_sys_open;
                sys_call_table[__NR_mkdir]=(mkdir_t)fake_sys_mkdir;
                set_cr0(cr0);
    
                printk(KERN_ALERT "sys_open is patched!\n");
            }
        }else
        {
            printk("no sys call table found\n");
            ret=-1;
        }
        return ret;
    }
    static void patch_cleanup(void)
    {
        if(sys_call_table[__NR_open]==fake_sys_open)
        {
            int cr0 = 0;
            cr0=wpoff_cr0();
            sys_call_table[__NR_open]=old_open_func;
            set_cr0(cr0);
    
            printk(KERN_ALERT "sys_open is unpatched!\n");
        }
    
        if(sys_call_table[__NR_mkdir]==fake_sys_mkdir)
        {
            int cr0 = 0;
            cr0=wpoff_cr0();
            sys_call_table[__NR_mkdir]=old_mkdir_func;
            set_cr0(cr0);
    
            printk(KERN_ALERT "sys_mkdir is unpatched!\n");
        }
    }
    module_init(patch_init);
    module_exit(patch_cleanup);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163

    使用sys_call_table replace hook的技术方案,需要特别注意的技术点是

    1. 要保证replace hook的动作的原子性,即要避免sys_call_table被替换了,但是对应的hook_function没有装载到位,这个时候用户态发起的系统调用就会掉入一个无效内存地址
    //linux的LKM模块加载机制会保证这一点,在执行init_module函数之前,linux lkm loader已经将lkm中用到的函数加载到了内核内存中了
    
    2. 在执行rmmod模块的时候,需要将之前被replace hook的sys_call_table的函数指针替换回来,保证系统替换前后的状态一致性
    
    3. sys_call_table在恢复hook的时候需要使用"引用计数",因为这个时候有可能有其他的进程是通过被我们劫持后的fake_function流程进入内核原始系统调用的,这些系统调用例如sys_socketcall的select动作,是一个阻塞型的系统调用,用户态会一直阻塞等待这次系统调用的返回,如果我们不等到引用计数降到0(即没人在使用)之后,而是采取直接卸载模块,会导致那些系统调用返回后,回到一个被释放掉的内核内存区域中
    //使用"引用计数"会带来另一个问题,系统调用中有一些例如socket select这种阻塞性的系统调用,从用户态发起系统调用到最后从内核态返回会经历一个很长的时间,此时模块的引用计数会一直处于大于零的状态,而无法卸载
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    在这里插入图片描述

    为了解决这个问题,我们的内核模块需要能够实现以下目标

    • 模块的sys_call_table hook能够针对单个function hook point做细粒度的开关
    • sys_call_table hook的replace、restore动作要能够"原子实现",保证操作系统的系统调用流能无缝的进行切换
    • 解决rmmod模块卸载过程中的阻塞型系统调用未返回问题,使用push、ret方式构造特殊的栈空间(下面画图详细说明)

    在这里插入图片描述

    1.5.2.3 Linux LSM(Linux Security Modules) Hook技术

    关于LSM Hook技术,请参阅另一篇文章

    Relevant Link:

    • http://www.gilgalab.com.br/hacking/programming/linux/2013/01/11/Hooking-Linux-3-syscalls/
    • http://blog.csdn.net/echoisland/article/details/6782711
    • http://gadgetweb.de/linux/40-how-to-hijacking-the-syscall-table-on
    • http://stackoverflow.com/questions/2103315/linux-kernel-system-call-hooking-example

    1.5.2.4 VDSO劫持

    1、VDSO技术原理

    VDSO(Virtual Dynamically-linked Shared Object)是一个用于提升系统性能的机制,它将内核态的调用映射到用户态的地址空间中,使得调用开销更小

    为什么会有这项技术呢?这个要从操作系统提供的系统快速调用说起

    拿x86下的系统调用举例, 传统的系统调用由”int 0x80中断“触发,CPU的上下文切换比较慢。为此,Intel和AMD分别实现了sysenter,sysexit和syscall,sysret,即所谓的快速系统调用指令, 使用它们更快,但是同时也带来了兼容性的问题

    于是Linux实现了vsyscall,程序统一调用vsyscall,具体的底层选择哪个系统调用由内核来决定。而vsyscall的实现就在VDSO中

    Linux(kernel 2.6 or upper)环境下执行ldd /bin/sh,会发现有个名字叫linux-vdso.so.1(老点的版本是linux-gate.so.1)的动态文件,而系统中却找不到它,它就是VDSO。例如:

    在这里插入图片描述

    VDSO本质上是一段内核空间的代码,用来提供给用户态下更快地调用系统调用。攻击者可以通过内核驱动对这块内存空间进行地质修改,以此达到apihook的目的。本质上和syscall table hook是一样的

    内核态下VDSO页的权限是RW,这个代码页会被直接映射到用户态下的进程中,以满足gettime等频繁系统调用的速度。这个代码页映射到用户态的权限属性是RX,就是可以执行。所以,我们可以通过在内核态下通过任意写来修改VDSO的代码,譬如我们修改代码成为prepare_cred+commited_cred等,这样在用户调用VDSO时,就可以劫持控制流了

    1.5.3 总结

    Hook技术是进行主动防御、动态入侵检测的关键技术,从技术上来说,目前的很多Hook技术都属于"猥琐流",即:

    • 通过"劫持"在关键流程上的某些函数的执行地址,在Hook函数执行完之后,再跳回原始的函数继续执行(做好现场保护)
    • 或者通过dll、进程、线程注入比原始程序提前获得CPU执行权限

    但是随着windows的PatchGuard的出现,这些出于"安全性"的内核patct将被一视同仁地看作内核完整性的威胁者

    更加优美、稳定的方法,包括如下:

    • 注册标准的回调方法,包括:
      • 进程
      • 线程
      • 模块的创建
      • 卸载回调函数
      • 文件/网络等各种过滤驱动
    • 内核提供的标准处理流程Hook点
      • kprobe机制
    • 网络协议栈提供的标准Hook点
      • netfilter的链式处理流程

    0x02 车机(Android)设备中监控命令执行的一些想法

    系统命令执行的监控,也就是对外部进程创建的监控。在linux中,启动外部进程,是通过execve系统调用进行创建的,我们使用strace打印一下在bash中启动ls的系统调用,第一句就是通过execve启动ls

    但是我们在开发linux程序的时候,执行系统命令,并没有直接使用execve系统调用,这是因为libc/glibc库对execve系统调用封装成了函数,方便我们调用

    因此基于execve的系统命令监控方式,分成了用户态和内核态。用户态通过劫持libc/glibc的exec相关函数来实现,内核态则通过系统自身组件或者劫持execve syscall 来实现

    Linux进程监控,通常使用hook技术,而hook大概分为两类:

    • 应用级
    • 内核级

    常见的获取进程创建的信息的方式有以下四种:

    • So preload(用户态)
    • Netlink Connector(内核态)
    • Audit(内核态)
    • Syscall hook(内核态)

    实现内核系统调用的方式:

    • 通过修改内核源代码添加系统调用
    • 利用内核模块添加系统调用

    用户态:

    • libc/glibc中,对execve syscall进行了一系列的封装,简称exec族函数。exec系列函数调用时,启动新进程,替换掉当前进程。即程序不会再返回到原进程,具体内容如下:
    int execl(constchar*path,constchar*arg0,...,(char*)0);
    int execlp(constchar*file,constchar*arg0,...,(char*)0);
    int execle(constchar*path,constchar*arg0,...,(char*)0,char*const envp[]);
    
    int execv(cosnt char*path,char*const argv[]);
    int execvp(cosnt char*file,char*const argv[]);
    int execve(cosnt char*path,char*const argv[],char*const envp[]);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    劫持libc/glibc中的函数,就是我们前言内容和本文后面说的so preload劫持

    内核态:

    • 在内核态监控其实是最准确,而且是最难绕过的。在内核态,一般是通过三种办法来监控:
      • Netlink Connector
      • Audit
      • Hook execve syscall

    进程事件数据采集方案:

    • Hook 内核模块:拦截系统调用符号表 sys_call_table地址,hook fork、exec、connect等系统调用地址更改成内核模块中自定义函数地址
      • 优点:能抓取全量进程事件,不易被绕过
      • 缺点:方案过重,侵入内核,主机风险较高,需要兼容多个版本,稳定性低
    • Linux 动态库 preload 机制:利用 Linux 动态库 preload 机制,拦截 libc 同名 fork、exec 族函数,优先加载 hook 动态库自实现同名函数
      • 优点:方案轻量,实现简单
      • 缺点:侵入到主机所有进程,与业务耦合,稳定性低,并且对静态链接程序失效,会遗漏进程事件
    • Linux 连接器 (netlink connector) :由 Linux 内核提供的接口,安全可靠,结合用户态轻量级 ncp 自实现应用程序,抓取全量进程事件,能够覆盖更多安全检测场景,并对主机影响面较小
    • 优点:能抓取全量进程事件,且能够覆盖更多安全检测场景和对主机影响面较小,因为linux内核提供连接器模块与进程事件收集机制,无需任何改动,而在用户态实现轻量级ncp(netlink connector process)应用程序接收netlink进程事件消息
    • 缺点:无法获取ptrace的,是附加,解绑,还是获取/设置寄存器,读/写内存
    • https://www.freebuf.com/company-information/285300.html

    先说说传统LInux主机检测命令执行的一些方式,包括如下:

    • Bash历史指令的history syslog功能

    • w命令

    • top命令

      • 实时提供流程活动的视图,用top命令每秒钟从/proc读取所有进程的状态。其实,就是从/proc/[pid]/stat/proc/[pid]/status,这个两个proc的接口进行读取各个进程的信息
    • find命令

      • find /proc -path '/proc/sys/fs' -prune -o -print |xargs ls -al | grep 'exe ->' | sort -u
      • sys/fs用于描述系统中的所有文件系统,排除/proc/sys/fs搜索所有exe表示具体的执行路径
    • lsof 命令

      • 为什么选用lsof命令?因为netstat无权限控制,lsof有权限控制,只能看到本用户。而losf能看到PID和用户,可以找到哪个进程占用了这个端口
      • lsof -nPi | grep -v "127.0.0.1" |grep -v "::1" |sort -u'
    • Sydig是Linux开源,跨平台,功能强大且灵活的系统监控

    • 系统日志

    • Web日志

    • 沙盒(Androdeb + 自编内核 + 内核移植 + 高本版内核

      • 通过沙盒等方式在Android中运行一个常见的 Linux 发行版,比如 Ubuntu 或者 Debian。androdeb 就是提供了一个沙盒方式在Android 中运行其它系统,其核心是基于 chroot 在 Android 中运行了一个 Debian aarch64镜像,并可以通过 apt 等包管理工具安装所需要的编译工具链,从而在上面编译和运行 bcc 等 Linux 项目。其中的关键之处在于正确挂载原生 Android 中的映射,比如 procfs、devfs、debugfs
      • 需要一个支持动态调试的内核环境。在绝大多数官方固件中自带的内核都没有开启 KPROBES的支持,这意味着需要自行编译和加载内核,详细内容移步:https://evilpan.com/2022/01/03/kernel-tracing/#android-%E7%A7%BB%E6%A4%8D
      • 当你成功编译好内核并启动后,很可能会发现有一些内核分析工具比如 BCC在使用上会出现各种问题,这通常是内核版本的原因。由于 eBPF目前在内核中也在频繁更新,因此许多新的特性并没有增加到当前内核上。因此,为了减少可能遇到的兼容性问题,尽量使用最新版本的内核,当然通常厂商都只维护一个较旧的LTS 版本,只进行必要的安全性更新,如果买机不淑的话就需要自食其力了
      • 基于内核级别的监控,让应用或系统中所有的进程隐藏、命令执行等攻击手段无所遁形,且可在应用启动的初期进行观察,让应用或系统中的一切行为在我们眼中无所遁形
      • 在 Android 上运行 Debian 系统的示例如下:
        在这里插入图片描述
    • strace命令

      • strace 是 Linux 中一个知名的用户态系统调用跟踪工具,可以输入目标进程所执行的系统调用的名称以及参数,常用于快速的应用调试和诊断
      • 对于需要监控系统调用的场景,strace 是个非常合适的工具,因为它基于PTRACE_SYSCALL 去跟踪并基于中断的方式去接管所有系统调用,因此即便目标使用了不依赖 libc 的内联 svc 也可以被识别到。不过这个缺点也很明显,从名称也看出来,本质上该程序是基于ptrace 对目标进行跟踪,因此如果对方代码中有反调试措施,那么就很有可能被检测到
      • 另外,在 Android 系统中,APP 进程都是由zygote fork 而出,因此使用 strace比较不容易确定跟踪时机,而且由于许多应用有多个进程,就需要对输出结果进行额外的过滤和清洗
      • 更多关于strace的实现原理可以参考: How does strace work?
    • jtrace

      • 在早期 strace程序还不支持 arm64,因此 Jonathan Levin 在编写Android Internal 一书时就写了jtrace 这个工具,旨在用于对 Android 应用的跟踪。虽然现在 Google 也在 AOSP 中支持了 strace,但 jtrace 仍然有其独特的优点:
        • 支持系统属性的访问监控 (setprop/getprop)
        • 支持输入事件的监控 (InputReader)
        • 支持 Binder 信息的解析
        • 支持 AIDL 的解析
        • ……
      • jtrace 是闭源的,但提供了独特的插件功能,用户可以根据其提供的接口去编写一个插件(动态库),并使用--plugin 参数或者JTRACE_EXT_PATH 环境变量指定的路径加载插件,从而实现自定义的系统调用参数解析处理
      • jtrace优点比 strace 多了不少,但其缺点并没有解决,jtrace 本身依然是基于PTRACE_SYSCALL 进行系统调用跟踪的,因此还是很容易被应用的反调试检测到
      • 更多关于jtrace 的内容: jtrace - 具有插件架构的增强型、Linux/Android 感知 strace
    • Frida

      • Frida 是目前全球最为知名的动态跟踪工具集 (Instrumentation),支持使用 JS 脚本来对目标应用程序进行动态跟踪。其功能之丰富毋庸置疑,但也有一些硬伤,比如:
        • frida-gum基于 inline-hook对目标跟踪代码进行实时重编译 (JIT),对于应用本身有较大的侵入性
        • frida-inject需要依赖 ptrace对目标应用进行第一次注入并加载 agent,有一个较短的注入窗口可能会被反调试应用检测到
        • frida目前尚不支持系统调用事件级别的追踪,虽然 frida-stalker可以做到汇编级别,但是开销过大
        • frida太过知名,以至于有很多针对 frida 的特征检测
        • ……
        • 类似的 Instrumentation工具还有 QDBIhookzz 等等。
    • ebpf

      • Android 从9.0版本开始全面支持eBPF(extended Berkeley Packet Filters),其主要用在流量统计上,也可以用来监控CPU/IO/内存等模块的状态。简单来说,eBPF可以与内核的kprobe/tracepoints/skfilter等模块相结合,将eBPF的函数hook到内核事件从而监控相应的系统状态
      • 改内核?水平不够;自编译ROM?太复杂,而且耗时,并高定制化;使用沙盒APP或利用容器系统挂载到Android设备里进行检测?沙盒APP难以贴近真机环境,容器系统会产生新的容器安全风险,增加攻击面。而eBPF可以以不侵入的方式去记录程序的一举一动,对于APP或攻击者执行系统命令来说是完全无感的
      • 使用 eBPF 来跟踪 fork() 系统调用,我们编写 eBPF 程序有多种方式,比如使用原生 eBPF 汇编来编写,但使用原生 eBPF 汇编编写程序的难度较大;也可以使用 eBPF 受限的 C 语言来编写,难度比使用原生 eBPF 汇编简单些;最简单的方法是使用 BCC 工具来编写,BCC 工具帮我们简化了很多繁琐的工作,比如不用编写加载器
      • 由于 eBPF对内核的版本有较高的要求,不同版本的内核对 eBPF 的支持可能有所不相同。所以使用 eBPF 时,最好使用最新版本的内核
      • 需要注意,Android系统自带有追踪系统调用的功能,正是基于eBPF提供的
      • 用户态:
        • 用户编写 eBPF 程序,可以使用 eBPF 汇编或者 eBPF 特有的 C 语言来编写
        • 使用LLVM/CLang 编译器,将 eBPF 程序编译成 eBPF 字节码
        • 调用bpf() 系统调用把 eBPF 字节码加载到内核
      • 内核态:
        • 当用户调用bpf() 系统调用把 eBPF 字节码加载到内核时,内核先会对 eBPF 字节码进行安全验证
        • 使用JIT(Just In Time)技术将 eBPF 字节编译成本地机器码(Native Code
        • 然后根据 eBPF 程序的功能,将 eBPF 机器码挂载到内核的不同运行路径上(如用于跟踪内核运行状态的 eBPF 程序将会挂载在 kprobes 的运行路径上)。当内核运行到这些路径时,就会触发执行相应路径上的 eBPF 机器码
      • eBPF的hook点,作用在syscall的kprobe、tracepoint事件类型,倘若用在后门rootkit场景,是十分可怕的。比如,修改内核态返回给用户态的数据,拦截阻断用户态行为等为所欲为。而更可怕的是,常见的HIDS都是基于内核态或者用户态做行为监控,就绕过了大部分HIDS的监控,且不产生任何日志。然而,比如之前很火的log4j漏洞还可怕的是rootkit这种本身并没有产生用户态行为日志,也没有改文件,系统里查不到这个用户信息。整个后门行为不产生数据,让大部分HIDS失灵
    • 其它工具:比如ltrace、gdb 等但这些工具都不能完美实现监控系统调用的需求

    • 常规的主机安全防御产品一般用netlink linux kernel module等技术实现进程创建、网络通讯等行为感知,而eBPF的hook点可以比这些技术更加深,比他们执行更早,意味着常规HIDS并不能感知发现他们

    • 传统rootkit,采用Hook API方法,替换原来函数,导致执行函数调用地址发生变化,已有成熟检测机制,eBPF hook不同于传统rootkit ,函数调用堆栈不变,更多详情请移步:https://www.anquanke.com/post/id/269887#h3-14
      /proc/[pid]/cmdline,获取进程启动的是启动命令, 可以通过获取/proc/[pid]/cmdline的内容来获得

    多啰嗦一句,如果是基于 proc_events (基于 netlink,自动创建进程事件netlink socket并监听,并返回一个告警事件,比如开源项目:https://github.com/dbrandt/proc_events)采集入侵检测的事件,是需要异步的对处理内核传递的所有消息,会比较损耗服务器性能,且会有串号问题,这里就有人会使用 ld.so.preload机制注入 so,来让在每个进程启动时选择性上报异常告警,且无串号问题,以及可以采集全部的进程信息,需要注意注入的 so 和配置文件要设置为只能root修改以及增加so完整性校验,

    上面有针对内核的或用户态、日志、系统内置命令等来实现的检测,但是否兼容性高?、是否高定制?、是否难以维护?、是否会遍历/proc路径会影响设备性能开销?在传统安全里面,最简单最不吃开销的依然是读取bash历史指令的history syslog功能回传日志到安全运营平台进行数据分析,但存在绕过的风险,如果攻击者无日志执行系统命令呢?这里延伸出一个新的话题,就是我们即想要发生命令执行时我们能检测到,但又不想牺牲服务器性能,真有种想要马儿跑,就是不给马儿吃草的感觉,哈哈。为了解决这个问题,一般情况下会考虑在内核方向上来做命令执行的入侵检测,那么只是为了实现系统调用监控,以及部分系统调用参数的修改(例如 IO 重定向),许多开发人员都会想到修改内核源码然后自行编译再导入Android设备中,但这样做存在几个问题,第一个问题:重新编译内核以及对应的 AOSP 代码,编译效率低且复杂,容易失败,并且不一定兼容所有Android设备;第二个问题:在不同的系统调用相关函数中增加代码,引入过多修改后可能会导致后期更新内核失败的现象出现。还有一种情况(考虑在内核方向上来做命令执行的入侵检测),是通过在内核代码中引入一次性的 trampoline,然后在后续增加或者减少系统调用监控入口时通过内核模块的方式去进行修改,但现在市面上关于内核已经有了许多类似的监控方案,纯属重复造轮子,效率低不说,还可能随时引入kernel panic

    • 传统Linux 中的hook 系统调用,是否适合Android?

    : Linux 发行版上的 trace 方法在 Android 上行得通吗?理论上 AOSP 的代码是开源的,内核也是开源的,编译一下内核再导入设备即,但真正做起来会遇到一些困难,如下:

    
    1、许多工具需要编译代码,BCC 工具还需要 Python 运行,这在默认的 Android 环境中不存在
    2、原厂提供的预编译内核镜像不带有 kprobe 等监控功能支持,需要自行修改配置,烧写和编译内核
    3、Linux 旧版本对于 eBPF 的支持不完善,许多新功能都是在 5.x 后才引进,而 Android 的 Linux 内核都比较旧,需要进行 cherry-pick 甚至手动 backport
    4、AOSP 较新版本引入了 GKI(Generic Kernel Image),需要保持内核驱动接口的兼容性,因此内核代码不能引入过多修改
        GKI地址:https://source.android.com/devices/architecture/kernel/generic-kernel-image
    ……
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 那么,内核中都有哪些监控方案?

    :kprobe、jprobe、uprobe、eBPF、tracefs、systemtab、perf

    内核中监控方案的详情:Linux tracing systems & how they fit together

    如下内容来自:https://evilpan.com/2022/01/03/kernel-tracing/#%E7%8E%B0%E6%9C%89%E6%96%B9%E6%A1%88

    内核监控方案/工具可大概分为三类:

    • 数据: 根据监控数据的来源划分
      • Kprobes
      • Uprobes
      • Tracepoints
      • Userland Statically Defined Tracing,也称为USDT
    • 采集: 根据内核提供给用户态的原始事件回调接口进行划分
    • 前端: 获取和解析监控事件数据的用户工具
      • 采集 &&前端包含的工具:
      • ftrace
      • perf
      • eBPF
        • BCC
        • bpftrace
      • SystemTap
      • LTTng
      • trace-cmd
      • kernelshark

    在这里插入图片描述

    内核监控方案静态动态内核用户
    Kprobes
    Uprobes
    Tracepoints
    USDT

    2.1 kprobe

    简单来说,kprobe 可以实现动态内核的注入,基于中断的方法在任意指令中插入追踪代码,并且通过 pre_handler/post_handler/fault_handler去接收回调

    参考 Linux 源码中的 samples/kprobes/kprobe_example.c,一个简单的 kprobe 内核模块实现如下:

    #include 
    #include 
    #include 
    
    #define MAX_SYMBOL_LEN  64
    static char symbol[MAX_SYMBOL_LEN] = "_do_fork";
    module_param_string(symbol, symbol, sizeof(symbol), 0644);
    
    /* For each probe you need to allocate a kprobe structure */
    static struct kprobe kp = {
        .symbol_name    = symbol,
    };
    /* kprobe pre_handler: called just before the probed instruction is executed */
    static int handler_pre(struct kprobe *p, struct pt_regs *regs)
    {
        pr_info("<%s> pre_handler: p->addr = 0x%p, pc = 0x%lx\n", p->symbol_name, p->addr, (long)regs->pc);
        /* A dump_stack() here will give a stack backtrace */
        return 0;
    }
    /* kprobe post_handler: called after the probed instruction is executed */
    static void handler_post(struct kprobe *p, struct pt_regs *regs, unsigned long flags)
    {
        pr_info("<%s> post_handler: p->addr = 0x%p\n", p->symbol_name, p->addr);
    }
    /*
     * fault_handler: this is called if an exception is generated for any
     * instruction within the pre- or post-handler, or when Kprobes
     * single-steps the probed instruction.
     */
    static int handler_fault(struct kprobe *p, struct pt_regs *regs, int trapnr)
    {
        pr_info("fault_handler: p->addr = 0x%p, trap #%dn", p->addr, trapnr);
        /* Return 0 because we don't handle the fault. */
        return 0;
    }
    
    static int __init kprobe_init(void)
    {
        int ret;
        kp.pre_handler = handler_pre;
        kp.post_handler = handler_post;
        kp.fault_handler = handler_fault;
    
        ret = register_kprobe(&kp);
        if (ret < 0) {
            pr_err("register_kprobe failed, returned %d\n", ret);
            return ret;
        }
        pr_info("Planted kprobe at %p\n", kp.addr);
        return 0;
    }
    
    static void __exit kprobe_exit(void)
    {
        unregister_kprobe(&kp);
        pr_info("kprobe at %p unregistered\n", kp.addr);
    }
    
    module_init(kprobe_init)
    module_exit(kprobe_exit)
    MODULE_LICENSE("GPL");
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61

    安装该内核模块后,每当系统中的进程调用 fork,就会触发我们的 handler,从而在 dmesg 中输出对应的日志信息。值得注意的是,kprobe 模块依赖于具体的系统架构,上述 pre_handler 中我们打印指令地址使用的是 regs->pc,这是 ARM64 的情况,如果是 X86 环境,则对应regs->ip,可查看对应 arch 的 struct pt_regs 实现

    kprobe 框架基于中断实现。当 kprobe 被注册后,内核会将对应地址的指令进行拷贝并替换为断点指令(比如 X86 中的 int 3),随后当内核执行到对应地址时,中断会被触发从而执行流程会被重定向到我们注册的pre_handler 函数;当对应地址的原始指令执行完后,内核会再次执行post_handler(可选),从而实现指令级别的内核动态监控。也就是说,kprobe 不仅可以跟踪任意带有符号的内核函数,也可以跟踪函数中间的任意指令

    另一个 kprobe 的同族是 kretprobe,只不过是针对函数级别的内核监控,根据用户注册时提供的entry_handler ret_handler 来分别在函数进入时和返回前进行回调。当然,在实现上和 kprobe 也有所不同,不是通过断点而是通过 trampoline进行实现,可略为减少运行开销

    有人可能听说过 Jprobe,那是早期 Linux 内核的的一个监控实现,现已被 Kprobe替代

    拓展阅读:

    2.2 uprobe

    uprobe顾名思义,相对于内核函数/地址的监控,主要用于用户态函数/地址的监控。听起来是不是有点神奇,内核怎么监控用户态函数的调用呢?

    站在用户视角,我们先看个简单的例子,假设有这么个一个用户程序:

    // test.c
    #include 
    void foo() {
        printf("hello, uprobe!\n");
    }
    int main() {
        foo();
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    编译好之后,查看某个符号的地址,然后告诉内核我要监控这个地址的调用:

    $ gcc test.c -o test
    $ readelf -s test | grep foo
        87: 0000000000000764    32 FUNC    GLOBAL DEFAULT   13 foo
    $ echo 'p /root/test:0x764' > /sys/kernel/debug/tracing/uprobe_events
    $ echo 1 > /sys/kernel/debug/tracing/events/uprobes/p_test_0x764/enable
    $ echo 1 > /sys/kernel/debug/tracing/tracing_on
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    运行用户程序并检查内核的监控返回:

    $ ./test && ./test
    hello, uprobe!
    hello, uprobe!
    
    $ cat /sys/kernel/debug/tracing/trace
    # tracer: nop
    #
    # WARNING: FUNCTION TRACING IS CORRUPTED
    #          MAY BE MISSING FUNCTION EVENTS
    # entries-in-buffer/entries-written: 3/3   #P:8
    #
    #                              _-----=> irqs-off
    #                             / _----=> need-resched
    #                            | / _---=> hardirq/softirq
    #                            || / _--=> preempt-depth
    #                            ||| /     delay
    #           TASK-PID   CPU#  ||||    TIMESTAMP  FUNCTION
    #              | |       |   ||||       |         |
                test-7958  [006] .... 34213.780750: p_test_0x764: (0x6236218764)
                test-7966  [006] .... 34229.054039: p_test_0x764: (0x5f586cb764)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    关闭监控:

    $ echo 0 > /sys/kernel/debug/tracing/tracing_on
    $ echo 0 > /sys/kernel/debug/tracing/events/uprobes/p_test_0x764/enable
    $ echo > /sys/kernel/debug/tracing/uprobe_events
    
    • 1
    • 2
    • 3

    上面关闭监控的接口是基于 debugfs(在较新的内核中使用 tracefs),即读写文件的方式去与内核交互实现 uprobe 监控。其中写入 uprobe_events时会经过一系列内核调用:

    • probes_write
    • create_trace_uprobe
    • kern_path:打开目标 ELF文件
    • alloc_trace_uprobe:分配 uprobe结构体
    • register_trace_uprobe:注册 uprobe
    • regiseter_uprobe_event:将 probe 添加到全局列表中,并创建对应的 uprobe debugfs目录,即上文示例中的 p_test_0x764

    当已经注册了 uprobe的 ELF 程序被执行时,可执行文件会被 mmap 映射到进程的地址空间,同时内核会将该进程虚拟地址空间中对应的 uprobe 地址替换成断点指令。当目标程序指向到对应的 uprobe 地址时,会触发断点,从而触发到 uprobe 的中断处理流程 (arch_uprobe_exception_notify),进而在内核中打印对应的信息

    与 kprobe 类似,我们可以在触发 uprobe 时候根据对应寄存器去提取当前执行的上下文信息,比如函数的调用参数等。同时 uprobe 也有类似的同族:uretprobe。使用 uprobe 的好处是我们可以获取许多对于内核态比较抽象的信息,比如 bash readline函数的返回、SSL_read/write 的明文信息等

    拓展阅读:

    2.3 tracepoints

    tracepont是内核中提供的一种轻量级代码监控方案,可以实现动态调用用户提供的监控函数,但需要子系统的维护者根据需要自行添加到自己的代码中

    tracepoint 的使用和 uprobe 类似,主要基于 debugfs/tracefs的文件读写去进行实现。一个区别在于 uprobe 使用的的用户自己定义的观察点(event),而tracepoint 使用的是内核代码中预置的观察点

    查看内核(或者驱动)中定义的所有观察点:

    $ cat /sys/kernel/debug/tracing/available_events
    sctp:sctp_probe
    sctp:sctp_probe_path
    sde:sde_perf_uidle_status
    ....
    random:random_read
    random:urandom_read
    ...
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    events对应目录下包含了以子系统结构组织的观察点目录:

    $ ls /sys/kernel/debug/tracing/events/random/
    add_device_randomness  credit_entropy_bits  extract_entropy       get_random_bytes       mix_pool_bytes_nolock  urandom_read
    add_disk_randomness    debit_entropy        extract_entropy_user  get_random_bytes_arch  push_to_pool           xfer_secondary_pool
    add_input_randomness   enable               filter                mix_pool_bytes         random_read
    
    $ ls /sys/kernel/debug/tracing/events/random/random_read/
    enable  filter  format  id  trigger
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    urandom 为例,这是内核的伪随机数生成函数,对其开启追踪:

    $ echo 1 > /sys/kernel/debug/tracing/events/random/urandom_read/enable
    $ echo 1 > /sys/kernel/debug/tracing/tracing_on
    $ head -c1 /dev/urandom 
    $ cat /sys/kernel/debug/tracing/trace_pipe
                head-9949  [006] .... 101453.641087: urandom_read: got_bits 40 nonblocking_pool_entropy_left 0 input_entropy_left 2053
    
    • 1
    • 2
    • 3
    • 4
    • 5

    其中trace_pipe 是输出的管道,以阻塞的方式进行读取,因此需要先开始读取再获取/dev/urandom,然后就可以看到类似上面的输出。这里输出的格式是在内核中定义的

    关闭trace 监控:

    $ echo 0 > /sys/kernel/debug/tracing/events/random/urandom_read/enable
    $ echo 0 > /sys/kernel/debug/tracing/tracing_on
    
    • 1
    • 2

    根据Linux 内核文档介绍,子系统的维护者如果想在他们的内核函数中增加跟踪点,需要执行两步操作:

    • 定义跟踪点
    • 使用跟踪点

    内核为跟踪点的定义提供了TRACE_EVENT 宏。还是以 urandom_read这个跟踪点为例,其在内核中的定义在 include/trace/events/random.h

    #undef TRACE_SYSTEM
    #define TRACE_SYSTEM random
    
    TRACE_EVENT(random_read,
        TP_PROTO(int got_bits, int need_bits, int pool_left, int input_left),
    
        TP_ARGS(got_bits, need_bits, pool_left, input_left),
    
        TP_STRUCT__entry(
            __field(      int,  got_bits        )
            __field(      int,  need_bits       )
            __field(      int,  pool_left       )
            __field(      int,  input_left      )
        ),
    
        TP_fast_assign(
            __entry->got_bits   = got_bits;
            __entry->need_bits  = need_bits;
            __entry->pool_left  = pool_left;
            __entry->input_left = input_left;
        ),
    
        TP_printk("got_bits %d still_needed_bits %d "
              "blocking_pool_entropy_left %d input_entropy_left %d",
              __entry->got_bits, __entry->got_bits, __entry->pool_left,
              __entry->input_left)
    );
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    其中:

    • random_readtrace 事件的名称,不一定要内核函数名称一致,但通常为了易于识别会和某个关键的内核函数相关联。隶属于random 子系统(由 TRACE_SYSTEM 宏定义)
    • TP_PROTO:定义了跟踪点的原型,可以理解为入参类型
    • TP_ARGS:定义了”函数“的调用参数
    • TP_STRUCT__entry:用于fast binary tracing,可以理解为一个本地 C 结构体的定义
    • TP_fast_assign:上述本地 C 结构体的初始化
    • TP_printk:类似于 printk 的结构化输出定义,上节中 trace_pipe的输出结果就是这里定义的

    TRACE_EVENT宏并不会自动插入对应函数,而是通过展开定义了一个名为 trace_urandom_read 的函数,需要内核开发者自行在代码中进行调用。 上述跟踪点实际上是在 drivers/char/random.c文件中进行了调用:

    static ssize_t
    urandom_read_nowarn(struct file *file, char __user *buf, size_t nbytes,
                loff_t *ppos)
    {
        int ret;
    
        nbytes = min_t(size_t, nbytes, INT_MAX >> (ENTROPY_SHIFT + 3));
        ret = extract_crng_user(buf, nbytes);
        trace_urandom_read(8 * nbytes, 0, ENTROPY_BITS(&input_pool)); // <-- 这里
        return ret;
    }
    
    static ssize_t
    urandom_read(struct file *file, char __user *buf, size_t nbytes, loff_t *ppos)
    {
        unsigned long flags;
        static int maxwarn = 10;
    
        if (!crng_ready() && maxwarn > 0) {
            maxwarn--;
            if (__ratelimit(&urandom_warning))
                pr_notice("%s: uninitialized urandom read (%zd bytes read)\n",
                      current->comm, nbytes);
            spin_lock_irqsave(&primary_crng.lock, flags);
            crng_init_cnt = 0;
            spin_unlock_irqrestore(&primary_crng.lock, flags);
        }
    
        return urandom_read_nowarn(file, buf, nbytes, ppos);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    值得注意的是实际上是在urandom_read_nowarn函数中而不是urandom_read 函数中调用的,因此也可见注入点名称和实际被调用的内核函数名称没有直接关系,只需要便于识别和定位即可。根据上面的介绍我们可以了解到,tracepoint 相对于 probe 来说各有利弊:

    优缺点:

    • 优点:对于参数格式有明确定义,并且在不同内核版本中相对稳定,kprobe 跟踪的内核函数可能在下个版本就被改名或者优化掉了
    • 缺点:需要开发者自己定义并且加入到内核代码中,对代码略有侵入性

    另外,tracepoint 除了在内核代码中直接定义,还可以在驱动中进行动态添加,用于方便驱动开发者进行动态调试,复用已有的 debugfs 最终架构。这里有一个简单的自定义 tracepoint 示例,可用于加深对 tracepoint 使用的理解

    拓展阅读:

    2.4 Userland Statically Defined Tracing

    Userland Statically Defined Tracing也称为USDT,即用户静态定义追踪。最早源于 Sun 的 Dtrace 工具,因此 USDT probe 也常被称为 Dtrace probe。可以理解为 kernel tracepoint的用户层版本,由应用开发者在自己的程序中关键函数加入自定义的跟踪点,有点类似于 printf 调试法(误)

    一个简单的示例:

    #include "sys/sdt.h"
    int main() {
      DTRACE_PROBE("hello_usdt", "enter");
      int reval = 0;
      DTRACE_PROBE1("hello_usdt", "exit", reval);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    DTRACE_PROBEn是 UDST (systemtap) 提供的追踪点定义+插入辅助宏n表示参数个数。编译上述代码后就可以看到被注入的 USDT probe信息:

    • readelf -n表示输出 ELF 中 NOTE 段的信息
    $ apt-get install systemtap-sdt-dev
    $ gcc hello-usdt.c -o hello-usdt
    $ readelf  -n ./hello-usdt
    ...
    Displaying notes found in: .note.stapsdt
      Owner                Data size        Description
      stapsdt              0x0000002e       NT_STAPSDT (SystemTap probe descriptors)
        Provider: "hello_usdt"
        Name: "enter"
        Location: 0x0000000000001131, Base: 0x0000000000002004, Semaphore: 0x0000000000000000
        Arguments:
      stapsdt              0x00000038       NT_STAPSDT (SystemTap probe descriptors)
        Provider: "hello_usdt"
        Name: "exit"
        Location: 0x0000000000001139, Base: 0x0000000000002004, Semaphore: 0x0000000000000000
        Arguments: -4@-4(%rbp)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    在使用 trace 工具(如 BCC、SystemTap、dtrace) 对该应用进行追踪时,会在启动过程中修改目标进程的对应地址,将其替换为 probe ,在触发调用时候产生对应事件,供数据收集端使用。通常添加 probe 的方式是 基于 uprobe 实现的

    使用 USDT 的一个好处是应用开发者可以在自己的程序中定义更加上层的追踪点,方便对于功能级别监控和分析,比如 node.js server 就自带了 USDT probe 点可用于追踪 HTTP 请求,并输出请求的路径等信息。由于 USDT 需要开发者配合使用,不符合我们的需求

    :USDT 不算是一种独立的内核监控数据源,因为其实现还是依赖于 uprobe

    总结:如上所述了几种当今内核中主要的监控数据来源,基本上可以涵盖所有的监控需求。不过从易用性上来看,只是实现了基本的架构,使用上有的是基于内核提供的系统调用/驱动接口,有的是基于 debugfs/tracefs,对用户而言不太友好,因此就有了许多封装再封装的监控前端

    拓展阅读:

    2.5 ftrace

    ftrace 是内核中用于实现内部追踪的一套框架,这么说有点抽象,但实际上我们前面已经用过了,就是 tracefs 中的使用的方法

    在旧版本中内核中(4.1 之前)使用 debugfs,一般挂载到/sys/kernel/debug/tracing;在新版本中使用独立的 tracefs,挂载到/sys/kernel/tracing。但出于兼容性原因,原来的路径仍然保留,所以我们将其统一称为 tracefs

    ftrace 通常被叫做function tracer,但除了函数跟踪,还支持许多其他事件信息的追踪:

    • hwlat:硬件延时追踪
    • irqsoff:中断延时追踪
    • preemptoff:追踪指定时间片内的 CPU 抢占事件
    • wakeup:追踪最高优先级的任务唤醒的延时
    • branch:追踪内核中的 likely/unlikely调用
    • mmiotrace:追踪某个二进制模块所有对硬件的读写事件
    • ……

    Android 中提供了一个简略的文档指导如何为内核增加 ftrace 支持,详见: Using ftrace

    2.6 perf

    perf 是 Linux 发行版中提供的一个性能监控程序,基于内核提供的 perf_event_open 系统调用来对进程进行采样并获取信息。Linux 中的 perf 子系统可以实现对 CPU 指令进行追踪和计数,以及收集kprobe、uprobe 和 tracepoints 的信息,实现对系统性能的分析。

    在 Android 中提供了一个简单版的 perf 程序 simpleperf,接口和 perf 类似

    虽然,可以监测到系统调用,但缺点是无法获取系统调用的参数,更不可以动态地修改内核。因此,对于渗透测试而言作用不大,更多是给 APP 开发者和手机厂商用于性能热点分析。值得一提的是,perf 子系统曾经出过不少漏洞,在 Android 内核提权史中也曾经留下过一点足迹

    2.7 eBPF

    eBPF 为 extended Berkeley Packet Filter 的缩写,BPF 最早是用于包过滤的精简虚拟机,拥有自己的一套指令集,我们常用的 tcpdump 工具内部就会将输入的过滤规则转换为 BPF 指令,比如:

    $ tcpdump -i lo0 'src 1.2.3.4' -d
    (000) ld       [0]
    (001) jeq      #0x2000000       jt 2	jf 5
    (002) ld       [16]
    (003) jeq      #0x1020304       jt 4	jf 5
    (004) ret      #262144
    (005) ret      #0
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    该汇编指令表示令过滤器只接受 IP 包,并且来源 IP 地址为1.2.3.4。其中的指令集可以参考 Linux Socket Filtering aka Berkeley Packet Filter (BPF)

    eBPF 在 BPF 指令集上做了许多增强(extend):

    • 寄存器个数从 2 个增加为 10 个 (R0 - R9)
    • 寄存器大小从 32 位增加为 64 位
    • 条件指令jt/jf 的目标替换为jt/fall-through,简单来说就是 else 分支可以默认忽略
    • 增加了 bpf_call 指令以及对应的调用约定,减少内核调用的开销
    • … …

    内核存在一个 eBPF 解释器,同时也支持实时编译(JIT)增加其执行速度,但很重要的一个限制是 eBPF 程序不能影响内核正常运行,在内核加载 eBPF 程序前会对其进行一次语义检查,确保代码的安全性,主要限制为:

    • 不能包含循环,这是为了防止 eBPF 程序过度消耗系统资源(5.3 中增加了部分循环支持)
    • 不能反向跳转,其实也就是不能包含循环
    • BPF 程序的栈大小限制为 512 字节
    • … …

    具体的限制策略都在内核的eBPF verifier中,不同版本略有差异。值得一提的是,最近几年 Linux 内核出过很多 eBPF 的漏洞,大多是 verifier 的验证逻辑错误,其中不少还上了 Pwn2Own,但是由于权限的限制在 Android 中普通应用无法执行 bpf(2) 系统调用,因此并不受影响

    eBPF 和 perf_event类似,通过内核虚拟机的方式实现监控代码过滤的动态插拔,这在许多场景下十分奏效。对于普通用户而言,基本上不会直接编写 eBPF 的指令去进行监控,虽然内核提供了一些宏来辅助 eBPF 程序的编写,但实际上更多的是使用上层的封装框架去调用,其中最著名的一个就是 BCC

    2.7.1 BCC

    BCC (BPF Compiler Collection) 包含了一系列工具来协助运维人员编写监控代码,其中使用较多的是其 Python 绑定。一个简单的示例程序如下:

    from bcc import BPF
    prog="""
    int kprobe__sys_clone(void *ctx) {
        bpf_trace_printk("Hello, World!\\n");
        return 0;
    }
    """
    BPF(text=prog).trace_print()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    执行该 python 代码后,每当系统中的进程调用 clone 系统调用,该程序就会打印 “Hello World” 输出信息。 可以看到这对于动态监控代码非常有用,比如我们可以通过 python 传入参数指定打印感兴趣的系统调用及其参数,而无需频繁修改代码

    eBPF 可以获取到内核中几乎所有的监控数据源,包括 kprobes、uprobes、tracepoints等等,官方 repo 中给出了许多示例程序,比如 opensnoop 监控文件打开行为、execsnoop 监控程序的执行

    在这里插入图片描述

    2.7.2 bpftrace

    bpftrace 是 eBPF 框架的另一个上层封装,与 BCC 不同的是 bpftrace 定义了一套自己的 DSL 脚本语言,语法(也)类似于 awk,从而可以方便用户直接通过命令行实现丰富的功能,截取几条官方给出的示例:

    # 监控系统所有的打开文件调用(open/openat),并打印打开文件的进程以及被打开的文件路径
    bpftrace -e 'tracepoint:syscalls:sys_enter_open { printf("%s %s\n", comm, str(args->filename)); }'
    
    # 统计系统中每个进程执行的系统调用总数
    bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'
    
    • 1
    • 2
    • 3
    • 4
    • 5

    官方同样也给出了许多.bt 脚本示例,可以通过其代码进行学习和编写

    拓展阅读:

    2.8 SystemTap

    SystemTap (stab) 是 Linux 中的一个命令行工具,可以对各种内核监控源信息进行结构化输出。同时也实现了自己的一套 DSL 脚本,语法类似于awk,可实现系统监控命令的快速编程

    使用 systemtap 需要包含内核源代码,因为需要动态编译和加载内核模块。在 Android 中还没有官方的支持,不过有一些开源的 systemtap 移植

    :还有许多开源的内核监控前端,比如 LTTng、trace-cmd、kernelshark等,内核监控输出以结构化的方式进行保存、处理和可视化,对于大量数据而言是非常实用的

    拓展阅读:

    那么Android 系统中,是否能直接套娃Linux 的一些解决方案,来对系统命令执行来做实现呢?笔者浅见,觉得应该可行,但不一定所有适用于Android,比如日志存储这块,Android 是阉割过的Linux系统,没有历史命令等日志存储的功能,除非定制化系统或Hook后将执行的命令转储路径保存

    想实时对执行的命令进行监控,分析异常或入侵行为,有助于安全事件的发现和预防。命令执行的一些检测方法:

    • 遍历/proc目录,但无法捕获瞬间结束的进程
    • Linux kprobes调试技术,并非所有Linux都有此特性,需要编译内核时配置
    • 修改glic库中的execve函数,但是可通过int 0x80绕过glic
    • 修改sys_call_table,通过LKM(loadable kernel module)实时安装和卸载监控模块,但是内核模块需要适配内核版本

    应用级

    • So preload(用户态):
      • 在ring3通过/etc/ld.so.preload劫持系统调用
      • 二次开发glibc加入监控代码
      • 基于调试器思想通过ptrace()主动注入
      • 总结:在应用层做Hook的好处是不受内核版本影响,通用性较好,而且技术难度相对较低,但是缺点更明显,因为ring3层的Hook都是针对glibc库做的监控,只要直接陷入int 0x80中断,即可绕过glibc库直接调用系统调用

    内核级

    • API Inline Hook
    • Syscall(sys_call_table) hook
    • IDT Hook
    • 利用LSM (Linux Security Module)
    • Audit
    • Netlink Connector

    API Inline Hook以及IDT Hook操作难度较大,而且兼容性较差,利用LSM监控API虽然性能最好,但是必须编译进内核才能使用,不可以实时安装卸载,而sys_call_table的Hook相对易于操作,作为防守方也可以直接从” /boot/System.map-uname -r ”中直接获取sys_call_table地址,也可以利用LKM(loadable kernel module)技术实现实时安装卸载。如果选择修改sys_call_table中的execve系统调用来监控命令执行的入侵检测,虽然要适配内核版本,但能百分之100%检测到调用系统调用的命令执行,除非没有调用系统里面execve,而是用自行编写类似的程序

    在这里插入图片描述

    详情内容请移步「驭龙」Linux执行命令监控驱动实现解析:https://www.anquanke.com/post/id/103520#h2-1

    2.9 内核模块介绍

    模块是内核的一部分,但是并没有被编译到内核里面去。它们被分别编译并连接成一组目标文件, 这些文件能被插入到正在运行的内核,或者从正在运行的内核中移走。内核模块至少必须有2个函数:

    • int_module:第一个函数是在把模块插入内核时调用的
    • cleanup_module:第二个函数则在删除该模块时调用

    需要注意,由于内核模块是内核的一部分,所以能访问所有内核资源。根据对linux系统调用机制的分析,如果要增加系统调用,可以编写自己的函数来实现,然后在sys_call_table表中增加一项,使该项中的指针指向自己编写的函数,就可以实现系统调用

    为什么要使用内核模块的方式添加系统调用?

    • 编译内核的方式比较花费时间
    • 不方便调试

    :编译内核,如果投入到Android 系统批量运营,定制化系统,要不然只是把其中一台Android系统编译好的内核模块在一个机子上运行成功后,如果移植到另外一个机子上马上就会出现错误,为什么呢?因为每个机子上sys_call_table的地址可能不一样。可以使用如下命令查看系统调用表sys_call_table的地址(虚拟地址)

    cat /proc/kallsyms | grep sys_call_tables
    
    • 1

    需要注意,还有一个需要关注的就是预留的系统调用号,可以使用预留的系统调用号(my_syscall_num),如下(arch/x86/include/asm/unistd.h文件中查看预留的系统调用号,该预留的系统调用号为223):

    #define my_syscall_num 223
    
    • 1

    code

        #include 
        #include 
        #include 
        #include 
        #include 
        #include 
    
        #define my_syscall_num 223
        //如下的这个值要到你机子上查。cat /proc/kallsyms | grep sys_call_table
        #define sys_call_table_adress 0xc1511160
    
    
        unsigned int clear_and_return_cr0(void);
        void setback_cr0(unsigned int val);
        asmlinkage long sys_mycall(void);
    
        int orig_cr0;
        unsigned long *sys_call_table = 0;
        static int (*anything_saved)(void);
    
        unsigned int clear_and_return_cr0(void)
        {
         unsigned int cr0 = 0;
         unsigned int ret;
         asm("movl %%cr0, %%eax":"=a"(cr0));
         ret = cr0;
         cr0 &= 0xfffeffff;
         asm("movl %%eax, %%cr0"::"a"(cr0));
         return ret;
        }
    
        void setback_cr0(unsigned int val) //读取val的值到eax寄存器,再将eax寄存器的值放入cr0中
        {
         asm volatile("movl %%eax, %%cr0"::"a"(val));
        }
    
        static int __init init_addsyscall(void)
        {
         printk("hello, kernel\n");
         sys_call_table = (unsigned long *)sys_call_table_adress;//获取系统调用服务首地址
         anything_saved = (int(*)(void)) (sys_call_table[my_syscall_num]);//保存原始系统调用的地址
         orig_cr0 = clear_and_return_cr0();//设置cr0可更改
         sys_call_table[my_syscall_num] = (unsigned long)&sys_mycall;//更改原始的系统调用服务地址
         setback_cr0(orig_cr0);//设置为原始的只读cr0
         return 0;
        }
    
        asmlinkage long sys_mycall(void)
        {
         printk("This is my_syscall!\n");
         return current->pid;
        }
    
        static void __exit exit_addsyscall(void)
        {
         //设置cr0中对sys_call_table的更改权限。
         orig_cr0 = clear_and_return_cr0();//设置cr0可更改
    
         //恢复原有的中断向量表中的函数指针的值。
         sys_call_table[my_syscall_num] = (unsigned long)anything_saved;
         
         //恢复原有的cr0的值
         setback_cr0(orig_cr0);
    
         printk("call exit \n");
        }
    
        module_init(init_addsyscall);
        module_exit(exit_addsyscall);
        MODULE_LICENSE("GPL");
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70

    如下内容来自:https://www.freebuf.com/column/208928.html

    2.10 So preload

    基础知识:

    • Linux 中大部分的可执行程序是动态链接的,常用的有关进程执行的函数例如 execve均实现在 libc.so这个动态链接库中
    • Linux 提供了一个 so preload的机制,它允许定义优先加载的动态链接库,方便使用者有选择地载入不同动态链接库中的相同函数

    结合上述两点不难得出,我们可以通过 so preload 来覆盖 libc.so中的 execve等函数来监控进程的创建

    Demo

    • 创建文件 hook.c ,内容如下:
    #define _GNU_SOURCE
    #include 
    #include 
    #include 
    
    typedef ssize_t (*execve_func_t)(const char* filename, char* const argv[], char* const envp[]);
    static execve_func_t old_execve = NULL;
    
    int execve(const char* filename, char* const argv[], char* const envp[]) {
    printf("Running hook\n");
    printf("Program executed: %s\n", filename);
    old_execve = dlsym(RTLD_NEXT, "execve");
    return old_execve(filename, argv, envp);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    该文件的主要部分就是重新定义了 execve函数,在原始的 execve执行之前打印可执行文件的名字。

    • 生成动态链接库:gcc hook.c-fPIC-shared-o hook.so
    • 将上面生成的动态链接库注册成 preload :echo'/path/to/hook.so'>/etc/ld.so.preload
    • 退出当前 shell 并重新登录(下面会讲原因),执行命令即可看到我们编写的代码已被执行:

    在这里插入图片描述

    使用条件

    • 该方法没有什么条件限制,只需有 root 权限即可(做入侵监控程序 root 权限是必需的,后面的几种方法默认也都是在 root 权限下)

    优点:

    • 轻量级,只修改库函数代码,不与内核进行交互

    缺点:对于使用方法的第四步,可能大家会有疑问:为什么一定要重新获取 shell 才可以看到效果呢?这是因为其实在当前 shell 下执行命令(也就是执行 execve)的实际上是当前的 shell 可执行程序,例如 bash ,而 bash 所需的动态链接库在其开始运行时就已确定,所以我们后续添加的 preload 并不会影响到当前 bash ,只有在添加 preload 之后创建的进程才会受 preload 的影响

    • 只能影响在 preload 之后创建的进程,这就需要检测 Agent 安装得越早越好,尽量在其他应用进程启动之前就完成安装
    • 无法监控静态链接的程序:目前一些蠕虫木马为了降低对环境的依赖性都是用静态链接,不会加载共享库,这种情况下这种监控方式就失效
    • 容易被攻击者发现并篡改:目前一些蠕虫木马本身也会向 /etc/ld.so.preload中写入后门,以方便其对机器的持久掌控,这种情况下这种监控方式也会失效
    • 攻击者可通过 int80h绕过 libc 直接调用系统调用,这种情况下这种监控方式也会失效

    2.11 Netlink Connector

    Netlink 是什么,Netlink 是一个套接字家族(socket family),它被用于内核与用户态进程以及用户态进程之间的 IPC 通信,我们常用的 ss命令就是通过 Netlink 与内核通信获取的信息。

    Netlink Connector是一种 Netlink ,它的 Netlink 协议号是 NETLINK_CONNECTOR,其代码位于 https://github.com/torvalds/linux/tree/master/drivers/connector中,其中connectors.c cnqueue.cNetlink Connector 的实现代码,而 cnproc.c 是一个应用实例,名为进程事件连接器,我们可以通过该连接器来实现对进程创建的监控

    系统架构:

    在这里插入图片描述

    具体流程:

    在这里插入图片描述

    图中的 ncp 为 Netlink Connector Process,即用户态就是我们需要开发用来检测的程序

    Demo

    在 Github 上已有人基于进程事件连接器开发了一个简单的进程监控程序:https://github.com/ggrandes-clones/pmon/blob/master/src/pmon.c,其核心函数为以下三个:

    • nl_connect:与内核建立连接
    • set_proc_ev_listen:订阅进程事件
    • handle_proc_ev:处理进程事件

    其执行流程正如上图所示,我们通过gcc pmon.c-o pmon生成可执行程序,然后执行该程序即可看到效果:

    在这里插入图片描述

    获取到的 pid 之后,再去 /proc//目录下获取进程的详细信息即可

    使用条件:

    • 内核支持 Netlink Connector
      • 版本> 2.6.14
      • 内核配置开启: cat/boot/config-$(uname-r)|egrep'CONFIG_CONNECTOR|CONFIG_PROC_EVENTS'

    优缺点:

    • 优点:轻量级,在用户态即可获得内核提供的信息
    • 缺点:仅能获取到 pid ,详细信息需要查/proc//,这就存在时间差,可能有数据丢失

    2.12 Audit

    Linux Audit 是 Linux 内核中用来进行审计的组件,可监控系统调用和文件访问

    • 用户通过用户态的管理进程配置规则(例如图中的go-audit ,也可替换为常用的 auditd ),并通过 Netlink 套接字通知给内核
    • 内核中的 kauditd 通过 Netlink 获取到规则并加载
    • 应用程序在调用系统调用和系统调用返回时都会经过 kauditd ,kauditd 会将这些事件记录下来并通过 Netlink 回传给用户态进程
    • 用户态进程解析事件日志并输出

    架构图如下:

    在这里插入图片描述

    Demo

    从上面的架构图可知,整个框架分为用户态和内核态两部分,内核空间的 kauditd 是不可变的,用户态的程序是可以定制的,目前最常用的用户态程序就是 auditd ,除此之外知名的 osquery 在底层也是通过与 Audit 交互来获取进程事件的。下面我们就简单介绍一下如何通过 auditd 来监控进程创建

    首先安装并启动 auditd :

    apt update && apt install auditd
    systemctl start auditd && systemctl status auditd
    
    • 1
    • 2

    auditd 软件包中含有一个命名行控制程序 auditctl,我们可以通过它在命令行中与 auditd 进行交互,用如下命令创建一个对 execve这个系统调用的监控:

    auditctl -a exit,always -F arch=b64 -S execve
    
    • 1

    再通过 auditd 软件包中的 ausearch来检索 auditd 产生的日志:

    ausearch -sc execve | grep /usr/bin/id
    
    • 1

    整个过程的执行结果如下:

    在这里插入图片描述

    至于其他的使用方法可以通过man auditdman auditctl来查看

    使用条件:

    • 内核开启 Audit:cat/boot/config-$(uname-r)|grep^CONFIG_AUDIT

    优缺点

    • 优点:
      • 组件完善,使用 auditd 软件包中的工具即可满足大部分需求,无需额外开发代码
      • 相比于Netlink Connector ,获取的信息更为全面,不仅仅是 pid
    • 缺点:
      • 性能消耗随着进程数量提升有所上升,需要通过添加白名单等配置来限制其资源占用

    2.13 Syscall hook

    Netlink Connector和 Audit 都是 Linux 本身提供的监控系统调用的方法,如果我们想拥有更大程度的可定制化,我们就需要通过安装内核模块的方式来对系统调用进行 hook

    目前常用的 hook 方法是通过修改 sys_call_table( Linux 系统调用表)来实现,具体原理就是系统在执行系统调用时是通过系统调用号在 sys_call_table中找到相应的函数进行调用,所以只要将 sys_call_tableexecve对应的地址改为我们安装的内核模块中的函数地址即可

    YSRC 的这篇关于驭龙 HIDS 如何实现进程监控的文章:

    • https://mp.weixin.qq.com/s/ntE5FNM8UaXQFC5l4iKUUw
    • https://www.anquanke.com/post/id/103520#h2-5

    在这里插入图片描述

    关于 Syscall hook的 Demo ,我在 Github 上找了很多 Demo 代码,其中就包括驭龙 HIDS 的 hook 模块,但是这些都无法在我的机器上( Ubuntu 16.04 Kernel 4.4.0-151-generic )正常运行,这也就暴露了Syscall hook的兼容性问题

    最后,决定使用 Sysdig 来进行演示,Sysdig 是一个开源的系统监控工具,其核心原理是通过内核模块监控系统调用,并将系统调用抽象成事件,用户根据这些事件定制检测规则。作为一个相对成熟的产品,Sysdig 的兼容性做得比较好,所以这里用它来演示,同时也可以方便大家自己进行测试,具体步骤如下:

    • 通过官方的安装脚本进行安装:
    curl-s https://s3.amazonaws.com/download.draios.com/stable/install-sysdig | sudo bash
    
    • 1
    • 检测内核模块是否已经安全:
    lsmod|grep sysdig
    
    • 1
    • 启动对 execve的监控:
    sysdig evt.type=execve
    
    • 1

    最终的执行效果如下:

    在这里插入图片描述

    有关于 Sysdig 的更多信息可以访问其 wiki 进行获取,另外,Sysdig 团队推出了一个专门用于安全监控的工具 Falco ,Falco 在 Sysdig 的基础上抽象出了可读性更高的检测规则,并支持在容器内部署,同样,大家如果感兴趣可以访问其 wiki 获取更多信息

    使用条件

    • 可以安装内核模块
    • 需针对不同 Linux 发行版和内核版本进行定制

    优缺点:

    • 优点:
      • 高定制化,从系统调用层面获取完整信息
    • 缺点:
      • 开发难度
      • 兼容性差,需针对不同发行版和内核版本进行定制和测试

    2.13.1 四种方式总结

    • So preload:Hook 库函数,不与内核交互,轻量但易被绕过
    • Netlink Connector :从内核获取数据,监控系统调用,轻量,仅能直接获取 pid ,其他信息需要通过读取 /proc//来补全
      • 单纯地看监控进程创建这方面,推荐使用 Netlink Connector的方式,此方式在保证从内核获取数据的前提下又足够轻量,方便进行定制化开发
    • Audit:从内核获取数据,监控系统调用,功能多,不只监控进程创建,获取的信息相对全面
      • 单纯地看监控进程创建这方面,推荐使用 Netlink Connector的方式,此方式在保证从内核获取数据的前提下又足够轻量,方便进行定制化开发
    • Syscall hook:从内核获取数据,监控系统调用,最接近实际系统调用,定制度高,兼容性差

    2.14 小实验

    【待补充,后面有空再补充一个实际案例的检测(其实主要是太菜了,不会弄 O(∩_∩)O哈哈~)】

    下面命令中的路径在不同系统可能不同,即有些系统只有其中一个或两个都有,两个都有的话,功能其实都是一样的,如下:

    • /sys/kernel/tracing
    • /sys/kernel/debug/tracing

    博主这里用树莓派4做的测试,环境如下:

    • Android 10
      • 如何刷树莓派为Android 10的教程,之前文章有发
    • 树莓派4
    • root权限

    adb shell 进入设备中,首先查看是否支持syscall调用的追踪,如果出现CONFIG_HAVE_SYSCALL_TRACEPOINTS=y那就说明支持,如下:

    zcat /proc/config.gz | grep TRACEPOINT
    
    • 1

    在这里插入图片描述

    执行下面两个命令,对系统调用之前的状态进行追踪,其中第一个需要root权限

    echo 1 > /sys/kernel/debug/tracing/events/raw_syscalls/sys_enter/enable
    echo 1 > /sys/kernel/debug/tracing/tracing_on
    
    • 1
    • 2

    在这里插入图片描述

    如果不想对系统调用之前的状态进行追踪的话,只需要改为0 即可,避免消耗系统性能,命令如下:

    echo 0 > /sys/kernel/debug/tracing/events/raw_syscalls/sys_enter/enable
    echo 0 > /sys/kernel/debug/tracing/tracing_on
    
    • 1
    • 2

    相关的输出会保存到/sys/kernel/debug/tracing/trace,而默认有一个格式,执行命令查看:

    head /sys/kernel/debug/tracing/trace
    
    • 1

    在这里插入图片描述

    执行命令展示的结果表示会打印PID信息,可以过滤下PID,减少输出的日志信息。然后就是日志信息会实时输出到/sys/kernel/debug/tracing/trace_pipe

    查看指定PID的系统调用,下图中NR后面的数字就是系统调用号,命令如下:

    cat /sys/kernel/debug/tracing/trace_pipe | grep 2351
    
    • 1

    在这里插入图片描述

    上图中日志输出的格式,发现系统提供的比较简单,开发人员可以自行编写eBPF程序来解析参数来自定义自己想打印的系统调用详细情况日志格式。上图中日志输出的格式,可通过如下命令查看,:

    cat /sys/kernel/debug/tracing/events/raw_syscalls/sys_enter/format
    
    • 1

    在这里插入图片描述

    :除了sys_enter,还有sys_exit,是系统调用结束后的状态,我们可以使用sys_enter开启的方式来开启sys_exit的系统调用追踪,执行如下命令即可开启sys_exit系统调用追踪的功能:

    echo 1 > /sys/kernel/debug/tracing/events/raw_syscalls/enable
    
    • 1

    需要注意:

    • /sys/kernel/debug/tracing/events下面会有许多类型的事件,它们都可以通过向对应的enable管道写入配置,开启tracing_on,实现对应信息的追踪
    • /sys/kernel/debug/tracing/events下面有许多文件夹,每个文件夹下可能还有文件夹
    • 如果向更高层级的文件夹的enable写入1,那么文件夹下的子文件夹的对应事件也会被开启记录,而/sys/kernel/debug/tracing/events/enable则是最顶层的控制开关,如果向其中写入1,那么将记录所有内置的事件,此时就会输出非常多内容

    2.15 基于 Patch Shell解释器的命令监控

    如下内容来自:https://mp.weixin.qq.com/s/fx3ywEZiXEUStbrtzbpwrQ

    基于 Patch Shell解释器的命令监控是基于execve的系统命令监控的补充方案,因为通过监控execve系统调用的方式,理论上可以完全覆盖系统命令的调用,那为什么还要 Patch Shell解释器呢?

    大家别忘了,shell不仅可以调用外部系统命令,自身还有很多内置命令。内置命令是shell解释器中的一部分,可以理解为是shell解释器中的一个函数,并不会额外创建进程。因此监控execve系统调用是无法监控这部分的,当然能用作恶意行为的内置命令并不多,算是一个补充

    如何判断是否是内置命令呢?通过type指令,示例如下:

    [root@localhost ~]# type cd
    cd is a Shell builtin
    [root@localhost ~]# type ifconfig
    ifconfig is/sbin/ifconfig
    
    • 1
    • 2
    • 3
    • 4

    完整的内置命令列表,请参考 shell内置命令:http://c.biancheng.net/view/1136.html

    如何Patch Shell解释器 ? 原理很简单,对shell解释器的输入进行修改,将输入写入到文件中,进行分析即可。shell解释器很多,以bash举例:

    • 通过-c参数输入命令
    • 通过stdin输入命令

    在这两个地方将写文件的代码嵌入进去即可

    2.16 绕过Patch Shell检测方法

    对抗命令监控:

    • 绕过Shell命令监控方法,使之收集不到命令执行的日志
    • 无法绕过命令监控,但是能篡改命令执行的进程和参数,使之收集到假的日志
    • 无法绕过监控,也无法篡改内容,猜测命令告警的策略并绕过(例如通过混淆绕过命令静态检测)

    :上面说的第一种和第二种方法算是比较根本的方法,没有真实的数据,策略模型就无法命中目标并告警,第三种方法需要较多的经验,但是通过混淆命令绕过静态检测策略,也是比较常见的

    2.6.1 无日志-绕过Shell命令监控

    已知的绕过命令监控的方案:用户态glibc/libc exec劫持,Patch Shell解释器,内核态的execve监控,均可被绕过

    2.16.2 绕过glibc/libc exec劫持

    • 方法1:glibc/libc是linux中常用的动态链接库,也就是说在动态链接程序的时候才会用到它,那么我们只需要将木马后门进行静态编译即可,不依赖系统中的glibc/libc执行,就不会被劫持

    • 方法2: glibc/libc是对linux系统调用(syscall)的封装,我们使用它是为了简化对系统调用的使用,其实我们可以不用它,直接使用汇编sysenter/int 0x80指令调用execve系统调用,下面是使用int 0x80调用execve syscall的简写代码:

    mov     byte       al, 0x0b # 好了,现在把execve()的系统调用号11号放入eax的最下位的al中
    mov                ebx, esi # 现在是第一个参数,字符串的位置放入ebx
    lea                ecx, [esi+8] # 第二个参数,要点是这个参数类型是char **, 如果/bin/sh有其它参数的话,整个程序写法就又不一样了
    lea                edx, [esi+12] #  最后是null的地址,注意,是null的地址,不是null,因为写这是为了shellcode做准备,shellcode中不可以有null
    int 0x80
    
    • 1
    • 2
    • 3
    • 4
    • 5

    或重写LD_PRELOAD环境变量,但这样的动作较大,容易被发现

    2.16.3 绕过Patch Shell解释器

    • 方法1:不使用shell解释器执行命令,直接使用execve
    • 方法2:不使用被Patch的shell解释器,例如大家常用的bash被patch,那你可以使用linux另一个 tcsh解释器来执行命令
    [root@VM_0_13_centos ~]# tcsh -c 
    "echo hello"hello
    
    • 1
    • 2

    2.16.4 绕过内核态execve syscall

    只要使用了execve执行了命令,就绝对逃不过内核态execve syscall的监控,除非你把防御方的内核驱动给卸载了。既然如此,那怎么绕过呢?

    • 方法1:不使用execve系统调用,自己写一个类似功能程序的代码来执行命令
    • 方法2:假日志 - 混淆进程名与进程参数
      • 混淆进程名
      • 混淆进程参数
    • 方法3:无"命令"反弹shell

    2.16.4.1 方法2中的混淆进程名

    在linux中有个syscall,名字叫做memfd_create (http://man7.org/linux/man-pages/man2/memfd_create.2.html)

    memfd_create()会创建一个匿名文件并返回一个指向这个文件的文件描述符.这个文件就像是一个普通文件一样,所以能够被修改,截断,内存映射等等.不同于一般文件,此文件是保存在RAM中。一旦所有指向这个文件的连接丢失,那么这个文件就会自动被释放

    • 这就是说memfd_create创建的文件是存在与RAM中,那这个的文件名类似 /proc/self/fd/%d,也就是说假如我们把 ls命令bin文件使用memfd_create写到内存中,然后在内存中使用execve执行,那看到的不是ls,而是执行的 /proc/self/fd/%d ,从而实现了进程名称混淆和无文件

    详细内容请前往该地址:http://www.polaris-lab.com/index.php/archives/666/

    2.16.4.2 方法2中的混淆进程参数

    使用的是linux中另一个syscall: ptraceptrace是用来调试程序用的,使用execve启动进程,相对于自身来说是启动子进程,ptrace的使用流程一般是这样的:

    • 父进程 fork() 出子进程,子进程中执行我们所想要 trace的程序,在子进程调用exec() 之前,子进程需要先调用一次 ptrace,以 PTRACETRACEME 为参数。这个调用是为了告诉内核,当前进程已经正在被 traced,当子进程执行 execve()之后,子进程会进入暂停状态,把控制权转给它的父进程(SIGCHLD信号), 而父进程在fork()之后,就调用 wait() 等子进程停下来,当 wait() 返回后,父进程就可以去查看子进程的寄存器或者对子进程做其它的事情了

    假如我们想执行ls-alh,在上文中ls 已经可以被混淆了。接下来使用ptrace -alh进行混淆。大体的操作流程如下:

    • 第一步:首先我们fork出来一个子进程,然后在子进程中先调用ptrace,接着执行execve("ls xxxxxx"),这个时候基于execve监控到的就是一个假参数
    • 第二步:既然传入的是假参数,那肯定是是无法执行到想要的结果,这个时候父进程等待子进程execve后停下来,然后修改传入参数的寄存器,将其修改为-alh,最后接着让子进程继续运行即可

    详细内容请前往该地址:http://www.polaris-lab.com/index.php/archives/667/

    参考链接

    https://sq.sf.163.com/blog/article/311384915510648832

    https://www.cnblogs.com/ronny/p/7789057.html

    http://www.embeddedlinux.org.cn/html/jishuzixun/201101/06-1062.html

    https://docs.huihoo.com/joyfire.net/6-1.html#Content

    https://www.cnblogs.com/LittleHann/p/3854977.html

    https://cloud.tencent.com/developer/article/1939486

    http://blog.chinaunix.net/uid-27033491-id-3245321.html

    https://www.anquanke.com/post/id/103520#h2-5

    https://www.freesion.com/article/1205184276/

    https://blog.csdn.net/youzhangjing_/article/details/124178417

    https://evilpan.com/2022/01/03/kernel-tracing

    https://blog.seeflower.dev/archives/88/

    https://sniffer.site/2021/11/26/%E7%90%86%E8%A7%A3android-ebpf/


    我自横刀向天笑,去留肝胆两昆仑


  • 相关阅读:
    接口
    JS中数组的遍历方法有那些?
    如何快速开发app?小程序+插件少不了
    云赛道---人工智能前沿应用场景
    常见弱口令汇编
    私有云:【2】AD域的安装
    第二十三章 原型链
    SpringBoot基于Netty实现对接硬件,接受硬件报文
    小黑受到了封校的恐惧,秋招结束该何去何从的日常积累:进程初步
    技术内幕 | StarRocks Pipeline 执行框架(下)
  • 原文地址:https://blog.csdn.net/Ananas_Orangey/article/details/126290933