pwnable.kr rootkit

写这题之前我以为会需要很多rootkit的前置知识,但是做完之后发现并不需要,但还是要知道内核模块相关的知识即LKM,以及内核处理syscall的过程。

逆向

逆向是解决问题的第一步,我们首先需要明白这一题的rootkit究竟做了什么事情。

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
undefined4 init_module(void)

{
int iVar1;

sct = 0xc15fa020;
sys_open = _DAT_c15fa034;
sys_openat = _DAT_c15fa4bc;
sys_symlink = _DAT_c15fa16c;
sys_symlinkat = _DAT_c15fa4e0;
sys_link = _DAT_c15fa044;
sys_linkat = _DAT_c15fa4dc;
sys_rename = _DAT_c15fa0b8;
sys_renameat = _DAT_c15fa4d8;
wp();
iVar1 = sct;
*(code **)(sct + 0x14) = sys_open_hooked;
*(code **)(iVar1 + 0x49c) = sys_openat_hooked;
*(code **)(iVar1 + 0x14c) = sys_symlink_hooked;
*(code **)(iVar1 + 0x4c0) = sys_symlinkat_hooked;
*(code **)(iVar1 + 0x24) = sys_link_hooked;
*(code **)(iVar1 + 0x4bc) = sys_linkat_hooked;
*(code **)(iVar1 + 0x98) = sys_rename_hooked;
*(code **)(iVar1 + 0x4b8) = sys_renameat_hooked;
wp();
*(undefined4 *)(__this_module._4_4_ + 4) = __this_module._8_4_;
*(undefined4 *)__this_module._8_4_ = __this_module._4_4_;
__this_module._4_4_ = 0x105a4;
__this_module._8_4_ = 0x105a4;
return 0;
}

sctsystem call table,顾名思义,system call table把syscall ID映射到对应实现syscall的内核函数地址。内核在处理syscall时并不会直接去在内核中寻找对应实现syscall的内核函数,而是以系统调用号作为偏移,在系统调用表中索引实现syscall的内核函数地址。于是,使用最多也是最经典的rootkit方法就是劫持系统调用表,通过篡改系统调用表中存放的数据以劫持系统调用。Linux内核提供了简单的获取内核函数和符号地址的方法,简单的来说,当内核编译选项CONFIG_KALLSYMS开启时,内核会将符号地址存放在文件/proc/kallsyms中。需要注意的是,rootkit.ko直接使用了系统调用表的绝对地址0xc15fa020,但在如今大部分的Linux kernel中是行不通的,当KASLR选项开启时,内核函数的地址会在每次重启内核时发生变化。通过uname -a可以知道pwnable.kr上使用的内核大版本号为3.7,而KASLR这一特性在3.14后才被引入,所以直接使用系统调用表的绝对地址是可行的。

1
2
3
4
5
6
7
8
9
10
$~ cat /proc/kallsyms | grep sys_call_table  
c15fa020 R sys_call_table

$~ cat /proc/kallsyms | grep sys_open
c106c7c0 W compat_sys_open_by_handle_at
c1158bc0 T do_sys_open
c1158d70 T sys_open
c1158db0 T sys_openat
c11a37b0 T sys_open_by_handle_at
c11b47d0 t proc_sys_open

kallsyms可以找到一些重要的符号地址,比如sys_call_tablesys_open,其中sys_open就是内核中真正用于处理系统调用open的函数。取得系统调用表后,rootkit不能直接去修改表中对应系统调用的数据,还需要关闭写保护,关于写保护要细说起来就更麻烦了,这里简单的理解成开启内核内存的写权限就行。最终,通过在系统调用表对应位置写入hook函数sys_xxx_hooked以完成系统调用的hook。

sys_open_hooked举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
undefined4 sys_open_hooked(undefined4 param_1,undefined4 param_2,undefined4 param_3)

{
char *pcVar1;
undefined4 uVar2;
char *in_stack_ffffffec;
char *in_stack_fffffff0;

mcount();
pcVar1 = strstr(in_stack_ffffffec,in_stack_fffffff0);
if (pcVar1 == (char *)0x0) {
uVar2 = (*sys_open)(param_1,param_2,param_3);
}
else {
printk("You will not see the flag...\n");
uVar2 = 0xffffffff;
}
return uVar2;
}

Ghidra和IDA反编译都看不到函数strstr的参数字符串flag,这是因为内核中传参的调用约定与用户态不同,汇编能看到strstr的两个参数分别放在寄存器eaxedx中。当open的参数含有flag子串时,sys_open_hooked会过滤掉这一系统调用不予处理,否则使用sys_open执向的函数,即原本用于处理系统调用open的内核函数sys_open

总结一下rootkit.ko做了以下几件事:

  1. 保留原本处理系统调用的内核函数地址至符号sys_xxx中。
  2. 将系统调用表中存放的相关函数地址更改为sys_xxx_hooked
  3. sys_xxx_hooked函数对原本系统调用的参数进行检查,若不包含flag子串则使用sys_xxx处理系统调用,否则过滤不予执行。

解决

类比用户态pwn的一些技巧,很容易联想到劫持系统调用表的方式与修改GOT表类似。那么最直接的方法,直接还原系统调用表就可以了,即把我们需要的系统调用表中的open所存放的数据还原成sys_open的地址。其对应的kernel module代码也比较好写,我这里提供一份不完整的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

#define ___NR_open_ 5

static int __init antikit_init(void)
{
void** sct = 0xc15fa020;
void* sys_open = 0xc1158d70;
wp();
// x86 write protection
sct[___NR_open_] = sys_open;
wp();
return 0;
}

static void __exit antikit_exit(void)
{
}

module_init(antikit_init);
module_exit(antikit_exit);

麻烦之处在于需要找服务器对应版本的Linux Header去编译,所以我这里详细解释第二种方法,也是我主要参考的方式。

既然编译kernel module很麻烦,那么直接修改原本的rootkit是否可行呢?答案是肯定的。分析一下系统调用被过滤掉的主要原因,即sys_xxx_hooked函数的被写入了系统调用表中,那么重写系统调用表就可以再次hook系统调用到正常的sys_xxx函数中去。

那能联想到最朴素的一个思路就是,修改原本rootkit中的sys_xxx_hooked函数的汇编代码,或者把flag子串替换成无意义的字符串。除此之外,原本的rootkit已经存在于内核模块中,还需要把module name即rootkit替换成其他字符串:

1
2
3
4
5
6
7
8
with open("./rootkit", "rb") as f:
rootkit = f.read()

antikit = (
rootkit.replace(b"\x75\x1d", b"\x90\x90")
.replace(b"\x75\x24", b"\x90\x90")
.replace(b"rootkit", b"antikit")
)

我这里把jnz指令替换为两个nop,从而令控制流改变。这个过程还算简单,但直接放在服务器上跑是行不通的,我们需要再次分析sys_xxx_hooked的逻辑。再次insmod的过程的确改变了系统调用表中存放的地址,但sys_xxx_hooked使用的并不是内核内存中的真正用于处理系统调用的sys_xxx函数,而是从系统调用表中获得的函数地址!在系统启动时rootkit就被装载入内核中,此时内核系统调用表中存放的函数地址已经被替换为sys_xxx_hooked,仅仅替换子串再次加载module只会再次调用第一次rootkit装载时使用的sys_xxx_hooked,这条路似乎走向了瓶颈。

再次仔细查看init_module的实现方式,我们需要注意到sys_xxx_hooked通过保存在.bss段的全局变量sys_xxx从系统调用表中获取对应的sys_xxx函数地址,注意这两者的区别,一个是全局变量,另一个是真正存放在内存中用于处理系统调用的内核函数地址。

而全局变量sys_xxx,是通过如下方式赋值的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                 undefined init_module()
00010300 55 PUSH EBP

00010301 a1 34 MOV EAX,[DAT_c15fa034]
a0 5f
c1

00010306 89 e5 MOV EBP,ESP
004
00010308 c7 05 MOV dword ptr [sct],0xc15fa020
40 07
01 0...

00010312 a3 3c MOV [sys_open],EAX
07 01
00

那么答案很简单了,只需要把MOV EAX,[DAT_c15fa034]这条命令修改为MOV EAX, [ADDR OF sys_open]sys_xxx_hooked就会直接调用sys_open而不是第一个rootkit的sys_open_hooked。所以最终修改后的rootkit为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from base64 import b64encode

with open("./rootkit", "rb") as f:
rootkit = f.read()

antikit = (
rootkit.replace(b"\x75\x1d", b"\x90\x90")
.replace(b"\x75\x24", b"\x90\x90")
.replace(b"\xa1\x34\xa0\x5f\xc1", b"\xb8\x70\x8d\x15\xc1")
.replace(b"rootkit", b"antikit")
)
antikit_b64 = b64encode(antikit)
with open("./antikit_b64", "wb") as f:
f.write(antikit_b64)

服务器上不能直接传rawdata,所以大部分解决方式都使用了base64传输本地patch后的rootkit,我是用vi保存生成的base64编码,然后:

1
2
cat antikit.base64 | base64 -d > antikit.ko
insmod antikit.ko

这样就可以打开flag了,但flag格式不是纯文本,而是压缩文件,tar xvf flag就可以读到flag了。

Reference

  1. Linux Rootkits — Multiple ways to hook syscall(s)
  2. How does the Linux kernel handle a system call
  3. https://aufarg.github.io/pwnablekr-rootkit-400.html
  4. System.map
  5. Differences between ASLR, KASLR and KARL