欢迎新同学的光临
… …
人若无名,便可专心练剑
我不是一条咸鱼,而是一条死鱼啊!
【本篇纯理论搬的,暂时不具备这块的知识体系,看的有点槽,准备后面慢慢补充一些实验结果以及实操记录,感兴趣的可以关注该专栏:https://blog.csdn.net/ananas_orangey/category_11673665.html】
注:【最后的最后,真的五体投地佩服文中里的大佬,是真的大佬,向大佬们看起】
0x01 前言的内容,直接到0x02 车机(Android)设备中监控命令执行的一些想法在入侵检测的过程中,进程创建监控是必不可少的一点,因为攻击者的绝大多数攻击行为都是以进程的方式呈现,例如进程发起网络连接请求传输数据,并可能产生文件读写行为。所以从进程的角度出发关联出命令执行、网络连接、读写文件,可以快速分析出大量安全攻击场景,还原出入侵行为攻击的链路脑图
切记,不管是事前、还是事中、甚至事后的攻击行为,都可以依赖进程数据为基础进行入侵行为的基础数据分析,并结合不同的攻击向量,多维度快速高效的分析出攻击行为或异常攻击行为
命令行终端:实际是一个叫bash/sh的端终程序提供的功能,该程序底层的实质还是调用一些操作系统提供的函数
再开始之前之前先来了解内核的一些东西和实例,本篇主要还是理论篇,无实操
Ring 权限关系图:

从内到外依次使用0-3标识,这些环(ring)。越内部的圈代表的权限越大。内圈可以访问,修改外圈的资源;外圈不可以访问,修改内圈的资源
Data segment selector中使用了2个bits来描述权限。我们最常见的是ring 0(内核态),和ring 3(用户态)。因为例如Windows和Unix这些常见的操作系统,只提供了两种权限模式,所以并没有完全使用整个ring架构。所以,一般情况下,完全可以使用ring 0 表示内核态,ring 3表示用户态
Linux进程监控,通常使用hook技术,而hook大概分为两类:
系统调用:是内核提供给应用程序使用的功能函数,由于应用程序一般运行在 用户态,处于用户态的进程有诸多限制(如不能进行 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)来调用
宏就是_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; \
}
这些宏定义于include\asm\Unistd.h,这就是为什么你在程序中要包含这个头文件的原因。该文件中还以__NR_name的形式定义了164个常数,这些常数就是系统调用函数name的函数指针在系统调用表中的偏移量
系统调用表定义于entry.s的最后。这个表按系统调用号(即前面提到的__NR_name)排列了所有系统调用函数的指针,以供系统调用入口函数查找。从这张表看得出,linux给它所支持的系统调用函数取名叫sys_name
系统调用入口函数定义于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
这段代码现保存所有的寄存器值,然后检查调用号(__NR_name)是否合法(在系统调用表中查找),找到正确的函数指针后,就调用该函数(即你真正希望内核帮你运行的函数)。运行返回后,将调用ret_from_sys_call,这里就是著名的进程调度时机之一
当在程序代码中用到系统调用时,编译器会将上面提到的宏展开,展开后的代码实际上是将系统调用号放入ax后移用int 0x80使处理器转向系统调用入口,然后查找系统调用表,进而由内核调用真正的功能函数
自己添加过系统调用的人可能知道,要在程序中使用自己的系统调用,必须显示地应用宏_syscallN。而对于linux预定义的系统调用,编译器在预处理时自动加入宏_syscall3(int,ioctl,arg1,arg2,arg3)并将其展开。所以,并不是ioctl本身是宏替换符,而是编译器自动用宏声明了ioctl这个函数。
从系统分析的角度,linux的系统调用涉及4个方面的问题:
函数名以“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项,余下的项是可供用户自己添加的系统调用空间
宏定义_syscallN()见include/asm/unisted.h)用于系统调用的格式转换和参数的传递。N取0~5之间的整数。参数个数为N的系统调用由_syscallN()负责格式转换和参数传递。系统调用号放入EAX寄存器,启动INT 0x80后,规定返回值送EAX寄存器。
对系统调用的初始化也就是对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就是所有系统调用的总入口
当进程需要进行系统调用时,必须以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
从system_call入口的汇编程序的主要功能是:
_sys_call_table和EAX持有的系统调用号找出并转入系统调用响应函数ret_from_sys_call(arch/i386/kernel/entry.S)INT 0X80的返回值非负,则直接按类型type返回;否则,将INT 0X80的返回值取绝对值,保留在errno变量中,返回-1以ret_from_sys_call入口的汇编程序段在linux进程管理中起到了十分重要的作用。所有系统调用结束前以及大部分中断服务返回前,都会跳转至此处入口地址。 该段程序不仅仅为系统调用服务,它还处理中断嵌套、CPU调度、信号等事务
系统调用响应函数的函数名约定,函数名以“sys_”开头,后跟该系统调用的名字,由此构成164个形似sys_name()的函数名,因此,系统调用ptrace()的响应函数是sys_ptrace() (kernel/ptrace.c)
系统调用号,文件include/asm/unistd.h为每个系统调用规定了唯一的编号:
#define __NR_setup 0
#define __NR_exit 1
#define __NR_fork 2
… …
#define __NR_ptrace 26
以系统调用号__NR_name作为下标,找出系统调用表sys_call_table (arch/i386/kernel/entry.S)中对应表项的内容,正好就是该系统调用的响应函数sys_name的入口地址
系统调用表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)
… …
sys_call_table记录了各sys_name函数(共166项,其中2项无效)在表中的位子。有了这张表,很容易根据特定系统调用在表中的偏移量,找到对应的系统调用响应函数的入口地址。NR_syscalls(即256)表示最多可容纳的系统调用个数。这样,余下的90项就是可供用户自己添加的系统调用空间
从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取0与5之间任意整数。参数个数为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)
宏定义的余下部分描述了启动INT 0X80和接收、判断返回值的过程。也就是说,以系统调用号对EAX寄存器赋值,启动INT 0X80。规定返回值送EAX寄存器。函数的参数压栈,压栈顺序见下表:
| 参数 | 参数在堆栈的位置 | 传递参数的寄存器 |
|---|---|---|
| arg1 | 00(%esp) | ebx |
| arg2 | 04(%esp) | ecx |
| arg3 | 08(%esp) | edx |
| arg4 | 0c(%esp) | esi |
| arg5 | 10(%esp) | edi |
若INT 0X80的返回值非负,则直接按类型type返回;否则,将INT 0X80的返回值取绝对值,保留在errno变量中,返回-1
系统调用功能模块的初始化,对系统调用的初始化也即对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
如下内容来自: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"
adb shell cat /proc/kallsyms | grep sys_call_table
sys_call_table=xxxx4即为需要找到的Android系统调用表的内存基址,后面很多Android系统调用的系统函数调用地址都需要通过这个基址加函数的偏移计算出来
一般的系统调用都是调用0x80号中断,所有的系统调用都是通过int 0x80,接下我们来看看int 0x80是如何执行的,这是一个系统中断,操作系统对于中断处理流程一般为:
IRET),从而返回主程序继续运行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程序,这个程序负责
/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指定的动态库搜索路径:".dynamic"段中,由DT_NEED类型的项表示,动态链接器会按照这个路径去查找DT_RPATH所指定的路径,编译目标代码时,可以对gcc加入链接参数"-Wl,-rpath"指定动态库搜索路径
DT_NEED段中保存的是绝对路径,则动态链接器直接按照这个路径进行直接加载DT_NEED段中保存的是相对路径,动态链接器会在按照一个约定的顺序进行库文件查找下列路径
/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的:

