PRoot沙箱机制浅析

2,172 阅读8分钟

玩机的小伙伴一定对Termux比较熟悉,他可以让安卓系统运行LINUX,这其中PRoot发挥了重要的作用。

什么是PRoot

PRoot 是 chrootmount --bind 和 binfmt_misc 的用户态实现。用户不需要拥有系统特权就可以在任意目录建立一个新的根文件系统。从而在建立的根文件系统内做任何事情。也可以借助QEMU user-mode甚至能够运行其他CPU构架的程序。

从技术上来说,PRoot是依靠ptrace机制实现的。ptrace允许程序在没有拿到系统特权(root)时,父进程观察并修改子进程的系统调用,这也是PRoot的核心原理。本文主要也是分析这一机制。

PRoot使用情况

使用他最简单的功能

proot -r alpine

默认会去执行sh,也可以指定命令

查看进程树

pstree -p 193609
proot(193609)───sh(193610)

我们进一步详细查看sh(193610)进程

COMMAND    PID  USER   FD      TYPE DEVICE SIZE/OFF     NODE NAME  
sh      193610 yiran  cwd       DIR  259,4     4096 13762562 /home/yiran  
sh      193610 yiran  rtd       DIR  259,4     4096        2 /  
sh      193610 yiran  txt       REG   0,33     8984    13381 /tmp/prooted-193609-QypvZL  
sh      193610 yiran  mem       REG  259,4   837272 13907433 /home/yiran/code/alpine/bin/busybox  
sh      193610 yiran  mem       REG  259,4   604704 14167975 /home/yiran/code/alpine/lib/ld-musl-x86_64.so.1  
sh      193610 yiran    0u      CHR 136,13      0t0       16 /dev/pts/13  
sh      193610 yiran    1u      CHR 136,13      0t0       16 /dev/pts/13  
sh      193610 yiran    2u      CHR 136,13      0t0       16 /dev/pts/13  
sh      193610 yiran   10u      CHR 136,13      0t0       16 /dev/pts/13  
sh      193610 yiran   32r  a_inode   0,14        0    10448 inotify

和chroot机制不同,proot的根还是在/,这是正确的。chroot的系统调用权限较高,普通用户在不提权的情况无法进行chroot。

PRoot分析:main函数

因为proot的main函数比较短,我就直接就复制过来逐行进行分析。分析就直接写在注释里面。

int main(int argc, char *const argv[])
{
        //这个struct非常重要,记录着和trace的进程相关的信息
	Tracee *tracee;
	int status;

	/* 引入talloc内存池系统,配置内存分配器  */
	talloc_enable_leak_report();

#if defined(TALLOC_VERSION_MAJOR) && TALLOC_VERSION_MAJOR >= 2
	talloc_set_log_stderr();
#endif

	/* 创建第一个tracee */
	tracee = get_tracee(NULL, 0, true);
	if (tracee == NULL)
		goto error;
	tracee->pid = getpid();

	/* 根据命令行参数,对该tracee进行配置 */
	status = parse_config(tracee, argc, argv);
	if (status < 0)
		goto error;

	/* 启动进程,该进程为tracee->exe */
	status = launch_process(tracee, &argv[status]);
	if (status < 0) {
		print_execve_help(tracee, tracee->exe, status);
		goto error;
	}

	/* 开始跟踪第一个tracee及其所有子项。  */
	exit(event_loop());

error:
	TALLOC_FREE(tracee);

	if (exit_failure) {
		fprintf(stderr, "fatal error: see `%s --help`.\n", basename(argv[0]));
		exit(EXIT_FAILURE);
	}
	else
		exit(EXIT_SUCCESS);
}

看了main函数,多少有点初步印象,可以猜想proot生成所有子进程会绑定一个tracee,第一个tracee会将后续的tracee如同子进程一样挂在自己下面。proot在父进程中借助这一串派生出来的tracee对子进程跟踪。

既然tracee如此重要,那我们来分析一下struct Tracee


