Linux4.19-通过IDT获取sys_call_table实现系统调用劫持

疫情影响不能回临港海大,在家无聊搞Linux系列(二)

上一篇博客写了如何获取IDT地址,这里将接着上一篇博客继续写,如果还不清楚如何获取IDT可以看一下我的上一篇博客:Linux4.19-获取IDT地址:https://blog.csdn.net/qq_41208289/article/details/106012230

这里再次声明一下,博主的系统为目前最新版本的Debian10,内核为Linux4.19.0-8-686(32位x86机器)

前言

网上的绝大部分博客对于获取sys_call_table的方法在目前的内核中都已经失效,Linux大概从4.8开始加入了保护机制,每次开机sys_call_table的地址都会变化,不像以前可以在/boot/System.map中查到sys_call_table的地址,现在即使查到了那也是假的地址,无法使用。我这个方法是从IDT表入手逆向分析获取sys_call_table地址,网上博客类似的方法是旧Linux内核,方法也已经失效,新版本Linux的0x80中断处理程序发生了变化,需要重新分析。

正文

上一篇博客已经获取到了IDT表地址,再回顾一下IDT表的表项的数据结构:

struct
{
	unsigned short offset_low;
	unsigned short selector;
	unsigned char reserved;
	unsigned char flag;
	unsigned short offset_high;
}__attribute__((packed)) idt; // idt表 8字节

4项共8个字节,其中低16位与高16位分别为offset_low与offset_high,而0x80中断所在的表项的地址=IDT表地址+0x80*8,因此通过如下代码获取0x80中断的表项:

memcpy(&idt, idtr.addr+8*0x80, sizeof(idt)); //IDT表每项占8个字节,所以sizeof(idt)=8

获取到0x80中断表项后通过左移和按位或的方式拼接offset_low与offset_high

sys_call_off = ((idt.offset_high<<16) | idt.offset_low); // 将offset_high和offset_low拼接成32位

此时sys_call_off即为0x80中断处理程序的地址,我们反汇编这个地址的代码

push eax
cld 
push gs
push fs
push es
push ds
push FFFFFFDAh
push ebp
push edi
push esi
push edx
push ecx
push ebx
mov edx, 0000007Bh
mov ds, dx
mov es, dx
mov edx, 000000D8h
mov fs, dx
mov edx, 000000E0h
mov gs, dx
nop 
lea esi, dword ptr [esi+00h]
jmp Label1
mov eax, cr3
test eax, 00001000h
je Label1
and eax, FFFFEFFFh
mov cr3, eax
or eax, 00001000h
Label1:
and dword ptr [esp+34h], 0000FFFFh
mov ecx, dword ptr fs:[C49F86A8h]
add ecx, 00001100h
sub ecx, esp
cmp ecx, 00000100h
jnc Label2
mov esi, esp
mov edi, esi
and edi, FFFFFF00h
add edi, 00000100h
mov edi, dword ptr [edi+00000F0Ch]
mov ecx, dword ptr [esp+34h]
and ecx, 03h
cmp ecx, 03h
jc Label3
mov ecx, 00000044h
Label4:
sub edi, ecx
mov esp, edi
shr ecx, 02h
cld 
rep movsd 
jmp Label2
Label3:
mov ecx, esi
and ecx, FFFFFF00h
add ecx, 00000100h
sub ecx, esi
or dword ptr [esp+34h], 80000000h
test eax, 00001000h
je Label4
or dword ptr [esp+34h], 40000000h
jmp Label4
Label2:
mov eax, esp
call ff98c5fe

汇编代码太长看不懂?没事,我找来了Linux4.19的源代码来分析

ENTRY(entry_INT80_32)
	ASM_CLAC
	pushl	%eax			/* pt_regs->orig_ax */

	SAVE_ALL pt_regs_ax=$-ENOSYS switch_stacks=1	/* save rest */

	/*
	 * User mode is traced as though IRQs are on, and the interrupt gate
	 * turned them off.
	 */
	TRACE_IRQS_OFF

	movl	%esp, %eax
	call	do_int80_syscall_32

这下很简洁明了了,首先是将eax入栈保存,然后SAVE_ALL是一个宏定义,就是将各种寄存器全部入栈,也对应了上面反汇编后代码开头的一大堆push操作,TRACE_IRQS_OFF也是一个宏定义,最后的mov和call是关键的代码,也对应了反汇编后最后的mov和call指令。

过去的方法在这里就可以获取到sys_call_table了,因为旧版linux在最后这个call处是根据eax中的系统中断号直接查表,然后call向对应的函数地址,在这里sys_call_table就已经暴露了,但是现在的linux不再是直接查表,而是调用了do_int80_syscall_32这个函数来实现系统调用。

如何获取do_int80_syscall_32的地址呢?我们知道,call指令后面接的不是地址而是一个4字节偏移量,真正跳向的地址=call指令下一条指令的地址+偏移,也就是call指令(字节码为e8)所在的地址+5+偏移,先加5是因为call后跟4字节偏移量,偏移量后面才是下一条指令的地址因此为4+1=5,所以我们可以通过特征码搜索的方法来找到这个call所在的地址,我们知道call前有一个movl %esp, %eax的指令,其机器码为89 e0,后面跟call指令机器码为e8,因此只需要搜索第一个连续的89 e0 e8,就可以找到这个call指令地址,通过如下代码实现:

char *p = sys_call_off;
unsigned int calladr=0;
for(i=0; i<300; i++)
{
	// movl	%esp, %eax; call do_int80_syscall_32的机器码
	if(p[i]=='\x89' && p[i+1]=='\xe0' && p[i+2]=='\xe8')
	{
		calladr = (unsigned int)(p+i) + 2; // p+i为mov指令的地址,再加2为call指令地址
		break;
	}
}
unsigned int offset = *(unsigned int *)(calladr+1); // call向的地址的偏移
unsigned int dist_adr = calladr + 5 + offset; // 实际地址=call指令地址+5+偏移

最后的dist_adr就是利用上面讲的公式,计算出do_int80_syscall_32的地址。

分析do_int80_syscall_32,还是先来看一下linux源码:

/* Handles int $0x80 */
__visible void do_int80_syscall_32(struct pt_regs *regs)
{
	enter_from_user_mode();
	local_irq_enable();
	do_syscall_32_irqs_on(regs);
}

没错,这里有三个调用,而真正实现系统调用的是do_syscall_32_irqs_on(regs);而经过不断分析源码发现,第一个函数enter_from_user_mode()是一个空壳函数,而第二个函数local_irq_enable()里面是一个宏定义,只有一条指令。这将大大降低我们反汇编的分析难度。

继续来看do_syscall_32_irqs_on(regs);源码:

static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs)
{
	struct thread_info *ti = current_thread_info();
	unsigned int nr = (unsigned int)regs->orig_ax;

	if (READ_ONCE(ti->flags) & _TIF_WORK_SYSCALL_ENTRY) {
		nr = syscall_trace_enter(regs);
	}

	if (likely(nr < IA32_NR_syscalls)) {
		nr = array_index_nospec(nr, IA32_NR_syscalls);

		regs->ax = ia32_sys_call_table[nr](
			(unsigned int)regs->bx, (unsigned int)regs->cx,
			(unsigned int)regs->dx, (unsigned int)regs->si,
			(unsigned int)regs->di, (unsigned int)regs->bp);
	}

	syscall_return_slowpath(regs);
}

这里有一个非常关键的信息,就是这个函数前有一个__always_inline,也就是说这个函数是强制内联的,什么是内联函数?内联函数就是在编译器编译时,原本这个函数是作为call调用,但是为了提高运行效率,不采用call调用而是将这个函数的汇编指令直接接在需要调用它的位置,因为call指令相对是低效的,这样就免去了call指令带来的额外开销。

所以,在do_int80_syscall_32中直接就可以看到do_syscall_32_irqs_on(regs)的所有指令,而不是用call来调用,再加上前面我分析,do_int80_syscall_32中的第一个函数是空函数,第二个函数只是一条指令,这样一来,反汇编后的do_int80_syscall_32()整体上来看就是内联函数do_syscall_32_irqs_on(regs)的指令。

