【原創】訪問Linux進程文件表導致系統異常復位的排查記錄
前提知識:
Linux內核、Linux 進程和文件數據結構、vmcore解析、匯編語言
問題背景:
這個問題出自項目的一個安全模塊,主要功能是確定某進程是否有權限訪問其正在訪問的文件。
實現功能時,需要在內核裏通過掃描該進程打開的文件表,獲取文件的路徑,和安全模塊裏配置的可訪問文件的進程白名單進行匹配;
模塊會一直到搜索到進程pid為1的進程,也就是init進程。在訪問中間某個父進程的文件表時,出現struct task_struct的files指針為空的情況,
導致系統異常復位。
下面就是這次異常的分析和定位過程,希望對大家有所幫助,有什麽想法我們可以交流討論。
接到現場保障時,沒想到又是這個模塊導致的,因為這個模塊剛升級版本,首先是確認系統有無crash vmcore文件生成,還好這次有
vmcore文件,先登錄上去看異常堆棧吧。
確實是安全內核模塊出了異常,為了保密,我省去了很多信息。
在dmesg.txt文件裏,還有一句話:
<1>[259267.001561] BUG: unable to handle kernel NULL pointer dereference at 0000000000000008從堆棧信息可以產出,異常進程comm名稱是gzip,pid為10877,task指針為ffff88207f7f2380,異常原因是訪問了NULL指針。
通過nm和addr2line命令,我們定位具體出異常的代碼行:
include/linux/fdtable.h: 87
緊接著查看files_fdtable代碼實現,是對files->fdt的訪問:
#define files_fdtable(files) \
(rcu_dereference_check_fdtable((files), (files)->fdt))分析到這裏,初步判斷是訪問files執行了異常。我們結合vmcore信息進一步確認。
vmcore文件分析
查找異常進程的files成員變量值是正常的,如下所示:
## crash> struct task_struct.files ffff88207f7f2380
## files = 0xffff881f97cff380
異常進程的進程名稱:
## crash> struct task_struct.comm ffff88207f7f2380
## comm = "gzip\000\000\000\000\000\000\000\000\000\000\000"
問題不是訪問當前進程files導致的,聯想到此模塊會向上遍歷parent進程,並獲取相關files中打開的文件信息,問題可能出自中間過程。
但是究竟是訪問哪個進程出的問題呢?這就需要查看調用函數的堆棧信息和寄存器信息。
查找異常進程的父子進程關系
通過crash的ps命令,我們可以得到異常時所有的進程信息,我們摘出與gzip相關的進行信息:
從上圖我們可以看到與gzip進程(pid=10877)相關的父子進程關系,我們上溯到pid=10875的進程是,發現其VSZ和RSS都是0,比較可疑。
通過crash該進程信息,可以看到其files,mm變量都為NULL。
從這裏可以推斷可能是訪問該進程的異常files成員變量,導致了系統異常。
到底是不是這個訪問引起的,我們還要從當時的堆棧信息做最終的確認。
通過crash dis 命令,可以得到 堆棧中顯示的異常函數的匯編代碼,截取代碼片段如下:
異常堆棧顯示異常代碼是fdtable.h line 87,其上面一段代碼: mov 0x730(%rdi),%rax就是裝載task->files變量到rax寄存器。
結合堆棧信息,寄存器rdi值正是訪問的task結構體指針ffff881f1d3ce280,而當前rax寄存器值為0。所以,會引起訪問NULL指針
的異常。
另外,struct task_struct結構體中,files成員的偏移是1840,也就是0x730。
crash> struct task_struct.files
struct task_struct {
[1840] struct files_struct *files;
}
現在我們完全可以確定,安全模塊函數訪問了進程的files空指針,引起了系統異常。
但是,為什麽父進程的files成員變量會為NULL呢?一般fork出來的子進程都會copy父進程的files等變量的呀。
關於這個問題,還是要從業務的源代碼分析。業務中做文件壓縮的模擬代碼如下:
1 pid_t pid; 2 if ( (pid = vfork())<0 ) 3 { 4 debug(("fork first process error.") ); 5 } 6 7 if (pid == 0) 8 { 9 if ( (pid = vfork())<0 ) 10 { 11 debug(("fork second process error.") ); 12 } 13 14 if (pid == 0) 15 { 16 if(execlp("/XXX/mygzip.sh", "-f", ttemp.c_str(), t.c_str(), (char *) 0) <0 ) 17 { 18 debug(("execlp gzip error.") ); 19 } 20 } 21 _exit(0); 22 } 23 else 24 { 25 if ( waitpid(pid, NULL, 0) <0 ) 26 { 27 debug( ("wait error.") ); 28 } 29 }
業務代碼裏通過vfork出來子進程調用execlp執行mygzip.sh腳本來做文件壓縮。
查找vfork函數說明,有如下描述:
vfork() differs from fork(2) in that the parent is suspended until the child terminates (either normally, by calling exit(2), or abnormally, after
delivery of a fatal signal), or it makes a call to execve(2). Until that point, the child shares all memory with its parent, including the stack. The
child must not return from the current function or call exit(3), but may call _exit(2).
翻譯一下,就是: 調用vfork的父進程會一直阻塞到子進程終結。
分析vfork的內核源碼,也可以得到相應的印證:do_fork會調用copy_process函數,拷貝files,mm,fs等信息;由於vfork調用do_fork是帶有
CLONE_VFORK標記,會等待子進程返回。
1 int sys_vfork(struct pt_regs *regs) 2 { 3 return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs->sp, regs, 0, NULL, NULL); 4 }
1 long do_fork(unsigned long clone_flags, 2 unsigned long stack_start, 3 struct pt_regs *regs, 4 unsigned long stack_size, 5 int __user *parent_tidptr, 6 int __user *child_tidptr) 7 { 8 ...... 9 /* copy files,mm,fs,namespace等信息 */ 10 p = copy_process(clone_flags, stack_start, regs, stack_size, 11 child_tidptr, NULL, trace); 12 /* 13 * Do this prior waking up the new thread - the thread pointer 14 * might get invalid after that point, if the thread exits quickly. 15 */ 16 if (!IS_ERR(p)) { 17 struct completion vfork; 18 19 ...... 20 21 wake_up_new_task(p); 22 23 tracehook_report_clone_complete(trace, regs, 24 clone_flags, nr, p); 25 26 if (clone_flags & CLONE_VFORK) { 27 freezer_do_not_count(); 28 wait_for_completion(&vfork); /* 等待子進程返回 */ 29 freezer_count(); 30 tracehook_report_vfork_done(p, nr); 31 } 32 } else { 33 nr = PTR_ERR(p); 34 } 35 return nr; 36 }
以上說明,正常情況,子進程執行完成後,父進程才繼續執行,其files,mm等成員不應該為空才對。
關鍵是,vfork說明裏還有關鍵的一句:
(either normally, by calling exit(2), or abnormally, after delivery of a fatal signal), or it makes a call to execve(2).
說明子進程返回有三種情況:調用exit返回,或發送致命信號異常返回,或調用execve函數族返回。
業務代碼調用了execlp函數,子進程啟動mygzip.sh腳本後,立即返回了,父進程等到了子進程退出,也調用了_exit(0)函數。
接著分析exit函數實現,會發現do_exit函數會釋放父進程的mm,files等數據:
1 NORET_TYPE void do_exit(long code) 2 { 3 struct task_struct *tsk = current; 4 ...... 5 exit_signals(tsk); /* sets PF_EXITING */ 6 ...... 7 exit_mm(tsk); /* 釋放mm數據 */ 8 ...... 9 exit_sem(tsk); 10 exit_files(tsk); /* 釋放打開的文件表 */ 11 exit_fs(tsk); 12 check_stack_usage(); 13 exit_thread(); 14 15 ...... 16 }
exit_files函數實現:
1 void exit_files(struct task_struct *tsk) 2 { 3 struct files_struct * files = tsk->files; 4 5 if (files) { 6 task_lock(tsk); 7 tsk->files = NULL; /* 進程files賦值為NULL */ 8 task_unlock(tsk); 9 put_files_struct(files); /* 會調用close_files函數,接著看下面的代碼 */ 10 } 11 }
put_files_struct函數:
1 void put_files_struct(struct files_struct *files) 2 { 3 struct fdtable *fdt; 4 5 if (atomic_dec_and_test(&files->count)) { 6 close_files(files); /* 會調用 cond_resched(); */ 7 ......19 } 20 }
closes_files函數:
1 static void close_files(struct files_struct * files) 2 { 3 ...... 4 rcu_read_lock(); 5 fdt = files_fdtable(files); 6 rcu_read_unlock(); 7 for (;;) { 8 unsigned long set; 9 i = j * __NFDBITS; 10 if (i >= fdt->max_fds) 11 break; 12 set = fdt->open_fds->fds_bits[j++]; 13 while (set) { 14 if (set & 1) { 15 struct file * file = xchg(&fdt->fd[i], NULL); 16 if (file) { 17 filp_close(file, files); 18 cond_resched(); /* 正式這一句代碼,讓gzip進程有了執行的機會,父進程此時還未完全退出,但是其files已經是NULL */ 19 } 20 } 21 i++; 22 set >>= 1; 23 } 24 } 25 }
cond_resched();
正式這一句代碼,讓gzip進程有了執行的機會,父進程此時還未完全退出,但是其files已經是NULL。當gzip訪問父進程的files變量時,
就會出現NULL訪問異常,系統異常復位。
經過以上的分析,可以得出如下結論:
1.由於子進程訪問了父進程的空files,導致了系統異常;
2.由於vfork和execlp函數的特性,共同決定了父進程files值為NULL的可能;
3.子進程通過parent訪問父進程的成員變量是不安全的。
最後一個問題:如果才能安全訪問進程的parent及其成員變量呢?這又是一個課題了,有待後續分析。
PS:您的支持是對博主最大的鼓勵??,感謝您的認真閱讀。
本文版權歸作者所有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接,否則保留追究法律責任的權利。
【原創】訪問Linux進程文件表導致系統異常復位的排查記錄