代码沙箱实现
技术选型
Springboot + Docker 实现的代码沙箱。遵守单一职责原则,整个沙箱只用来执行代码。现在暂时只支持 Java 代码的执行。
基本架构
在项目最初构建的时候,我的设想是构建两种代码沙箱,一种是在本地执行,第二种是用 Docker 实现,但是由于第一种方式过于复杂(由于 Java SecurityManager 的落伍),并且存在较大漏洞,故而将这种方式从生产环境中去除,将其作为项目测试,跑通流程的测试沙箱。
由于代码执行的步骤都是相似的,所以为了能够增强代码的可拓展性,我选择使用模板方式模式,实现一个 JavaCodeSandboxTemplate 类,在这个类中,我实现了 5 个模板方法,可以通过继承该类来对模板方法进行修改,该类中的方法为:
saveCodeToFile保存后端传递而来的代码文件为一个文件,方便执行代码;compileFile编译用户代码;runFile运行用户代码,并且返回每一组输入用例对应的输出;getOutputResponse得到输出信息,方便返回给后端进去判题;deleteCodeFile删除创建的临时文件;execute该方法是实现了 CodeSandbox 接口而来的,用来统筹以上五个方法;
docker-java 的封装性仍然不强,故而我们设计了类 DockerManager 来统一管理 Docker 容器,提供更高级别的抽象:
- 创建容器;
- 从
application.yml中提取定制化信息; - 拉取镜像 image;
- 检查镜像是否存在;
- 在 springboot 程序启动时,就进行 Docker 的相关操作,从而极大的增加了程序的健壮性,增加了可读性,进一步降低了项目的耦合度;
- 通过 Manager 来同一进行 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));