← 全部文章

从零构建一个代码沙箱

如何安全地执行不受信任的用户代码或AI生成的代码?

更新于 2026.08.07

本文目录 65 节
从零构建一个代码沙箱封面

项目地址:https://github.com/Ezzzi-Y/ezzi-code-sand-box

如何安全地执行不受信任的用户代码或AI生成的代码?


一、项目背景与定位

1.1 问题:执行不受信任的代码

当你构建一个在线编程平台时,最核心也最危险的需求是:执行用户提交的任意代码。

用户可能提交的不只是 Hello World——它可能是一个 fork 炸弹、一段尝试读取 /etc/passwd 的恶意脚本、或者一个试图建立反向 Shell 的网络程序。你必须在保证代码能正常执行的同时,确保它无法伤害你的服务器。

直接使用 Runtime.exec() 或 subprocess.run() 执行用户代码的后果是灾难性的:

风险类型典型攻击后果
资源耗尽while(1) fork()进程数爆炸,系统崩溃
内存炸弹无限分配内存OOM,其他服务被杀
信息泄露读取 /etc/passwd服务器用户列表泄露
网络攻击建立反向 Shell服务器被完全控制
持久化攻击写入定时任务长期后门

1.2 Ezzi Code Sandbox 的定位

Ezzi Code Sandbox 是一个独立部署的代码执行引擎。它不是一个完整的 OJ 平台,而是其中最核心、最危险的那一层——专门负责:

  • 接收代码和输入数据
  • 在隔离环境中编译/运行
  • 返回结构化的执行结果(stdout、stderr、耗时、内存、退出码)

它被设计为”可被上游业务系统调用的微服务”,典型调用方包括:

场景调用方式
在线评测平台(OJ)判题服务通过 HTTP 调用沙箱
编程教学平台课程系统提交学生代码到沙箱
AI 代码执行LLM 生成代码后交给沙箱验证
面试/笔试系统候选人代码在沙箱中运行

1.3 技术栈一览

组件技术选型版本
运行时Java21
框架Spring Boot3.5.7
容器化Docker20.10+
Docker SDKdocker-java + zerodep transport3.3.4
HTTP 下载Hutool5.8.38
压缩处理Apache Commons Compress1.26.0

二、系统架构

2.1 整体架构

系统采用经典的分层架构,自上而下分为 API 层、Service 层、Core 层和 Infrastructure 层:

