本文的内容包括:
1. 用execve系统调用加载和执行一个可执行程序的代码演示
2. 用gdb跟踪系统调用execve的执行过程
3. execve系统调用处理过程分析
一、如何用execve系统调用加载一个可执行程序
下面的代码可以展示如何用execlp函数启动一个新的进程,execlp是对系统调用execve的一层封装。
其中第19行的输出是故意加上的。执行结果如下,可以看到第19行的输出根本没有显示出来,原因就是exec系列函数会用被加载的进程替换掉原来的进程。
在Linux帮助手册中关于exec系列函数的说明也说明了这一点:
execve() does not return on success, and the text, data, bss, and stack of the calling process are overwritten by that of the program loaded. (execve函数执行成功后不会返回,而且代码段,数据段,bss段和调用进程的栈会被被加载进来的程序覆盖掉)
二、 用gdb跟踪execve系统调用的实验方法
要用gdb调试execve系统调用,首先需要在我们的menuos中添加execve系统调用的入口,程序和上面的代码差不多,只是我们的menuos中还没有ls命令,所以我们需要做另外一个可执行程序让execve系统调用来加载。代码如下所示:
其中的载入的hello程序是我们准备的一个hello world程序,他做的事情就是简单地输出一行Hello Linux Kernel!的文字。我们用静态编译的方式构造这个hello程序,然后放到我们的根文件系统的根目录下,使用的命令和执行效果如下所示:
要调试我们的exec程序,只需要重新使用qemu -kernel ../linux-3.18.6/arch/x86/boot/bzImage -initrd ../rootfs.img -s启动,然后用另一个中断打开gdb远程调试就可以了。不需要使用-S参数,因为我们不需要调试启动过程,只需要等系统启动之后设置execve的断点,然后执行exec命令就可以追踪到了。
我们在下面几个函数中添加断点
1. do_execve
2. do_execve_common
3. exec_binprm
实验截图:
三、 execve系统调用处理过程分析
从上面的实验可以看到,主要的处理过程都在do_execve_common() 函数中,我们来分析这个函数的代码。 为了更清楚的看到这个函数的结构,下面代码删掉了一些错误处理的部分。
1430 static int do_execve_common(struct filename *filename,
1431 struct user_arg_ptr argv,
1432 struct user_arg_ptr envp)
1433 {
1434 struct linux_binprm *bprm; // 用于解析ELF文件的结构
1435 struct file *file;
1436 struct files_struct *displaced;
1437 int retval;
1456 current->flags &= ~PF_NPROC_EXCEEDED; // 标记程序已被执行
1458 retval = unshare_files(&displaced); // 拷贝当前运行进程的fd到displaced中
1463 bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
1467 retval = prepare_bprm_creds(bprm); // 创建一个新的凭证
1471 check_unsafe_exec(bprm); // 必要的安全检查
1472 current->in_execve = 1;
1474 file = do_open_exec(filename); // 打开要执行的文件
1479 sched_exec(); // 下面是Linux代码中对这个函数的解释:
// execve() is a valuable balancing opportunity, because at
// this point the task has the smallest effective memory and cache footprint.
1481 bprm->file = file;
1482 bprm->filename = bprm->interp = filename->name;
1484 retval = bprm_mm_init(bprm); // 为ELF文件分配内存,其中的一些值还是
// 默认值,需要在后面的函数中修正
1488 bprm->argc = count(argv, MAX_ARG_STRINGS);
1492 bprm->envc = count(envp, MAX_ARG_STRINGS);
1496 retval = prepare_binprm(bprm); // 从打开的可执行文件中读取信息,填充bprm结构
// 下面的4句是将运行参数和环境变量都拷贝到bprm结构的内存空间中
1500 retval = copy_strings_kernel(1, &bprm->filename, bprm);
1504 bprm->exec = bprm->p;
1505 retval = copy_strings(bprm->envc, envp, bprm);
1509 retval = copy_strings(bprm->argc, argv, bprm);
// 开始执行加载到内存中的ELF文件
1513 retval = exec_binprm(bprm);
/* 执行完成,清理并返回 */
1518 current->fs->in_exec = 0;
1519 current->in_execve = 0;
1520 acct_update_integrals(current);
1521 task_numa_free(current);
1522 free_bprm(bprm);
1523 putname(filename);
1524 if (displaced)
1525 put_files_struct(displaced);
1526 return retval;
1547 }
上面的过程中,最重要的莫过于1513行的exec_binprm()函数。下面来看exec_binprm的实现:
1405 static int exec_binprm(struct linux_binprm *bprm)
1406 {
1407 pid_t old_pid, old_vpid;
1408 int ret;
1409
1410 /* Need to fetch pid before load_binary changes it */
1411 old_pid = current->pid;
1412 rcu_read_lock();
1413 old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent)) ;
1414 rcu_read_unlock();
1415
1416 ret = search_binary_handler(bprm);
1417 if (ret >= 0) {
1418 audit_bprm(bprm);
1419 trace_sched_process_exec(current, old_pid, bprm);
1420 ptrace_event(PTRACE_EVENT_EXEC, old_vpid);
1421 proc_exec_connector(current);
1422 }
1423
1424 return ret;
1425 }
其中,需要理解的就是search_binary_handler()函数,代码如下:
1349 /*
1350 * cycle the list of binary formats handler, until one recognizes the image
1351 */
1352 int search_binary_handler(struct linux_binprm *bprm)
1353 {
1354 bool need_retry = IS_ENABLED(CONFIG_MODULES);
1355 struct linux_binfmt *fmt;
1356 int retval;
1357
1358 /* This allows 4 levels of binfmt rewrites before failing hard. */
1359 if (bprm->recursion_depth > 5)
1360 return -ELOOP;
1361
1362 retval = security_bprm_check(bprm); // 检查用户是否有权限运行该文件
1363 if (retval)
1364 return retval;
1365
1366 retval = -ENOENT;
1367 retry:
1368 read_lock(&binfmt_lock);
1369 list_for_each_entry(fmt, &formats, lh) { // 尝试每一种格式的解析函数,
// 支持的格式由__register_binfmt() 函数注册进来
1370 if (!try_module_get(fmt->module))
1371 continue;
1372 read_unlock(&binfmt_lock);
1373 bprm->recursion_depth++;
1374 retval = fmt->load_binary(bprm); // 关键步骤,调用合适格式的处理函数加载该可执行文件
// 对ELF文件来说,这个处理函数是 load_elf_binary
1375 read_lock(&binfmt_lock);
1376 put_binfmt(fmt);
1377 bprm->recursion_depth--;
1378 if (retval < 0 && !bprm->mm) {
1379 /* we got to flush_old_exec() and failed after it */
1380 read_unlock(&binfmt_lock);
1381 force_sigsegv(SIGSEGV, current);
1382 return retval;
1383 }
1384 if (retval != -ENOEXEC || !bprm->file) {
1385 read_unlock(&binfmt_lock);
1386 return retval;
1387 }
1388 }
1389 read_unlock(&binfmt_lock);
1390
1391 if (need_retry) {
1392 if (printable(bprm->buf[0]) && printable(bprm->buf[1]) &&
1393 printable(bprm->buf[2]) && printable(bprm->buf[3]))
1394 return retval;
1395 if (request_module("binfmt-%04x", *(ushort *)(bprm->buf + 2)) < 0)
1396 return retval;
1397 need_retry = false;
1398 goto retry;
1399 }
1400
1401 return retval;
1402 }
在load_elf_binary()函数中,加载进来的可执行文件将把当前正在执行的进程的内存空间完全覆盖掉,如果可执行文件是静态链接的文件,进程的IP寄存器值将被设置为main函数的入口地址,从而开始新的进程;而如果可执行文件是动态链接的,IP的值将被设置为加载器ld的入口地址,是程序的运行由该加载器接管,ld会处理一些依赖的动态链接库相关的处理工作,使程序继续往下执行,而不管哪种执行方式,当前的进程都会被新加载进来的程序完全替换掉,这也是我们最早的那个程序中第19行的信息没有在终端上显示的原因。
四、总结
简单总结一下execve系统调用的执行过程:
1. 陷入内核
2. 加载新的可执行文件并进行可执行性检查
3. 将新的可执行文件映射到当前运行进程的进程空间中,并覆盖原来的进程数据
4. 将EIP的值设置为新的可执行程序的入口地址。如果可执行程序是静态链接的程序,或不需要其他的动态链接库,则新的入口地址就是新的可执行文件的main函数地址;如果可执行程序还需要其他的动态链接库,则入口地址是加载器ld的入口地址
5. 返回用户态,程序从新的EIP出开始继续往下执行。至此,老进程的上下文已经被新的进程完全替代了,但是进程的PID还是原来的。从这个角度来看,新的运行进程中已经找不到原来的对execve调用的代码了,所以execve函数的一个特别之处是他从来不会成功返回,而总是实现了一次完全的变身。