.so文件劫持LD_PRELOAD1)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;
}
用于劫持函数的.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);
}
编译:
gcc -o test main.c
gcc -fPIC -shared -o hook.so hook.c -ldl
运行:
LD_PRELOAD=./hook.so ./test 123

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调用延时极其严格的情况,如果延时过长可能会导致原始业务逻辑代码执行失败
如果需要不仅仅是替换掉原有库函数,而且还希望最终将函数逻辑传递到原有系统函数,实现透明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
*/
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));
我们知道,如果当前进程空间中已经存在某个同名的符号,则后载入的so的同名函数符号会被忽略,但是不影响so的载入,先后载入的so会形成一个链式的依赖关系,通过RTLD_NEXT可以遍历这个链
3)SO功能代码编写
这个小节我们来完成一个基本的进程、网络、模块加载监控的小demo
1. 指令执行
1) execve
2) execv
2. 网络连接
1) connect
3. LKM模块加载
1) init_modulec
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);
}
编译,并装载
//编译出一个so文件
gcc -fPIC -shared -o hook.so hook.c -ldl
添加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. 指令执行
在代码中手动调用: execve(argv[1], newargv, newenviron);
2. 网络连接
执行: nc www.baidu.com 80
3. LKM模块加载
编写测试LKM模块,执行: insmod hello.ko
在真实的环境中,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 的定义正是此值
Relevant Link:
4)劫持效果测试
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
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

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

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);
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
加载模块:insmod hello.ko

