内核和驱动文件的感染

新闻资讯   2023-06-07 18:00   81   0  




驱动文件感染


1.1. phrack原文


http://phrack.org/issues/61/10.html

--[ 1 - Introduction


rootkis是一种可以隐藏文件、进程,以及可以实现其它很多事情的内核模块(驱动),最初的rootkis,都是直接利用insmod加载,这样很容易通过lsmod被发现。目前,已经有很多隐藏内存模块的技术,比如Plaguez提出的方法,以及Adore Rootkit中使用的更加刁钻的方法,几年后,又出现了通过/dev/kmem,修改内核内存镜像的方法,最后,还出现了静态捆绑内核文件的方法(见:2.内核文件感染),这个方法解决了一个重要的问题:系统reboot后,rootkis还是加载的。

本节将介绍一种新的内核模块隐藏技术,并且也能在系统reboot之后,还是加载的。稍后将会基于linux 2.4.x内核环境(实验也适用于其它使用elf格式的系统),演示这种感染技术。不过,由于内核模块是elf格式的,所以阅读后续内容之前,先要熟悉elf格式(http://www.skyfree.org/linux/references/ELF_Format.pdf),并且要重点理解其符号表,之后才好明白,向正常内核模块注入感染代码的原理。

--[ 2 - ELF Basis


ELF(Executable and Linking),是Linux操作系统中可执行文件的格式,接下来仅仅介绍一下,感染内核模块,需要了解的部分:当链接2个elf文件时,需要知道这2个文件中包含哪个符号,每个elf文件(比如内核模块),都有2个包含符号信息的节区,下面就是对这2个节区的介绍。

额外补充


有些elf文件,下一步要由静态ld处理(比如.o、.ko文件),或由动态ld处理(比如.so文件),有些elf文件,加载到内存就可以直接运行(比如可执行文件、内核文件),而不管哪一种elf文件类型,都要为下一步处理,提供足够的信息,所以,elf文件类型不同,具体的组成内容是有区别的。



另外,首次学习elf格式时,很容易被大量的结构体,搞的眼花缭乱,这里先放一张示意图,展示了部分重要结构体之间的联系,用于辅助理解后续内容:


比如:init_module()函数体文件内偏移的查找过程为:
① 根据elf头部中的e_shoff成员,找到节区头表;
② 遍历节区头表,找到.symtab节区头部(类型为SHT_SYMTAB);
③ 根据.symtab节区头部中的sh_offset成员,找到.symtab节区内容,根据sh_link成员,找到符号名称所在节区(.strtab);
④ 遍历.symtab节区(即Elf32_Sym[]数组),根据Elf32_Sym的sh_link成员,在.strtab节区中找到符号名称;
⑤ 如果称号名称为”init_module”,根据Elf32_Sym的st_shndx和st_value成员,最终找到由init_module()函数编译出来的机器码,在.text节区中的某个位置。

----[ 2.1 - The .symtab section


.symtab节区(符号表)的内容,是一个供链接器使用的Elf32_Sym结构数组,Elf32_Sym结构定义在内核的/usr/include/elf.h头文件:

typedef struct
{
Elf32_Word st_name; /* Symbol name (string tbl index) */
Elf32_Addr st_value; /* Symbol value */
Elf32_Word st_size; /* Symbol size */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility */
Elf32_Section st_shndx; /* Section index */
} Elf32_Sym;

◆st_name

st_name代表符号名称的节内偏移(符号名称所在节区,存于.symtab节区头的sh_link成员中)。

◆st_value

符号本质上就是程序中的变量和函数名,也就是一块数据或指令所在空间的”代号”,它是上层语言诞生的基石,相应的,也必须被编译链接过程,映射为所在空间的位置,程序才能运行,因为cpu只能通过地址访问内存。

st_value成员正是用于,将符号一步步映射为加载地址:(1) 在重定位文件中(未经链接),只要不是.bss节区中的变量,就代表映射空间所在节内的偏移(这时,st_shndx成员为所在节区在节区表中的下标),否则代表占用空间的字节对齐数(未初始化的全局变量,不需要记录初始值,所以在elf文件中不占有空间,相应的,st_value暂时也就没有位置可指,只能为将来加载进内存的地址,记录对齐节字数);(2) 在可执行文件和共享库中(已链接),代表占用空间的加载地址(根据文件加载地址+符号的文件偏移,共同推算)。

◆st_size

符号所占空间的大小,根据变量类型确定,比如int类型占4字节,如果是函数,则为函数被编译为机器码后的大小。

◆st_info

用于记录符号属性,比如全局/局部、变量/函数等。

◆st_shndx

代表符号占用空间所在节区,st_shndx只在st_value表示节内偏移时有意义,当辅助ld推算出符号最终的加载地址后,就不再需要了,另外,如果符号占用空间所在节区为.bss,则st_shndx值为SHN_COMMON(0xfff2),这是为了避免,与真正存在于elf文件的节区下标冲突。

----[ 2.2 - The .strtab section


.strtab节区的内容,是一个字符串表,包含很多以’\0’字符结尾的字符串,通过修改.strtab节区,可以将指定函数,修改为其它名称,原文9.1节提供了ElfStrChange程序,所以就没有说明原理(不过对照”ELF Basis”一节中的”额外补充”内容,还是很好理解的),只是提醒修改时要遵守一个原则:新名称不能比旧名称长,否则就会覆盖旧名称后面的’\0’字符,以及后面的其它字符串。

这里再额外补充一点:假设elf文件中既有aabb符号,又有bb符号,.strtab中可能会让它们共享一个”aabb\0”字符串,所以如果这种情况将”bb”改掉,会导致”aabb”符号无效。

稍后的内容就会介绍,具体如何修改内核模块的函数名,并且最终实现向一个内核模块,注入另一个内核模块。

--[ 3 - Playing with loadable kernel modules


接下来先分析内核模块动态加载的代码,用于进一步理解内核模块感染的原理。

----[ 3.1 - Module Loading


内核模块是通过应用程序insmod加载链接的(高版本内核已经将链接功能移到了内核代码中,不过文中实验环境,使用的是Linux 2.4.x内核),所以需要分析insmod.c(代码见原文):

◆init_module()函数

(1) 定义并填充module结构变量,需要关注的是,module结构的init和cleanup成员,它们分别会被设置为,内核模块的init_module()和cleanup_module()函数地址;

(2) 调用obj_find_symbol()函数,遍历加载内核模块的符号表,查找init_module符号,传给obj_symbol_final_value()进一步获取函数地址;

(3) 调用obj_find_symbol()函数,遍历加载内核模块的符号表,查找cleanup_module符号,传给obj_symbol_final_value()进一步获取函数地址;

(4) module结构变量填充好之后,调用sys_init_module()函数,将内核模块加载到内核空间。

◆sys_init_module()函数

(1) 调用copy_from_user()函数,将内核模块文件内容,从用户空间,拷贝到内核空间;

(2) 执行module结构变量的init函数指针,即加载内核模块的init_module()函数。

----[ 3.2 - .strtab modification


前面已经说过,内核模块的初始化函数名称”init_module”,位于.strtab节区,如果将其改为内核模块中的另外一个函数名,比如”evil_module”,就能使内核模块加载时,调用evil_module()函数,而不是init_module()函数。

原文提供了一个简单的驱动程序test.c,包含init_module()、evil_module()、cleanup_module()三个函数,然后将test.c编译为test.o,并通过objdump查看其符号表,主要关注init_module、evil_module两个符号(用于修改后进行对比)。

这时,就可以按照以下顺序,修改.strtab节区了:
rename
1) init_module ------> dumm_module
2) evil_module ------> init_module

先将”init_module”改为”dumm_module”(避免和后续新的init_module重名),再将”evil_module”改为”init_module”(顶替),这些都可以通过原文9.1节提供的ElfStrChange程序进行修改:
./elfstrchange test.o init_module dumm_module
./elfstrchange test.o evil_module init_module

再用objdump查看符号表,包含的就是dumm_module、init_module两个符号了,并且init_module的文件偏移,为原来evil_module的文件偏移(0x14)。这是因为,只是”init_module”在.strtab节区中的位置变了,导致它对应的Elf32_Sym位置也变了,而这个位置上正是原来evil_module对应的Elf32_Sym,它的内容并没有改变,通过它的st_shndx和st_info成员,仍然得到0x14偏移值。

----[ 3.3 - Code injection


以上实验,证明了确实可以将evil_module(),伪装成init_module()函数,然而,如果能从外部,向内核模块注入evil_module()函数,那就更好了,好在这个目的可以利用ld命令轻松实现。

为了证实这一点,原文将上个实验中的test.c,划分为2份驱动代码,original.c包含init_module()和cleanup_module()函数,inject.c包含inje_module()函数,然后分别编译为original.o和inject.o,再利用ld的-r选项进行部分链接,最终展示了,得到的仍然是一个.o文件,并且通过objdump查看,同时包含init_module、cleanup_module、inje_module三个符号。这是因为内核模块本质是.o文件(即使后缀为.ko),可以与其它.o文件部分链接,拼接成一个.o文件(比如当前实验中的evil.o),并且和通过常规方式编译生成的内核模块(比如上个实验中的test.o),没有任何区别,也可以加载到内核。所以说,向已有内核模块,注入额外的代码,并不是什么难题。

----[ 3.4 - Keeping stealth


通常,都是对正常使用的内核模块进行感染,然后,将init_module()改为注入函数后,启动了感染者的功能,却丧失了内核模块本身的功能,这就很容易被发现做了手脚,不过,也有办法保留内核模块原本的功能,因为.strtab节区被修改之后,init_module()函数名被改为dumm_module,所以只要在注入的evil_module()函数中,调用一下dumm_module(),实际执行的就是真正的init_module()。

--[ 4 - Real life example


修改init_module()的方法,也完全适用于修改cleanup_module(),因此,以下展示将一个完整的内核模块(著名的Adore rootkit),注入到声卡驱动(i810_audio.o),处理过程其实非常简单:

----[ 4.1 - Lkm infecting mini-howto


1) 修改adore.c代码:
① 在init_module()函数中,添加dumm_module()函数调用
② 在cleanup_module()函数中,添加dummcle_module()函数调用
③ 将init_module()函数名,修改为evil_module
④ 将cleanup_module函数名,修改为evclean_module

2) 执行make编译adore

3) 使用"ld -r"部分链接adore.o和i810_audio.o,作为新的i810_audio.o

4) 按如下方式,使用elfstrchange修改.strtab节区:
replace
init_module  ------> dumm_module
evil_module  ------> init_module (内部调用dumm_module)
cleanup_module ------> evclean_module
evclean_module ------> cleanup_module (内部调用evclean_module)

5) 加载并测试新的i810_autio.o

原文9.2节提供了lkminfect.sh自动脚本,不过步骤1)中的前面2步,需要手动完成。

----[ 4.2 - I will survive (a reboot)


感染内核模块加载之后,有2个选择,并且各有利弊:

◆将感染内核模块,放进/lib/modules目录,替换正常的内核模块,这可以保证reboot后,感染功能仍会加载,但是,这样也有可能会被类似Tripwire的HIDS(主机入侵检测系统)检测到,不过,内核模块既不是可执行文件,也不是suid文件,所以除非将HIDS检测级别调到最高,否则不会被检测。

◆insmod之后,删掉感染内核模块,保持/lib/modules目录不变,这样,reboot之后,感染功能就消失了,但也不会被HIDS通过监测文件变化检测到。




内核文件感染


2.1. phrack原文


http://phrack.org/issues/60/8.html

--[ 1 - Introduction


本文介绍了一种方法,可以将Linux内核模块文件(LKM),捆绑到内核文件(linuxz),并且可以在系统reboot时被加载。相比insmod或者/dev/kmem实现的内核后门,这种方式更隐蔽,附件提供了具体的实现代码(依赖/boot/System.map文件),已经基于redhat 7.2进行安装和测试。

--[ 2 - Get kernel from the image

vmlinuz是一个统称,包括zImage和bzImage两种形式,是指vmlinux原始内核文件经过压缩之后,与其它功能合成的文件(详细原理,可以参考《Linux内核情景分析》第10章:系统的引导和初始化)。了解vmlinuz内核文件的结构,一方面为了从中剥出vmlinux文件,进而捆绑LKM,另一方面为了将捆绑后的vmlinux,再转回vmlinuz文件。

根据/usr/src/linux/arch/i386/boot/Makefile可以看出:zImage文件(bzImage与之相似),是根据bootsect、setup、compressed/vmlinux.out三个文件合成的。其中,bootsect、setup分别由bootsect.s、setup.s汇编链接生成,compressed/vmlinux.out是通过objcopy命令,根据compressed/vmlinux文件生成。

根据/usr/src/linux/arch/i386/boot/compressed/Makefile可以看出:compressed/vmlinux文件(注意:顶层目录中的vmlinux才是原始内核文件),是由piggy.o、head.o和misc.o链接而成。其中,misc.c中定义了decompress_kernel()函数,head.S会调用该函数,解压内核文件,解压过程中会引用input_len和input_data两个符号,它们定义在piggy.o文件中,piggy.o只包含.data节区:

piggy.o: $(SYSTEM)
tmppiggy=_tmp_$$$$piggy; \
rm -f $$tmppiggy $$tmppiggy.gz $$tmppiggy.lnk; \
$(OBJCOPY) $(SYSTEM) $$tmppiggy; \
gzip -f -9 < $$tmppiggy > $$tmppiggy.gz; \
echo "SECTIONS { .data : { input_len = .; \
LONG(input_data_end - input_data) input_data = .; \
*(.data) input_data_end = .; }}" > $$tmppiggy.lnk;
$(LD) -r -o piggy.o -b binary $$tmppiggy.gz -b elf32-i386 -T \
$$tmppiggy.lnk;
rm -f $$tmppiggy $$tmppiggy.gz $$tmppiggy.lnk

文章没有说明$(SYSTEM)的值,但是根据完整的Makefile(比如:https://elixir.bootlin.com/linux/2.4.0/source/arch/i386/boot/compressed/Makefile),就会发现:SYSTEM = $(TOPDIR)/vmlinux(即顶层目录中的vmlinux),也就是说,piggy.o是根据原始内核文件生成的,具体过程为:

① 执行objcopy,将原始内核文件中的部分内容(-R移除.note和.comment节区,-S移除调试信息),拷贝到$tmppiggy临时文件;

② 将$tmppiggy文件,压缩为$tmppiggy.gz;

③ 生成链接脚本$tmppiggy.lnk:

SECTIONS {
.data : {
input_len = .;
LONG(input_data_end - input_data)
input_data = .;
*(.data)
input_data_end = .;
}
}

显然,根据$tmppiggy.lnk链接生成的文件,只包含.data节区,并且.data节区包含一个LONG值,以及所有参与链接的文件的.data节区集合,其中,LONG值保存了.data节区集合的长度,所在地址用input_len符号记录,.data节区集合的地址,用input_data符号记录,misc.c中可以通过extern引用这两个变量,就是因为它们在.data节区中存在。

④ 执行ld,链接生成piggy.o:
-b binary $$tmppiggy.gz:将整个$tmppiggy.gz文件内容,添加到.data节区集合;
-T $$tmppiggy.lnk:按照$tmppiggy.lnk的指示进行链接。

结论:
编译生成原始vmlinux -> 压缩从原始vmlinux中objcopy出来的内容,作为.data节区,生成piggy.o -> 链接piggy.o、head.o和misc.o,生成compressed/vmlinux -> 根据compressed/vmlinux,objcopy得到compressed/vmlinux.out -> 链接bootsect、setup和compressed/vmlinux.out,生成zImage。

--[ 3 - Allocate some space in image to use


加载内核时,会把紧接着内核文件的内存区域,作为.bss节区的存储空间,会被head.S中的代码,全部清为0(.bss节区的内容初始一定为全0,所以elf文件中只会记录起始和结束位置),因此,不能直接将LKM捆绑在vmlinux文件后面,否则加载到内存后,内容很快就会被擦除掉,而是先要追加一段与.bss节区同等大小的内容(文中称为”dummy data”),再追加LKM。

除此以外,还要解决另外一个问题,先看一下内容启动过程中执行的代码:



图中鼠标选中部分,估计是作者复制错了位置,以下根据linux-2.4.0内核代码截取(https://elixir.bootlin.com/linux/2.4.0/source/arch/i386/kernel/setup.c#L598):



图中的代码,将_end(即.bss节区的结束地址)赋值给了init_mm.brk和start_pfn,其中,start_pfn的值是个页面号,代表可供内核动态分配的第一个页面。显然,这个值需要调整,跳过紧接在.bss节区后面的LKM内容,否则内核的动态分配+写操作,会将加载到内存中的LKM覆盖掉。

下图是从《Linux内核情况分析》截取:


--[ 4 - Relocate the symbol in module file


本节主要表达,.o文件中的很多地址值,在编译阶段是无法确定的,所以编译器会在.o文件中,留下重定项信息,供链接器在后期确认后加以修改。关于重定位原理,我在这篇文章中深入介绍过:32位elf格式中的10种重定位类型(https://bbs.kanxue.com/thread-246373.htm)



然而,捆绑的LKM,是我们自己追加到vmlinux内核文件后面的,不是通过ld程序链接进去的,所以LKM中的重定项,也需要我们自己处理。

--[ 5 - Make it autorun when reboot


解决以上所有问题之后,还得找个时机,触发捆绑LKM的加载,也就是调用LKM的init_module()函数。jbtzhm提供的方法是,再在dummy data与捆绑LKM之间,添加如下指令块:


然后,再修改内核的系统调用表,具体来说,就是将SYS_getpid系统调用号对应的表项(因为reboot时,该系统调用一定会被执行,很多程序(比如init)都会调用getpid()函数),修改为init_code[]的地址,进入init_code[]后,就会在真正调用sys_getpid()之前,悄悄先加载捆绑的LKM。

另外,这段指令中涉及的地址,要根据init_code[]的实际存放位置,以及具体的内核符号表,才能确定(具体见附件中kpatch.c的实现)。

--[ 6 - Possible solutions


根据内核文件感染的原理可以看出,感染过程需要获取__bss_start、_end、sys_call_table这些符号的地址,并且本文提供的感染工具,是根据/boot/System.map文件获取的,但是删除/boot/System.map文件,并不能防御感染,因为Silvio展示过直接从内核获取内核符号地址的方法,所以,最终要通过检查内核文件内容是否有变化,检查是否被感染。

--[ 7 - Conclusion


[2]~[5]节,介绍了感染的大体流程,至于实现过程中要解决的一些具体问题,只是抛出,并未给出答案,所以附件中提供了一份基于redhat 7.2环境开发和测试的感染程序,后续通过分析这份代码,就能明白完整的思路了。

--[ 8 - References


参考文档链接。

--[ 9 - Appendix: The implementation


附件需要通过以下步骤,还原成kpatch.tgz文件,然后解压:
① 创建xx.txt,并将[begine, end]区间的内容,复制进去;
② uudecode xx.txt # 还原kpatch.tgz(如果没有uudecode命令,要先安装sharutils);
③ tar zxvf kpatch.tgz # 解压得到kpatch目录,里面包含kpatch.c等代码文件。

2.2. kpatch代码分析


常规情况下,LKM都是在加载时,才会由insmod和内核,使其跟vmlinux内容进行链接,捆绑的LKM显然要尽早完成链接,否则,等到可以触发insmod执行的时候,LKM可能早已被其它应用程序,通过执行getpid()系统调用执行过了,而那个时候还没有完成链接,就会导致系统崩溃。所以,kpatch程序的主要部分,就是一个链接器。

既然是链接器,就要对LKM中的很多内容进行修改,比如根据重定项,将目标位置修改为全局变量运行时的内存地址等,因此,kpatch代码中会有很多赋值语句。为了防止看代码的时候犯晕,有必要事先明确:kpatch是对自己read()的LKM内核进行修改,修改值是在reboot后运行时使用,所以赋值语句的左值位置和右值范围,一定如下图所示:



另外,要执行LKM,就要访问LKM的.text、.data等节区,加载到内存后,LKM所在的这块内存,是由谁分配的?

分析kpatch代码的时候就会看到,kpatch会修改vmlinux中的一条mov语句(源操作数为start_pfn),将其目的操作数改大,从而将LKM的大小也包含进去,这样,内核启动阶段,就会分配这块内存,而LKM是某个应用程序执行getpid()系统调用时触发执行的,这个时候内核显然已经启动完成,换句话说,这块内存也已经分配了(页目录表中有映射)。

下图是从《Linux内核情况分析》截取:



这就相当于欺骗内核,分配更多的保留页面,但实际能用到的并没有变多,也就是说内核并不会对LKM所在内存区域执行写操作,因此LKM的内容也不会糟到破坏。除此之外,在”Allocate some space in image to use”一节中还提到,修改start_pfn,还会将LKM移到内核的堆区之外,所以可以说是一举两得。
 
kpatch.c及其调用关系:


程序中的0xC0100000值,包含2部分:0xC0000000是内核空间的地始虚拟地址,0x100000来自链接脚本,是内核文件加载的起始地址。

下图是从《Linux内核情况分析》截取:





看雪ID:jmpcall

https://bbs.kanxue.com/user-home-815036.htm

*本文为看雪论坛优秀文章,由 jmpcall 原创,转载请注明来自看雪社区


# 往期推荐

1、在 Windows下搭建LLVM 使用环境

2、深入学习smali语法

3、安卓加固脱壳分享

4、Flutter 逆向初探

5、一个简单实践理解栈空间转移

6、记一次某盾手游加固的脱壳与修复




球分享

球点赞

球在看

文章引用微信公众号"看雪学苑",如有侵权,请联系管理员删除!

博客评论
还没有人评论,赶紧抢个沙发~
发表评论
说明:请文明发言,共建和谐网络,您的个人信息不会被公开显示。