跳至主要內容

【OJ】⑥代码沙箱

holic-x...大约 26 分钟项目oj-platform

【OJ】⑥代码沙箱

​ 基于判题服务的构建,基本打通了用户提交问题记录的业务逻辑,前面的实现是基于Example代码沙箱,主要是为了打通业务逻辑而设定的存在,参考原有远程代码沙箱设计概念,可单独构建一个可执行程序用作用作远程代码沙箱执行操作

沙箱服务构建

​ 创建一个web项目,构建沙箱服务

  • Springboot:2.7.6

  • Maven:3.5.2

  • JDK版本:1.8版本(切换Server URL:https://start.aliyun.com)

  • 初始化引用依赖:Spring Web、lombok

​ 创建完成,配置Maven依赖,构建一个最简单的Controller查看服务访问是否成功。配置端口:8090,启动访问http://localhost:8090/health

@RestController("/")
public class MainController {

    @GetMapping("/health")
    public String healthCheck() {
        return "ok";
    }
}

1.Java原生代码沙箱

(1)沙箱的工作机制

​ 原生沙箱:尽可能不借助第三方库和依赖,用最干净、最原始的方式实现代码沙箱

​ 代码沙箱核心:接受代码 => 编译代码(javac)=> 执行代码(java)

​ 首先通过一个简单的示例来理解这个过程,先创建一个示例代码,然后放到一个文件夹下执行java指令进行编译、执行

​ 例如创建一个SimpleCompute实现两数之和(注意此处要去掉包名)

public class SimpleCompute {
    public static void main(String[] args) {
        Integer a = Integer.parseInt(args[0]);
        Integer b = Integer.parseInt(args[1]);
        System.out.println("结果:" + (a + b));
    }
}

确认编译环境

​ 如果是windows环境,首先要确保javac、java指令可以正常执行,如果无法找到该指令(javac不是内部或外部命令)则需要进一步配置环境变量(win10的环境变量配置需要注意使用绝对路径,而非相对路径,参考解决方案):

1)首先要确认JDK、JRE的环境是否完整安装(执行java -version指令查看JDK版本信息,如果执行javac指令失败则说明环境配置没有配置好)

2)配置系统环境变量:系统属性=》环境变量=》在【系统变量】处分别配置CLASSPATH、Path、JAVA_HOME

  • CLASSPATH:.;%JAVA_HOME%\lib\dt.jar;%JAVA_HOME%\lib\tools.jar
  • Path:分别配置JDK、JRE安装路径下的bin目录(win10下为绝对路径、且必须保证分条添加,不要通过分号隔开)
    • 参考:.;%JAVA_HOME%\bin;%JAVA_HOME%\jre\bin;(在win10环境不生效)
    • 参考:win10环境必须使用绝对路径,例如C:\custom\develop\CONFIG\jdk\jdk1.8\jdk1.8.0_151\bin,且分条处理(不要用分号;分隔挤在一条记录,而是要分开添加到不同的框)(C:\custom\develop\CONFIG\jdk\jdk1.8\jdk1.8.0_151\jre\bin
  • JAVA_HOME:配置JDK安装路径(例如:C:\custom\develop\CONFIG\jdk\jdk1.8\jdk1.8.0_151

3)配置完成之后需要重开cmd窗口再访问测试,有时候不重启CMD还是提示错误(重启尝试)

编译、执行、测试

​ 创建SimpleCompute.java,随后执行编译好的文件,确认结果

# 编译,查看生成的SimpleCompute.class
javac SimpleCompute.java

# 执行(表示输入1 2,执行返回结果)
java -cp . SimpleCompute 1 2

# 如果输出为中文乱码,则需进一步解决,可以先确认命令行终端编码和java代码本身的UTF-8编码是否一致(是否导致乱码)

​ 如果提示错误:Main.java:1: 错误: 类Test是公共的, 应在名为 Test.java 的文件中声明,则需确认指令的编译的文件名和public class xxx(xxx对应的类名)要保持一致

​ 如果输出为中文乱码,则需进一步解决,可以先确认命令行终端编码和java代码本身的UTF-8编码是否一致(是否导致乱码),一般不建议直接改变终端来解决编译乱码(这样解决不了代码本身编码的问题,如果别人要运行代码也要试着去改变依赖环境,这种做法兼容性比较差)。参考方案可以选择编译的时候指定编码格式

# 指定编码格式编译
javac -encoding utf-8 SimpleCompute.java

# 执行
java -cp . SimpleCompute 1 2

# 查看确认中文是否正常

执行代码命名规范约定

​ 实际 OJ 系统中,对用户输入的代码会有一定的要求,便于系统统一的处理。所以此处,可以把用户输入代码的类名限制为 Main (参考 Poj),可以减少类名不一致的风险,而且不用从用户代码中提取类名,更方便处理数据。

​ 参考示例代码Main.java

public class Main {

    public static void main(String[] args) {
        Integer a = Integer.parseInt(args[0]);
        Integer b = Integer.parseInt(args[1]);
        System.out.println("结果:" + (a + b));
    }
}
扩展说明

​ 上述SimpleCompute是通过args接收用户输入参数,还有一种方式是通过scanner控制台交互实现(很多 OJ 都是 ACM 模式,需要和用户交互的方式,让用户不断输入内容并获取输出,对于此类程序,需要使用 OutputStream 向程序终端发送参数,并及时获取结果,注意最后要关闭流释放资源)

import java.io.*;
import java.util.*;

public class Main
{
    public static void main(String args[]) throws Exception
    {
        Scanner cin=new Scanner(System.in);
        int a=cin.nextInt(),b=cin.nextInt();
        System.out.println(a+b);
    }
}

(2)沙箱实现

思路构建

核心实现思路构建:用程序代替人工,用程序来操作命令行,去编译执行代码

Java 进程执行管理类: Process

1)把用户的代码保存为文件

2)编译代码,得到 class 文件

3)执行代码,得到输出结果

4)收集整理输出结果

5)文件清理

6)错误处理,提升程序健壮性

依赖引入:引入Hutool工具类

    	<!-- https://hutool.cn/docs/index.html#/-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.8</version>
        </dependency>

​ 清理无用的代码,然后在此前的oj-backend实现的构建代码沙箱所需的内容引入:ExecuteCodeRequest、ExecuteCodeResponse、JudgeInfo、CodeSandbox,然后在这个基础上实现代码沙箱

构建实现

​ 创建一个JavaNativeCodeSandbox实现CodeSandbox,参考核心思路编写代码逻辑

1)、2)、3):保存用户代码文件、编译文件、执行文件

  • testCode/simpleComputeArgs/Main.java 编译运行测试(基于args参数输入)
public class JavaNativeCodeSandbox implements CodeSandbox {

    // 全局文件存储文件夹定义
    private static final String GLOBAL_CODE_DIR_NAME = "tmpCode";

    // 存储的文件名定义
    private static final String GLOBAL_JAVA_CLASS_NAME = "Main.java";


    public static void main(String[] args) {

        JavaNativeCodeSandbox javaNativeCodeSandbox = new JavaNativeCodeSandbox();
        ExecuteCodeRequest executeCodeRequest = new ExecuteCodeRequest();
        executeCodeRequest.setInputList(Arrays.asList("1 2", "1 3"));
        String code = ResourceUtil.readStr("testCode/simpleComputeArgs/Main.java", StandardCharsets.UTF_8);
        executeCodeRequest.setCode(code);
        executeCodeRequest.setLanguage("java");
        ExecuteCodeResponse executeCodeResponse = javaNativeCodeSandbox.executeCode(executeCodeRequest);
        System.out.println(executeCodeResponse);

    }


    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        List<String> inputList = executeCodeRequest.getInputList();
        String code = executeCodeRequest.getCode();
        String language = executeCodeRequest.getLanguage();

        // 1)把用户的代码保存为文件
        String userDir = System.getProperty("user.dir");
        String globalCodePathName = userDir + File.separator + GLOBAL_CODE_DIR_NAME;
        // 判断全局代码目录是否存在,没有则新建
        if (!FileUtil.exist(globalCodePathName)) {
            FileUtil.mkdir(globalCodePathName);
        }

        // 把用户的代码隔离存放
        String userCodeParentPath = globalCodePathName + File.separator + UUID.randomUUID();
        String userCodePath = userCodeParentPath + File.separator + GLOBAL_JAVA_CLASS_NAME;
        File userCodeFile = FileUtil.writeString(code, userCodePath, StandardCharsets.UTF_8);


        // 2)编译代码,得到 class 文件
        String compileCmd = String.format("javac -encoding utf-8 %s", userCodeFile.getAbsolutePath());
        try {
            Process compileProcess = Runtime.getRuntime().exec(compileCmd);
            // 自定义ProcessUtils获取控制台输出(通过 exitValue 判断程序是否正常返回,通过 inputStream 和 errorStream 获取控制台输出)
            // 方式1:通过args方式输入参数(项目核心)
            ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(compileProcess, "编译");
            System.out.println(executeMessage);
        } catch (Exception e) {
            return getErrorResponse(e);
        }


        // 3)执行代码,得到输出结果(命令中指定 -Dfile.encoding=UTF-8 参数,解决中文乱码)
        for (String inputArgs : inputList) {
            String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);
            try {
                Process runProcess = Runtime.getRuntime().exec(runCmd);

                ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(runProcess, "运行");
                System.out.println(executeMessage);
            } catch (Exception e) {
                System.out.println("异常:" + getErrorResponse(e));
                return getErrorResponse(e);
            }
        }
    }
}



// ProcessUtils.java
/**
 * 进程工具类
 */
public class ProcessUtils {

    /**
     * 执行进程并获取信息
     *
     * @param runProcess
     * @param opName
     * @return
     */
    public static ExecuteMessage runProcessAndGetMessage(Process runProcess, String opName) {
        ExecuteMessage executeMessage = new ExecuteMessage();

        try {
            StopWatch stopWatch = new StopWatch();
            stopWatch.start();
            // 等待程序执行,获取错误码
            int exitValue = runProcess.waitFor();
            executeMessage.setExitValue(exitValue);
            // 正常退出
            if (exitValue == 0) {
                System.out.println(opName + "成功");
                // 分批获取进程的正常输出
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
                List<String> outputStrList = new ArrayList<>();
                // 逐行读取
                String compileOutputLine;
                while ((compileOutputLine = bufferedReader.readLine()) != null) {
                    outputStrList.add(compileOutputLine);
                }
                executeMessage.setMessage(StringUtils.join(outputStrList, "\n"));
            } else {
                // 异常退出
                System.out.println(opName + "失败,错误码: " + exitValue);
                // 分批获取进程的正常输出
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
                List<String> outputStrList = new ArrayList<>();
                // 逐行读取
                String compileOutputLine;
                while ((compileOutputLine = bufferedReader.readLine()) != null) {
                    outputStrList.add(compileOutputLine);
                }
                executeMessage.setMessage(StringUtils.join(outputStrList, "\n"));

                // 分批获取进程的错误输出
                BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(runProcess.getErrorStream()));
                // 逐行读取
                List<String> errorOutputStrList = new ArrayList<>();
                // 逐行读取
                String errorCompileOutputLine;
                while ((errorCompileOutputLine = errorBufferedReader.readLine()) != null) {
                    errorOutputStrList.add(errorCompileOutputLine);
                }
                executeMessage.setErrorMessage(StringUtils.join(errorOutputStrList, "\n"));
            }
            stopWatch.stop();
            executeMessage.setTime(stopWatch.getLastTaskTimeMillis());
        } catch (Exception e) {
            e.printStackTrace();
        }
        return executeMessage;
    }
    
    /**
     * 获取错误响应
     *
     * @param e
     * @return
     */
    private ExecuteCodeResponse getErrorResponse(Throwable e) {
        ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
        executeCodeResponse.setOutputList(new ArrayList<>());
        executeCodeResponse.setMessage(e.getMessage());
        // 表示代码沙箱错误
        executeCodeResponse.setStatus(2);
        executeCodeResponse.setJudgeInfo(new JudgeInfo());
        return executeCodeResponse;
    }
}

image-20240503211552256

  • testCode/simpleCompute/Main.java 编译运行测试(基于控制台交互式操作),需要考虑的因素很多,相对比较复杂,本项目中还是以args参数作为主要参考(为了不和前面的内容混淆,这个部分仅仅是作为扩展测试,参考JavaNativeCodeSandboxTest实现测试,具体还要考虑很多细节)
// ProcessUtils交互式runInteractProcessAndGetMessage
/**
 * 进程工具类
 */
public class ProcessUtils {
    /**
     * 执行交互式进程并获取信息(参考:以simpleCompute/Main.java为例演示交互式输入方式操作)
     *
     * @param runProcess
     * @param args
     * @return
     */
    public static ExecuteMessage runInteractProcessAndGetMessage(Process runProcess, String args) {
        ExecuteMessage executeMessage = new ExecuteMessage();

        try {
            // 向控制台输入程序
            OutputStream outputStream = runProcess.getOutputStream();
            OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
            String[] s = args.split(" ");
            String join = StrUtil.join("\n", s) + "\n";
            outputStreamWriter.write(join);
            // 相当于按了回车,执行输入的发送
            outputStreamWriter.flush();

            // 分批获取进程的正常输出
            InputStream inputStream = runProcess.getInputStream();
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
            StringBuilder compileOutputStringBuilder = new StringBuilder();
            // 逐行读取
            String compileOutputLine;
            while ((compileOutputLine = bufferedReader.readLine()) != null) {
                compileOutputStringBuilder.append(compileOutputLine);
            }
            executeMessage.setMessage(compileOutputStringBuilder.toString());
            // 记得资源的释放,否则会卡死
            outputStreamWriter.close();
            outputStream.close();
            inputStream.close();
            runProcess.destroy();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return executeMessage;
    }
}
public class JavaNativeCodeSandboxTest implements CodeSandbox {

    // 全局文件存储文件夹定义
    private static final String GLOBAL_CODE_DIR_NAME = "tmpCode";

    // 存储的文件名定义
    private static final String GLOBAL_JAVA_CLASS_NAME = "Main.java";

    public static void main(String[] args) {
        JavaNativeCodeSandboxTest javaNativeCodeSandbox = new JavaNativeCodeSandboxTest();
        ExecuteCodeRequest executeCodeRequest = new ExecuteCodeRequest();
        executeCodeRequest.setInputList(Arrays.asList("1 2", "1 3"));
        String code = ResourceUtil.readStr("testCode/simpleCompute/Main.java", StandardCharsets.UTF_8);
        executeCodeRequest.setCode(code);
        executeCodeRequest.setLanguage("java");
        ExecuteCodeResponse executeCodeResponse = javaNativeCodeSandbox.executeCode(executeCodeRequest);
        System.out.println(executeCodeResponse);
    }


    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        String code = executeCodeRequest.getCode();

        // 1)把用户的代码保存为文件
        String userDir = System.getProperty("user.dir");
        String globalCodePathName = userDir + File.separator + GLOBAL_CODE_DIR_NAME;
        // 判断全局代码目录是否存在,没有则新建
        if (!FileUtil.exist(globalCodePathName)) {
            FileUtil.mkdir(globalCodePathName);
        }

        // 把用户的代码隔离存放
        String userCodeParentPath = globalCodePathName + File.separator + UUID.randomUUID();
        String userCodePath = userCodeParentPath + File.separator + GLOBAL_JAVA_CLASS_NAME;
        File userCodeFile = FileUtil.writeString(code, userCodePath, StandardCharsets.UTF_8);

        // 2)编译代码,得到 class 文件
        String compileCmd = String.format("javac -encoding utf-8 %s", userCodeFile.getAbsolutePath());
        try {
            Process compileProcess = Runtime.getRuntime().exec(compileCmd);
            ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(compileProcess, "编译");
        } catch (Exception e) {
            return getErrorResponse(e);
        }

        // 3)执行代码,得到输出结果(命令中指定 -Dfile.encoding=UTF-8 参数,解决中文乱码)
        String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Main", userCodeParentPath);
        try {
            Process runProcess = Runtime.getRuntime().exec(runCmd);
            ExecuteMessage executeMessage = ProcessUtils.runInteractProcessAndGetMessage(runProcess, "1 3");
            System.out.println(executeMessage);
        } catch (Exception e) {
            System.out.println("异常:" + getErrorResponse(e));
            return getErrorResponse(e);
        }
        return null;
    }

    /**
     * 获取错误响应
     *
     * @param e
     * @return
     */
    private ExecuteCodeResponse getErrorResponse(Throwable e) {
        ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
        executeCodeResponse.setOutputList(new ArrayList<>());
        executeCodeResponse.setMessage(e.getMessage());
        // 表示代码沙箱错误
        executeCodeResponse.setStatus(2);
        executeCodeResponse.setJudgeInfo(new JudgeInfo());
        return executeCodeResponse;
    }

}

image-20240503214101712

4)收集并整理输出结果

​ 此处注意一个细节,ProcessUtils进程工具类中通过StopWatch设置计时监听,最终会将每个执行操作的执行时间返回并设置到ExecuteMessage中,考虑到不止一组执行参数,但代码响应参数时间只有一个,因此要选择这组执行参数中用时最长的作为参照用于判断是否超时。因此在拿到执行结果之后,需要对获取到的List<ExecuteMessage>集合进行整理,获取到最大的时间进行超时判断

1)通过for循环遍历执行结果,从中获取到输出列表

2)获取程序执行时间(可以使用Spring的StopWatch获取一段程序的执行时间),此处用最大值来统计时间(便于后续判题服务计算程序是否超时)

// ProcessUtils中runProcessAndGetMessage处理的时候定义
StopWatch stopWatch = new StopWatch();
stopWatch.start();
..... 程序逻辑操作 ......
stopWatch.stop();
executeMessage.setTime(stopWatch.getLastTaskTimeMillis());


// 收集整理
	    // 取用时最大值,便于判断是否超时
        long maxTime = 0;
        for (ExecuteMessage executeMessage : executeMessageList) {
            ......
            Long time = executeMessage.getTime();
            if (time != null) {
                maxTime = Math.max(maxTime, time);
            }
        }

3)获取内存信息(实现比较复杂,因为无法从Process对象中获取到子进程号,也不推荐在Java原生实现代码沙箱的过程中获取,不建议硬核钻这块的内容)

5)文件清理

		if (userCodeFile.getParentFile() != null) {
            boolean del = FileUtil.del(userCodeParentPath);
            System.out.println("删除" + (del ? "成功" : "失败"));
        }

6)错误处理:封装一个错误处理方法,当程序抛出异常的时候,直接返回错误响应

	/**
     * 获取错误响应
     *
     * @param e
     * @return
     */
    private ExecuteCodeResponse getErrorResponse(Throwable e) {
        ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
        executeCodeResponse.setOutputList(new ArrayList<>());
        executeCodeResponse.setMessage(e.getMessage());
        // 表示代码沙箱错误
        executeCodeResponse.setStatus(2);
        executeCodeResponse.setJudgeInfo(new JudgeInfo());
        return executeCodeResponse;
    }


// 在异常处理try catch中捕获异常并处理,返回自定义的错误响应(参考示例如下)

 		try {
                ..... 逻辑处理 .....
            } catch (Exception e) {
                System.out.println("异常:" + getErrorResponse(e));
                return getErrorResponse(e);
            }
构建参考初版
public class JavaNativeCodeSandbox implements CodeSandbox {

    // 全局文件存储文件夹定义
    private static final String GLOBAL_CODE_DIR_NAME = "tmpCode";

    // 存储的文件名定义
    private static final String GLOBAL_JAVA_CLASS_NAME = "Main.java";

    public static void main(String[] args) {

        JavaNativeCodeSandbox javaNativeCodeSandbox = new JavaNativeCodeSandbox();
        ExecuteCodeRequest executeCodeRequest = new ExecuteCodeRequest();
        executeCodeRequest.setInputList(Arrays.asList("1 2", "1 3"));
        String code = ResourceUtil.readStr("testCode/simpleComputeArgs/Main.java", StandardCharsets.UTF_8);
        executeCodeRequest.setCode(code);
        executeCodeRequest.setLanguage("java");
        ExecuteCodeResponse executeCodeResponse = javaNativeCodeSandbox.executeCode(executeCodeRequest);
        System.out.println(executeCodeResponse);

    }


    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        List<String> inputList = executeCodeRequest.getInputList();
        String code = executeCodeRequest.getCode();
        String language = executeCodeRequest.getLanguage();

        // 1)把用户的代码保存为文件
        String userDir = System.getProperty("user.dir");
        String globalCodePathName = userDir + File.separator + GLOBAL_CODE_DIR_NAME;
        // 判断全局代码目录是否存在,没有则新建
        if (!FileUtil.exist(globalCodePathName)) {
            FileUtil.mkdir(globalCodePathName);
        }

