linux源码解读(十二):系统调用(strace命令0和中断&字节跳动HIDS简要分析
中断是整个计算机体系最核心的功能之一,关于中断硬件原理可以参考文章末尾的链接1(https://www.cnblogs.com/theseventhson/p/13068709.html),这里不再赘述;中断常见的种类如下:
- 硬件中断:键盘、鼠标、网卡等输入
- 软件中断:int 3、int 0xe(page fault)
- 自定义中断
- 信号中断(kill -signum),比如kill -9 pid杀死进程
- 系统异常和错误->利于排错
1、(1)本人刚开始学习的时候,看到很多资料把中断、异常、陷阱放在一起介绍,很容易混淆这3个概念,这里详细列举一下各个概念之间的关系如下:
- 异步中断:都是由硬件产生的,比如键盘、鼠标、网卡等输入,导致硬件中断的事件是不可预测的(cpu也不可能知道用户啥时候敲键盘、移动鼠标,也不可能提前知道网卡什么时候接收到数据);
- 同步中断:都是可预见的指令流产生的
- fault:最常见的是page fault缺页异常;异常产生后可以重新执行产生异常的指令;逆向时用这个特性可以通过更改目标内存属性产生的page fault异常达到定位关键代码的目的;
- trap:
- 软中断:最常见的就是int 3了,常用于调试器单步调试;trap产生后会执行下一条指令,所以调试器调试时插入int 3才能达到单步调试的目的;
- 系统调用:3环app要调用内核的api,比如读写文件、通过wifi收发数据、在屏幕打印日志等,需要进入0环执行;linux早期采用int 0x80做系统调用(早期的windows比如xp系统是通过int 0x2e进入内核的);后来x86架构的cpu硬件支持syscall、sysenter这种专门的系统调用(也就是从3环进入0环)指令;由于syscall/sysenter这种cpu原生支持的系统调用指令没有特权级别检查的处理,也没有压栈的操作,所以执行速度比 INT n/IRET 快了不少;如下:时间快了接近1倍!
- abort:比如除0
(2)上述所有的操作,统称为中断!再说直白一点,中断的本质就是被打断!举个例子:cpu正在执行A进程的代码,突然用户敲了一下键盘,或者移动了鼠标,这时候就要马上接受用户的输入,然后采取相应的措施处理用户的输入;
- 接受用户输入的功能已经在硬件上实现了,接下来操作系统需要做的就是实现中断响应的方法了,俗称handler!
- 中断的种类有很多(linux有256种中断),这么多中断种类,为了方便管理,各自都是有自己的编号的!每个编号自然也会有对应的响应handler(官方叫做中断处理routine);这么多的handler,执行的时候怎么才能快速找到了?
- cpu硬件层面有个IDTR寄存器,存放了IDT表的基址;IDT表本质上就是中断号和中断处理handler的映射;cpu硬件层面会根据中断号找到中断处理handler的入口地址,然后跳转到handler执行代码;有些病毒木马会hook键盘输入的handler,借此记录用户输入的所有字符来盗取账号!
- 早期windows系统下部分杀毒软件为了确保内核或进程安全,也会通过驱动的方式hook一些系统调用SSDT来确保能及时发现恶意程序是否在作恶,比如hook openprocess来监控有没有恶意程序打开自己的进程注入代码(tp保护也是这个原理);
2、操作系统关于中断的开发,最核心的部分就是填充IDT了,本质就是先写好不同中断号的handler,再把handler函数的入口地址填写到正确的IDT表项(当然格式要符合中断描述符的要求)!接下来看看linux 4.9版本是怎样一步一步填充IDT和使用中断的!
(1)填充IDT,也就是中断初始化,在arch\x86\kernel\traps.c种的trap_init函数中:
void __init trap_init(void) { int i; #ifdef CONFIG_EISA void __iomem *p = early_ioremap(0x0FFFD9, 4); if (readl(p) == 'E' + ('I'<<8) + ('S'<<16) + ('A'<<24)) EISA_bus = 1; early_iounmap(p, 4); #endif set_intr_gate(X86_TRAP_DE, divide_error); set_intr_gate_ist(X86_TRAP_NMI, &nmi, NMI_STACK); /* int4 can be called from all */ set_system_intr_gate(X86_TRAP_OF, &overflow); set_intr_gate(X86_TRAP_BR, bounds); set_intr_gate(X86_TRAP_UD, invalid_op); set_intr_gate(X86_TRAP_NM, device_not_available); #ifdef CONFIG_X86_32 set_task_gate(X86_TRAP_DF, GDT_ENTRY_DOUBLEFAULT_TSS); #else set_intr_gate_ist(X86_TRAP_DF, &double_fault, DOUBLEFAULT_STACK); #endif set_intr_gate(X86_TRAP_OLD_MF, coprocessor_segment_overrun); set_intr_gate(X86_TRAP_TS, invalid_TSS); set_intr_gate(X86_TRAP_NP, segment_not_present); set_intr_gate(X86_TRAP_SS, stack_segment); set_intr_gate(X86_TRAP_GP, general_protection); set_intr_gate(X86_TRAP_SPURIOUS, spurious_interrupt_bug); set_intr_gate(X86_TRAP_MF, coprocessor_error); set_intr_gate(X86_TRAP_AC, alignment_check); #ifdef CONFIG_X86_MCE set_intr_gate_ist(X86_TRAP_MC, &machine_check, MCE_STACK); #endif set_intr_gate(X86_TRAP_XF, simd_coprocessor_error); /* Reserve all the builtin and the syscall vector: 将前32个中断号都设置为已使用状态 */ for (i = 0; i < FIRST_EXTERNAL_VECTOR; i++) set_bit(i, used_vectors); //设置0x80系统调用的系统中断门 #ifdef CONFIG_IA32_EMULATION set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_compat); set_bit(IA32_SYSCALL_VECTOR, used_vectors); #endif #ifdef CONFIG_X86_32 set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32); set_bit(IA32_SYSCALL_VECTOR, used_vectors); #endif /* * Set the IDT descriptor to a fixed read-only location, so that the * "sidt" instruction will not leak the location of the kernel, and * to defend the IDT against arbitrary memory write vulnerabilities. * It will be reloaded in cpu_init() */ __set_fixmap(FIX_RO_IDT, __pa_symbol(idt_table), PAGE_KERNEL_RO); idt_descr.address = fix_to_virt(FIX_RO_IDT); /* * Should be a barrier for any external CPU state: 执行CPU的初始化,对于中断而言,在 cpu_init() 中主要是将 idt_descr 放入idtr寄存器中 */ cpu_init(); /* * X86_TRAP_DB and X86_TRAP_BP have been set * in early_trap_init(). However, ITS works only after * cpu_init() loads TSS. See comments in early_trap_init(). */ set_intr_gate_ist(X86_TRAP_DB, &debug, DEBUG_STACK); /* int3 can be called from all */ set_system_intr_gate_ist(X86_TRAP_BP, &int3, DEBUG_STACK); x86_init.irqs.trap_init(); #ifdef CONFIG_X86_64 memcpy(&debug_idt_table, &idt_table, IDT_ENTRIES * 16); set_nmi_gate(X86_TRAP_DB, &debug); set_nmi_gate(X86_TRAP_BP, &int3); #endif }
used_vectors变量是一个bitmap,它用于记录中断向量表中哪些中断已经被系统注册和使用,哪些未被注册使用;
(2)trap_init()已经完成了异常和陷阱的初始化。对于linux而言,中断号0~19是专门用于陷阱和故障使用的,20~31一般是intel用于保留的;而外部IRQ线使用的中断为32~255(代码中32号中断被用作汇编指令异常中断)。所以,在trap_init()代码中,专门对0~19号中断的门描述符进行了初始化,最后将新的中断向量表起始地址放入idtr寄存器中;相应的handler定义和实现在arch\x86\kernel\traps.c中,举个大家都熟悉的int 3为例,实现如下:
/* May run on IST stack. */ dotraplinkage void notrace do_int3(struct pt_regs *regs, long error_code) { #ifdef CONFIG_DYNAMIC_FTRACE /* * ftrace must be first, everything else may cause a recursive crash. * See note by declaration of modifying_ftrace_code in ftrace.c */ if (unlikely(atomic_read(&modifying_ftrace_code)) && ftrace_int3_handler(regs)) return; #endif if (poke_int3_handler(regs)) return; ist_enter(regs); RCU_LOCKDEP_WARN(!rcu_is_watching(), "entry code didn't wake RCU"); #ifdef CONFIG_KGDB_LOW_LEVEL_TRAP if (kgdb_ll_trap(DIE_INT3, "int3", regs, error_code, X86_TRAP_BP, SIGTRAP) == NOTIFY_STOP) goto exit; #endif /* CONFIG_KGDB_LOW_LEVEL_TRAP */ #ifdef CONFIG_KPROBES if (kprobe_int3_handler(regs)) goto exit; #endif if (notify_die(DIE_INT3, "int3", regs, error_code, X86_TRAP_BP, SIGTRAP) == NOTIFY_STOP) goto exit; /* * Let others (NMI) know that the debug stack is in use * as we may switch to the interrupt stack. */ debug_stack_usage_inc(); preempt_disable(); cond_local_irq_enable(regs); do_trap(X86_TRAP_BP, SIGTRAP, "int3", regs, error_code, NULL);//核心代码 cond_local_irq_disable(regs); preempt_enable_no_resched(); debug_stack_usage_dec(); exit: ist_exit(regs); }
(3)部分中断比如网卡接受到数据后,通过中断通知cpu来读取;如果数据量很大,cpu读取和处理数据的时候一直关闭中断,可能导致其他中断被延迟甚至忽略(大家肯定都遇到过电脑“卡死”的情况:敲击键盘、移动鼠标都没反应,很有可能是cpu还在处理旧中断,来不及响应新的中断);为了在处理上一个中断的同时避免耽误下一个中断,linux把中断分成了上中断和下中断两部分(类似windows的DPC机制)。上部分代码优先级高,但是代码量较少,耗时不多;下半段执行优先级低但是耗时的代码;上半段执行时依然关闭中断,下半段就可以开中断了;此过程称之为softtirq,图示如下:
arch\x86\entry\entry_64.S中的调用代码:
/* Call softirq on interrupt stack. Interrupts are off. */ ENTRY(do_softirq_own_stack) pushq %rbp mov %rsp, %rbp incl PER_CPU_VAR(irq_count) cmove PER_CPU_VAR(irq_stack_ptr), %rsp push %rbp /* frame pointer backlink */ call __do_softirq leaveq decl PER_CPU_VAR(irq_count) ret END(do_softirq_own_stack)
实际使用时,linux提供了workqueue的机制和接口(create、destroy、insert等)供用户调用;
(4)站在上层3环业务应用的角度,各行业不同的业务都有自己的需求,可能需要自定义大量的中断处理程序,每个处理程序对应不同的中断号用于区分。但是管理中断的硬件设备的引脚是有限的;加上软件中断号也不超过256个中断,万一业务需要的中断号超过256个后该怎么办了? 这个个hashtable实现的原理一摸一样了,以java的hashtable为了:用户刚开始创建hashtable时,会分配一个固定大小的数组作为索引,查询数组上元素的时间复杂度是O(1);如果有多个key的hash结果都一样了,就用链表来存放(key,value),由此解决hash冲突;其实在中断号的管理和hashtable完全一样,一旦中断号重复,同样可以通过链表的方式记录不同中断号、业务所对应的handler,图示如下:
由此依赖,理论上用户使用的中断号数量就没有任何限制了(只要内存容量支持),所以也能自由地让用户自己注册中断服务了(让用户直接操作IDT也危险),妈妈再也不用担心中断号不够用🙂!调用的接口如上图所示:request_irq(),如下:
int request_irq(unsigned int irq, irq_handler_t handler, unsigned long irqflags, const char *devname, void *dev_id)
- unsigned int irq:为要注册中断服务函数的中断号,比如外部中断0就是16,定义在mach/irqs.h
- irq_handler_t handler:为要注册的中断服务函数,就是(irq_desc+ irq )->action->handler
- unsigned long irqflags: 触发中断的参数,比如边沿触发, 定义在linux/interrupt.h。
- const char *devname:中断程序的名字,使用cat /proc/interrupt 可以查看中断程序名字
- void *dev_id:传入中断处理程序的参数,注册共享中断时不能为NULL,因为卸载时需要这个做参数,避免卸载其它中断服务函数
(5)有些时候异步中断产生的速度远超cpu处理中断的速度,导致中断无法被及时响应,这时外部的中断就只能被忽略不理会了?万一重要的中断被漏掉了怎么办了?还是举用户键盘输入的例子:当电脑变得卡顿的时候,只要没死机,用户还是可以在键盘输入的,只不过暂时在频幕上看不到输入;过一段时间后用户的输入就会出现在频幕上了,这是怎么做到的了?
做服务器后台上层应用开发的时候,同样会遇到生产者和消费者速度不匹配的时候,最常见的解决办法就是加消息队列来缓冲了,目前市面上最流行的消息缓冲队列非kafka莫属了;底层中断场景遇到这种问题同样可以用类似的思路解决:加个消息队列缓冲,队列的名字就叫kfifo(猜测全称是kernel first in first out)!为了循环利用和节约空间,kfifo采用了环形队列!实际使用时,linux也提供了kfifo_alloc、kfifo_free、kfifo_in、kfifo_out等接口供用户直接调用!
总结对比:为了不漏掉异步中断,linux在两方面做了改进(本质上是使用了两个队列记录信息):
- 执行中断handler时分成了上、下;两部分;上半部代码很少,可以关闭中断,一般执行很紧急的业务;下半部代码集较多、处理时间较长,可以开中断;用户实际使用时,可以调用work_queue相关接口把下半部任务加入队列
- 响应异步中断时,为了不漏掉中断请求,也可以增加kfifo队列!
3、系统调用的核心意义:
- 为什么要用系统调用了?
- 每个3环app都需要底层的硬件交互,最常见的诸如在屏幕输出字符、读写磁盘的文件、通过网卡收发数据等;和硬件交互,肯定要调用硬件自身的驱动,但是硬件的种类非常多,如果每个app都单独调用硬件的驱动,会导致app开发的成本高昂!此时linux VFS的作用就凸显了: VFS统一对接种类繁多的硬件驱动,起到了类似各种“中台”的作用;上层app仅需调用linux提供的api,底层不同硬件的驱动由linux操作系统去适配(这里就是VFS啦),app不需要自己挨个调用每个硬件的驱动了,极大降低的开发的难度和成本!这里再发一次之前VFS的图示:
-
- 同样的硬件只有1个,多个进程或线程都要使用硬件,比如多个进程/线程都要读写磁盘、都要从网卡收发数据,肯定有个先后顺序,这时也需要操作系统来协调;
- linux提供的VFS解决了app适配不同硬件的老问题,但是新问题也来了:为了保护VFS和硬件的驱动不被app恶意篡改,VFS和驱动都在0环内核;但是app在3环啊,EIP直接从3环去取0环的指令是会出错的(如果cpu硬件层面不报错,内核代码就毫无安全性可言了,早期的DOS操作系统就是这样的,很容易被ap篡改代码或数据搞崩!),所以cpu硬件层面诞生了软中断:通过int+中断号的形式让EIP顺利进入内核执行代码;而且3环的app只需要执行“int+中断号”即可,完全看不见具体的VFS或驱动代码是怎么写的,极大的简化了app调用api的方法,也保护了VFS或驱动的代码安全(EIP从3环进入0环后,只能按照硬件厂家事先写好的驱动代码执行,没法干其他任何事情了),一箭双雕!
- 因为int要检查特权级别,还要出栈入栈保存上下文,比较耗时,cpu在硬件层面诞生了专门的系统调用指令:syscall/sysenter,但是核心功能和int是一样的!
4、 系统调用在逆向/安全防护的应用:以字节跳动HIDS为例
早期在windows下无论是杀毒软件,还是逆向破解的程序,都喜欢hook SSDT来监控3环的app在干啥,比如hook openProcess就知道3环有没有app在调试自己;在linux平台上原理类似,也可以通过hook 系统调用来做防护,拿字节跳动的HIDS举例(末尾参考5、6两个链接):
(1)mprotect 函数挂钩:函数本是用来设置物理内存页的rwx属性的,利用这个功能可以用来调试和反调试
- 调试: 先把关键的物理页设置为不可写,一旦有代码试图写该页就会产生page fault异常,由此可以定位关键的代码,这就是传说中的(硬件)内存读写断点,一般用来定来定位关键的加密字段生成代码,也可以用来注入自己的恶意代码;
- 反调试:把物理内存页面设置为不可写,调试的时候由于需要插入int 3,遇到这种不可写的内存是会报错;大家用ida调试时经常遇到各种signal弹窗告警有一部分就是内存属性不可写导致的!
(2)open函数挂钩:函数本来是用来打开文件、获取文件句柄的,利用这个可以用来:
- 检测自己的so是否被第三方调用:loadlibrary函数底层最终会调用linux系统提供的open函数打开so,然后才能加载so到内存执行代码
(3)prctl函数挂钩:函数原本是用来设置进程属性的,利用这个可以用来:
- 逆向调试
- 设置PR_SET_PTRACER属性用来把代码注入到目标进程,frida底层貌似用的就是ptrace注入代码;
- 改进程/线程的名字躲避安全防护的检测
- 安全防护
- 检测自己的进程/线程名字是否被更改;
- 检测自己的进程/线程是否被设置PR_SET_PTRACER或PR_SET_MM属性
(4)ptrace函数挂钩:这可能是逆向最有用的系统调用了,frida底层貌似就用了这个函数;HIDS hook这个函数记录了关键信息有:
POKETEXT
/POKEDATA
- 进程ID
- 内存地址
- 拷贝的数据
- 执行程序
- 进程树
这样就很容易检测自己的进程是不是正在被调试了!
还有很多重要的系统调用如execve、init_module等都被hook了,这里不再赘述!
5、其实linux系统有现成的strace工具可以查看进程都触发了哪些系统调用,以打开文件为例,命令很简单:cat 1.txt;为了看清楚cat命令做了哪些系统调用,可以用strace来查看,完整的命令是:“strace cat 1.txt”,结果如下:
└─# strace cat 1.txt execve("/usr/bin/cat", ["cat", "1.txt"], 0x7ffd36672918 /* 51 vars */) = 0 brk(NULL) = 0x55cbf772d000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=94692, ...}) = 0 mmap(NULL, 94692, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fc9c4d5e000 close(3) = 0 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0@n\2\0\0\0\0\0"..., 832) = 832 fstat(3, {st_mode=S_IFREG|0755, st_size=1839792, ...}) = 0 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fc9c4d5c000 mmap(NULL, 1852680, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7fc9c4b97000 mprotect(0x7fc9c4bbc000, 1662976, PROT_NONE) = 0 mmap(0x7fc9c4bbc000, 1355776, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x25000) = 0x7fc9c4bbc000 mmap(0x7fc9c4d07000, 303104, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x170000) = 0x7fc9c4d07000 mmap(0x7fc9c4d52000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1ba000) = 0x7fc9c4d52000 mmap(0x7fc9c4d58000, 13576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7fc9c4d58000 close(3) = 0 arch_prctl(ARCH_SET_FS, 0x7fc9c4d5d580) = 0 mprotect(0x7fc9c4d52000, 12288, PROT_READ) = 0 mprotect(0x55cbf6e3e000, 4096, PROT_READ) = 0 mprotect(0x7fc9c4da0000, 4096, PROT_READ) = 0 munmap(0x7fc9c4d5e000, 94692) = 0 brk(NULL) = 0x55cbf772d000 brk(0x55cbf774e000) = 0x55cbf774e000 openat(AT_FDCWD, "/usr/lib/locale/locale-archive", O_RDONLY|O_CLOEXEC) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=3041456, ...}) = 0 mmap(NULL, 3041456, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fc9c48b0000 close(3) = 0 fstat(1, {st_mode=S_IFCHR|0600, st_rdev=makedev(0x88, 0), ...}) = 0 openat(AT_FDCWD, "1.txt", O_RDONLY) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=5, ...}) = 0 fadvise64(3, 0, 0, POSIX_FADV_SEQUENTIAL) = 0 mmap(NULL, 139264, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fc9c488e000 read(3, "11111", 131072) = 5 write(1, "11111", 511111) = 5 read(3, "", 131072) = 0 munmap(0x7fc9c488e000, 139264) = 0 close(3) = 0 close(1) = 0 close(2) = 0 exit_group(0) = ? +++ exited with 0 +++
从上面的系统调用来看,一个简单的打开文件,尽然使用了这么多的系统调用,常见的execve、mmap、open、read、write、cloes等都用上了!每次系统调用从3环进出0环时都要保存上下文,效率很低,建议少用系统调用!
参考:
1、https://www.cnblogs.com/theseventhson/p/13068709.html 实模式中断原理
2、https://www.cnblogs.com/jiading/p/12606978.html linux中断和系统调用解析
3、https://www.cnblogs.com/LittleHann/p/4111692.html?utm_source=tuicool&utm_medium=referral Linux Systemcall Int0x80方式、Sysenter/Sysexit Difference Comparation
4、https://bbs.pediy.com/thread-226254.htm syscall/sysenter具体过程
5、https://mp.weixin.qq.com/s/rm_hXHb_YBWQqmifgAqfaw 最后防线:字节跳动HIDS分析
6、https://github.com/EBWi11/AgentSmith-HIDS https://github.com/bytedance/Elkeid/blob/main/README-zh_CN.md
7、https://blog.csdn.net/hunter___/article/details/83063131 prctl()函数详解
8、https://www.jianshu.com/p/b1f9d6911c90 ptrace使用介绍
9、https://www.cnblogs.com/tolimit/p/4415348.html linux中断源码分析-初始化
10、https://www.cnblogs.com/vedic/p/11069249.html linux workqueue讲解
11、https://blog.csdn.net/MyArrow/article/details/8090504 workqueue接口函数