┌──────────────────────────────────────────────────────────────────┐
│                        上游业务服务                                │
│              (OJ 判题服务 / 教学平台 / AI Agent)                  │
└────────────────────────────┬─────────────────────────────────────┘
                             │ HTTP (POST /execute/*)
                             ▼
┌──────────────────────────────────────────────────────────────────┐
│                     Ezzi Code Sandbox (:6060)                    │
│                                                                  │
│  ┌─ API Layer ─────────────────────────────────────────────────┐ │
│  │  ExecuteController (/execute/*)   HealthController (/health)│ │
│  └───────────┬─────────────────────────────────────────────────┘ │
│              │                                                    │
│  ┌─ Service Layer ─────────────────────────────────────────────┐ │
│  │  ExecutionService   InputDataService  LanguageStrategyFactory│ │
│  └───────────┬──────────────┬──────────────────────────────────┘ │
│              │              │                                     │
│  ┌─ Core Layer ────────────┼───────────────────────────────────┐ │
│  │  DockerCodeExecutor   ContainerManager     ContainerPool    │ │
│  └───────────┬──────────────────────────────────────────────────┘ │
│              │                                                    │
│  ┌─ Infrastructure ────────────────────────────────────────────┐ │
│  │  Docker Engine        Local Disk Cache      HTTP Download   │ │
│  └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘

关键设计原则:沙箱服务本身运行在一个 Docker 容器中(控制面),通过挂载宿主机的 Docker Socket (/var/run/docker.sock) 来管理执行容器(数据面)。控制面有网络访问权限(需要下载输入数据),而执行容器的网络被完全禁用。

2.2 一次代码执行的完整旅程

让我们跟踪一个请求从进入到返回的全过程:

1. 客户端 POST /execute/single,携带 code + language + input
        │
2. ExecuteController 接收请求,参数校验
        │
3. ExecutionServiceImpl 编排执行流程
        │
   ├── 3a. LanguageStrategyFactory 获取对应语言策略
   ├── 3b. 设置默认超时和内存限制
   │
4. DockerCodeExecutor.execute() 核心执行
        │
   ├── 4a. 危险代码扫描(正则黑名单)
   ├── 4b. 从容器池获取预热容器(或创建新容器)
   ├── 4c. 通过 docker exec + base64 写入源代码
   ├── 4d. 编译(如需要)
   ├── 4e. 逐个运行测试用例,采集 stdout/stderr/time/memory
   ├── 4f. 清理任务目录
   └── 4g. 归还容器到池中
        │
5. 构建 SingleExecuteResponse 返回

2.3 包结构

com.github.ezzziy.codesandbox
├── CodeSandBoxApplication.java          # 启动类
├── common/                              # 通用模块
│   ├── enums/                           # ExecutionStatus, LanguageEnum
│   └── result/Result.java               # 统一响应包装
├── config/                              # 配置层
│   ├── DockerConfig.java                # Docker 客户端配置
│   └── ExecutionConfig.java             # 执行限制配置
├── controller/                          # API 层
│   ├── ExecuteController.java           # 代码执行接口
│   └── HealthController.java            # 健康检查接口
├── service/impl/                        # 服务层
│   ├── ExecutionServiceImpl.java        # 执行服务
│   └── InputDataServiceImpl.java        # 输入数据缓存
├── executor/                            # 执行器层(核心)
│   ├── DockerCodeExecutor.java          # Docker 代码执行器
│   ├── ContainerManager.java            # 容器生命周期管理
│   └── CommandResult.java               # 命令执行结果
├── pool/                                # 容器池
│   ├── ContainerPool.java               # 池管理器
│   └── PooledContainer.java             # 池化容器对象
├── strategy/                            # 语言策略
│   ├── LanguageStrategy.java            # 策略接口
│   ├── LanguageStrategyFactory.java     # 策略工厂
│   ├── CLanguageStrategy.java           # C
│   ├── CppLanguageStrategy.java         # C++
│   ├── Java8LanguageStrategy.java       # Java 8
│   ├── Java17LanguageStrategy.java      # Java 17
│   └── Python3LanguageStrategy.java     # Python 3
├── model/                               # 数据模型
│   ├── dto/                             # 请求/内部 DTO
│   └── vo/                              # 响应 VO
├── exception/                           # 异常体系
│   ├── SandboxException.java            # 基类(携带 ExecutionStatus)
│   ├── CompileException.java
│   ├── DangerousCodeException.java
│   ├── TimeLimitException.java
│   ├── MemoryLimitException.java
│   └── GlobalExceptionHandler.java      # 全局异常处理器
└── util/
    ├── OssUrlParser.java                # 预签名 URL 解析
    └── JavaUnicodeDecoder.java          # Unicode 转义预处理

三、多语言支持与策略模式

3.1 支持的语言

语言标识Docker 镜像编译器/解释器编译命令
Ccsandbox-gccGCC (C11)gcc -std=c11 -O2 -Wall -Wextra -fno-asm -lm
C++cpp11sandbox-gccG++ (C++11)g++ -std=c++11 -O2 -Wall -Wextra -fno-asm
Java 8java8sandbox-java8OpenJDK 8javac -encoding UTF-8
Java 17java17sandbox-java17OpenJDK 17javac -encoding UTF-8
Python 3python3sandbox-pythonPython 3.10无(解释型)

3.2 策略模式的设计

每种语言的编译、运行、安全扫描规则都不同。项目采用策略模式(Strategy Pattern)来解耦语言差异:

public interface LanguageStrategy {
    LanguageEnum getLanguage();        // 语言标识
    String getDockerImage();           // 使用哪个镜像
    String getSourceFileName();        // 源文件名(main.c / Main.java / main.py)
    String[] getCompileCommand(...);   // 编译命令(解释型返回 null)
    String[] getRunCommand(...);       // 运行命令
    String getExecutableFileName();    // 可执行文件名
    List<Pattern> getDangerousPatterns(); // 危险代码正则

    default boolean needCompile() {
        return getCompileCommand("", "") != null;
    }

    default String checkDangerousCode(String code) {
        for (Pattern pattern : getDangerousPatterns()) {
            if (pattern.matcher(code).find()) {
                return pattern.pattern();
            }
        }
        return null;
    }
}

每种语言实现这个接口,通过 Spring 的自动扫描注册为 Bean:

@Component
public class CLanguageStrategy implements LanguageStrategy { ... }

@Component
public class Python3LanguageStrategy implements LanguageStrategy { ... }

工厂类使用 EnumMap 管理所有策略,@PostConstruct 时自动注册:

@PostConstruct
public void init() {
    for (LanguageStrategy strategy : strategies) {
        strategyMap.put(strategy.getLanguage(), strategy);
        log.info("注册语言策略: {} -> {}", strategy.getLanguage().getCode(),
                strategy.getClass().getSimpleName());
    }
}

3.3 扩展新语言只需三步

策略模式的优势在于扩展极其简单,添加一门新语言(如 Rust)只需要:

  1. 枚举中加一项:RUST("rust", "Rust", "rust:1.70", ".rs")
  2. 写一个策略类:实现 LanguageStrategy 接口,标注 @Component
  3. 构建沙箱镜像:在 sandbox-images/ 下添加 Dockerfile

无需修改工厂、无需改配置文件——Spring 会自动发现并注册新策略。

3.4 编译参数的安全考量

编译参数的选择不仅影响性能,也关系安全:

  • -fno-asm:禁止内联汇编。如果不加这个标志,用户可以用 asm volatile("syscall" ...) 直接调用系统调用,绕过所有应用层限制
  • -O2:开启优化。OJ 场景下需要真实的性能表现
  • -Wall -Wextra:编译警告全开,帮助发现潜在问题
  • Java -Djava.security.manager=default(Java 8):启用安全管理器,限制反射等敏感操作
  • Python -u:禁用输出缓冲,确保 stdout 实时刷新,避免输出丢失

四、Docker 容器隔离——安全的核心

这是整个项目最关键、涉及底层知识最多的部分。让我们逐层拆解沙箱的安全机制。

4.1 为什么选择 Docker?

代码沙箱的隔离方案有很多:

方案隔离级别性能复杂度适用场景
进程级 (seccomp/ptrace)低极高高单语言、极致性能
容器级 (Docker/nsjail)中高中多语言、生产级
虚拟机 (Firecracker/gVisor)高中高极高安全要求

Docker 的选择是安全性与工程复杂度的平衡:

  • Namespace 隔离:PID、网络、文件系统、用户命名空间全面隔离
  • Cgroup 资源限制:CPU、内存、进程数的精确控制
  • 成熟生态:docker-java SDK、compose 编排、镜像管理
  • 多语言友好:不同语言用不同镜像,天然隔离运行时

4.2 Linux Namespace——让容器看到自己的世界

Docker 容器的隔离基础是 Linux Namespace。每个执行容器拥有独立的:

Namespace隔离内容安全意义
PID Namespace进程 ID容器内进程看不到宿主机进程
Network Namespace网络栈容器无法访问宿主机网络
Mount Namespace文件系统容器有独立的挂载视图
UTS Namespace主机名隔离主机标识
IPC Namespace进程间通信隔离共享内存、信号量

当我们创建容器时设置 .withNetworkDisabled(true),Docker 甚至不会为容器创建网络设备——这比普通的网络 Namespace 隔离更彻底。容器内的进程连 lo 以外的网络接口都没有,无法发送或接收任何网络数据包。

4.3 Cgroup——精确的资源限制

Linux Control Groups (cgroups) 是内核提供的资源限制机制。项目中的资源配置全部通过 ContainerManager.buildHostConfig() 集中构建。

4.3.1 内存限制

.withMemory(memoryBytes)          // 硬内存限制(默认 256MB)
.withMemorySwap(memoryBytes)      // 设为等于 memory → 禁用 swap
.withOomKillDisable(false)        // 允许 OOM Killer 终止进程

底层原理:这对应 cgroup 的 memory.limit_in_bytes 和 memory.memsw.limit_in_bytes。当进程的 RSS(Resident Set Size,常驻内存集合)达到限制时,内核的 OOM Killer 会终止该进程。

关键点是 withMemorySwap 设为等于 withMemory。在 Docker 中:

  • memorySwap = memory 表示总内存(物理 + swap)等于物理内存限制,即 swap 可用量为 0
  • 如果不设置 memorySwap,默认为 memory * 2,容器可以使用等量的 swap,实际可用内存翻倍
  • 在 OJ 场景下,我们需要精确控制内存,必须禁用 swap

4.3.2 CPU 限制

.withCpuPeriod(100000L)           // CFS 调度周期:100ms
.withCpuQuota(cpuQuota)           // CFS 配额:cpuLimit * 100000

底层原理:Linux 的 CFS(Completely Fair Scheduler,完全公平调度器)通过 cpu.cfs_period_us 和 cpu.cfs_quota_us 实现 CPU 限制。例如 cpuLimit = 1.0 意味着:

  • 周期 = 100,000μs(100ms)
  • 配额 = 100,000μs
  • 含义:每 100ms 调度周期内,容器最多使用 100ms CPU 时间 = 1 个完整核心

如果设为 0.5,则每 100ms 只能用 50ms,相当于半个核心。当配额用尽时,内核调度器会暂停该 cgroup 内的所有线程,直到下一个周期开始。

4.3.3 进程数限制(防 Fork Bomb)

.withPidsLimit(1024L)             // cgroup pids.max

底层原理:对应 cgroup 的 pids.max。经典的 Fork Bomb:

while(1) fork();  // 指数级创建进程,秒级拖垮系统
import os
while True: os.fork()

没有 PID 限制,一个 fork bomb 可以在几秒内创建数万个进程,耗尽系统进程表,导致整台服务器无法创建新进程(包括 SSH 登录都无法建立)。pidsLimit = 1024 将容器内的进程总数(包括线程)硬限制在 1024 个,Fork Bomb 达到上限后 fork() 返回 -1(EAGAIN),无法继续扩散。

4.4 Linux Capabilities——精确的权限控制

Linux Capabilities 是对传统 root 权限的细粒度拆分。传统 Unix 权限模型只有两种状态:root(全部权限)和普通用户(受限权限)。Capabilities 将 root 的特权拆分为约 40 个独立能力:

.withCapDrop(Capability.ALL)      // 删除所有 capabilities

这意味着即使容器内以某种方式获得了 root 身份,也无法执行特权操作。被删除的关键能力包括:

Capability功能不删除的风险
CAP_NET_RAW原始套接字可以进行 ARP 欺骗、网络嗅探
CAP_SYS_ADMIN系统管理可以挂载文件系统、操作命名空间——这是最危险的 capability
CAP_SYS_PTRACE进程追踪可以调试其他进程、读取其内存
CAP_SYS_MODULE内核模块可以加载内核模块(最高危)
CAP_MKNOD创建设备文件可以创建设备节点访问硬件
CAP_SETUID/SETGID切换用户可以提权为其他用户
CAP_DAC_OVERRIDE绕过文件权限可以读写任何文件,无视 rwx 权限位

配合 no-new-privileges:true 安全选项:

.withSecurityOpts(List.of("no-new-privileges:true"))

这个内核参数确保容器进程无法通过 execve() setuid 二进制文件或其他方式获取额外权限。即使容器里有一个 setuid-root 的程序,执行它也不会获得 root 权限。

4.5 只读根文件系统与 tmpfs

.withReadonlyRootfs(true)         // 只读根文件系统
.withTmpFs(Map.of(
    "/tmp", "rw,noexec,nosuid,size=64m",
    "/sandbox/workspace", "rw,exec,nosuid,size=64m"
))

设计思路:

容器的根文件系统设为只读,这意味着容器内的 /usr、/bin、/etc 等目录都无法写入。用户代码无法修改系统二进制文件、无法篡改配置文件、无法在系统目录下植入恶意程序。

但编译和运行需要写权限,通过 tmpfs 精确开放两个目录:

路径权限大小用途
/sandbox/workspacerw, exec, nosuid64MB工作目录——编译产物需要可执行
/tmprw, noexec, nosuid64MB临时文件——不允许执行

tmpfs 的底层原理:tmpfs 是 Linux 内核提供的内存文件系统,数据存储在页缓存(page cache)和 swap 中(但容器禁用了 swap,所以纯内存)。它受 cgroup 内存限制约束,容器销毁后自动清空,不会残留数据到磁盘。

挂载选项的安全含义:

  • noexec:该分区上的文件不能被执行(设置了 x 位也不行),防止在 /tmp 下放置恶意可执行文件
  • nosuid:忽略 setuid/setgid 位,防止通过特殊权限位提权
  • size=64m:限制 tmpfs 大小,防止通过大量写入耗尽内存

4.6 Seccomp——系统调用过滤

Docker 默认启用 seccomp(Secure Computing Mode)配置文件,禁止约 44 个危险系统调用:

被禁系统调用功能风险
reboot重启系统直接关机
mount/umount挂载/卸载文件系统逃逸容器
init_module/finit_module加载内核模块内核级攻击
kexec_load加载新内核替换运行中的内核
keyctl内核密钥管理密钥泄露
ptrace进程追踪调试攻击

底层原理:seccomp 工作在内核层面,通过 BPF(Berkeley Packet Filter)规则在系统调用入口处过滤。当用户态程序执行 syscall 指令时,内核会先检查 seccomp 规则,匹配则拒绝(返回 EPERM 或直接终止进程),不匹配才继续正常的系统调用处理。即使用户代码通过内联汇编直接触发 syscall 指令,seccomp 也能拦截——因为它在内核的系统调用分发器(do_syscall_64)之前执行。

4.7 用户隔离

// exec 时指定 sandbox 用户
dockerClient.execCreateCmd(containerId)
    .withUser("sandbox")   // uid=1000, gid=1000
    .withCmd(cmd)
    .exec();

沙箱镜像在构建时创建了专用用户:

RUN addgroup -g 1000 sandbox && \
    adduser -D -u 1000 -G sandbox sandbox

所有用户代码都以 sandbox 身份执行,不是 root。这是纵深防御的重要一环——即使 capabilities 和 seccomp 都被绕过,非 root 用户仍然受到传统 Unix DAC(Discretionary Access Control)权限模型的约束。

4.8 Ulimit——用户级资源限制

.withUlimits(List.of(
    new Ulimit("nofile", 256, 256),        // 最大打开文件描述符
    new Ulimit("nproc", 1024, 1024),       // 最大进程数(用户级)
    new Ulimit("fsize", 65536, 65536)      // 最大文件大小(字节)
))

Ulimit 在用户级别提供额外的资源限制,与 cgroup 形成互补:

  • nofile = 256:限制文件描述符数量。恶意代码可能尝试打开大量文件描述符耗尽系统资源
  • nproc = 1024:与 cgroup 的 pidsLimit 双重限制进程数
  • fsize = 65536:限制单个文件的最大大小(64KB),防止生成巨大文件耗尽 tmpfs

4.9 安全防护总览

用户代码
  │
  ▼
┌─────────────────────────────────────────────┐
│  第一层:危险代码正则扫描(应用层)            │
│  • 拦截 system(), fork(), eval() 等          │
│  • Java Unicode 转义预处理                    │
│  • C/C++ 连字符检测                           │
│  • Python dunder 反射链拦截                    │
└─────────────┬───────────────────────────────┘
              │
┌─────────────▼───────────────────────────────┐
│  第二层:容器隔离(内核层,不可绕过)          │
│  • Namespace 隔离(PID/Net/Mount/UTS/IPC)   │
│  • Cgroup 资源限制(CPU/Memory/PID)         │
│  • Capabilities 全部删除                      │
│  • Seccomp 系统调用过滤                       │
│  • 只读根文件系统 + tmpfs                     │
│  • 网络完全禁用                               │
│  • 非 root 用户执行                           │
│  • no-new-privileges                         │
│  • Ulimit (nofile/nproc/fsize)               │
└─────────────────────────────────────────────┘

即使第一层被绕过(黑名单本质是”尽力而为”),第二层的容器隔离确保:

  • 无敏感数据可读——只读根文件系统 + 无宿主机目录挂载
  • 无网络可外传——网络完全禁用
  • 无持久化路径可写——仅 tmpfs 可写,容器销毁后清空
  • 无特权可提升——capabilities 全删 + no-new-privileges

五、容器池——高吞吐的秘密

5.1 为什么需要容器池?

创建一个 Docker 容器的开销并不小:

docker create → docker start → 等待容器就绪 → 执行代码 → docker stop → docker rm

每次创建/销毁容器大约需要 500ms~2s。对于 OJ 场景(批量测试用例、高并发提交),这个开销不可接受。

容器池的核心思想:预先创建一批容器保持运行,每次请求从池中取一个、用完归还,避免频繁创建/销毁。

5.2 容器池的架构

┌─────────────────────────────────────────────────┐
│                   ContainerPool                  │
│                                                  │
│  C/C++ Pool:  [Container-1] [Container-2]        │
│  Java8 Pool:  [Container-3] [Container-4]        │
│  Java17 Pool: [Container-5] [Container-6]        │
│  Python Pool: [Container-7] [Container-8]        │
│                                                  │
│  配置: minSize=2, maxSize=10, maxUseCount=100    │
└─────────────────────────────────────────────────┘

容器生命周期:

create → start → [在池中等待] → acquire → 执行代码 → release → [回到池中]
                                                         │
                                                    (使用次数 ≥ 100)
                                                         │
                                                  remove → 重建新容器

5.3 并发控制

容器池使用 ReentrantLock + Condition 实现线程安全的获取/归还:

private static class PoolState {
    private final ConcurrentLinkedQueue<PooledContainer> containers;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition available = lock.newCondition();
}

获取容器的三级策略:

  1. 优先复用:从池中找到一个空闲且存活的容器
  2. 按需创建:池未满时创建新容器
  3. 等待归还:池已满时等待其他线程归还(最多 30 秒)
public PooledContainer acquireContainer(LanguageStrategy strategy) {
    state.lock.lock();
    try {
        // 1. 优先获取空闲容器
        PooledContainer container = findAvailableContainerLocked(state, now);
        if (container != null) return container;

        // 2. 池未满,创建新容器
        if (state.containers.size() < maxPoolSize) {
            return createAndAddContainer(strategy, state);
        }

        // 3. 池已满,等待归还
        long nanos = TimeUnit.SECONDS.toNanos(30);
        while (nanos > 0) {
            nanos = state.available.awaitNanos(nanos);
            container = findAvailableContainerLocked(state, now);
            if (container != null) return container;
        }
        throw new RuntimeException("等待容器超时");
    } finally {
        state.lock.unlock();
    }
}

归还时通过 signal() 唤醒等待线程:

container.setInUse(false);
state.available.signal();  // 唤醒一个等待的线程

5.4 任务隔离——同一容器的安全复用

容器复用带来一个问题:如何确保前一个任务的文件不影响下一个任务?

解决方案是任务子目录隔离:

/sandbox/workspace/              # 容器工作目录(长期存在)
  ├── job-req-001/               # 任务1的独立目录
  │   ├── Main.java
  │   └── Main.class
  └── job-req-002/               # 任务2的独立目录
      └── main.py

每个请求创建独立的子目录(job-{requestId}),所有编译和运行都在该子目录内执行。任务完成后在容器内执行 rm -rf 清理,然后归还容器。

清理操作有安全校验——只允许清理 /sandbox/workspace/ 下的目录:

public void cleanupTaskDirectory(String containerId, String taskDir) {
    // 安全检查:确保只清理 /sandbox/workspace/ 下的目录
    if (!taskDir.startsWith("/sandbox/workspace/")) {
        log.error("非法的任务目录路径,拒绝清理: {}", taskDir);
        return;
    }
    // 以 sandbox 用户执行 rm -rf
    dockerClient.execCreateCmd(containerId)
        .withUser("sandbox")
        .withCmd("rm", "-rf", taskDir)
        .exec();
}

5.5 定时维护

容器池通过 Spring @Scheduled 实现三种自动维护:

定时任务频率功能
cleanIdleContainers每 60 秒清理空闲超时容器(超过 10 分钟未使用)
cleanZombieContainers每 10 分钟检查并移除已停止的僵尸容器
destroy(@PreDestroy)应用关闭时销毁所有池内容器

空闲清理时会保证每种语言至少保留 minPoolSize 个容器,不会全部回收。


六、代码执行的底层细节

6.1 源代码写入——base64 巧妙绕过只读文件系统

由于容器的根文件系统是只读的,Docker 的 copyArchiveToContainer API(基于 tar 归档)无法写入文件——该 API 试图在容器的 overlay 文件系统上创建文件,被 readonlyRootfs 阻止。

项目采用了一种巧妙的方式——通过 docker exec 在容器内部执行命令写入 tmpfs:

byte[] codeBytes = code.getBytes(StandardCharsets.UTF_8);
String base64Code = Base64.getEncoder().encodeToString(codeBytes);
String filePath = taskDir + "/" + fileName;

// 合并 mkdir 和写文件为 1 次 exec 调用
String writeCmd = "mkdir -p " + taskDir +
    " && echo '" + base64Code + "' | base64 -d > " + filePath;

dockerClient.execCreateCmd(containerId)
    .withUser("sandbox")
    .withCmd("sh", "-c", writeCmd)
    .exec();

为什么能写入? 因为 readonlyRootfs 限制的是容器的 overlay 文件系统层,而 /sandbox/workspace 是 tmpfs 挂载——从容器内部视角,这是一个独立的可写文件系统。docker exec 在容器的 mount namespace 内执行命令,对 tmpfs 的写入不受 readonlyRootfs 影响。

为什么用 base64? 源代码可能包含单引号、双引号、反斜杠、换行符等各种特殊字符,直接通过 shell 命令传递会导致注入问题。base64 编码将任意二进制数据转换为安全的 ASCII 字符集(A-Z, a-z, 0-9, +, /, =),避免了所有 shell 转义问题。

6.2 精确计时——纳秒级时间测量

OJ 场景对执行时间的测量精度要求很高(差 10ms 可能决定是 AC 还是 TLE)。项目没有使用 Java 层面的时间测量(包含 Docker API 通信开销),而是在容器内部通过 shell 脚本精确测量:

MEMFILE=/tmp/mem_$$;
START=$(date +%s%N);                                    # 纳秒时间戳
printf '%s' '<input>' | /usr/bin/time -f '%M' -o $MEMFILE sh -c 'exec <cmd>';
EXIT_CODE=$?;                                           # 捕获真实退出码
END=$(date +%s%N);                                      # 纳秒时间戳
echo '__EXEC_TIME_NS__:'$((END-START));                 # 输出时间标记
MEM=$(cat $MEMFILE 2>/dev/null || echo 0); rm -f $MEMFILE;
echo '__EXEC_MEM_KB__:'$MEM;                            # 输出内存标记
exit $EXIT_CODE                                         # 传递退出码给 Docker

这段脚本有几个精妙之处:

  1. date +%s%N:获取纳秒级时间戳(epoch 秒数 + 纳秒部分),精度远高于毫秒级。Java 层再将纳秒差值除以 1,000,000 转换为毫秒
  2. sh -c 'exec <cmd>':exec 系统调用让用户程序替换当前 shell 进程,不创建子进程。这确保 /usr/bin/time 统计的是用户程序本身的资源消耗,而非 shell 进程
  3. /usr/bin/time -f '%M' -o $MEMFILE:GNU time 的 %M 输出最大 RSS(单位 KB),-o 将结果写入临时文件,不污染 stdout/stderr。$$ 是当前 shell 的 PID,确保并发时文件名不冲突
  4. EXIT_CODE=$? + exit $EXIT_CODE:在用户命令后立即用 $? 捕获退出码(后续的 date、echo 命令会覆盖 $?),最后用 exit 传递给 Docker exec
  5. __EXEC_TIME_NS__: 和 __EXEC_MEM_KB__::约定的标记前缀,Java 层从 stdout 末尾 lastIndexOf 提取后截取移除,用户程序的正常输出不受影响

6.3 内存测量的原理

/usr/bin/time 是 GNU time 工具(不是 bash 内建的 time 关键字),其 %M 格式符输出进程的最大驻留集大小(Maximum Resident Set Size)。

底层原理:GNU time 通过 wait4() 系统调用获取子进程的 struct rusage,其中 ru_maxrss 字段记录了进程整个生命周期内 RSS 的峰值(单位 KB)。RSS 是进程实际占用物理内存的大小,不包含 swap 或共享库的未加载部分。

这是内核级别的精确测量,优于:

  • Docker Stats API 轮询(有延迟、有开销)
  • /proc/[pid]/status 读取(需要额外的监控进程)
  • cgroup memory.max_usage_in_bytes(包含内核缓存,不够精确)

6.4 超时处理

boolean completed = callback.awaitCompletion(timeoutMs, TimeUnit.MILLISECONDS);
if (!completed) {
    containerManager.killContainer(containerId);  // 发送 SIGKILL
    return CommandResult.timeout(timeoutMs);
}

超时使用 Docker exec 的 awaitCompletion 带超时版本。如果在指定时间内未完成,直接 kill 容器——底层是 docker kill,发送 SIGKILL 信号。SIGKILL(信号 9)是 Linux 中不可被捕获、不可被忽略、不可被阻塞的信号,确保即使是死循环、睡眠、或捕获了 SIGTERM 的进程也能被终止。

6.5 Shell 输入转义

用户输入通过 printf '%s' '<input>' 管道传递给程序的 stdin。输入中的单引号需要特殊处理:

private String escapeShellInput(String input) {
    // 在单引号字符串中,处理单引号本身:'text'\''more'
    // 结束当前单引号 → 添加转义的单引号 → 开始新的单引号
    return input.replace("'", "'\\''");
}

这是 shell 中处理单引号的标准技巧——单引号内的内容是完全字面的(不做任何转义),唯一的方法是结束单引号、用 \' 表示一个单引号、再开始新的单引号。


七、危险代码检测——正则黑名单与反绕过

7.1 为什么需要黑名单?

容器隔离是安全兜底,但不意味着我们应该让恶意代码到达容器层。正则黑名单是第一道防线——成本低、速度快(微秒级),能拦截绝大部分明显的恶意代码,避免不必要的容器资源浪费。

7.2 各语言的黑名单策略

C/C++ 黑名单(约 35-40 条规则)

拦截类别:系统调用、文件操作、网络、内联汇编、危险头文件、动态库加载

Pattern.compile("\\bsystem\\s*\\("),           // 系统命令执行
Pattern.compile("\\bexec[lv]?[pe]?\\s*\\("),   // exec 族函数
Pattern.compile("\\bfork\\s*\\("),             // 创建子进程
Pattern.compile("\\bsocket\\s*\\("),           // 网络 socket
Pattern.compile("\\b__asm__\\b"),              // GCC 内联汇编
Pattern.compile("\\basm\\s*\\("),              // 内联汇编
Pattern.compile("\\bptrace\\s*\\("),           // 进程追踪
Pattern.compile("\\bmmap\\s*\\("),             // 内存映射
Pattern.compile("\\bdlopen\\s*\\("),           // 动态库加载
Pattern.compile("\\bfopen\\s*\\("),            // 文件打开
Pattern.compile("#include\\s*<sys/socket\\.h>"), // 网络头文件
Pattern.compile("%:\\s*include"),               // 连字符绕过检测

Java 黑名单(约 40 条规则)

拦截类别:命令执行、反射、文件操作、网络、类加载器、JNI、Unsafe

Pattern.compile("Runtime\\.getRuntime\\(\\)"),     // 命令执行
Pattern.compile("ProcessBuilder"),                 // 进程构建器
Pattern.compile("Class\\.forName\\s*\\("),         // 反射加载
Pattern.compile("setAccessible\\s*\\(\\s*true"),   // 绕过访问控制
Pattern.compile("System\\.exit"),                  // 退出 JVM
Pattern.compile("sun\\.misc\\.Unsafe"),            // Unsafe 操作
Pattern.compile("ClassLoader"),                    // 类加载器
Pattern.compile("import\\s+java\\.net\\."),        // 网络类导入
Pattern.compile("\\bFiles\\."),                    // NIO Files 操作

Python 黑名单(约 50 条规则)

拦截类别:系统命令、eval/exec、文件操作、网络、ctypes、pickle、dunder 反射链

Pattern.compile("\\bos\\.system\\s*\\("),          // 系统命令
Pattern.compile("\\bsubprocess\\."),               // subprocess 模块
Pattern.compile("\\beval\\s*\\("),                 // eval 执行
Pattern.compile("\\bexec\\s*\\("),                 // exec 执行
Pattern.compile("import\\s+ctypes"),               // C 接口
Pattern.compile("__subclasses__"),                 // 子类链遍历
Pattern.compile("__globals__"),                    // 全局变量访问
Pattern.compile("__builtins__"),                   // 内建函数访问
Pattern.compile("\\bgetattr\\s*\\("),              // 动态属性获取
Pattern.compile("\\btype\\s*\\("),                 // 元类操作

7.3 反绕过:三类已知攻击的防御

攻击 1:Java Unicode 转义绕过

原理:Java 编译器在词法分析之前解析 \uXXXX 转义(JLS 3.3 规范)。这是 Java 语言的一个独特设计——Unicode 转义发生在编译的最早阶段,甚至早于注释的识别。

\u0052untime.getRuntime().exec("whoami");  // \u0052 = 'R'
// 正则看到的是 \u0052untime,匹配不到 Runtime

防御:在正则匹配前,通过 JavaUnicodeDecoder 预处理源码,将所有 \u+XXXX 还原为实际字符:

// JavaUnicodeDecoder - 遵循 JLS 3.3,支持 \uuuuXXXX 多 u 形式
private static final Pattern UNICODE_ESCAPE = Pattern.compile("\\\\u+([0-9a-fA-F]{4})");

public static String decode(String source) {
    Matcher matcher = UNICODE_ESCAPE.matcher(source);
    StringBuilder sb = new StringBuilder();
    while (matcher.find()) {
        char ch = (char) Integer.parseInt(matcher.group(1), 16);
        matcher.appendReplacement(sb, Matcher.quoteReplacement(String.valueOf(ch)));
    }
    matcher.appendTail(sb);
    return sb.toString();
}

Java 8 和 Java 17 的策略类都覆盖了 checkDangerousCode(),先解码再检测。

攻击 2:C/C++ 连字符(Digraph)绕过

原理:C11/C++11 标准定义了连字符(digraph),%: 等价于 #:

%:include <sys/socket.h>   // 等价于 #include <sys/socket.h>

防御:为每条 #include 规则增加对应的 %:include 版本,使用 %:\\s*include 匹配(允许 %: 和 include 之间有空格)。

攻击 3:C/C++ 宏 Token Pasting

#define CONCAT(a,b) a##b
CONCAT(sys,tem)("whoami");   // 预处理后变成 system("whoami")

无法用正则解决——宏展开是 C 预处理器的工作,在编译管线中远早于我们能介入的时机。

防御策略:依赖容器层兜底。即使 system("whoami") 执行成功,容器内没有网络外传数据、没有敏感文件可读、没有持久化路径可写。

攻击 4:Python dunder 反射链

原理:Python 的对象模型极其灵活,通过 __class__、__bases__、__subclasses__() 等特殊属性可以遍历整个类继承树:

().__class__.__bases__[0].__subclasses__()  # 获取 object 的所有子类
# 从中找到 os._wrap_close 等类,进而获取 os 模块

防御:拦截所有 dunder 属性访问(__builtins__、__subclasses__、__globals__ 等)和反射函数(getattr、setattr、type)。这些在正常 OJ 代码中不会使用——OJ 只需要 input()/print()/数据结构/算法,误杀率极低。


八、沙箱镜像的构建

8.1 统一的沙箱用户模型

所有沙箱镜像遵循统一的模式:

官方基础镜像 (alpine / eclipse-temurin / python)
  │
  ├── 安装必要工具(bash, coreutils)
  ├── 创建 sandbox 用户 (uid=1000, gid=1000)
  ├── 创建 /sandbox/workspace 目录
  └── CMD ["tail", "-f", "/dev/null"]  ← 保持容器运行

8.2 以 GCC 镜像为例

FROM alpine:3.18

# 安装 GCC 工具链
RUN apk add --no-cache gcc g++ musl-dev libstdc++ bash coreutils

# 创建沙箱用户(固定 UID/GID 确保跨容器一致性)
RUN addgroup -g 1000 sandbox && \
    adduser -D -u 1000 -G sandbox sandbox

# 创建工作目录(sticky bit 确保安全)
RUN mkdir -p /sandbox/workspace && \
    chown sandbox:sandbox /sandbox && \
    chmod 1777 /sandbox/workspace

WORKDIR /sandbox/workspace
USER root

# 保持容器运行(Alpine 的 busybox sleep 不支持 infinity)
CMD ["tail", "-f", "/dev/null"]

为什么用 tail -f /dev/null 而不是 sleep infinity? Alpine Linux 使用 busybox 提供的精简版命令,其 sleep 不支持 infinity 参数。tail -f /dev/null 是一种通用的、几乎零资源消耗的”保持容器运行”方案——它打开 /dev/null(永远为空),然后 tail -f 阻塞等待新数据(永远不会有),进程保持运行但不消耗 CPU。

为什么使用 Alpine? Alpine Linux 基于 musl libc 和 busybox,基础镜像体积仅约 5MB。小镜像意味着更小的攻击面(更少的系统工具和库可被利用)和更快的构建/拉取速度。

chmod 1777 中的 sticky bit:1 是 sticky bit,在目录上设置时意味着只有文件所有者才能删除自己的文件,防止不同任务(虽然在当前实现中不会并行使用同一容器)互相删除文件。

8.3 镜像构建

项目在 docker-compose.yml 中使用 profiles: ["build-only"] 管理构建镜像:

sandbox-gcc:
  build:
    context: ./sandbox-images/gcc
  image: sandbox-gcc:latest
  profiles: ['build-only'] # 只构建不启动

同时提供 build.sh 脚本一键构建所有镜像。


九、输入数据缓存体系

9.1 批量执行的数据流

对于批量测试(如 OJ 判题),输入数据以 ZIP 包形式存储在远端(如 OSS),通过预签名 URL 下载:

调用方 → POST /execute/batch { inputDataUrl, inputDataVersion }
  │
  ▼
InputDataService.getInputDataSet()
  │
  ├── 本地缓存存在且版本匹配?→ 直接返回(零网络请求)
  │
  └── 缓存不存在或版本不匹配?→ GET 下载 ZIP → 解压 *.in → 写入缓存

9.2 版本号驱动的缓存策略

模式 A:版本号驱动(推荐,零网络请求)

  1. 调用方传入 inputDataVersion(推荐 sha256)
  2. 解析预签名 URL,提取 ObjectKey 定位本地缓存目录
  3. 读取本地 _meta.properties 中的 version 字段
  4. 字符串精确比较——一致则直接读取本地 *.in 文件
  5. 不一致则 GET 下载、解压、更新版本号

模式 B:GET 回退(未传版本号时)

  1. GET 下载 ZIP + 响应头
  2. 从 ETag 推导版本号
  3. 与本地比对——一致则丢弃下载内容、用本地缓存

9.3 本地缓存结构

/var/lib/sandbox-inputs/           # 缓存根目录
  └── judgedata/                   # ObjectKey 路径
      └── problem-1/              # 去掉 .zip 后缀
          ├── _meta.properties    # version=sha256:abcd..., updatedAt=...
          ├── 1.in                # 测试用例输入
          ├── 2.in
          └── 3.in

ZIP 解压时只接受匹配 ^\d+\.in$ 的文件,按数字排序组成输入列表。非法文件名直接忽略。


十、API 设计

10.1 单次执行

curl -X POST 'http://localhost:6060/execute/single' \
  -H 'Content-Type: application/json' \
  -d '{
    "requestId": "demo-001",
    "language": "python3",
    "code": "print(sum(map(int, input().split())))",
    "input": "10 20",
    "timeLimit": 1000,
    "memoryLimit": 256
  }'

响应:

{
  "code": 1,
  "message": "success",
  "data": {
    "status": "SUCCESS",
    "result": {
      "index": 1,
      "status": "SUCCESS",
      "output": "30",
      "errorOutput": "",
      "time": 15,
      "memory": 2048,
      "exitCode": 0
    },
    "totalTime": 32
  }
}

10.2 批量执行

curl -X POST 'http://localhost:6060/execute/batch' \
  -H 'Content-Type: application/json' \
  -d '{
    "requestId": "batch-001",
    "language": "java17",
    "code": "import java.util.*; public class Main { ... }",
    "inputDataUrl": "https://oss.example.com/data/1001.zip?sign=xxx",
    "inputDataVersion": "sha256:abcd1234",
    "timeLimit": 2000,
    "memoryLimit": 256
  }'

批量执行会返回每个测试用例的独立结果,以及汇总统计:

{
    "data": {
        "status": "RUNTIME_ERROR",
        "results": [...],
        "summary": { "total": 3, "success": 2, "failed": 1 },
        "totalTime": 5600
    }
}

10.3 执行状态枚举

状态码枚举值含义
0SUCCESS执行成功
1COMPILE_ERROR编译错误
2RUNTIME_ERROR运行时错误(段错误、非零退出码等)
3TIME_LIMIT_EXCEEDED超时
4MEMORY_LIMIT_EXCEEDED内存超限
5OUTPUT_LIMIT_EXCEEDED输出超限(超过 64KB)
6SYSTEM_ERROR系统内部错误
7DANGEROUS_CODE危险代码被拦截

10.4 健康检查

GET /health/ping       → 简单存活检测
GET /health/liveness   → K8s 存活探针(始终返回 UP)
GET /health/readiness  → K8s 就绪探针(检查 Docker 连接)
GET /health            → 详细信息(JVM 状态、Docker 状态、容器池统计)

十一、部署架构

11.1 Docker-out-of-Docker 模式

沙箱服务本身运行在容器中,通过挂载 Docker Socket 管理执行容器:

services:
  sandbox:
    build: .
    user: root # 需要 root 才能访问 docker.sock
    ports:
      - '6060:6060'
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock # 核心:Docker Socket
      - /var/lib/sandbox-inputs:/var/lib/sandbox-inputs
    healthcheck:
      test: ['CMD-SHELL', 'curl -f http://localhost:6060/health/ping || exit 1']
      interval: 20s
      retries: 5

注意区分:这是 Docker-out-of-Docker(DooD),不是 Docker-in-Docker(DinD)。DooD 方式下,沙箱服务调用宿主机的 Docker daemon,执行容器直接运行在宿主机上,与沙箱服务是兄弟关系而非父子关系。这比 DinD 更高效(无嵌套虚拟化开销)、更稳定(无嵌套存储驱动问题)。

11.2 多阶段构建

主服务使用 Docker 多阶段构建,最终镜像基于精简的 JRE:

# 阶段 1:Maven 构建
FROM maven:3.9-eclipse-temurin-21-alpine AS builder
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests -B

# 阶段 2:运行时镜像
FROM eclipse-temurin:21-jre-alpine
RUN apk add --no-cache docker-cli curl tzdata
COPY --from=builder /build/target/*.jar app.jar
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

--mount=type=cache 利用 Docker BuildKit 的缓存挂载,Maven 依赖只需在首次构建时下载。


十二、异常处理体系

12.1 异常层级

SandboxException (基类,携带 ExecutionStatus + requestId)
  ├── CompileException          → COMPILE_ERROR
  ├── DangerousCodeException    → DANGEROUS_CODE(携带匹配的 pattern)
  ├── RuntimeErrorException     → RUNTIME_ERROR(携带 exitCode + errorOutput)
  ├── TimeLimitException        → TIME_LIMIT_EXCEEDED(携带 timeLimit)
  └── MemoryLimitException      → MEMORY_LIMIT_EXCEEDED(携带 memoryLimit + actualMemory)

12.2 业务异常返回 HTTP 200

关键设计决策:所有沙箱异常返回 HTTP 200。

@ExceptionHandler(SandboxException.class)
public ResponseEntity<ExecuteResponse> handleSandboxException(SandboxException e) {
    return ResponseEntity.ok(ExecuteResponse.builder()
            .status(e.getStatus())
            .errorMessage(e.getMessage())
            .build());
}

这不是疏忽,而是有意为之——对调用方来说,“编译失败”或”超时”不是服务器错误,而是正常的业务结果。调用方只需检查 response.data.status 即可,不需要同时处理 HTTP 状态码和业务状态码。只有参数校验失败(400)和未预期的系统异常(500)才返回非 200 状态码。

12.3 路径脱敏

执行结果中的错误信息会经过路径脱敏处理,防止容器内部路径泄露:

private String sanitizeInternalPath(String output) {
    return output
        .replaceAll("/sandbox/workspace/job-[a-f0-9\\-]+/", "")
        .replace("/sandbox/workspace/", "");
}
// 编译错误:"/sandbox/workspace/job-abc123/Main.java:5: error"
//        → "Main.java:5: error"

这确保用户看到的错误信息中不包含容器内部路径,减少信息面暴露。


十三、配置参数速查

sandbox:
  docker:
    host: unix:///var/run/docker.sock # Docker Socket 地址
    connect-timeout: 30 # 连接超时(秒)
    response-timeout: 60 # 读取超时(秒)
    max-connections: 100 # 最大连接数

  pool:
    enabled: true # 启用容器池
    min-size: 2 # 每语言最小池大小
    max-size: 10 # 每语言最大池大小
    max-idle-minutes: 10 # 空闲超时(分钟)
    max-use-count: 100 # 单容器最大使用次数

  execution:
    compile-timeout: 30 # 编译超时(秒)
    run-timeout: 10 # 运行超时(秒)
    total-timeout: 300 # 总超时(秒)
    memory-limit: 256 # 默认内存限制(MB)
    cpu-limit: 1.0 # CPU 限制(核数)
    output-limit: 65536 # 最大输出(字节 = 64KB)
    max-processes: 1024 # 最大进程数
    max-open-files: 256 # 最大文件描述符
    max-test-cases: 100 # 最大测试用例数
    enable-code-scan: true # 启用危险代码扫描
    max-concurrent-containers: 10 # 最大并发容器数

  input-data:
    storage-dir: /var/lib/sandbox-inputs # 缓存目录
    download-timeout: 30000 # 下载超时(毫秒)
    max-file-size: 10485760 # ZIP 最大大小(10MB)

十四、设计哲学与权衡

14.1 纵深防御

安全不是单一机制能解决的。项目采用纵深防御(Defense in Depth)策略——多层安全机制层层叠加,每一层都假设上一层可能被突破:

防御层机制可被绕过?
应用层正则黑名单可以(宏、编码等手段)
内核层Namespace 隔离需要内核漏洞
内核层Cgroup 限制基本不可绕过
内核层Capabilities 删除需要内核漏洞
内核层Seccomp 过滤需要内核漏洞
文件系统层只读 rootfs + tmpfs基本不可绕过
网络层完全禁用不可绕过

14.2 黑名单是”尽力而为”

项目文档明确指出:黑名单是”尽力而为”的第一道防线,容器层兜底是真正的安全边界。

这是务实的安全观——承认黑名单必然存在绕过方式(尤其是 C/C++ 的宏系统),但通过容器层的硬隔离确保即使绕过也无法造成实质伤害。对比两种极端做法:

  • 过于严格的黑名单:大量误杀正常代码,用户体验差
  • 没有黑名单:所有恶意代码都到达容器层,浪费资源

当前方案在两者之间取得平衡:拦截明显恶意代码 + 容器兜底未知攻击。

14.3 简洁优于复杂

项目刻意避免过度工程:

  • 没有 Caffeine/Redis 缓存框架——磁盘缓存 + 版本号比对足够
  • 没有独立的 SecurityConfigBuilder——安全配置集中在 ContainerManager 一个方法中
  • 没有独立的 DangerousCodeScanner——扫描逻辑内聚在语言策略中
  • 没有 Swagger/SpringDoc——API 通过文档说明
  • 没有 @EnableAsync——同步执行足以满足需求

每一个”没有”都是有意的取舍——在当前需求规模下,简单方案已经足够好。


十五、总结

Ezzi Code Sandbox 展示了如何构建一个生产级的代码执行沙箱。它认真对待了安全、性能、可扩展性这三个核心关注点:

关注点解决方案
安全纵深防御:正则黑名单 → Namespace → Cgroup → Capabilities → Seccomp → 只读FS → 网络禁用 → 非 root 执行
性能容器池预热复用、tmpfs 内存文件系统、容器内纳秒级计时、输入数据缓存
可扩展策略模式 + Spring 自动注册,添加新语言只需三步
可运维K8s 健康检查、Docker Compose 一键部署、定时容器维护、路径脱敏

核心经验:

  1. 安全没有银弹——多层防御比单一完美方案更可靠
  2. 容器池是性能瓶颈的关键优化——从秒级降到毫秒级
  3. 策略模式让多语言支持变得简单——扩展成本极低
  4. 容器内测量比容器外测量更精确——消除了 API 通信开销

如果你正在构建在线编程平台、AI 代码执行引擎,或者任何需要安全执行不受信任代码的系统,希望这篇文章能为你提供有价值的参考。

评论