        // 把用户的代码隔离存放
        String userCodeParentPath = globalCodePathName + File.separator + UUID.randomUUID();
        String userCodePath = userCodeParentPath + File.separator + GLOBAL_JAVA_CLASS_NAME;
        File userCodeFile = FileUtil.writeString(code, userCodePath, StandardCharsets.UTF_8);


        // 2)编译代码,得到 class 文件
        String compileCmd = String.format("javac -encoding utf-8 %s", userCodeFile.getAbsolutePath());
        try {
            Process compileProcess = Runtime.getRuntime().exec(compileCmd);
            // 自定义ProcessUtils获取控制台输出(通过 exitValue 判断程序是否正常返回,通过 inputStream 和 errorStream 获取控制台输出)
            // 方式1:通过args方式输入参数(项目核心)
            ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(compileProcess, "编译");
            System.out.println(executeMessage);
        } catch (Exception e) {
            return getErrorResponse(e);
        }


        // 3)执行代码,得到输出结果(命令中指定 -Dfile.encoding=UTF-8 参数,解决中文乱码)
        List<ExecuteMessage> executeMessageList = new ArrayList<>();
        for (String inputArgs : inputList) {
            String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);
            try {
                Process runProcess = Runtime.getRuntime().exec(runCmd);
                ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(runProcess, "运行");
                System.out.println(executeMessage);
                // 将执行结果加入列表
                executeMessageList.add(executeMessage);
            } catch (Exception e) {
                System.out.println("异常:" + getErrorResponse(e));
                return getErrorResponse(e);
            }
        }

        // 4)收集整理输出结果
        ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
        List<String> outputList = new ArrayList<>();
        // 取用时最大值,便于判断是否超时
        long maxTime = 0;
        for (ExecuteMessage executeMessage : executeMessageList) {
            String errorMessage = executeMessage.getErrorMessage();
            if (StrUtil.isNotBlank(errorMessage)) {
                executeCodeResponse.setMessage(errorMessage);
                // 用户提交的代码执行中存在错误
                executeCodeResponse.setStatus(3);
                break;
            }
            outputList.add(executeMessage.getMessage());
            Long time = executeMessage.getTime();
            if (time != null) {
                maxTime = Math.max(maxTime, time);
            }
        }
        // 正常运行完成
        if (outputList.size() == executeMessageList.size()) {
            executeCodeResponse.setStatus(1);
        }
        executeCodeResponse.setOutputList(outputList);
        JudgeInfo judgeInfo = new JudgeInfo();
        judgeInfo.setTime(maxTime);
        // todo 要借助第三方库来获取内存占用,非常麻烦,此处不做实现
        // judgeInfo.setMemory();
        executeCodeResponse.setJudgeInfo(judgeInfo);


        // 5)文件清理
        if (userCodeFile.getParentFile() != null) {
            boolean del = FileUtil.del(userCodeParentPath);
            System.out.println("删除" + (del ? "成功" : "失败"));
        }

        // 6)错误处理,提升程序健壮性(通过自定义封装getErrorResponse错误处理方法,当程序抛出异常时,直接返回错误响应)参考getErrorResponse方法

        // 返回执行代码结果响应
        return executeCodeResponse;
    }


    /**
     * 获取错误响应
     *
     * @param e
     * @return
     */
    private ExecuteCodeResponse getErrorResponse(Throwable e) {
        ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
        executeCodeResponse.setOutputList(new ArrayList<>());
        executeCodeResponse.setMessage(e.getMessage());
        // 表示代码沙箱错误
        executeCodeResponse.setStatus(2);
        executeCodeResponse.setJudgeInfo(new JudgeInfo());
        return executeCodeResponse;
    }
}

​ 测试流程:执行main方法进行测试,默认是编译执行resources/testCode/simpleComputeTrgs/Main.java文件

public class Main {
    public static void main(String[] args) {
        int a = Integer.parseInt(args[0]);
        int b = Integer.parseInt(args[1]);
        System.out.println("结果:" + (a + b));
    }
}

​ 执行过程可以通过设置断点查看每一步的执行效果,例如生成文件=》编译=》执行=》结果整理=》文件清理=》返回响应结果

image-20240503222256188

(3)沙箱优化

​ 编写一些异常代码(在rsources/unsafeCode下编写多个异常代码,记得去掉包名),来针对性进行沙箱优化

  • 去掉包名
  • 且类名为Main
异常情况演示

​ 编写Main方法,然后测试一些异常情况,查看会发生什么

1)执行阻塞,占用资源不释放

​ 无限睡眠阻塞程序(提交的程序卡死了,就会占用沙箱资源)

/**
 * 无限睡眠(阻塞程序执行)
 */
public class Main {
    public static void main(String[] args) throws InterruptedException {
        long ONE_HOUR = 60 * 60 * 1000L;
        Thread.sleep(ONE_HOUR);
        System.out.println("睡完了");
    }
}

image-20240503224003290

2)占用内存不释放
import java.util.ArrayList;
import java.util.List;

/**
 * 无限占用空间(浪费系统内存)
 */
public class Main {

    public static void main(String[] args) throws InterruptedException {
        List<byte[]> bytes = new ArrayList<>();
        while (true) {
            bytes.add(new byte[10000]);
        }
    }
}

image-20240503224223515