所以我们直接反汇编前面得到的do_int80_syscall_32()的地址,代码有点多,我只贴出关键的前一部分

push ebp
mov ebp, esp
push ebx
mov ebx, eax
sti 
nop 
lea esi, dword ptr [esi+00h]
mov eax, dword ptr fs:[DB9F86B4h]
mov eax, dword ptr [eax]
test eax, 100801C1h
jne Label1
mov eax, dword ptr [ebx+2Ch]            # nr = (unsigned int)regs->orig_ax;
Label5:
cmp eax, 00000182h
jnbe Label2
cmp eax, 00000183h
sbb edx, edx
and eax, edx
push dword ptr [ebx+14h]
mov eax, dword ptr [DB67B200h+eax*4]	# sys_call_table[eax*4]获取系统调用处理函数
push dword ptr [ebx+10h]                # 6个push压入6个参数
push dword ptr [ebx+0Ch]
push dword ptr [ebx+08h]
push dword ptr [ebx+04h]
push dword ptr [ebx]
call 0A0424E0h				 # ia32_sys_call_table[nr]()
mov dword ptr [ebx+18h], eax		 # regs->ax = ia32_sys_call_table[nr]()
add esp, 18h
Label2:
mov eax, dword ptr fs:[DB9F86B4h]
mov edx, dword ptr [eax]
test edx, 10000091h
jne Label3
cli 

很明显,这些指令除了前几条之外后面的都是do_syscall_32_irqs_on(regs)对应的指令,此时ebx中存放的就是就是参数regs的地址。很明显mov eax, dword ptr [ebx+2Ch] 对应nr = (unsigned int)regs->orig_ax,而前面的C源码系统调用时有一个很明显的特点就是有6个参数

regs->ax = ia32_sys_call_table[nr](
			(unsigned int)regs->bx, (unsigned int)regs->cx,
			(unsigned int)regs->dx, (unsigned int)regs->si,
			(unsigned int)regs->di, (unsigned int)regs->bp);

因此对应的汇编指令应该是6个push操作,而这6个push中夹着的mov eax, dword ptr [DB67B200h+eax*4]指令就是根据eax中系统调用号查找sys_call_table获取相应处理函数的地址,因为地址是4字节,sys_call_table每个表项就是4字节,系统调用号对应的地址就是sys_call_table首地址+4*系统调用号,好了这里我们已经找到sys_call_table的地址也就是0xDB67B200,但是我们前面说过sys_call_table的地址每次启动是随机的,这个地址确是固定的,那么能不能用呢?经过博主重启后测试发现,此处指令的地址在重启后是会变化的,也就是说Linux内核会自己修改这段指令中的地址。所以我们必须和前面一样,用特征码搜索的方法定位到这条指令,获取到sys_call_table地址。

其中push dword ptr [ebx+14h]和mov eax, dword ptr [DB67B200h+eax*4]的前四个指令码为ff 73 14 8b,搜索这个连续的字节码可以得到push指令的地址,然后push指令地址+6就是下一条mov指令中sys_call_table的地址。

char *p = dist_adr;
for(i=0; i<300; i++)
{
	printk(KERN_ALERT "0x%x: %02x\n", p+i, (unsigned char)p[i]);
		
	// push dword ptr [ebx+14h];mov eax, dword ptr [D467B200h+eax*4]
	if(p[i]=='\xff' && p[i+1]=='\x73' && p[i+2]=='\x14' && p[i+3]=='\x8b')
	{
		ret = *(unsigned int*)(p+i+6); 
		// push dword ptr [ebx+14h]指令地址+6的值为sys_call_table地址
		printk(KERN_ALERT "sys_call_table: 0x%x\n", ret);
		break;
	}
}

拿到sys_call_table地址后就可以进行系统调用劫持啦,需要注意的是在修改系统调用表之前一定要通过修改CR0寄存器关闭写保护,否则系统会直接挂掉的,修改完成后再恢复CR0寄存器。

完整的代码如下

欢迎来给我的github点星:https://github.com/CalvinXu17/Linux4.19-IDT-Hook-sys_call_table

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/errno.h>
#include <linux/types.h>
#include <linux/unistd.h>
#include <linux/sched.h>
#include <linux/kallsyms.h>
#include <asm/cacheflush.h>
#include <asm/page.h>
#include <asm/current.h>
	