/* Information related to a tracee process. */
typedef struct tracee {
	/**********************************************************************
	 * Private resources                                                  *
	 **********************************************************************/
	/* tracee的双向链表  */
	LIST_ENTRY(tracee) link;

	/* 进程pid */
	pid_t pid;

	/* 唯一的Tracee标识符 */
	uint64_t vpid;

	/* 是否正在运行  */
	bool running;

	/* 是否准备好释放 */
	bool terminated;

        /* 终止此跟踪是否意味着立即终止所有跟踪。 */
        bool killall_on_exit;

	/* 父级tracee */
	struct tracee *parent;

	/* 它是一个“克隆”吗,即具有与其创建者相同的父级。 */
	bool clone;

	/* 在沙箱中对ptrace进行仿真实现 (tracer side).  */
	struct {
		size_t nb_ptracees;
		LIST_HEAD(zombies, tracee) zombies;

		pid_t wait_pid;
		word_t wait_options;

		enum {
			DOESNT_WAIT = 0,
			WAITS_IN_KERNEL,
			WAITS_IN_PROOT
		} waits_in;
	} as_ptracer;

	/* 在沙箱中对ptrace进行仿真实现 (tracee side).  */
	struct {
		struct tracee *ptracer;

		struct {
			#define STRUCT_EVENT struct { int value; bool pending; }

			STRUCT_EVENT proot;
			STRUCT_EVENT ptracer;
		} event4;

		bool tracing_started;
		bool ignore_loader_syscalls;
		bool ignore_syscalls;
		word_t options;
		bool is_zombie;
	} as_ptracee;

	/* 当前的状态
	 *        0: enter syscall
	 *        1: exit syscall no error 
	 *   -errno: exit syscall with error.  */
	int status;

#define IS_IN_SYSENTER(tracee) ((tracee)->status == 0)
#define IS_IN_SYSEXIT(tracee) (!IS_IN_SYSENTER(tracee))
#define IS_IN_SYSEXIT2(tracee, sysnum) (IS_IN_SYSEXIT(tracee) \
				     && get_sysnum((tracee), ORIGINAL) == sysnum)

	/* 如何重新启动此tracee */
	PTRACE_REQUEST_TYPE restart_how;

	/* tracee 跟踪的通用寄存器的值。  */
	struct user_regs_struct _regs[NB_REG_VERSION];
	bool _regs_were_changed;
	bool restore_original_regs;

	/* 对SIGSTOP的状态进行特殊处理。  */
	enum {
		SIGSTOP_IGNORED = 0,  /* Ignore SIGSTOP (once the parent is known).  */
		SIGSTOP_ALLOWED,      /* Allow SIGSTOP (once the parent is known).   */
		SIGSTOP_PENDING,      /* Block SIGSTOP until the parent is unknown.  */
	} sigstop;

	/* 用于收集所有临时动态内存分配的上下文。  */
	TALLOC_CTX *ctx;

	/* 用于收集释放此tracee后应释放的所有动态内存分配的上下文。  */
	TALLOC_CTX *life_context;

	/* Specify the type of the final component during the
	 * initialization of a binding.  This variable is first
	 * defined in bind_path() then used in build_glue().  */
	mode_t glue_type;

	/* 在子重新配置期间,新设置与@tracee的文件系统名称空间相对应。此外,@paths保存其$PATH环境变量,以模拟execvp(3)行为。  */
	struct {
		struct tracee *tracee;
		const char *paths;
	} reconf;

	/* PRoot在实际系统调用后插入的未请求的系统调用。这是一个系统调用链*/
	struct {
		struct chained_syscalls *syscalls;
		bool force_final_result;
		word_t final_result;
	} chain;

         /*加载运行期间所需要的加载信息*/
	struct load_info *load_info;
	/**********************************************************************
	 * Private but inherited resources                                    *
	 **********************************************************************/

	/* 调试信息详细级别  */
	int verbose;

	/* 这个tracee的seccomp加速状态.  */
	enum { DISABLED = 0, DISABLING, ENABLED } seccomp;

	/* 确保在seccomp下始终命中sysexit阶段。 */
	bool sysexit_pending;


	/**********************************************************************
	 * Shared or private resources, depending on the CLONE_FS/VM flags.   *
	 **********************************************************************/

	/* 与文件系统名称空间相关的信息。 */
	FileSystemNameSpace *fs;

	/* 虚拟堆,使用常规内存映射进行模拟。 */
	Heap *heap;


	/**********************************************************************
	 * Shared resources until the tracee makes a call to execve().        *
	 **********************************************************************/

	/* 执行程序的路径  */
	char *exe;
	char *new_exe;


	/**********************************************************************
	 * Shared or private resources, depending on the (re-)configuration   *
	 **********************************************************************/

	/* Runner command-line.  */
	char **qemu;

	/* guest rootfs和host rootfs用来映射的路径 */
	const char *glue;

	/* 为此tracee启用的扩展列表。  */
	struct extensions *extensions;


	/**********************************************************************
	 * Shared but read-only resources                                     *
	 **********************************************************************/

	/* 对于混合模式,guest LD_LIBRARY_PATH在“guest->host”转换期间保存,以便在“host->client”转换期间恢复(仅当主机LD_LIBRORY_PATH未更改时)。 */
	const char *host_ldso_paths;
	const char *guest_ldso_paths;

	/* 用于诊断目的 */
	const char *tool_name;

} Tracee;