​ 实际运行中,会发现内存占用到达一定空间后,程序就自动报错:java.lang.OutOfMemoryError: Java heap space 。而不是无限增加内存占用,直到系统死机。例如当触发了内存溢出异常,程序报错,资源慢慢释放,这是 JVM 的一个保护机制。通过windows任务管理器查看idea的CPU占用率也是从一开始的一路飚高到慢慢释放、恢复正常状态

🚀🚀🚀推荐使用**JVisualVM 或 JConsole** 工具,都可以连接到 JVM 虚拟机上来可视化查看运行状态。

image-20240503193522223

3)读文件(文件信息泄露)

​ 直接通过相对路径获取文件信息

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;

/**
 * 读取服务器文件(文件信息泄露)
 */
public class Main {
    public static void main(String[] args) throws IOException {
        String userDir = System.getProperty("user.dir");
        String filePath = userDir + File.separator + "src/main/resources/application.yml";
        List<String> allLines = Files.readAllLines(Paths.get(filePath));
        System.out.println(String.join("\n", allLines));
    }
}

image-20240503224839896

4)写文件,越权植入木马
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;

/**
 * 向服务器写文件(植入危险程序)
 */
public class Main {

    public static void main(String[] args) throws InterruptedException, IOException {
        String userDir = System.getProperty("user.dir");
        String filePath = userDir + File.separator + "src/main/resources/木马程序.bat";
        String errorProgram = "java -version 2>&1";
        Files.write(Paths.get(filePath), Arrays.asList(errorProgram));
        System.out.println("写木马成功,你完了哈哈");
    }
}

image-20240503225008878

5)运行其他程序

​ 直接通过 Process 执行危险程序,或者电脑上的其他程序

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;

/**
 * 运行其他程序(比如危险木马)
 */
public class Main {

    public static void main(String[] args) throws InterruptedException, IOException {
        String userDir = System.getProperty("user.dir");
        String filePath = userDir + File.separator + "src/main/resources/木马程序.bat";
        Process process = Runtime.getRuntime().exec(filePath);
        process.waitFor();
        // 分批获取进程的正常输出
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
        // 逐行读取
        String compileOutputLine;
        while ((compileOutputLine = bufferedReader.readLine()) != null) {
            System.out.println(compileOutputLine);
        }
        System.out.println("执行异常程序成功");
    }
}

image-20240503225139905

6)执行高危命令

甚至都不用写木马文件,直接执行系统自带的危险命令

比如删除服务器的所有文件

比如执行 dir (windows)、ls(linux)获取系统上的所有文件信息

🤡🤡快速测试

​ 修改JavaNativeCodeSandbox的main方法,将code来源修改为指定的代码路径进行测试

String code = ResourceUtil.readStr("testCode/unsafeCode/RunFileError.java", StandardCharsets.UTF_8);
解决异常情况
1)超时控制(SleepError.java)

​ 通过创建一个守护线程,超时后自动中断 process 实现:其原理在于在程序执行的时候创建一个守护线程(睡一段时间),当守护线程自动唤醒之后,如果发现代码还没执行完返回结果,就直接把执行代码的进程给杀掉。(这是一种基础的构建思路,需要思考如果这个进程还在正常运行的话需要做什么处理,结合实际场景分析)

// 设置超时控制时长
private static final long TIME_OUT = 5000L;

// 超时控制
new Thread(() -> {
    try {
        Thread.sleep(TIME_OUT);
        System.out.println("超时了,中断");
        runProcess.destroy();
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}).start();

image-20240503230751609

​ 此处针对SleepError的攻击防守,设置了5s超时时长限定,由于SleepError设置了超长时长睡眠,所以基于这个逻辑,新建的线程sleep 5s之后会中断这个runProcess线程,那么这里就会存在一个问题,是否要对runProcess的状态进行一个额外的判断,判断它是否执行完成,那么此处可以切换到正常执行的Code(testCode/simpleComputeArgs/Main.java)进行确认,会发现程序正常执行之后,会有超时提示,说明这里对正常情况下处理的逻辑需要进一步调整。

image-20240503232612242

2)限制资源的分配(MemoryError.java)

​ 不能让每个 java 进程的执行占用的 JVM 最大堆内存空间和系统的一致,实际上应该小一点,比如说 256 MB

在启动 Java 时,可以指定 JVM 的参数:-Xmx256吗(最大堆空间大小)-Xms(初始堆空间大小)

java -Xmx256m

查看效果,会发现它报错比以前更快了,是因为限制了它最大的资源,所以超出这个限额就会触发JVM的保护机制,中断程序

String code = ResourceUtil.readStr("testCode/unsafeCode/MemoryError.java", StandardCharsets.UTF_8);
String runCmd = String.format("java -Xmx256m -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);

可以试着将参数调大一点(4G:java -Xmx4096m),然后打开任务管理器跟踪idea的内存状态,会发现其cpu占用率慢慢升高,甚至可能超出设定的大小

String code = ResourceUtil.readStr("testCode/unsafeCode/MemoryError.java", StandardCharsets.UTF_8);
String runCmd = String.format("java -Xmx4096m -Dfile.encoding=UTF-8 -cp %s Main %s", userCodeParentPath, inputArgs);

注意! -Xmx 参数、JVM 的堆内存限制,不等同于系统实际占用的最大资源,可能会超出。

image-20240503233840642

因此如果需要更严格的内存限制,要在系统层面去限制,而不是 JVM 层面的限制

如果是 Linux 系统,可以使用 cgroup 来实现对某个进程的 CPU、内存等资源的分配。

补充:常用JVM启动参数

内存相关参数:

  • -Xms: 设置 JVM 的初始堆内存大小

  • -Xmx: 设置 JVM 的最大堆内存大小

  • -Xss: 设置线程的栈大小

  • -XX:MaxMetaspaceSize:设置 Metaspace(元空间)的最大大小

  • -XX:MaxDirectMemorySize:设置直接内存(Direct Memory)的最大大小

垃圾回收相关参数:

  • -XX:+UseSerialGC: 使用串行垃圾回收器。-XX:+UseParallelGC: 使用并行垃圾回收器
  • -XX:+UseConcMarkSweepGC: 使用 CMS 垃圾回收器,。-XX:+UseG1GC: 使用 G1 垃圾回收器

线程相关参数:

  • -XX:ParallelGCThreads: 设置并行垃圾回收的线程数
  • -XX:ConcGCThreads:设置并发垃圾回收的线程数
  • -XX:ThreadStackSize:设置线程的栈大小

JT 编译器相关参数:

  • -XX:TieredStopAtLevel: 设置JT 编译器停止编译的层次

其他资源限制参数:

  • -XX:MaxRAM: 设置 JVM 使用的最大内存
3)限制代码 - 黑白名单(ReadFileError.java、RunFileError.java)

​ 定义一个黑名单,例如那些操作是禁止的,可以列一个列表

// 定义黑名单
public static final List<String> blackList = Arrays.asList("Files", "exec");

​ 像是一些危险代码,可以在一开始就进行校验,不满足则剔除、不允许操作(甚至不需要走后面的编译,直接从源头扼杀)

​ 基本思路构建:定义黑白名单列表,校验代码是否存在敏感字段,进而选择过滤

​ 优化思考:引入Hutool的字典树代替存储单词

  • 使用字典树代替表存储单词,用 更少的空间 存储更多的敏感词汇,并且实现 更高效的 敏感词查找。

🚀字典树原理:

image-20240503194037862

字典树应用:使用HuTool工具类的字典树工具类,WordTree(不用自己手写)

  • 先初始化字典树,插入禁用词
public static final WordTree WORD_TREE;

static {
    //初始化字典树
    WORD_TREE = new WordTree();
    WORD_TREE.addWords(blackList);
}
  • 检验用户代码是否包含禁用词
//校验代码中是否可包含黑名单中的命令
FoundWord foundWord = WORD_TREE.matchWord(code);
if (foundWord != null) {
    System.out.println("包含禁止词:" + foundWord.getFoundWord());
    return null;
}

image-20240503235039026

缺点:

  • 无法遍历所有的黑名单
  • 不同的编程语言,对应的领域、关键词都不一样,限制人工成本很大
4)限制权限-Java安全管理器(实现更严格的校验)

目标:限制用户对文件、内存、CPU、网络等资源的操作和访问

Java 安全管理器(Security Manager)是 Java 提供的保护 JVM、Java 安全的机制,可以实现更严格的资源和操作限制。

编写安全管理器,只需要继承SecurityManager:

所有权限放开

定义DefaultSecurityManager继承SecurityManager

package com.leeoj.leeojcodesandbox.security;

import java.security.Permission;

/**
 * 默认安全管理器
 */
public class DefaultSecurityManager extends SecurityManager{

    /**
     * 检查所有的权限
     * @param perm   the requested permission.
     */
    @Override
    public void checkPermission(Permission perm) {
        System.out.println("默认不做任何权限限制");
        System.out.println(perm);
        // super.checkPermission(perm);
    }
}

测试:在executeCode方法最开始设定安全管理器,然后查看效果。因为执行指令会涉及到资源访问、分配等,都需要SecurityManager管理,所以执行操作会被进行控制

image-20240504000133164

所有权限拒绝

public class DenySecurityManager extends SecurityManager{

    /**
     * 检查所有的权限
     * @param perm   the requested permission.
     */
    @Override
    public void checkPermission(Permission perm) {
        throw new SecurityException("权限异常:" + perm.toString());
    }
}

​ DenySecurityManager:当触发到JAVA安全管理器控制范围,就会提示拒绝。例如此处到了读取文件(可以先注释掉前面黑白名单校验,进一步检测Java安全管理器的作用)部分就会提示报错

image-20240504000628902

限制读权限

​ 创建一个MySecurityManager,继承SecurityManager。自定义配置自己的权限规则(需要注意多个权限规则的影响),例如此处设定一个checkRead权限。

​ 创建一个TestSecurityManager,测试MySecurityManager的校验规则

@Override
public void checkRead(String file, Object context) {
    throw new SecurityException("checkRead 权限异常:" + file);
}

image-20240504084242895

​ 经由checkRead限制,直接不允许hutool包使用,因此需要一步步进行放行,确认是否还有其他禁止读取的内容

	@Override
    public void checkRead(String file) {
        System.out.println(file);
        // 设置放行名单
        if (file.contains("hutool")) {
            return;
        }
        throw new SecurityException("checkRead 权限异常:" + file);
    }

​ 调整后再次执行,提示 checkRead异常,不允许访问resources.jar,在读取真正目的文件前就做了层层限制。

