写这题之前我以为会需要很多rootkit的前置知识,但是做完之后发现并不需要,但还是要知道内核模块相关的知识即LKM,以及内核处理syscall的过程。
逆向
逆向是解决问题的第一步,我们首先需要明白这一题的rootkit究竟做了什么事情。
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;
}
sct
即system 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后才被引入,所以直接使用系统调用表的绝对地址是可行的。
$~ 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_table
和sys_open
,其中sys_open
就是内核中真正用于处理系统调用open
的函数。取得系统调用表后,rootkit不能直接去修改表中对应系统调用的数据,还需要关闭写保护,关于写保护要细说起来就更麻烦了,这里简单的理解成开启内核内存的写权限就行。最终,通过在系统调用表对应位置写入hook函数sys_xxx_hooked
以完成系统调用的hook。
以sys_open_hooked
举例:
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
的两个参数分别放在寄存器eax
和edx
中。当open的参数含有flag
子串时,sys_open_hooked
会过滤掉这一系统调用不予处理,否则使用sys_open
执向的函数,即原本用于处理系统调用open的内核函数sys_open
。
总结一下rootkit.ko
做了以下几件事:
- 保留原本处理系统调用的内核函数地址至符号
sys_xxx
中。 - 将系统调用表中存放的相关函数地址更改为
sys_xxx_hooked
。 -
sys_xxx_hooked
函数对原本系统调用的参数进行检查,若不包含flag
子串则使用sys_xxx
处理系统调用,否则过滤不予执行。
解决
类比用户态pwn的一些技巧,很容易联想到劫持系统调用表的方式与修改GOT表类似。那么最直接的方法,直接还原系统调用表就可以了,即把我们需要的系统调用表中的open
所存放的数据还原成sys_open
的地址。其对应的kernel module代码也比较好写,我这里提供一份不完整的伪代码:
#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
替换成其他字符串:
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
,是通过如下方式赋值的:
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为:
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编码,然后:
cat antikit.base64 | base64 -d > antikit.ko
insmod antikit.ko
这样就可以打开flag了,但flag格式不是纯文本,而是压缩文件,tar xvf flag
就可以读到flag了。
Reference
- Linux Rootkits — Multiple ways to hook syscall(s)
- How does the Linux kernel handle a system call
- https://aufarg.github.io/pwnablekr-rootkit-400.html
- System.map
- Differences between ASLR, KASLR and KARL