在查看进程的时候我们发现有两个进程在运行,proot是在launch_process中进行了fork操作,我们接下来将父子进程分别进行分析。

proot子进程

子进程的逻辑比较简单,我们先来看子进程。所谓子进程就是进入proot沙箱后执行的命令,这里默认执行的是一个sh。

在launch_process进行fork()后,子进程的运行逻辑:

int launch_process(Tracee *tracee, char *const argv[])
{
	char *const default_argv[] = { "-sh", NULL };
	long status;
	pid_t pid;

	/* Warn about open file descriptors. They won't be
	 * translated until they are closed. */
	list_open_fd(tracee);

	pid = fork();
	switch(pid) {
	case -1:
		note(tracee, ERROR, SYSTEM, "fork()");
		return -errno;

	case 0: /* child */
		/* 指示该进程将由其父进程跟踪 */
		status = ptrace(PTRACE_TRACEME, 0, NULL, NULL);
		if (status < 0) {
			note(tracee, ERROR, SYSTEM, "ptrace(TRACEME)");
			return -errno;
		}

		/* 与跟踪程序的事件循环同步。如果没有这个技巧,跟踪程序只能看到来自下一个execve(2)的“返回”,因此PRoot不会处理解释器/运行程序。我还验证了strace也做了同样的事情。 */
		kill(getpid(), SIGSTOP);

		/* 使用seccomp模式2提高性能,除非明确禁用此支持 */
		if (getenv("PROOT_NO_SECCOMP") == NULL)
			(void) enable_syscall_filtering(tracee);

		/* 现在进程被ptraced,所以当前的rootfs已经是guest rootfs。注意:Valgrind不能处理“外来”二进制文件(ENOEXEC)上的execve(2),但可以处理此类二进制文件上的execvp(3)。 */
		execvp(tracee->exe, argv[0] != NULL ? argv : default_argv);
		return -errno;

	default: /* parent */
		/* We know the pid of the first tracee now.  */
		tracee->pid = pid;
		return 0;
	}

	/* Never reached.  */
	return -ENOSYS;
}

ptrace(PTRACE_TRACEME, 0, NULL, NULL);

PRoot沙箱能成立的基础就是ptrace这个跟踪机制,跟踪器可以使用各种ptrace请求来检查和修改跟踪。设置父进程跟踪后,给该子进程发送了一个SIGSTOP信号,用来告诉父进程,有第一个新的子进程上线啦。一切准备就绪,就可以执行指定的命令了execvp(tracee->exe, argv[0] != NULL ? argv : default_argv);

proot父进程

父进程在launch_process中主要是fork出子进程,主要的业务逻辑都在event_loop()函数之中。

