代码沙箱实现

技术选型

Springboot + Docker 实现的代码沙箱。遵守单一职责原则,整个沙箱只用来执行代码。现在暂时只支持 Java 代码的执行。

基本架构

在项目最初构建的时候,我的设想是构建两种代码沙箱,一种是在本地执行,第二种是用 Docker 实现,但是由于第一种方式过于复杂(由于 Java SecurityManager 的落伍),并且存在较大漏洞,故而将这种方式从生产环境中去除,将其作为项目测试,跑通流程的测试沙箱。

由于代码执行的步骤都是相似的,所以为了能够增强代码的可拓展性,我选择使用模板方式模式,实现一个 JavaCodeSandboxTemplate 类,在这个类中,我实现了 5 个模板方法,可以通过继承该类来对模板方法进行修改,该类中的方法为:

docker-java 的封装性仍然不强,故而我们设计了类 DockerManager 来统一管理 Docker 容器,提供更高级别的抽象:

安全管理

整体控制

通过 docker-java 操作 docker 容器。通过 HostConfig 来设置整体的安全限制:

HostConfig hostConfig = new HostConfig();  
hostConfig.withMemory(100 * 1000 * 1000L);  
hostConfig.withCpuCount(1L);  
hostConfig.withMemorySwap(0L);  
hostConfig.withAutoRemove(true);

另外通过创建容器 Container 的时候,进行设置来进一步的保证执行的安全:

CreateContainerCmd containerCmd = this.dockerClient.createContainerCmd(imageName);  
CreateContainerResponse createContainerResponse = containerCmd  
        .withHostConfig(hostConfig)  
        .withAttachStdin(true)  
        .withAttachStdout(true)  
        .withAttachStderr(true)  
        .withNetworkDisabled(true)  
        .withReadonlyRootfs(true)  
        .withTty(true)  
        .exec();

可以看到,我们禁用了用户程序访问外部网络的可能。

超时控制

我们利用 docker-java 中的 callback 调用实现对于程序执行超时的检测:

final boolean[] timeout = {true};  
String execId = execCreateCmdResponse.getId();  
ExecStartResultCallback execStartResultCallback = new ExecStartResultCallback() {  
    @Override  
    public void onNext(Frame frame) {  
        StreamType streamType = frame.getStreamType();  
        if (StreamType.STDERR.equals(streamType)) {  
            errorMessage[0] = new String(frame.getPayload());  
            System.out.println("输出错误结果:" + errorMessage[0]);  
        } else {  
            message[0] = new String(frame.getPayload());  
            System.out.println("输出结果:" + message[0]);  
        }  
        super.onNext(frame);  
    }  
  
    @Override  
    public void onComplete() {  
        // 如果执行完成,则表示没有超时  
        timeout[0] = false;  
        super.onComplete();  
    }  
};

我们同样可以通过 timeout[0] 来得到后端需要的用户程序执行耗时。

内存监测

用于返回给后端的内存数据,从而判断是否内存超出的错误:

final long[] maxMemory = {0L};  
StatsCmd statsCmd = this.dockerClient.statsCmd(containerId);  
ResultCallback<Statistics> statisticsResultCallback = statsCmd.exec(new ResultCallback.Adapter<Statistics>() {  
    @Override  
    public void onNext(Statistics statistics) {  
        System.out.println("内存占用:" + statistics.getMemoryStats().getUsage());  
        maxMemory[0] = Math.max(statistics.getMemoryStats().getUsage(), maxMemory[0]);  
    }});  
statsCmd.exec(statisticsResultCallback);

在执行这个指令的时候,docker 会自动开始内存监测,通过 onNext 函数持续更新内存的最值,在程序执行完后就可以通过 memory[0] 得到程序执行的内存消耗。

额外完全措施

我们可以通过 Linux 自身自带的内核安全指令 seccomp ,来控制程序的行为,例如控制系统的系统调用。为了实现这个功能,我们首先需要新建一个 seccomp 配置文件,例如:

{
  "defaultAction": "SCMP_ACT_ALLOW",
  "syscalls": [
    {
      "name": "write",
      "action": "SCMP_ACT_ALLOW"
    },
    {
      "name": "read",
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

然后通过 Java 程序调用:

hostConfig.withSecurityOpts(Arrays.asList("seccomp=" + config));