unsigned long *syscall_table; // 系统调用表地址

struct 
{
	unsigned short size;
	unsigned int addr; // 高32位为idt表地址
}__attribute__((packed)) idtr; // idtr是48位6字节寄存器

struct
{
	unsigned short offset_low;
	unsigned short selector;
	unsigned char reserved;
	unsigned char flag;
	unsigned short offset_high;
}__attribute__((packed)) idt; // idt表 8字节

void get_addr_idt(void)
{
	asm("sidt %0":"=m"(idtr)); //通过idtr获取IDT表的地址
	printk(KERN_ALERT "idt table adr: 0x%x\n", idtr.addr);
}

unsigned long* find_sys_call_table(void)
{
	unsigned int sys_call_off;
	char *p;
	int i;
	unsigned int ret=0;
 
	//将0x80中断处理程序的地址存放到idt中
	memcpy(&idt, idtr.addr+8*0x80, sizeof(idt));//IDT表每项占8个字节,所以sizeof(idt)=8
	sys_call_off = ((idt.offset_high<<16) | idt.offset_low); // 将offset_high和offset_low拼接成32位
 
	p = sys_call_off;
	unsigned int calladr=0;
	for(i=0; i<300; i++)
	{
		// movl	%esp, %eax; call do_int80_syscall_32的机器码
		if(p[i]=='\x89' && p[i+1]=='\xe0' && p[i+2]=='\xe8')
		{
			calladr = (unsigned int)(p+i) + 2; // p+i为mov指令的地址,再加2为call指令地址
			break;
		}
	}
	unsigned int offset = *(unsigned int *)(calladr+1); // call向的地址的偏移
	unsigned int dist_adr = calladr + 5 + offset; // 实际地址=call指令地址+5+偏移
	printk(KERN_ALERT "calladr: 0x%x, offset: 0x%x, jmp to: 0x%x\n", calladr, offset, dist_adr);
	
	
	p = dist_adr;
	for(i=0; i<300; i++)
	{
		
		// push dword ptr [ebx+14h];mov eax, dword ptr [D467B200h+eax*4]
		if(p[i]=='\xff' && p[i+1]=='\x73' && p[i+2]=='\x14' && p[i+3]=='\x8b')
		{
			ret = *(unsigned int*)(p+i+6); 
			// push dword ptr [ebx+14h]指令地址+6的值为sys_call_table地址
			printk(KERN_ALERT "sys_call_table: 0x%x\n", ret);
			break;
		}
	}
	
	return (unsigned long*)ret;
}
 
unsigned int oldadr;
void myfunc(void)
{
	printk(KERN_ALERT "hook uname\n");
	return;
}

static int lkm_init(void)
{
	get_addr_idt(); // 获取idt表地址
	
	syscall_table = find_sys_call_table();
	if (syscall_table)
	{
		write_cr0(read_cr0() & (~0x10000)); // 关闭内核写保护
		oldadr = (unsigned int)syscall_table[__NR_uname]; // 保存真实地址
		syscall_table[__NR_uname] = myfunc; // 修改地址
		write_cr0(read_cr0() | 0x10000); // 恢复写保护
		printk(KERN_ALERT "hook success\n");
	} else {
		printk(KERN_ALERT "hook failed\n");
	}
	return 0;
}
 
static void lkm_exit(void)
{
	if (syscall_table) {
		write_cr0(read_cr0() & (~0x10000));	
		syscall_table[__NR_uname] = oldadr; // 恢复原地址
		write_cr0(read_cr0() | 0x10000);
		printk(KERN_ALERT "resume syscall table, module removed\n");
	}
	printk(KERN_ALERT "Good Bye Kernel!\n");
}

MODULE_LICENSE("GPL");
MODULE_AUTHOR("calvin");
MODULE_DESCRIPTION("hook idt");
module_init(lkm_init);
module_exit(lkm_exit);

实验效果

后续

