一个靠谱的OJ后端判题机,最难的不是题目数据和前端页面,而是怎么安全执行用户提交的Java代码。我在给某高校的在线评测系统做后端重构时,就被“原生实现Java安全管理器环境隔离”这个需求卡了很久——说难也不难,说简单又到处是坑。这篇文章就把整套判题机架构和基于SecurityManager的隔离方案拆开讲清楚,适合正在自研OJ判题模块、或者想理解沙箱原理的朋友参考。
1. 判题机整体架构:从提交到状态的完整链路
1.1 判题机在OJ系统中的位置
一次典型的OJ请求是这样的:用户在网页提交代码,前端把代码文本和题目ID扔给后端服务,后端负责编译、运行、采集输出、和标准答案比对,最后把“通过/超时/运行错误/答案错误”写回数据库。这套过程中,真正危险的是“运行”这一步,因为跑的是用户写的任意代码。你要是直接Runtime.exec或者反射调用一个类的main方法,那基本等于把服务器钥匙交给了陌生人。
所以判题机本质上是OJ系统的“隔离舱”。它要回答三个问题:
- 代码能不能编译通过?
- 运行结果是否在限制的时间和内存内产生?
- 这段代码有没有试图干超出题目范围的事?
我的做法是把判题机拆成独立模块,不跟Web服务混在一起,用消息队列接收判题请求。每个判题任务都是一个标准化的对象,包含源码、语言、时间限制、内存限制、标准输入和题目特殊要求。判题机拿到任务后,依次执行编译、执行、比对三个步骤,每一步都有独立的异常捕获和状态回传机制。
提示:判题机不要再依赖Web层的HTTP请求直接触发,一定要走异步队列。否则一个恶意死循环就能把后端线程池全部占满,导致整个OJ页面卡死。
1.2 为什么选择原生SecurityManager而不是Docker
现在一提代码沙箱,大家第一反应都是Docker:干净、彻底、好管理。但当时我们的环境有一个现实约束——判题机要跑在一些老旧服务器上,内核版本不够新,Docker运行不稳定,而且运维团队对容器技术不熟。再加上题目场景集中在Java语言,不需要支持多语言通用沙箱,所以我把目标缩小为:只隔离Java进程内的用户代码。
Java自己提供了一套安全管理机制,SecurityManager配合AccessController和权限策略文件,可以精确控制“哪段代码能读哪个文件、能不能网络连接、能不能写系统属性”。虽然它不是操作系统级别的隔离,但对“跑一段OJ提交的Java代码”这个场景来说,足够拦住99%的恶意行为。
这不是说Docker不好,而是说在特定约束下,原生方案更轻、更可控。如果你要一个判题机同时支持Python、C++、Java,老老实实上容器;如果只是Java单语言、并且Java版本可控,SecurityManager是成本最低的解法。
1.3 判题机核心模块划分
整个判题机我分成了五个模块:
- 任务接收模块:监听队列,把原始提交转换成内部任务对象。
- 编译模块:调用Java编译器,把内存中的源码写到临时目录,编译成class文件。
- 隔离执行模块:启用SecurityManager,在受限线程中加载并运行用户类。
- 资源监控模块:采集执行线程的CPU、内存、运行时间。
- 结果比对模块:对输出做规范化处理,和标准输出比对。
其中隔离执行模块是最核心的,后面所有内容都会围绕它展开。
2. Java安全管理器隔离的原理与策略设计
2.1 SecurityManager到底拦住了什么
SecurityManager是Java从1.0就有的东西,它本质上是一个“检查员”:每次关键操作发生前,JVM都会调用安全管理器里对应的checkXxx()方法,如果该方法抛出SecurityException,操作就被拒绝。
你可以把它理解为小区门口的保安。用户代码想读/etc/passwd,保安就问:你有出入证吗?你的Permission里有FilePermission吗?没有就不让进。写系统属性、连网络、访问环境变量、加载未授权的类库,全都要经过这一层检查。
但光理解概念没用,实际使用中有个关键点:基于特权机制的权限检查,在现代JDK里大部分是通过AccessController.checkPermission()来做的。而用户代码如果自己包了一个AccessController.doPrivileged,可能会绕过你设置的某些检查(前提是它能执行到那个特权块)。所以判题机里不能只依赖SecurityManager的默认检查,还得配合自定义逻辑。
2.2 权限策略文件:从零写一个最小白名单
Java的权限策略文件是一个后缀为.policy的文本,我们可以给它配置一个“白名单”。网上很多教程只会给一个全局AllPermission,那等于没设。我实际跑OJ时用的策略是这样的:
grant codeBase "file:/judge/tmp/user/*" { permission java.lang.RuntimePermission "accessDeclaredMembers"; permission java.lang.RuntimePermission "accessClassInPackage.sun.reflect"; permission java.lang.PropertyPermission "java.version", "read"; permission java.lang.PropertyPermission "java.vm.name", "read"; permission java.lang.PropertyPermission "os.name", "read"; permission java.lang.PropertyPermission "line.separator", "read"; permission java.lang.PropertyPermission "file.separator", "read"; permission java.lang.PropertyPermission "path.separator", "read"; permission java.util.PropertyPermission "user.name", "read"; permission java.io.FilePermission "/judge/tmp/user/-", "read,write,delete"; permission java.lang.RuntimePermission "setContextClassLoader"; };这份策略就表达了:用户在/judge/tmp/user/目录下的代码,只能读几个系统属性、访问自己目录下的文件,其他一律不行。这里没有给java.net.SocketPermission,所以用户代码发起任何网络连接都会直接抛异常;没有给java.lang.RuntimePermission "exitVM",所以System.exit根本执行不了。
注意:策略文件里的
codeBase必须和用户class文件实际加载路径完全匹配,否则权限不生效。我踩过的最深的坑就是把用户class输出到了带空格或中文的路径,策略匹配不上,所有权限全部失效。
2.3 动态权限控制:通过自定义SecurityManager按需放行
静态策略文件只能做“一刀切”的白名单,但判题场景经常需要某些操作在受限线程内“临时但可控地”打开。比如判题机自身要读用户目录下的输入文件,而用户代码不需要这个权限。
我写了一个自定义SecurityManager,重写了关键点的检查:
public class JudgeSecurityManager extends SecurityManager { private Thread judgeThread; public JudgeSecurityManager(Thread judgeThread) { this.judgeThread = judgeThread; } @Override public void checkPermission(Permission perm) { // 判题机自身的线程,全部放行 if (Thread.currentThread() == judgeThread) { return; } // 用户线程:禁止退出JVM、禁止网络、禁止写系统属性等 if (perm instanceof RuntimePermission) { String name = perm.getName(); if ("exitVM".equals(name) || "stopThread".equals(name) || "modifyThreadGroup".equals(name) || "setSecurityManager".equals(name)) { throw new SecurityException("Forbidden: " + name); } } if (perm instanceof SocketPermission) { throw new SecurityException("Network is blocked"); } if (perm instanceof PropertyPermission && "write".equals(perm.getActions())) { throw new SecurityException("System property write is blocked"); } // 其他情况,交给默认的策略检查 super.checkPermission(perm); } }这段代码解决了两个问题:一是判题机自己的框架代码不受干扰(因为线程匹配放行),二是用户代码无论从哪个入口触发,都会被我们自定义的规则二次拦截。
3. 判题机核心流程的落地实现
3.1 编译阶段的处理
用户提交的代码首先要编译成class。这里我不会直接调用javac命令行,而是用Java Compiler API,在内存里编译,或者编译到指定临时目录。好处是能捕获编译诊断信息,还能控制编译期是否允许某些危险操作。
我把源码写到一个以提交ID为名的临时目录下,比如/judge/tmp/user/20123/Main.java,然后调用JavaCompiler。编译阶段本身也要加上-encoding UTF-8,避免中文注释出问题。编译完成后,工作目录里会有Main.class,后续执行就针对这个class。
编译期安全比较容易被忽略。其实用户代码如果写了import java.net.*,编译期是拦不住的,必须在运行期通过SecurityManager拦截。但有些东西编译期就该拦,比如动态生成无数类,导致Metaspace被打爆——这个我会在资源限制部分细说。
3.2 在多线程中执行用户代码并捕获输出
执行用户代码不能直接在主判题线程里Class.forName(...).getMethod("main").invoke(...),因为一旦用户代码抛了底层异常,或者暂停了线程,判题流程就乱了。我采用的是“专用线程 + 输出重定向”方案:
ByteArrayOutputStream buffer = new ByteArrayOutputStream(); PrintStream userOut = new PrintStream(buffer, true, StandardCharsets.UTF_8); PrintStream userErr = new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8); Thread userThread = new Thread(() -> { // 绑定系统输出,让System.out和System.err进入缓冲区 PrintStream originalOut = System.out; PrintStream originalErr = System.err; System.setOut(userOut); System.setErr(userErr); try { Class<?> clazz = Class.forName("Main", true, userClassLoader); Method main = clazz.getMethod("main", String[].class); main.invoke(null, (Object) args); } catch (InvocationTargetException e) { throw (RuntimeException) e.getCause(); } finally { System.setOut(originalOut); System.setErr(originalErr); } }, "UserCodeThread");这里有个细节:System.setOut是全局的,而判题机自身日志也会走System.out。如果不在finally里恢复,一个用户线程就会把整个判题进程的输出污染掉。更稳妥的做法是不要改全局的System.out,而是通过SecurityManager配合自定义PrintStream的按线程过滤——不过这属于高级玩法了,我最后是用ThreadLocal标记加自定义流实现的,后面有机会再说。
3.3 让用户代码无法System.exit
这个其实是Java安全管理器最经典的关卡。调用System.exit会触发RuntimePermission "exitVM"。默认情况下,任何代码都能退出JVM,因为你没设置SecurityManager。
解决方法已经有标准答案:启动时启用SecurityManager。但要注意,如果你直接通过启动参数-Djava.security.manager启用,策略文件里的规则对所有线程生效,这会影响整个判题进程。更好的方式是在判题进程启动后、执行用户代码前,动态安装我们的JudgeSecurityManager:
System.setSecurityManager(new JudgeSecurityManager(judgeThread));一旦安装了自定义SecurityManager,System.exit在进入原生终止逻辑前,会先把控制权交给checkExit:
@Override public void checkExit(int status) { throw new SecurityException("System.exit blocked for user code"); }我见过有人用SecurityException拦截System.exit后,没有处理异常链,导致用户本来想正常return 0退出,却被判成“运行错误”。所以执行用户main的代码块里要对SecurityException做特殊封装:如果用户代码主动System.exit(0),且前面的标注输出完全正常,可以判“通过”。
3.4 超时控制与强制终止
所有判题系统都会遇到死循环,不处理会拖垮整个判题进程。超时控制有多种做法,常见的有:
Thread.stop():已废弃,极其危险,不要用。ExecutorService+Future.get(timeout):可以在超时后取消Future,但实际上并不能真正中断一个跑在死循环里的Java线程。future.cancel()只是打中断标记,如果用户代码不响应中断,线程会一直占着CPU。- 进程级终止:在独立JVM中运行用户代码,超时后杀掉整个子进程。这是最干净的方案。
我当时为了“原生实现”,选择了一种混合方案:把判题逻辑放进一个单独的守护进程,每次判题都新建一个JVM子进程,父进程用超时监控,超时就destroy子进程。子进程内部再用线程跑用户代码。这样一来,即使线程停不掉,整个进程也会被父进程杀掉,彻底回收资源。
我用ProcessBuilder拉起子进程,传参就是任务ID、临时目录、时间限制、内存限制。子进程的main方法负责启用SecurityManager、加载用户类、执行并输出结果。父进程只负责等待和超时终止:
Process process = processBuilder.start(); if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { process.destroyForcibly(); result.setStatus("TLE"); }提示:
destroyForcibly()在Windows和Linux上的行为有差异,Linux下会直接发SIGKILL,彻底终止。判题机一般都部署在Linux,实测没问题。
4. 资源限制的进阶方案
4.1 内存限制的坑
SecurityManager能拦文件和网络,但拦不住用户代码申请堆内存。用户代码写List<byte[]> list = new ArrayList<>(); while(true){ list.add(new byte[1024*1024]); },没一会儿就能把堆打满,抛OutOfMemoryError。如果JVM堆被撑爆,整个判题进程可能直接崩掉。
我采用的策略是:给判题子进程设置JVM堆上限,例如-Xmx256m。如果用户内存使用超限,子进程会抛出OutOfMemoryError并异常退出,父进程捕获到“非正常退出码”后,就把该判题任务标记为MLE(内存超限)。这里最需要注意的是,OutOfMemoryError不一定正好发生在用户代码里,它可能发生在判题子进程的框架代码里,所以判题框架代码也要尽量轻量,不能和用户代码共享大量缓存。
另一个坑是元空间(Metaspace)。用户代码如果动态生成大量类,Metaspace会涨。所以我给子进程也加-XX:MaxMetaspaceSize=64m。多亏这样,后来有用户提交动态代理代码,才没把判题机搞挂。
注意:SecurityManager的
RuntimePermission "setFactory"等也会影响一些常用库,如果你在子进程里引入了复杂依赖,要仔细核对权限。对于判题子进程,我的建议是只依赖JDK自带库,不要用Spring这类框架,否则权限策略会被依赖框架搞得很痛苦。
4.2 CPU/时间限制
JVM进程自身的CPU时间,Java没有直接获取的API。如果你走“子进程方式”,可以在父进程端读取/proc/<pid>/stat里的CPU时间字段,或者用ThreadMXBean在子进程内部测量用户线程的CPU时间。
我用的是子进程内部方案:在用户代码执行前,记录当前线程的ThreadMXBean.getThreadCpuTime(),然后启动一个监控线程,每隔50ms检查一次,如果CPU时间超过限制,就强制终止用户线程并抛出超时异常。
但这里有个微妙的点:用户线程可能在等IO(比如等输入),此时CPU时间不涨但墙钟时间在走。所以判题超时要区分“CPU时间超限”和“墙钟时间超限”。一般OJ题目限制的是“运行总时间”,通俗讲就是从开始到结束的墙钟时间。我最终是同时监控两种:墙钟超了直接判TLE;CPU时间超了也判TLE,但打印信息里会注明超的是CPU时间。
4.3 文件系统与网络隔离细节
权限策略里我只放开了/judge/tmp/user/-目录,但这个目录下的文件,用户代码能读能写,意味着它可以往自己目录写大量垃圾文件,把磁盘填满。只是“不炸系统”还不够,还要“不炸自己的临时目录”。
我在子进程里安装了一个自定义FilePermission检查,限制每个用户目录的大小。具体做法比较暴力:每写入一定量数据就检查当前用户目录总大小,超过阈值(比如20MB)就抛SecurityException。判题场景下,题目标准输入输出本身就不大,20MB完全够用。
网络访问方面,除了策略文件不给SocketPermission外,我还在自定义SecurityManager里直接禁止所有SocketPermission,包括连接、监听、接受动作。这样即使有人绕过策略文件,也会被第二道防线拦下。
5. 常见问题与排查技巧实录
5.1 安全策略不生效怎么办
新手最容易遇到的问题是:明明设置了SecurityManager,但用户代码还是能读文件或写系统属性。排查思路:
- 确认
SecurityManager真的被安装成功。在子进程启动后立即打印System.getSecurityManager(),如果你得到null,那就是安装失败或者被后来人覆盖了。 - 确认
codeBase路径匹配。Class文件的实际路径是不是和策略文件里完全一致?file:/judge/tmp/user/*里的*只能匹配一层目录,无法匹配多层路径,这点特别容易踩。 - 确认用户代码不是通过
AccessController.doPrivileged绕过的。如果你的策略里给某个目录授权过宽,比如grant { ... }(没有codeBase),那么所有代码都有那份权限,包括用户代码。写策略文件时永远不要写不带codeBase的全局grant,除非你确实想开放给所有代码。
5.2 用户线程不被中断怎么办
我前面提过,线程中断不是万能的。如果你用Thread.interrupt(),用户代码里的Thread.sleep()会响应,但一个纯计算的死循环是永远不响应中断的。这时候只能靠进程级终止。如果你的判题机没有采用子进程方式,就会出现“任务超时了,但CPU一直高”的假死状态。
排查时可以用jstack <pid>看线程栈,如果发现用户线程一直停在run方法里的循环字节码上,说明确实停不下来,只能杀进程。
5.3 用户代码探测类库信息
有些提交者会试图反射获取类路径下的文件,或者读取已知类库的类名。SecurityManager能限制文件访问,但无法完全阻止反射获取JDK内部的类信息。不过OJ场景下,这种探测类库的行为威胁不大,我们只需要保证核心判题数据(比如标准答案、其他用户提交代码)不在可访问目录中即可。判断用户是否在做恶意探测,可以在输出里检测敏感字符串,但这种策略容易误伤,我没做太多。
5.4 踩坑记录:并发判题下的安全管理器冲突
如果多线程共用一个JVM进行判题,那么System.setSecurityManager是全局状态。用户A的线程设置了安全管理器,可能导致用户B的线程权限异常。所以我的最终架构是“一个判题任务一个子进程”,子进程之间天然隔离,根本不存在共享安全管理器的问题。代价是启动子进程有几百毫秒开销,但对于一般OJ的并发量来说完全可接受。
另一个坑是用户代码通过自定义ClassLoader加载其他class。默认系统类加载器只会加载classpath下的类。我在子进程里给用户代码单独建了一个URLClassLoader,指向/judge/tmp/user/<taskId>/,这样用户代码之间不会互相影响。同时这个ClassLoader要交给Thread.currentThread().setContextClassLoader,否则有些动态代理会加载失败。
5.5 结果判定的“玄学”问题
比对输出时,最影响体验的是末尾换行和空行。我比对前会先去除首尾空白、统一换行符为\n。但对于需要精确空格的题目,不能盲目trim。所以我提供了两种比对模式:精确模式(一模一样)和宽松模式(忽略行尾空白)。这不算安全范畴,但直接影响OJ的判题准确性。
最后分享一点个人体会
说实话,用原生Java SecurityManager做OJ判题隔离,已经是相对“古典”的做法了。JDK 11之后,SecurityManager被标记为deprecated,不用太久,JDK新版本里可能真的会被移除。如果你现在是从零开始设计一个长期维护的OJ后端,我建议你优先考虑容器化方案——Docker或更轻的gVisor。但如果你维护的是一个老系统、Java版本锁死在8或11,或者你只是想理解“代码执行隔离”背后的原理,那这套原生实现依然非常有学习价值。
我在整个项目里最大的收获不是“跑通了判题”,而是理解了JVM权限模型和线程控制的底层逻辑。很多问题只有在真实对抗恶意代码时才会暴露,比如你以为System.exit拦住了,其实用户换一种方式调用又绕过去了;你以为线程超时靠interrupt就够,结果CPU还是被打满。这些坑不亲自踩一遍,很难对“安全执行不可信代码”有真正的敬畏感。最后再分享一个小技巧:如果你不想维护复杂的策略文件,可以把判题子进程的SecurityManager逻辑单独抽成一个公共库,然后用Java Agent的方式注入,这样主进程和判题子进程的权限模型完全解耦,后续切换容器化方案时也只需要改一层接口,不用动判题核心逻辑。