如果在入侵事件调查中,传统的工具完全失效了,你该怎么办?当我在对付入侵者已经加载的内核模块时,就陷入了这种困境。由于从用户空间升级到了内核空间,LKM方式的入侵改变了以往使用的入侵响应的技术。一旦内核空间遭破坏,影响将覆盖到整个用户空间,这样入侵者无须改动系统程序就能控制他们的行为。而用户即使将可信的工具包上传到被入侵的主机,这些工具也不再可信。下面我将揭示恶意的内核模块如何工作,并且给出一些我开发的对付此类入侵的工具。
LKM概述
LKM的存在对系统管理员是个福音,对入侵检测却是个噩梦。lkm最初被设计用来无须重新启动而改变运行中的内核,从而提供一些动态功能。动态内核提供了对诸如新文件系统类型和网卡等设备的额外支持。此外,由于内核模块能够访问内核的所有调用和存储区,它能不受控制地改动整个操作系统的各个部位,因而所有调用和内存常驻的结构都有被恶意内核模块修改的危险。
lkm的一个臭名昭著的例子是knark。一旦knark编译并加载到入侵主机,将改变系统调用表从而改变操作系统的行为。系统调用表常驻在内核空间,基本上是提供给用户级别程序访问操作系统的入口。大多数unix系统在手册的第二部分给出syscalls的正式定义。一旦内核作为用户空间运行,OS将把命令行上运行的所有命令和调用映像到系统调用表中。因此当knark改变系统调用表时也就改变了用户命令的执行。knark改动了以下的重要系统调用。
* getdents - 获得目标路径的目录项内容(即文件和子目录)。通过修改这个调用,knark实现对用户程序隐藏文件和目录。
* kill - 向进程发送信号,通常是杀掉进程。修改过的调用将使用无用的信号31,触发设置进程为"hidden"状态。当进程在hidden状态时,它在/proc中的纪录被删除,从而实现了对ps命令隐身。信号32被用来解除隐藏状态。
* read - 读取目标文件的内容。knark通过修改此调用实现对netstat隐藏入侵者的连接。
* ioctl - 改变文件和设备的状态。通过修改此调用,knark能够隐藏网卡的混杂位,同时在调用中插入了隐藏文件的函数。
* fork - 派生新进程。knark修改用来隐藏一个隐藏的父进程所派生的所有子进程。
* execve - 执行一个程序。每次用户在命令行下输入命令时调用。一旦此调用被劫持,内核模块可以控制命令的选择和运行。knark使入侵者可以把一个程序指向另一个,如同符号连接一样,而不留下罪证。knark控制了execve后,任何你希望执行的程序都有可能是入侵者的替代品。
* settimeofday - 设置系统时间。knark用来监控预定的时间。当这些预定时间之一被送给此系统调用时,knark可以触发某些管理任务或者立即赋予当前用户root的用户和组id。这样就无需更改到suid的shell而直接获得root权限。
由于系统调用被更改,那些管理工具的功能也被更改了。netstat将永远不报告网卡的混杂模式,来自特定地点的连接也被隐藏。ps和top命令不会报告隐藏的进程,因为/proc中没有信息。ls将跳过隐藏的文件和目录。所有这些,都是因为此类工具依靠操作系统提供信息,而入侵者在控制了操作系统后就能够向来自用户空间的请求反馈虚假情报,并且无需改动netstat,ps,top和ls程序的二进制文件。因此,tripwire一类的文件系统校验工具对这类工具将失效,也无法防备knark的执行重定向功能。如果入侵者将hackme连接到cat上,每次cat被调用,实际上是hackme在执行。这样,cat仍然保留在系统上,md5校验码也没有改变,但执行的功能却改变了。
更糟糕的是,将一套新的工具上传到被knark入侵的主机也无济于事。即使是可信的工具一样要使用系统调用,于是他们也变得不再可信。目前还无法绕过入侵者在内核级别的陷阱,除非我们也进入内核空间。基于此,我开发了检测系统是否安装了恶意LKM的工具。
之前有一点我们没有提及,lsmod会报告装载了knark.o模块。不幸的是,入侵者能轻易的将此信息抹去。knark同时还包括了另一个LKM叫做modhide,能够隐藏自身以及上一个模块。一旦模块隐藏,如果不重启动机器就无法卸载,而且没有简单的方法检测到模块的加载,所有的相关信息都不见了。正如之前介绍的,knark的所有功能令其成为终极秘密武器。
预防方法
阻止LKM破坏显然是最佳解决方案。我们有几种方法能够提前预防lkm。可以通过保护系统调用表来预防大部分的恶毒lkm。我们可以构造一个简单的lkm,定时的或者在其他模块加载时监控系统调用表。如果它发现系统调用表改变了,可以通知系统管理员甚至将调用表修改回原来的值。下面的例子能很好的工作在linux 2.2和2.4上。如果你的机器有超过一个处理器,可以用如下命令编译:gcc -D __SMP__ -c syscall_sentry.c。如果是单处理器,去掉-D __SMP__就行了。编译成功后,用insmod加载。
/*
* This LKM is designed to be a tripwire for the sys_call_table.
*/
#define MODULE_NAME "syscall_sentry"
/* This definition is the time between periodic checks. */
#define TIMEOUT_SECS 10
#define MODULE
#define __KERNEL__
#include<linux/module.h>
#include<linux/config.h>
#include<linux/version.h>
#include<linux/kernel.h>
#include<linux/sys.h>
#include<linux/param.h>
#include<linux/sched.h>
#include<linux/timer.h>
#include<sys/syscall.h>
/* This function is a simple string comparison function */
static int mystrcmp( const char *str1, const char *str2)
{
while(*str1 && *str2)
if (*(str1++) != *(str2++))
return -1;
return 0;
}
/* This function builds a timer struct for versions of linux
* less than linux 2.4. It is used to set a timer
*/
#if LINUX_VERSION_CODE < KERNEL_VERSION(2,4,0)
/* Initializes a timer */
void init_timer(struct timer_list * timer)
{
timer->next = NULL;
timer->prev = NULL;
}
#endif
/* This is our timer */
static struct timer_list syscall_timer;
/* This is the system’s syscall table */
extern void *sys_call_table[];
/* This is the saved, valid syscall table */
static void *orig_sys_call_table[ NR_syscalls ];
/* This function is needed to protect yourself */
static unsigned long (*orig_init_module) (const char *, struct module*);
/* This function checks the syscalls for changes
* and changes them back to the original if it has
* been changed.
*/
static int check_syscalls( void )
{
int i;
/* Add a new timer for our next check */
del_timer( &syscall_timer );
init_timer( &syscall_timer );
syscall_timer.function = (void *)check_syscalls;
syscall_timer.expires = jiffies + TIMEOUT_SECS * HZ;
add_timer( &syscall_timer );
for ( i = 0; i < NR_syscalls - 1; i++ )
{
if (orig_sys_call_table[i] != sys_call_table[i])
{
printk(KERN_INFO "
SysCallSentry - sys_call_table has been
modified in entry %d!
", i);
sys_call_table[i] = orig_sys_call_table[i];
}
}
return 1;
}
/* Check sys_call_table anytime a new module is loaded. */
static int long sys_init_module_wrapper( const char *name, struct
module *mod )
{
int i;
int res = (*orig_init_module)(name,mod);
for ( i = 0; i < NR_syscalls - 1; i++ )
{
if (orig_sys_call_table[i] != sys_call_table[i])
{
printk( KERN_INFO "
SysCallSentry - sys_call_table has been
modified in entry %d!
", i);
sys_call_table[i] = orig_sys_call_table[i];
}
}
return res;
}
/* Module Init Code */
static int init_module (void)
{
int i;
printk(KERN_INFO "
SysCallSentry Inserted
");
/* Initiate the periodic timer */
init_timer( &syscall_timer );
/* Save the old values of the sys_call_table */
orig_init_module = sys_call_table[SYS_init_module];
/* Wrap the init_module syscall. This will check to see
* if any calls have been altered when a new module loads.
*/
sys_call_table[SYS_init_module] = sys_init_module_wrapper;
for ( i=0; i < NR_syscalls - 1; i++ )
{
orig_sys_call_table[i] = sys_call_table[i];
}
/* Start our first check */
check_syscalls();
return(0);
}
/* Module Cleanup Code */
static void cleanup_module (void)
{
/* Return system status to the original */
sys_call_table[SYS_init_module] = orig_init_module;
printk(KERN_INFO "
SysCallSentry Removed
");
}
目前的lkm工具使用的技术都是修改系统调用表。因此,由于系统调用表在实际应用中极少改变,为调用表增加一个哨兵的做法是可行的。也许真正彻底的方法是完全禁止使用lkm。成品服务器应该将需要的全部编译到内核中,并禁止使用lkm。
另外还有一种防护恶意lkm的方法。一个叫做"St.Jude"的工具和"St.Michael"一起,基于在学习态中生成的规则集,分别监控对系统调用表的修改,和检查root状态的转换,作为入侵的证据。(http://www.sourceforge.net/projects/stjude).
调查工具和技术的研究
很显然,要有效的响应内核级别的入侵,必须检查机器的内核空间。因此,我们必须改变使用的工具和技术。假定在涉及到knark的入侵事件时,都建立了机器存储设备的备份。这样,我们就能获得所有隐藏的文件。我们无法获得的是隐藏的进程和网络信息。通过开发一个类似ps的内核级工具搜集每个进程的运行镜像,可以对此作出补救。这个工具应该是一个lkm,以便事故发生后能动态加载。本节将讲述这样一个工具以及他如何在linux 2.2平台上进行工作。
内核级别的ps最重要的数据结构是task_struct。它是系统当前所有进程的循环链表。表中有进程所有的行为信息,例如打开的文件,进程的执行镜像,打开的网络套接字,文件操作符等等。下面这些字段非常重要,并将写入日志中。
* 进程ID(PID) - 识别运行进程的唯一号码。
* 用户ID - 运行进程的用户号码。了解进程运行的权限级别很重要。
* 进程状态 - 指明进程目前如何运行。进程不能总是占用全部的cpu,有时会处于睡眠状态。此标志指明进程的运行状态。
* 进程名称 - 等同于执行进程的命令。
* 开始时间 - 从系统启动到进程运行所经过的系统时钟单位数。用来确定进程什么时候开始运行。显然在系统启动时使用启动脚本运行的进程数值相对较小。同时也未确定入侵时间提供了更多的线索。
* 打开文件句柄 - UNIX中什么都是文件,所以察看进程的打开文件句柄就能看见所有打开的常规文件,网络套接字和FIFO。这些信息在跟踪进程项文件存储信息(比如sniffer)或者打开套接字(比如后门)等有用。
* 命令行参数 - 在解释进程执行的选项时有用。比如,设想入侵者运行netcat,除非你获得命令行参数,否则很难观察到入侵者连接到哪里,而结构里的命令行参数则包含了netcat的ip地址和端口。
* 进程环境变量 - 每个运行的进程都有自己的环境变量表。一般来说,它是运行用户在执行进程时的环境表的拷贝。因此,检查这个表有助于获得入侵者入侵回话的额外信息。
由此,我们的工具应当遍历此双循环链表,将每个进程的信息记录到日志中。记录与ps -ef命令的结果很类似,因此我们能够轻松阅读结果。另外,将创建一个包含进程运行镜像的独立文件,供将来的离线工具分析。在linux中访问进程执行镜像不太容易直接完成,但仍然可能。镜像驻留在任务结构的内存映像中,在此内存映像结构中有一块虚拟内存区和任务相关联。内存区中的虚拟内存文件包含了文件操作符数组。一旦我们找到适当的读取函数,将执行映像读取出来并写到另一个文件中就非常简单了。理论上,由于进程在运行后其二进制代码可能被删除,进程应将可执行映像完全载入到内存中。获得映像最困难的部分是找到它在内存中的什么地方。
当我们的模块运行时,任何其他进程不能调度运行。中断和其他系统活动仍然可以发生,但是此模块优先运行。由于此模块“冻结”了进程表,我把它命名为“硝酸甘油”。源代码可以在http://www.foundstone.com 找到,作为在以后版本的linux和其他操作系统开发类似工具的基础。
最后一个判断knark是否在你的系统中加载的依据是查看网卡的状态。当knark加载,网卡将永远不会报告处于混杂状态。这是为了防止系统管理员注意到入侵者把网卡置于混杂状态的嗅探器。实际上,你需要做的,仅仅是运行tcpdump或者其他嗅探器并且用ifconfig观察网卡的状态。如果网卡仍不处于混杂状态,可能knark已经加载了。
结论
可加载模块对入侵响应来说是一个明显的冲击。破坏性的可加载模块已经公诸于众,并且可能已经被许多入侵者使用。这将入侵和入侵检测提高到了内核级别。尽管看起来进行调查时内核模块是很明显的要素,但这不是致命的。我们可以看到能够采取简单的方法来保护自己免受此类攻击。此外,对此类攻击的检测工具和技术同样利用运行在内核空间向入侵者开火 smile