最后还有一个小细节,就是在拿到系统调用地址放入eax后,并不是直接call eax跳转,而是call向了另一个地址,虽然这里已经拿到了sys_call_table,但是真正的跳转是如何实现的呢?其实反汇编后发现:

call Label1
Label2:
pause 
lfence 
jmp Label2
Label1:
mov dword ptr [esp], eax
ret

这个call上来直接又进行了一个call调用,而跳向的地址就是后面的Label1处,mov dword ptr [esp], eax又是什么意思呢?实际上,此时esp指向的堆栈中的值为call Label1的返回地址,用eax的内容覆盖这个返回地址后,因为eax中存放的是系统调用地址,因此此时esp指向的堆栈内容被修改为了系统调用地址,而下一条指令ret就是返回esp所指向的堆栈中存放的地址,原本应该返回call Label1的下一条指令,被修改后就返回到了系统调用的地址,也就是通过ret指令实现的跳转。个人猜测这样写应该是为了增加反汇编的难度。

至此分析彻底完成。

  • 4
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 10
    评论
`adc_keys_probe`是Linux内核中的一个函数,该函数用于初始化ADC输入设备并将其注册到系统。 在设备树中定义了ADC输入设备的相关信息,当系统启动时,内核调用ADC输入设备驱动中的`probe`函数,`probe`函数又将调用`adc_keys_probe`函数,从而完成ADC输入设备的初始化和注册。 下面是`adc_keys_probe`函数的代码分析: ``` static int adc_keys_probe(struct platform_device *pdev) { struct adc_keys_drvdata *ddata; struct device_node *np = pdev->dev.of_node; struct input_dev *input; struct resource *res; int ret; ddata = devm_kzalloc(&pdev->dev, sizeof(*ddata), GFP_KERNEL); if (!ddata) return -ENOMEM; /* 获取驱动中的配置参数 */ ret = adc_keys_parse_dt(&pdev->dev, ddata); if (ret) return ret; /* 请求ADC地址空间 */ res = platform_get_resource(pdev, IORESOURCE_MEM, 0); ddata->base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(ddata->base)) return PTR_ERR(ddata->base); /* 请求IRQ中断号 */ ddata->irq = platform_get_irq(pdev, 0); if (ddata->irq < 0) return ddata->irq; /* 申请输入设备 */ input = devm_input_allocate_device(&pdev->dev); if (!input) return -ENOMEM; /* 设置输入设备属性 */ input->name = pdev->name; input->id.bustype = BUS_HOST; input->dev.parent = &pdev->dev; /* 注册输入设备 */ ret = input_register_device(input); if (ret) return ret; /* 初始化ADC输入设备 */ ret = adc_keys_init(ddata); if (ret) return ret; /* 注册中断处理函数 */ ret = devm_request_irq(&pdev->dev, ddata->irq, adc_keys_irq_handler, IRQF_TRIGGER_FALLING | IRQF_TRIGGER_RISING, input->name, ddata); if (ret) return ret; /* 保存设备驱动数据 */ platform_set_drvdata(pdev, ddata); dev_info(&pdev->dev, "ADC input registered\n"); return 0; } ``` 首先,函数通过`devm_kzalloc`函数分配了一个结构体`adc_keys_drvdata`,该结构体用于保存驱动中的配置参数和其他信息。 然后,通过`adc_keys_parse_dt`函数获取了驱动中的配置参数。 接着,通过`platform_get_resource`函数获取了ADC地址空间的信息,并通过`devm_ioremap_resource`函数将其映射到内核地址空间中。 之后,通过`platform_get_irq`函数获取了中断号,并通过`devm_request_irq`函数注册了中断处理函数`adc_keys_irq_handler`。 接下来,通过`devm_input_allocate_device`函数申请了一个输入设备,并通过`input_register_device`函数将其注册到系统。 然后,通过`adc_keys_init`函数初始化了ADC输入设备。 最后,通过`platform_set_drvdata`函数保存了设备驱动数据,并输出了一个信息到内核日志中。 总的来说,`adc_keys_probe`函数的作用是初始化ADC输入设备,并将其注册到系统。在初始化过程中,需要请求ADC地址空间、申请输入设备、设置输入设备属性、注册输入设备、注册中断处理函数等。
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值