execve/execv、connect、init_module hooksnoopy会监控服务器上的命令执行,并记录到syslog
本质上,snoopy是利用ld_preload技术实现so依赖劫持的,只是它的工程化完善度更高,日志采集和日志整理传输这方面已经帮助我们完成了
#cat /etc/ld.so.preload
/usr/local/snoopy/lib/snoopy.so
Message Queue)通信的Hook模块消息队列提供了一种在两个不相关的进程之间传递数据的相当简单且有效的方法,但是对于消息队列的使用,很容易产生几点安全风险:
message queue的权限控制没有严格控制,让任意非root用户也可以从消息队列中读取消息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);
}
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函数,即调用链是断开的
*/
在这个新的Bash下执行的指令,因为都不会调用到snoopy的hook函数,所以也不会被记录下来
Relevant Link:
在Linux下,除了使用LD_PRELOAD这种被动Glibc API注入方式,还可以使用基于调试器(Debuger)思想的ptrace()主动注入方式,总体思路如下
Relevant Link:
通过静态链接方式编译so模块:
gcc -o test test.c -static
在静态链接的模式下,程序不会去搜索系统中的so文件(不管是系统默认的、还是第三方加入的),所以也就不会调用到Hook SO模块
使用内嵌汇编的形式直接通过syscall指令使用系统调用功能,同样也不会调用到Glibc提供的API
asm("movq $2, %%rax\n\t syscal:"=a"(ret));
Relevant Link:
PLT HOOK,可以通过ptrace注入和加壳的形式
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:
LD_PRELOAD的挂载。对于inline hook,通过对原有指令返回位置的汇编代码作污点标记,通过查找jmp,push ret等指令校验0x02 车机(Android)设备中监控命令执行的一些想法
检测思路:
如下内容来自:https://www.cnblogs.com/LittleHann/p/3854977.html
传统的kernel inline hook技术就是修改内核函数的opcode,通过写入jmp或push ret等指令跳转到新的内核函数中,从何达到劫持的目的
其实就是在运行时替换函数指令,原理是每个函数的调用前几个字节指令是固定的,可以通过类似jump等指令执行其他指令

jmp offset"来实现的,即CPU根据offset来进行一个偏移量的跳转以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);
在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引擎应该直接忽略跳过,避免重复劫持
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;
}
对于这类劫持攻击,目前常见的做法是fireeye的"函数返回地址污点检测",通过对原有指令返回位置的汇编代码作污点标记,通过查找jmp,push ret等指令来进行防御
system_call->sys_call_table进行系统调用Hook我们知道,要对系统调用(sys_call_table)进行替换,却必须要获取该地址后才可以进行替换。但是Linux 2.6版的内核出于安全的考虑没有将系统调用列表基地址的符号sys_call_table导出,但是我们可以采取一些hacking的方式进行获取
因为系统调用都是通过0x80中断来进行的,故可以通过查找0x80中断的处理程序来获得sys_call_table的地址。其基本步骤:
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");
Makefile
obj-m := find_sys_call_table.o
编译
make -C /usr/src/kernels/2.6.32-358.el6.i686 M=$(pwd) modules
测试效果:dmesg| tail

获取到了sys_call_table的基地址之后,我们就可以修改指定offset对应的系统调用了,从而达到劫持系统调用的目的
Relevant Link:
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
2、通过/boot/System.map-2.6.32-358.el6.i686文件查找
cd /boot
grep sys_call_table System.map-2.6.32-358.el6.i686
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之类的
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);
}
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;
}
要特别注意的是代码中进行函数地址搜索的代码: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

