前提知识:

   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函数族返回。

   业务代码调用了二次vfork函数,第二次vfork后,子进程2调用了execlp函数,该函数启动mygzip.sh脚本,而子进程1(第一次vfork后产生的子进程)

立即返回了(调用_exit(0)函数),父进程等到了子进程1退出。而子进程1是执行mygzip.sh脚本的父进程。

   接着分析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函数的特性,以及_exit函数调用,共同决定了父进程files值为NULL的可能;

   3.子进程通过parent访问父进程的成员变量是不安全的。

  

      最后一个问题:如果才能安全访问进程的parent及其成员变量呢?这又是一个课题了,有待后续分析。

     

 PS:您的支持是对博主最大的鼓励👍,感谢您的认真阅读。
     本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。