image-20240504085541679

​ PS:实际如果要使用SecurityManager的话,需要自行严格关注权限的使用,使用起来还是具备一定限制

image-20240504090534114

SecurityManager应用

​ 实际情况下,不应该在主类(开发者自己写的程序)中做限制,只需要限制子程序的权限即可。启动子进程执行命令时,设置安全管理器,而不是在外层设置(会限制住测试用例的读写和子命令的执行)

​ 例如此处在executeCode方法开始就设定安全管理器System.setSecurityManager(new DenySecurityManager());,这个配置会限制当前程序的所有操作,但实际上目前的需求只希望限制住用户传入的代码,因此可以在运行java程序的时候就进行安全校验,指定相应的安全管理器。且每个代码不需要单独编译自己的一个安全管理器,将安全管理器放在程序项目指定位置,在执行代码的时候指定要引用的自定义安全管理器即可,可以节省空间

具体操作如下:(例如此处测试执行程序的操作限制RunFileError.java

1)根据需要开发自定义的安全管理器(比如 MySecurityManager)

2)复制 MySecurityManager 类到 resources/security 目录下,移除类的包名

3)手动输入命令编译 MySecurityManager 类,得到 class 文件(javac -encoding utf-8 .\MySecurityManager.java,指令执行完成确认是否生成MySecurityManager.class)

4)在运行 java 程序时,指定安全管理器 class 文件的路径、安全管理器的名称。命令参考如下:

java -Dfile.encoding=UTF-8 -cp %s;%s -Djava.security.manager=MySecurityManager Main

​ 回归程序代码设计:JavaNativeCodeSandbox(先取消掉前面的优化项,测试Java安全管理器的作用,此处主要测试RunFileError.java执行程序攻击是否生效)

​ 修改指令:java -Dfile.encoding=UTF-8 -cp %s;%s -Djava.security.manager=%s Main %s,此处剖析指令中需要填充的参数依次分别是【执行程序代码根路径】、【安全管理器所在路径】、【安全管理器类名】、【执行参数】

// 自定义安全管理器存储路径
private static final String SECURITY_MANAGER_PATH = "E:\\workspace\\Git\\github\\PROJECT\\noob\\oj-platform\\oj-code-sandbox\\src\\main\\resources\\security";

// 自定义安全管理器类名
private static final String SECURITY_MANAGER_CLASS_NAME = "MySecurityManager";

// 指令
String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s;%s -Djava.security.manager=%s Main %s", userCodeParentPath,SECURITY_MANAGER_PATH,SECURITY_MANAGER_CLASS_NAME, inputArgs);

image-20240504092920419

安全管理器优缺点

优点:

  • 权限控制很灵活
  • 实现简单

缺点:

  • 如果要做比较严格的权限控制,需要自己去判断哪些文件、包名需要允许读写。粒度太细了,难以精细化控制
  • 安全管理器本身也是 Java 代码,也有可能存在漏洞。本质上还是程序层面的限制,没深入系统的层面

PS:JDK安全管理器不建议在Java9以上使用,后面安全管理器可能会有优化替代的计划

5)运行环境隔离

原理:操作系统层面上,把用户程序封装到沙箱里,和宿主机(我们的电脑 / 服务器)隔离开,使得用户的程序无法影响宿主机

实现方式:Docker 容器技术(底层是用 cgroup、namespace 等方式实现的),也可以直接使用 cgroup 实现

编译错误排查方案

细节1:javac 检查本地javac 指令执行是否正常,如果不是需要配置本地java环境

细节2:断点确认,查看当前上传的代码在哪个文件夹,然后去对应文件夹看生成的文件(默认写死都是Main,java(默认生成文件名都是这个)),如果说输入代码执行的时候自己写的public class类和Main不一样就提示

Main.java:1: 错误: 类Test是公共的, 应在名为 Test.java 的文件中声明
public class Test{
       ^
1 个错误

编译错误还需要注意:除了package部分不需要引入,其他依赖包和相关类定义都需要引入

编译错误提示信息:

2024-05-02 23:24:31.701 ERROR 14900 --- [nio-8090-exec-4] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: java.lang.RuntimeException: 编译错误] with root cause

java.lang.RuntimeException: 编译错误
	at com.yupi.yuojcodesandbox.JavaCodeSandboxTemplate.compileFile(JavaCodeSandboxTemplate.java:89) ~[classes/:na]
	at com.yupi.yuojcodesandbox.JavaCodeSandboxTemplate.executeCode(JavaCodeSandboxTemplate.java:40) ~[classes/:na]
	at com.yupi.yuojcodesandbox.JavaNativeCodeSandbox.executeCode(JavaNativeCodeSandbox.java:15) ~[classes/:na]
	at com.yupi.yuojcodesandbox.controller.MainController.executeCode(MainController.java:49) ~[classes/:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
	at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205) ~[spring-web-5.3.29.jar:5.3.29]

状态status:判题状态(2-成功)

通过率的计算规则:

<template #acceptedRate="{ record }">
        {{
          `${
            record.submitNum ? record.acceptedNum / record.submitNum : "0"
          }% (${record.acceptedNum}/${record.submitNum})`
        }}
      </template>

异步执行判题服务,然后判题服务中需要根据响应结果修改内容(例如题目提交数、题目的通过数等信息)(暂时还没开发)

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3