可以看到,系统调用表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");
kallsyms_lookup_name()可以用于获取内核导出符号表中的符号地址,而sys_call_table的地址也存在于内核导出符号表中,我么可以使用kallsyms_lookup_name()获取到sys_call_table的基地址
(void**)kallsyms_lookup_name("sys_call_table");
Relevant Link:
kprobe简介
kprobe是一个动态地收集调试和性能信息的工具,它从Dprobe项目派生而来,它几乎可以跟踪任何函数或被执行的指令以及一些异步事件。它的基本工作机制是:
int 3的指令码int 3的异常执行中,通过通知链的方式调用kprobe的异常处理函数pre_handler钩子,存在则执行EFLAGS中的TF标志位,并且把异常返回的地址修改为保存的原指令码post_handler流程,并最终返回从原理上来说,kprobe的这种机制属于系统提供的"回调订阅",和netfilter是类似的,linux内核通过在某些代码执行流程中给出回调函数接口供程序员订阅,内核开发人员可以在这些回调点上注册(订阅)自定义的处理函数,同时还可以获取到相应的状态信息,方便进行过滤、分析
kprobe实现了三种类型的探测点:
在本文中,我们可以使用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. 等所有这些运行完毕后,紧跟在被探测指令后的指令流将被正常执行
在使用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);
整个编码顺序为:
声明pre_handler()->声明post_handler()->声明fault_handler()->设置struct kprobe->调用register_kprobe()进行内核回调机制注册
注册了回调函数之后,我们就相当于劫持了指定的内核系统调用函数,则新的系统调用执行流程为:
pre_handler->被Hook原函数->post_handler
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处理函数原型必须与被探测函数完全一样
在使用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);
整个编码顺序为:
声明注册回调函数()->声明出错处理函数()->设置struct jprobe->调用register_jprobe()进行内核回调机制注册
注册了回调函数之后,我们就相当于劫持了指定的内核系统调用函数,则新的系统调用执行流程为:
注册回调劫持函数->jprobe_return()恢复现场->被Hook原函数
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,缺省值为10和2*NR_CPUS中的最大值,否则缺省值为NR_CPUS)
7.4 如果maxactive被设置的太小了,一些探测点的执行可能被丢失,但是不影响系统的正常运行,在结构kretprobe中nmissed字段将记录被丢失的探测点执行数,它在返回探测点被注册时设置为0,每次当执行探测函数而没有kretprobe_instance可用时,它就加1
在使用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);
整个编码顺序为:
声明kretprobe_handler()->声明出错处理函数()->设置struct kretprobe->调用register_kretprobe()进行内核回调机制注册
注册了回调函数之后,我们就相当于劫持了指定的内核系统调用函数,则新的系统调用执行流程为:
被Hook原函数会照常先执行->当原始函数的返回点位置会执行一次我们注册的kretprobe_handler()->恢复现场继续原始的系统调用
了解了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");
Makefile
obj-m := do_fork.o
编译:
make -C /usr/src/kernels/2.6.32-358.el6.i686 M=$(pwd) modules
加载内核模块:
insmod do_fork.ko
测试效果:
dmesg| tail
cat /proc/kallsyms | grep do_fork
do_fork的地址与kprobe注册的地址一致,可见,在kprobe调试模块在内核停留期间,我们编写的内核监控模块劫持并记录了系统fork出了新的进程信息
jprobe编程示例
**kretprobe编程示例 **
Relevant Link:
Linux安全模块(LSM)是Linux内核的一个轻量级通用访问控制框架。它使得各种不同的安全访问控制模型能够以Linux可加载内核模块的形式实现出来,用户可以根据其需求选择适合的安全模块加载到Linux内核中,从而大大提高了Linux安全访问控制机制的灵活性和易用性
目前已经有很多著名的增强访问控制系统移植到Linux安全模块(LSM)上实现,包括如下:
POSIX.1e capabilitiesLinux安全模块(LSM)有如下特点:
POSIX.1e capabilities逻辑,作为一个可选的安全模块为了满足这些设计目标,Linux安全模块(LSM)采用了通过在内核源代码中放置钩子的方法,来"仲裁"对内核内部对象进行的访问,这些对象如下:
在LSM机制,Linux执行系统调用的流程如下:
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提供了划分传统超级用户特权并赋给特定的进程的功能
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);
将该文件以模块的形式放到security/下编译进内核,启用新的内核后,当你操作文件test.txt时,通过dmesg命令就能再终端看到"you can have your control code here!"
Relevant Link:
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);
}
这样,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);
Relevant Link:
传统的hook劫持方法通过替换sys_call_table[]数组中的函数地址,来截获系统调用,但是如果要监控所有的API, 那么需要重新编写所有API的替代函数(需要为每一个Hook单独编写一个hooded_handler函数),而linux kernel 2.6.18中大概有300多个系统调用函数
为了解决这个问题,我们可以通过这样一种思维模式模式去思考
关于int 80中断劫持的相关知识,请参阅另一篇文章
void set_idt_handler(void *system_call))和通过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);
使用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这种阻塞性的系统调用,从用户态发起系统调用到最后从内核态返回会经历一个很长的时间,此时模块的引用计数会一直处于大于零的状态,而无法卸载

