基于 Docker 容器的沙盒化评测系统

在线评测系统(Online Judge)允许用户提交代码,在评测机上运行,并返回运行结果。而用户提交的代码有时是不安全的,它可能会无限创建进程或文件消耗评测机资源,或者建立到远程服务器的连接,给攻击者提供后门。保证评测机安全的方法之一,是使用沙盒(Sandbox)。

资源的限制

一般的评测系统都会限制程序的运行资源,如内存、时间用量,进程、线程数量。资源的限制通常使用 setrlimit()(Set Resource Limit)来完成。

setrlimit() 的帮助文档(Man Page)非常详细,此处不多做介绍。但值得一提的是,setrlimit() 限制的时间(RLIMIT_RTTIME)是 CPU 时间,而线程被挂起的时间并不计算在内 —— 如调用 sleep() 时的延时和 scanf() 等待用户输入的时间。为了正确限制目标程序的时间,我们创建一个监视线程,在指定的时间后将目标进程杀死。

系统调用的限制

程序的一切与操作系统有关的操作,比如输入输出,创建进程,获取系统信息等,都需要系统调用(System Call)。如果不限制系统调用,目标程序也可能会有一些危险的行为。比如,目标程序可以获得文件系统的一部分目录结构,如果它运行在与评测系统或 Web 服务器相同的用户下,可能会获取或修改一些影响服务器安全的信息。

目前常用的两个限制系统调用的方案是 ptrace()seccamp,前者的原理是在目标程序每次尝试进行系统调用时通知评测程序,如果发现危险的系统调用,可以及时将目标程序杀死。但 ptrace() 在每次系统调用时都会产生两次中断(进入系统调用前一次,系统调用返回后一次),影响效率。相比之下 seccamp 可能是更好的选择。

使用 ptrace() 的评测系统有:HustOJUOJ; 使用 seccamp 的评测系统有:QDUOJTJudger

基于 Docker 的沙盒

保护系统安全的另一种思路是将目标程序与系统环境隔离,形成一个沙盒(Sandbox)环境。如 chroot 可以保证程序只能访问某个目录下的内容,而 Docker 则可以实现完全隔离的容器。

每次运行目标程序时,创建一个 Docker 容器,将输入数据与目标程序的可执行文件传入,创建一个监视进程,fork() 出子进程调用 setrlimit() 后使用 exec 载入目标程序。

对于需要运行环境的语言(如基于 .Net 的语言和 Python 等脚本语言),我们需要把系统中的库目录映射到容器中 —— 一般不需要考虑这样做的安全性,因为容器中的目标程序也是以普通用户运行的,对系统目录没有写权限;而库目录中一般没有可以读到的配置文件等敏感信息。

Docker 提供的网络功能可以用来禁止容器中的目标程序联网。

因为命令行参数拼接可能导致命令注入,所以在实现中没有直接调用 docker 命令行工具,而是使用了 dockerode 库与 Docker 服务进行通信。

基于 Docker 的沙盒的优点在于不需要限制系统调用,因为一些语言的运行库(如基于 .Net 的语言)或解释器(如 Python 等脚本语言)需要额外的系统调用,而使用对 C++ 等低级语言的限制方法来限制这些语言的程序,可能会将这些系统调用误认为恶意程序而杀死。 缺点在于,创建 Docker 容器需要一定开销,经过测试每次运行需要额外的大约一秒的时间。并且,Docker 不能在 32 位操作系统下运行。

评测程序的实现

沙盒只是评测系统的一部分,我们还需要一个评测程序来完成与沙盒和后端 Web 服务器的交互。

评测程序的主要工作是:

  1. 循环请求 Web 服务器获取等待评测的记录;
  2. 如果需要,从 Web 服务器下载测试数据并解压;
  3. 根据语言选择编译器进行编译;
  4. 调用沙盒运行目标程序;
  5. 检验程序输出并评分;
  6. 将评测结果上传回 Web 服务器。

最后,用一张图来描述整个评测系统的大致架构: