一、前 言
二、虚拟地址获取
三、函数地址获取
四、shellcode编译
五、测试shellcode
六、总 结
在内核态漏洞利用、ko模块动态注入、USMA或者ebpf提权时,我们需要编写一段位置无关代码(position-Indenpendent Code )的shellcode的代码来提供给内核执行,以达成内核态漏洞利用、动态功能注入及系统行为控制等目的。
既然需要保证shellcode的通用性,那么就需要默认在shellcode执行开始时,所有的常规可变寄存器中的值都是无法确定的,我们需要找到一种确定的方式来获得一些必要的地址内存信息。即使是RSP或者RIP这种功能几乎固定的寄存器,也很难保证一定能够偏移到我们想要的地方。
直接映射区
在x64内核/Documentation/arch/x86/x86_64/mm.rst中有这样一段描述:
从0xffff888000000000开始,存在一个64TB大小的直接映射区,这段虚拟内存直接顺序映射了全部的物理内存。也就是说,在未开启KASLR时,page_offset_base+offset地址直接对应物理内存的offset地址。而在开启了KASLR时,KASLR会为物理内存实际映射地址前添加一个偏移,在这时物理地址offset对应的直接映射区虚拟地址变成了page_offset_base+kaslr_offset+offset,未被映射部分则为空洞,无实际意义。
在多处理器系统中,为了减少各个cpu同时访问导致的竞争问题,Linux采用了Per-CPU机制来缓解。内核会为每个CPU分配一块物理内存,其中包含了CPU缓存以及一些非共享的变量,如order0大小page的缓存池等。而为了方便每个CPU迅速访问到自己的Per-CPU缓存区,Linux选择将地址保存在gs_base寄存器中,CPU通过gs:[]的方式来访问自己的缓存。
如上图所示,当前CPU的gs_base为0xffff95c103400000,处于直接映射区,通过下述代码即可获取到直接映射区的起始地址,不受KASLR机制影响。
kernel .text段
我们现在已经获得了任意物理内存读写的能力,那么下一步自然是想办法获得kernel text段的地址。和用户态相似的,text段上保存了所有的内核可执行的代码部分,其中包含了我们希望调用的关键函数。此外,内核全局变量所在的bss段也同样是以text段地址为偏移访问的。因此我们迫切的需要通过某种方法获得.text段的地址。
和直接映射区一样,在未开启KASLR时,内核.text段起始地址为固定值:
正如前文所提到的,Linux内核将物理内存直接映射在了直接映射区,而text段中的所有可执行代码部分,都是vmlinux内核文件的映射,换言之,text段上的虚拟内存中的内容,一定都是从某片物理内存区域映射出来的,而一份虚拟内存的映射,又一定在页表中存在对应的项。综上所述,通过对页表的查找,可以实现text段地址的获取。
但是页表中包含的虚拟地址数量非常的多,我们该如何最快的查找的我们想要的text段地址呢?
在内核启动过程中,会通过extract_kernel()函数来加载vmlinux。而在开启了KASLR时,内核会使用choose_random_location()函数来为text段做随机化,和page_offset_base的随机化不同,由于text段的长度较长,text段采用了pmd_set_huge()的方式,直接映射2MB的内存页来减少映射页表项,提升映射性能。
如上述代码所示,text段的最大随机偏移为512<<20,并且需要保证和CONFIG_PHYSICAL_ALIGN=0x200000进行2MB的对齐,其中偏移的20位刚好对应页内偏移的12字节+PTE的9字节。也就是说,对于text段地址而言,KASLR仅会为PMD添加一个0-512的偏移值。
如图所示,我们只需要查找PMD中对应PTE的位置,即可计算出KASLR随机化后的实际.text段地址。
特别要注意,cr3中存储的PGD地址以及后续页表中的地址全都为物理地址,实际访问需要加上在2.1中获取到的page_offset_base。
EXPORT_SYMBOL
在Linux内核中,由于内核模块功能的加入,内核需要将一部分函数和符号提供给外部调用,因此就有了EXPORT_SYMBOL宏。只有被EXPORT_SYMBOL包含了的内核函数或者符号才可以被外部模块调用,否则在编译内核模块时会提示调用undefined。而所有的导出符号和函数,都会产生一个__ksymtab_为开始的ksymtab结构体。
-
第一个int为ksymtab.value_offset到对应符号实际地址的偏移;
-
第二个int为ksymtab.name_offset到对应符号的符号名字符串的偏移;
-
第三个namespace与我们的流程无关。
在目前的情况下,我们能够直接接触到的只有目标符号的名称,那么符号真实地址的查找也必然要从名称对应的地址开始入手:
-
直接从text段起始开始查找目标符号字符串的地址(kstrtab = 0xffffffffba8e7b71);
-
再次从text段起始开始,每4字节扫描,判断当前 addr + *addr == kstrtab,满足条件则意味着找到了ksymtab结构体,ksymtab = addr - 4;
-
最终计算出函数地址为 func_addr = ksymtab + *ksymtab。
trick
经过上述流程,我们现在已经知道了如何从无到有获得内核符号的真实地址,但是问题在于,上述的流程需要被编写成一段shellcode,并且这个流程十分冗长,如果使用传统的手工写asm的方式,有些过于复杂了,并且在大多数情况下,我们对于这段shellcode的长度要求并没有那么严格,并不需要极致的缩短shellcode长度。那么,使用C来编写一个binary并提取的方式,可以极大的减少编写的难度。
除此之外,如果我们希望通过提取binary的方式来生成shellcode的话,需要对shellcode中的函数布局存在一定的要求。我们需要保证shellcode的开始函数,在binary中为_start函数,必须要在.text段的开始,这样才能保证提取后的shellcode入口点正确,而非进入其他函数导致流程错误。
gcc的-T选项刚好可以满足我们的需求,只需要提供一个linker.ld文件,就可以控制.text段函数的布局。
附上一个简单的用于提权的shellcode示例:
我们使用一个简单的ko模块,创建一个rwx权限的段,来便于我们测试已经编写好的shellcode:
将我们编译好的shellcode通过proc文件写入内核中执行后,成功提升了进程的权限:
本文主要介绍了利用页表查询以及EXPORT_SYMBOL宏的方式,来尝试构建一个通用性更好的Linux内核态的shellcode,该方法对不同内核版本以及内核安全选项的兼容性要明显好于传统方法,可以极大提高编写的shellcode的通用性。尽管如此,由于内核版本的迅速迭代以及新的安全机制的引入,该方法仍然有其局限性,比如新的CONFIG_HAVE_ARCH_PREL32_RELOCATIONS编译选项会影响ksymstab的布局等问题。
【版权说明】
本作品著作权归影二つ所有
未经作者同意,不得转载
天工实验室二进制安全研究员。
专攻IOT设备以及Linux内核态安全。
原文始发于微信公众号(奇安信天工实验室):通用Linux x64内核态shellcode编写技巧
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论