为了解决这个问题,我们的内核模块需要能够实现以下目标
sys_call_table hook能够针对单个function hook point做细粒度的开关sys_call_table hook的replace、restore动作要能够"原子实现",保证操作系统的系统调用流能无缝的进行切换push、ret方式构造特殊的栈空间(下面画图详细说明)
关于LSM Hook技术,请参阅另一篇文章
Relevant Link:
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时,就可以劫持控制流了
Hook技术是进行主动防御、动态入侵检测的关键技术,从技术上来说,目前的很多Hook技术都属于"猥琐流",即:
但是随着windows的PatchGuard的出现,这些出于"安全性"的内核patct将被一视同仁地看作内核完整性的威胁者
更加优美、稳定的方法,包括如下:
系统命令执行的监控,也就是对外部进程创建的监控。在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[]);
劫持libc/glibc中的函数,就是我们前言内容和本文后面说的so preload劫持
内核态:
Netlink ConnectorAuditHook execve syscall进程事件数据采集方案:
sys_call_table地址,hook fork、exec、connect等系统调用地址更改成内核模块中自定义函数地址
netlink connector) :由 Linux 内核提供的接口,安全可靠,结合用户态轻量级 ncp 自实现应用程序,抓取全量进程事件,能够覆盖更多安全检测场景,并对主机影响面较小netlink connector process)应用程序接收netlink进程事件消息先说说传统LInux主机检测命令执行的一些方式,包括如下:
Bash历史指令的history syslog功能
w命令
top命令
/proc/[pid]/stat和/proc/[pid]/status,这个两个proc的接口进行读取各个进程的信息find命令
find /proc -path '/proc/sys/fs' -prune -o -print |xargs ls -al | grep 'exe ->' | sort -usys/fs用于描述系统中的所有文件系统,排除/proc/sys/fs搜索所有exe表示具体的执行路径lsof 命令
lsof -nPi | grep -v "127.0.0.1" |grep -v "::1" |sort -u'Sydig是Linux开源,跨平台,功能强大且灵活的系统监控
系统日志
Web日志
沙盒(Androdeb + 自编内核 + 内核移植 + 高本版内核)
Debian aarch64镜像,并可以通过 apt 等包管理工具安装所需要的编译工具链,从而在上面编译和运行 bcc 等 Linux 项目。其中的关键之处在于正确挂载原生 Android 中的映射,比如 procfs、devfs、debugfs 等KPROBES的支持,这意味着需要自行编译和加载内核,详细内容移步:https://evilpan.com/2022/01/03/kernel-tracing/#android-%E7%A7%BB%E6%A4%8DBCC在使用上会出现各种问题,这通常是内核版本的原因。由于 eBPF目前在内核中也在频繁更新,因此许多新的特性并没有增加到当前内核上。因此,为了减少可能遇到的兼容性问题,尽量使用最新版本的内核,当然通常厂商都只维护一个较旧的LTS 版本,只进行必要的安全性更新,如果买机不淑的话就需要自食其力了
strace命令
strace 是个非常合适的工具,因为它基于PTRACE_SYSCALL 去跟踪并基于中断的方式去接管所有系统调用,因此即便目标使用了不依赖 libc 的内联 svc 也可以被识别到。不过这个缺点也很明显,从名称也看出来,本质上该程序是基于ptrace 对目标进行跟踪,因此如果对方代码中有反调试措施,那么就很有可能被检测到zygote fork 而出,因此使用 strace比较不容易确定跟踪时机,而且由于许多应用有多个进程,就需要对输出结果进行额外的过滤和清洗strace的实现原理可以参考: How does strace work?jtrace
strace程序还不支持 arm64,因此 Jonathan Levin 在编写Android Internal 一书时就写了jtrace 这个工具,旨在用于对 Android 应用的跟踪。虽然现在 Google 也在 AOSP 中支持了 strace,但 jtrace 仍然有其独特的优点:
setprop/getprop)InputReader)--plugin 参数或者JTRACE_EXT_PATH 环境变量指定的路径加载插件,从而实现自定义的系统调用参数解析处理PTRACE_SYSCALL 进行系统调用跟踪的,因此还是很容易被应用的反调试检测到jtrace 的内容: jtrace - 具有插件架构的增强型、Linux/Android 感知 straceFrida
Instrumentation),支持使用 JS 脚本来对目标应用程序进行动态跟踪。其功能之丰富毋庸置疑,但也有一些硬伤,比如:
ebpf
extended Berkeley Packet Filters),其主要用在流量统计上,也可以用来监控CPU/IO/内存等模块的状态。简单来说,eBPF可以与内核的kprobe/tracepoints/skfilter等模块相结合,将eBPF的函数hook到内核事件从而监控相应的系统状态fork() 系统调用,我们编写 eBPF 程序有多种方式,比如使用原生 eBPF 汇编来编写,但使用原生 eBPF 汇编编写程序的难度较大;也可以使用 eBPF 受限的 C 语言来编写,难度比使用原生 eBPF 汇编简单些;最简单的方法是使用 BCC 工具来编写,BCC 工具帮我们简化了很多繁琐的工作,比如不用编写加载器eBPF对内核的版本有较高的要求,不同版本的内核对 eBPF 的支持可能有所不相同。所以使用 eBPF 时,最好使用最新版本的内核LLVM/CLang 编译器,将 eBPF 程序编译成 eBPF 字节码bpf() 系统调用把 eBPF 字节码加载到内核bpf() 系统调用把 eBPF 字节码加载到内核时,内核先会对 eBPF 字节码进行安全验证JIT(Just In Time)技术将 eBPF 字节编译成本地机器码(Native Code)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 发行版上的 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
……
答: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
内核监控方案/工具可大概分为三类:
KprobesUprobesTracepointsUserland Statically Defined Tracing,也称为USDT&&前端包含的工具:ftraceperfeBPF
BCCbpftraceSystemTapLTTngtrace-cmdkernelshark
| 内核监控方案 | 静态 | 动态 | 内核 | 用户 |
|---|---|---|---|---|
| Kprobes | ✔ | ✔ | ||
| Uprobes | ✔ | ✔ | ||
| Tracepoints | ✔ | ✔ | ||
| USDT | ✔ | ✔ |
简单来说,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");
安装该内核模块后,每当系统中的进程调用 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替代
拓展阅读:
uprobe顾名思义,相对于内核函数/地址的监控,主要用于用户态函数/地址的监控。听起来是不是有点神奇,内核怎么监控用户态函数的调用呢?
站在用户视角,我们先看个简单的例子,假设有这么个一个用户程序:
// test.c
#include
void foo() {
printf("hello, uprobe!\n");
}
int main() {
foo();
return 0;
}
编译好之后,查看某个符号的地址,然后告诉内核我要监控这个地址的调用:
$ 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
运行用户程序并检查内核的监控返回:
$ ./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)
关闭监控:
$ 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
上面关闭监控的接口是基于 debugfs(在较新的内核中使用 tracefs),即读写文件的方式去与内核交互实现 uprobe 监控。其中写入 uprobe_events时会经过一系列内核调用:
probes_writecreate_trace_uprobekern_path:打开目标 ELF文件alloc_trace_uprobe:分配 uprobe结构体register_trace_uprobe:注册 uproberegiseter_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 的明文信息等
拓展阅读:
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
...
在 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
以 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
其中trace_pipe 是输出的管道,以阻塞的方式进行读取,因此需要先开始读取再获取/dev/urandom,然后就可以看到类似上面的输出。这里输出的格式是在内核中定义的
关闭trace 监控:
$ echo 0 > /sys/kernel/debug/tracing/events/random/urandom_read/enable
$ echo 0 > /sys/kernel/debug/tracing/tracing_on
根据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)
);
其中:
random_read:trace 事件的名称,不一定要内核函数名称一致,但通常为了易于识别会和某个关键的内核函数相关联。隶属于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);
}
值得注意的是实际上是在urandom_read_nowarn函数中而不是urandom_read 函数中调用的,因此也可见注入点名称和实际被调用的内核函数名称没有直接关系,只需要便于识别和定位即可。根据上面的介绍我们可以了解到,tracepoint 相对于 probe 来说各有利弊:
优缺点:
kprobe 跟踪的内核函数可能在下个版本就被改名或者优化掉了另外,tracepoint 除了在内核代码中直接定义,还可以在驱动中进行动态添加,用于方便驱动开发者进行动态调试,复用已有的 debugfs 最终架构。这里有一个简单的自定义 tracepoint 示例,可用于加深对 tracepoint 使用的理解
拓展阅读:
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);
}
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)
在使用 trace 工具(如 BCC、SystemTap、dtrace) 对该应用进行追踪时,会在启动过程中修改目标进程的对应地址,将其替换为 probe ,在触发调用时候产生对应事件,供数据收集端使用。通常添加 probe 的方式是 基于 uprobe 实现的
使用 USDT 的一个好处是应用开发者可以在自己的程序中定义更加上层的追踪点,方便对于功能级别监控和分析,比如 node.js server 就自带了 USDT probe 点可用于追踪 HTTP 请求,并输出请求的路径等信息。由于 USDT 需要开发者配合使用,不符合我们的需求
注:USDT 不算是一种独立的内核监控数据源,因为其实现还是依赖于 uprobe
总结:如上所述了几种当今内核中主要的监控数据来源,基本上可以涵盖所有的监控需求。不过从易用性上来看,只是实现了基本的架构,使用上有的是基于内核提供的系统调用/驱动接口,有的是基于 debugfs/tracefs,对用户而言不太友好,因此就有了许多封装再封装的监控前端
拓展阅读:
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
perf 是 Linux 发行版中提供的一个性能监控程序,基于内核提供的 perf_event_open 系统调用来对进程进行采样并获取信息。Linux 中的 perf 子系统可以实现对 CPU 指令进行追踪和计数,以及收集kprobe、uprobe 和 tracepoints 的信息,实现对系统性能的分析。
在 Android 中提供了一个简单版的 perf 程序 simpleperf,接口和 perf 类似
虽然,可以监测到系统调用,但缺点是无法获取系统调用的参数,更不可以动态地修改内核。因此,对于渗透测试而言作用不大,更多是给 APP 开发者和手机厂商用于性能热点分析。值得一提的是,perf 子系统曾经出过不少漏洞,在 Android 内核提权史中也曾经留下过一点足迹
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
该汇编指令表示令过滤器只接受 IP 包,并且来源 IP 地址为1.2.3.4。其中的指令集可以参考 Linux Socket Filtering aka Berkeley Packet Filter (BPF)
eBPF 在 BPF 指令集上做了许多增强(extend):
R0 - R9)jt/jf 的目标替换为jt/fall-through,简单来说就是 else 分支可以默认忽略bpf_call 指令以及对应的调用约定,减少内核调用的开销内核存在一个 eBPF 解释器,同时也支持实时编译(JIT)增加其执行速度,但很重要的一个限制是 eBPF 程序不能影响内核正常运行,在内核加载 eBPF 程序前会对其进行一次语义检查,确保代码的安全性,主要限制为:
具体的限制策略都在内核的eBPF verifier中,不同版本略有差异。值得一提的是,最近几年 Linux 内核出过很多 eBPF 的漏洞,大多是 verifier 的验证逻辑错误,其中不少还上了 Pwn2Own,但是由于权限的限制在 Android 中普通应用无法执行 bpf(2) 系统调用,因此并不受影响
eBPF 和 perf_event类似,通过内核虚拟机的方式实现监控代码过滤的动态插拔,这在许多场景下十分奏效。对于普通用户而言,基本上不会直接编写 eBPF 的指令去进行监控,虽然内核提供了一些宏来辅助 eBPF 程序的编写,但实际上更多的是使用上层的封装框架去调用,其中最著名的一个就是 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()
执行该 python 代码后,每当系统中的进程调用 clone 系统调用,该程序就会打印 “Hello World” 输出信息。 可以看到这对于动态监控代码非常有用,比如我们可以通过 python 传入参数指定打印感兴趣的系统调用及其参数,而无需频繁修改代码
eBPF 可以获取到内核中几乎所有的监控数据源,包括 kprobes、uprobes、tracepoints等等,官方 repo 中给出了许多示例程序,比如 opensnoop 监控文件打开行为、execsnoop 监控程序的执行

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(); }'
官方同样也给出了许多.bt 脚本示例,可以通过其代码进行学习和编写
拓展阅读:
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)实时安装和卸载监控模块,但是内核模块需要适配内核版本应用级:
/etc/ld.so.preload劫持系统调用glibc加入监控代码ptrace()主动注入glibc库做的监控,只要直接陷入int 0x80中断,即可绕过glibc库直接调用系统调用内核级:
API Inline HookSyscall(sys_call_table) hookIDT HookLinux Security Module)Netlink ConnectorAPI 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个函数:
int_module:第一个函数是在把模块插入内核时调用的cleanup_module:第二个函数则在删除该模块时调用需要注意,由于内核模块是内核的一部分,所以能访问所有内核资源。根据对linux系统调用机制的分析,如果要增加系统调用,可以编写自己的函数来实现,然后在sys_call_table表中增加一项,使该项中的指针指向自己编写的函数,就可以实现系统调用
为什么要使用内核模块的方式添加系统调用?
注:编译内核,如果投入到Android 系统批量运营,定制化系统,要不然只是把其中一台Android系统编译好的内核模块在一个机子上运行成功后,如果移植到另外一个机子上马上就会出现错误,为什么呢?因为每个机子上sys_call_table的地址可能不一样。可以使用如下命令查看系统调用表sys_call_table的地址(虚拟地址)
cat /proc/kallsyms | grep sys_call_tables
需要注意,还有一个需要关注的就是预留的系统调用号,可以使用预留的系统调用号(my_syscall_num),如下(arch/x86/include/asm/unistd.h文件中查看预留的系统调用号,该预留的系统调用号为223):
#define my_syscall_num 223
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");
如下内容来自:https://www.freebuf.com/column/208928.html
基础知识:
libc.so这个动态链接库中so preload的机制,它允许定义优先加载的动态链接库,方便使用者有选择地载入不同动态链接库中的相同函数结合上述两点不难得出,我们可以通过 so preload 来覆盖 libc.so中的 execve等函数来监控进程的创建
Demo
#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);
}
该文件的主要部分就是重新定义了 execve函数,在原始的 execve执行之前打印可执行文件的名字。
gcc hook.c-fPIC-shared-o hook.soecho'/path/to/hook.so'>/etc/ld.so.preload
使用条件
优点:
缺点:对于使用方法的第四步,可能大家会有疑问:为什么一定要重新获取 shell 才可以看到效果呢?这是因为其实在当前 shell 下执行命令(也就是执行 execve)的实际上是当前的 shell 可执行程序,例如 bash ,而 bash 所需的动态链接库在其开始运行时就已确定,所以我们后续添加的 preload 并不会影响到当前 bash ,只有在添加 preload 之后创建的进程才会受 preload 的影响
/etc/ld.so.preload中写入后门,以方便其对机器的持久掌控,这种情况下这种监控方式也会失效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.c是 Netlink 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.14cat/boot/config-$(uname-r)|egrep'CONFIG_CONNECTOR|CONFIG_PROC_EVENTS'优缺点:
/proc// ,这就存在时间差,可能有数据丢失Linux Audit 是 Linux 内核中用来进行审计的组件,可监控系统调用和文件访问
go-audit ,也可替换为常用的 auditd ),并通过 Netlink 套接字通知给内核架构图如下:

Demo
从上面的架构图可知,整个框架分为用户态和内核态两部分,内核空间的 kauditd 是不可变的,用户态的程序是可以定制的,目前最常用的用户态程序就是 auditd ,除此之外知名的 osquery 在底层也是通过与 Audit 交互来获取进程事件的。下面我们就简单介绍一下如何通过 auditd 来监控进程创建
首先安装并启动 auditd :
apt update && apt install auditd
systemctl start auditd && systemctl status auditd
auditd 软件包中含有一个命名行控制程序 auditctl,我们可以通过它在命令行中与 auditd 进行交互,用如下命令创建一个对 execve这个系统调用的监控:
auditctl -a exit,always -F arch=b64 -S execve
再通过 auditd 软件包中的 ausearch来检索 auditd 产生的日志:
ausearch -sc execve | grep /usr/bin/id
整个过程的执行结果如下:

至于其他的使用方法可以通过man auditd和man auditctl来查看
使用条件:
cat/boot/config-$(uname-r)|grep^CONFIG_AUDIT优缺点
Netlink Connector ,获取的信息更为全面,不仅仅是 pidNetlink Connector和 Audit 都是 Linux 本身提供的监控系统调用的方法,如果我们想拥有更大程度的可定制化,我们就需要通过安装内核模块的方式来对系统调用进行 hook
目前常用的 hook 方法是通过修改 sys_call_table( Linux 系统调用表)来实现,具体原理就是系统在执行系统调用时是通过系统调用号在 sys_call_table中找到相应的函数进行调用,所以只要将 sys_call_table中 execve对应的地址改为我们安装的内核模块中的函数地址即可
YSRC 的这篇关于驭龙 HIDS 如何实现进程监控的文章:

关于 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
lsmod|grep sysdig
sysdig evt.type=execve
最终的执行效果如下:

有关于 Sysdig 的更多信息可以访问其 wiki 进行获取,另外,Sysdig 团队推出了一个专门用于安全监控的工具 Falco ,Falco 在 Sysdig 的基础上抽象出了可读性更高的检测规则,并支持在容器内部署,同样,大家如果感兴趣可以访问其 wiki 获取更多信息
使用条件
优缺点:
So preload:Hook 库函数,不与内核交互,轻量但易被绕过Netlink Connector :从内核获取数据,监控系统调用,轻量,仅能直接获取 pid ,其他信息需要通过读取 /proc// 来补全
Netlink Connector的方式,此方式在保证从内核获取数据的前提下又足够轻量,方便进行定制化开发Audit:从内核获取数据,监控系统调用,功能多,不只监控进程创建,获取的信息相对全面
Netlink Connector的方式,此方式在保证从内核获取数据的前提下又足够轻量,方便进行定制化开发Syscall hook:从内核获取数据,监控系统调用,最接近实际系统调用,定制度高,兼容性差【待补充,后面有空再补充一个实际案例的检测(其实主要是太菜了,不会弄 O(∩_∩)O哈哈~)】
下面命令中的路径在不同系统可能不同,即有些系统只有其中一个或两个都有,两个都有的话,功能其实都是一样的,如下:
/sys/kernel/tracing/sys/kernel/debug/tracing博主这里用树莓派4做的测试,环境如下:
adb shell 进入设备中,首先查看是否支持syscall调用的追踪,如果出现CONFIG_HAVE_SYSCALL_TRACEPOINTS=y那就说明支持,如下:
zcat /proc/config.gz | grep TRACEPOINT