int event_loop()
{
	struct sigaction signal_action;
	long status;
	int signum;

	/* 父进程退出的时候将杀死所有子进程  */
	status = atexit(kill_all_tracees);
	if (status != 0)
		note(NULL, WARNING, INTERNAL, "atexit() failed");

	/* 当调用信号处理程序时,所有信号都被阻止。
	 * SIGINFO 用于知道哪个进程向我们发出了信号
	 * RESTART 用于无缝重启waitpid(2).  */
	bzero(&signal_action, sizeof(signal_action));
	signal_action.sa_flags = SA_SIGINFO | SA_RESTART;
	status = sigfillset(&signal_action.sa_mask);
	if (status < 0)
		note(NULL, WARNING, SYSTEM, "sigfillset()");

	/* Handle all signals.  */
	for (signum = 0; signum < SIGRTMAX; signum++) {
		switch (signum) {
		case SIGQUIT:
		case SIGILL:
		case SIGABRT:
		case SIGFPE:
		case SIGSEGV:
			/* 杀死所有处于异常终止信号的tracee,这样就能确保所有进程处于进程之中  */
			signal_action.sa_sigaction = kill_all_tracees2;
			break;

		case SIGUSR1:
		case SIGUSR2:
			/* 在stderr上打印完整的talloc层次结构,用于调试。  */
			signal_action.sa_sigaction = print_talloc_hierarchy;
			break;

		case SIGCHLD:
		case SIGCONT:
		case SIGSTOP:
		case SIGTSTP:
		case SIGTTIN:
		case SIGTTOU:
			/* The default action is OK for these signals,
			 * they are related to tty and job control.  */
			continue;

		default:
			/* 忽略所有其他信号,包括终止信号(例如^C)。*/
			signal_action.sa_sigaction = (void *)SIG_IGN;
			break;
		}

		status = sigaction(signum, &signal_action, NULL);
		if (status < 0 && errno != EINVAL)
			note(NULL, WARNING, SYSTEM, "sigaction(%d)", signum);
	}

	while (1) {
		int tracee_status;
		Tracee *tracee;
		int signal;
		pid_t pid;

		/* 这是释放tracee的唯一安全地方。  */
		free_terminated_tracees();

		/* 等待子进程停止,并非终止,而是挂起的挂起的状态 */
		pid = waitpid(-1, &tracee_status, __WALL);
		if (pid < 0) {
			if (errno != ECHILD) {
				note(NULL, ERROR, SYSTEM, "waitpid()");
				return EXIT_FAILURE;
			}
			break;
		}

		/* 通过pid获取对应的tracee,本质上是遍历trace链表 */
		tracee = get_tracee(NULL, pid, true);
		assert(tracee != NULL);

		tracee->running = false;

		VERBOSE(tracee, 6, "vpid %" PRIu64 ": got event %x",
			tracee->vpid, tracee_status);

		status = notify_extensions(tracee, NEW_STATUS, tracee_status, 0);
		if (status != 0)
			continue;

		if (tracee->as_ptracee.ptracer != NULL) {
			bool keep_stopped = handle_ptracee_event(tracee, tracee_status);
			if (keep_stopped)
				continue;
		}

		signal = handle_tracee_event(tracee, tracee_status);
		(void) restart_tracee(tracee, signal);
	}

	return last_exit_status;
}

父进程在开始进入循环之前,设定了信号的回调操作。

主要业务与逻辑都在这个循环里面进行设定与执行。父进程通过waitpid函数接收所有子进程的停止事件,最重要的是该调用将返回一个状态值,该状态值包含指示示踪停止的原因的信息,然后在handle_tracee_event函数依据返回的tracee_status来判断事件类型,这时跟踪停止,父进程可以使用各种ptrace请求来检查和修改跟踪。

通过WIFSTOPPED(tracee_status)处理捕获跟踪事件,在处理中我们着重关注ptrace的使用 ptrace(PTRACE_SETOPTIONS, tracee->pid, NULL, default_ptrace_options | PTRACE_O_TRACESECCOMP)设置一些ptrace选项。

	/* Fall through. */
        case SIGTRAP | PTRACE_EVENT_SECCOMP2 << 8:
        case SIGTRAP | PTRACE_EVENT_SECCOMP << 8:
                ......
                status = ptrace(PTRACE_GETEVENTMSG, tracee->pid, NULL, &flags);
                if (status < 0)
                        break;

                if ((flags & FILTER_SYSEXIT) == 0) {
                        tracee->restart_how = PTRACE_CONT;
                        translate_syscall(tracee);
                }

接下来的将进入translate_syscall(tracee)该函数会将系统调用重新调整;比如最常用的cd命令,本质上调用了chdir的,但proot会将该系统调用动作进行篡改,使其按照预定的行为进行,所以我们在进程中查询到了cwd与沙箱中的实际路径是不一样的。

函数bool restart_tracee(Tracee *tracee, int signal)会将停止中子进程通过ptrace(tracee->restart_how, tracee->pid, NULL, signal);进行激活重启,继续接下来的流程。

总结

proot主要是使用ptrace的父子进程跟踪机制,将所有子进程通过tracee链表包裹串联起来,并通过篡改系统调用,实现沙箱机制。

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天,点击查看活动详情