中斷是整個計算機體系最核心的功能之一,關於中斷硬件原理可以參考文章末尾的鏈接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接口函數