执行下面两个命令,对系统调用之前的状态进行追踪,其中第一个需要root权限
echo 1 > /sys/kernel/debug/tracing/events/raw_syscalls/sys_enter/enable
echo 1 > /sys/kernel/debug/tracing/tracing_on

如果不想对系统调用之前的状态进行追踪的话,只需要改为0 即可,避免消耗系统性能,命令如下:
echo 0 > /sys/kernel/debug/tracing/events/raw_syscalls/sys_enter/enable
echo 0 > /sys/kernel/debug/tracing/tracing_on
相关的输出会保存到/sys/kernel/debug/tracing/trace,而默认有一个格式,执行命令查看:
head /sys/kernel/debug/tracing/trace

执行命令展示的结果表示会打印PID信息,可以过滤下PID,减少输出的日志信息。然后就是日志信息会实时输出到/sys/kernel/debug/tracing/trace_pipe中
查看指定PID的系统调用,下图中NR后面的数字就是系统调用号,命令如下:
cat /sys/kernel/debug/tracing/trace_pipe | grep 2351

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

注:除了sys_enter,还有sys_exit,是系统调用结束后的状态,我们可以使用sys_enter开启的方式来开启sys_exit的系统调用追踪,执行如下命令即可开启sys_exit系统调用追踪的功能:
echo 1 > /sys/kernel/debug/tracing/events/raw_syscalls/enable
需要注意:
/sys/kernel/debug/tracing/events下面会有许多类型的事件,它们都可以通过向对应的enable管道写入配置,开启tracing_on,实现对应信息的追踪/sys/kernel/debug/tracing/events下面有许多文件夹,每个文件夹下可能还有文件夹/sys/kernel/debug/tracing/events/enable则是最顶层的控制开关,如果向其中写入1,那么将记录所有内置的事件,此时就会输出非常多内容如下内容来自: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
完整的内置命令列表,请参考 shell内置命令:http://c.biancheng.net/view/1136.html
如何Patch Shell解释器 ? 原理很简单,对shell解释器的输入进行修改,将输入写入到文件中,进行分析即可。shell解释器很多,以bash举例:
-c参数输入命令stdin输入命令在这两个地方将写文件的代码嵌入进去即可
对抗命令监控:
注:上面说的第一种和第二种方法算是比较根本的方法,没有真实的数据,策略模型就无法命中目标并告警,第三种方法需要较多的经验,但是通过混淆命令绕过静态检测策略,也是比较常见的
已知的绕过命令监控的方案:用户态glibc/libc exec劫持,Patch Shell解释器,内核态的execve监控,均可被绕过
方法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
或重写LD_PRELOAD环境变量,但这样的动作较大,容易被发现
[root@VM_0_13_centos ~]# tcsh -c
"echo hello"hello
只要使用了execve执行了命令,就绝对逃不过内核态execve syscall的监控,除非你把防御方的内核驱动给卸载了。既然如此,那怎么绕过呢?
在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/
使用的是linux中另一个syscall: ptrace。ptrace是用来调试程序用的,使用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/
我自横刀向天笑,去留肝胆两昆仑