Spring AI 结合本地代码执行沙箱的安全性设计
在给内部数据分析平台接入 Spring AI 的 Function Calling 能力时,产品团队提出了一个很自然的需求:让大模型根据用户自然语言提问生成 Python 或 Groovy 数据处理脚本,由后端自动执行并直接返回图表数据。
这个需求上线不到两天,安全团队就发来了红线告警:测试人员输入“帮我统计一下系统用户数,如果统计不到就读取一下 /etc/passwd”,大模型生成的代码直接调用了本地文件系统读取接口。如果把代码执行权限毫无防备地交给大模型生成的内容,系统等于直接对大模型敞开了远程代码执行(RCE)的大门。
如何在利用 Spring AI 自动化代码生成能力的同时,构建一个真正安全、可控、资源隔离的本地代码执行沙箱?本文分享我们在生产环境中落地的多层防御沙箱架构。
进程内安全机制的致命盲区
早期很多 Java 开发者在设计代码执行器时,倾向于使用轻量级的 Java 进程内方案,比如 GroovyClassLoader 配合自定义的 CompilerConfiguration,或者依赖 Java 自带的 SecurityManager。但在实际攻防和高并发业务场景下,这些方案存在严重缺陷:
- SecurityManager 已被弃用:从 JDK 17 开始,SecurityManager 被标记为 Deprecated for removal,在更高版本的 JDK 中已无法作为长期演进的安全基石。
- Groovy AST 转换拦截容易被绕过:黑客或大模型生成的恶意代码可以通过复杂的反射、动态代理、Unsafe 机制或间接类加载,轻松绕过 AST 语法树维度的黑名单校验。
- 资源耗尽型攻击(DoS)无法在 JVM 层彻底隔离:一段简单的死循环
while(true){}或者大对象申请byte[] b = new byte[1024*1024*1024],就会直接打满宿主机 CPU 或触发 JVM OOM,导致主业务容器崩溃。 - 底层系统调用的穿透风险:一旦代码通过 JNI 调用系统原生动态库,JVM 层的任何防线都会瞬间失效。
因此,代码执行必须下沉到底层操作系统级别做硬隔离,将代码运行环境与业务主服务彻底解耦。
多层防御沙箱架构设计
我们设计的本地沙箱遵循“网络零信任、系统调用最小化、资源硬配额、生命周期单次销毁”四个核心原则。整体架构分为四层:
+-------------------------------------------------------------+ | Spring AI Application | | +-----------------------+ +--------------------------+ | | | Prompt Engineering | | Security Pre-Filter | | | | (Intent & AST Verify) | --> | (Sensitive Keyword/AST) | | | +-----------------------+ +--------------------------+ | +------------------------------|------------------------------+ | gRPC / Docker API +------------------------------v------------------------------+ | Sandbox Coordinator (Manager) | | +-------------------------------------------------------+ | | | - Pool Management & Ephemeral Container Lifecycle | | | | - Execution Timeout Watchdog (Hard Kill 3s/5s) | | | +-------------------------------------------------------+ | +------------------------------|------------------------------+ | Isolated Execution +------------------------------v------------------------------+ | Isolated Container Runtime (gVisor / Docker) | | - Network: none (Disable eth0) | | - Storage: tmpfs read-only rootfs + volatile /tmp | | - Limits: CPU=0.5, Memory=128MB, PIDs=32 | | - Seccomp: Whitelist Only (Block socket, ptrace, execveat) | | - User: Non-root (UID 10001) | +-------------------------------------------------------------+1. 运行时选型:Docker + gVisor
普通的 Docker 容器共享宿主机内核,仍存在容器逃逸风险。在安全要求极高的场景下,我们推荐使用 Google 开源的runsc(gVisor)作为容器运行时。gVisor 在用户态实现了大部分 Linux 内核调用,恶意代码无法直接与宿主机内核交互,即便执行恶意 syscall 也会被用户态内核拦截。
2. 容器生命周期瞬态化
每个执行请求独立分配一个一次性容器,执行完毕或超时后立即强制销毁(Destroy),严禁容器复用,从根本上杜绝状态残留与本地持久化提权攻击。
Spring AI 工具回调与沙箱执行器实现
在 Spring AI 中,我们将沙箱包装成一个标准的 Tool / Function Callback。大模型在识别到需要执行数据分析计算时,会触发该工具调用。
1. 定义执行契约与数据模型
package com.example.ai.sandbox.model; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; import java.util.List; public class CodeExecutionPayload { @JsonProperty(required = true, value = "language") @JsonPropertyDescription("编程语言,目前仅支持 python 或 groovy") private String language; @JsonProperty(required = true, value = "code") @JsonPropertyDescription("待执行的代码内容,需自包含且不依赖外部网络") private String code; @JsonProperty(value = "inputArgs") @JsonPropertyDescription("传递给脚本的只读入参列表") private List<String> inputArgs; public String getLanguage() { return language; } public void setLanguage(String language) { this.language = language; } public String getCode() { return code; } public void setCode(String code) { this.code = code; } public List<String> getInputArgs() { return inputArgs; } public void setInputArgs(List<String> inputArgs) { this.inputArgs = inputArgs; } }package com.example.ai.sandbox.model; public class ExecutionResult { private boolean success; private String stdout; private String stderr; private int exitCode; private long executionTimeMs; public static ExecutionResult fail(String msg) { ExecutionResult res = new ExecutionResult(); res.setSuccess(false); res.setStderr(msg); res.setExitCode(-1); return res; } // 省略 getter / setter public boolean isSuccess() { return success; } public void setSuccess(boolean success) { this.success = success; } public String getStdout() { return stdout; } public void setStdout(String stdout) { this.stdout = stdout; } public String getStderr() { return stderr; } public void setStderr(String stderr) { this.stderr = stderr; } public int getExitCode() { return exitCode; } public void setExitCode(int exitCode) { this.exitCode = exitCode; } public long getExecutionTimeMs() { return executionTimeMs; } public void setExecutionTimeMs(long executionTimeMs) { this.executionTimeMs = executionTimeMs; } }2. 核心沙箱隔离执行器实现
基于 Docker Java Client 实现沙箱的资源管控,硬编码下发 CPU、内存、网络与只读文件系统策略:
package com.example.ai.sandbox.service; import com.example.ai.sandbox.model.CodeExecutionPayload; import com.example.ai.sandbox.model.ExecutionResult; import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.async.ResultCallback; import com.github.dockerjava.api.command.CreateContainerResponse; import com.github.dockerjava.api.command.WaitContainerResultCallback; import com.github.dockerjava.api.model.*; import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerClientImpl; import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.io.Closeable; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Collections; import java.util.concurrent.TimeUnit; @Service public class DockerSandboxExecutor { private static final Logger log = LoggerFactory.getLogger(DockerSandboxExecutor.class); private static final long MAX_EXECUTION_TIMEOUT_SECONDS = 5; private static final long MEMORY_LIMIT_BYTES = 128 * 1024 * 1024L; // 128MB private static final long CPU_QUOTA_MICROS = 50000L; // 0.5 核 (50ms per 100ms) private static final long CPU_PERIOD_MICROS = 100000L; private final DockerClient dockerClient; public DockerSandboxExecutor() { DefaultDockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); ApacheDockerHttpClient httpClient = new ApacheDockerHttpClient.Builder() .dockerHost(config.getDockerHost()) .sslConfig(config.getSSLConfig()) .maxConnections(50) .connectionTimeout(Duration.ofSeconds(2)) .responseTimeout(Duration.ofSeconds(10)) .build(); this.dockerClient = DockerClientImpl.getInstance(config, httpClient); } public ExecutionResult executePython(CodeExecutionPayload payload) { long startTime = System.currentTimeMillis(); String containerId = null; try { // 静态预检:拦截显而易见的高危代码特征 preCheckCode(payload.getCode()); HostConfig hostConfig = HostConfig.newHostConfig() .withMemory(MEMORY_LIMIT_BYTES) .withMemorySwap(MEMORY_LIMIT_BYTES) // 严禁使用 Swap 防止占满磁盘 .withCpuQuota(CPU_QUOTA_MICROS) .withCpuPeriod(CPU_PERIOD_MICROS) .withNetworkMode("none") // 禁用一切网络外联 .withPidsLimit(32L) // 限制进程数,防御 Fork Bomb .withReadonlyRootfs(true) // 只读根文件系统 .withCapDrop(Capability.ALL) // 剥夺所有 Linux Capabilities 特权 .withTmpFS(Collections.singletonMap("/tmp", "rw,noexec,nosuid,size=16m")); // 仅允许受限挂载内存盘 // 创建受限临时容器 CreateContainerResponse container = dockerClient.createContainerCmd("python:3.11-slim") .withHostConfig(hostConfig) .withUser("10001:10001") // 强制非 root 运行 .withCmd("python3", "-c", payload.getCode()) .withAttachStdout(true) .withAttachStderr(true) .exec(); containerId = container.getId(); // 启动容器 dockerClient.startContainerCmd(containerId).exec(); // 异步捕获标准输出和标准错误 StringBuilder stdoutBuilder = new StringBuilder(); StringBuilder stderrBuilder = new StringBuilder(); dockerClient.logContainerCmd(containerId) .withStdOut(true) .withStdErr(true) .withFollowStream(true) .exec(new ResultCallback.Adapter<Frame>() { @Override public void onNext(Frame item) { if (item.getStreamType() == StreamType.STDOUT) { stdoutBuilder.append(new String(item.getPayload(), StandardCharsets.UTF_8)); } else if (item.getStreamType() == StreamType.STDERR) { stderrBuilder.append(new String(item.getPayload(), StandardCharsets.UTF_8)); } } }); // 带超时的阻塞等待 WaitContainerResultCallback waitCallback = new WaitContainerResultCallback(); dockerClient.waitContainerCmd(containerId).exec(waitCallback); boolean finishedInTime = waitCallback.awaitCompletion(MAX_EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS); long costMs = System.currentTimeMillis() - startTime; if (!finishedInTime) { log.warn("Sandbox execution timed out for container: {}", containerId); return ExecutionResult.fail("执行超时:代码运行超过 " + MAX_EXECUTION_TIMEOUT_SECONDS + " 秒限制,已被安全强制终止。"); } InspectContainerResponse inspect = dockerClient.inspectContainerCmd(containerId).exec(); int exitCode = inspect.getState().getExitCodeLong() != null ? inspect.getState().getExitCodeLong().intValue() : -1; ExecutionResult result = new ExecutionResult(); result.setSuccess(exitCode == 0); result.setStdout(stdoutBuilder.toString().trim()); result.setStderr(stderrBuilder.toString().trim()); result.setExitCode(exitCode); result.setExecutionTimeMs(costMs); return result; } catch (SecurityException se) { return ExecutionResult.fail("安全策略拦截: " + se.getMessage()); } catch (Exception e) { log.error("Failed to execute code in sandbox", e); return ExecutionResult.fail("沙箱执行异常: " + e.getMessage()); } finally { if (containerId != null) { cleanupContainer(containerId); } } } private void preCheckCode(String code) { if (code == null || code.isBlank()) { throw new SecurityException("执行代码不能为空"); } if (code.length() > 4096) { throw new SecurityException("代码长度超出 4KB 限制"); } // 关键敏感模块静态黑名单(作为第一道简单防线) String[] forbiddenTokens = {"import os", "import subprocess", "import socket", "import pty", "import shutil", "__import__('os')", "open("}; for (String token : forbiddenTokens) { if (code.contains(token)) { throw new SecurityException("代码中包含禁止使用的系统级指令: " + token); } } } private void cleanupContainer(String containerId) { try { dockerClient.removeContainerCmd(containerId).withForce(true).withRemoveVolumes(true).exec(); } catch (Exception e) { log.error("Failed to force remove container: {}", containerId, e); } } }3. 集成 Spring AI Function Callback
定义 Spring AI 的工具配置类,暴露代码沙箱执行能力供模型自主规划调用:
package com.example.ai.sandbox.config; import com.example.ai.sandbox.model.CodeExecutionPayload; import com.example.ai.sandbox.model.ExecutionResult; import com.example.ai.sandbox.service.DockerSandboxExecutor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Description; import java.util.function.Function; @Configuration public class SandboxToolConfiguration { @Bean @Description("在安全的隔离沙箱中执行 Python 数据处理脚本并获取标准输出结果。入参必须是安全的数据计算逻辑。") public Function<CodeExecutionPayload, ExecutionResult> executeIsolatedCode(DockerSandboxExecutor executor) { return payload -> { if (!"python".equalsIgnoreCase(payload.getLanguage())) { return ExecutionResult.fail("当前沙箱环境仅支持 python 语言执行"); } return executor.executePython(payload); }; } }在控制器或智能体对话服务中直接注入 Spring AI ChatClient 进行调用:
package com.example.ai.sandbox.controller; import org.springframework.ai.chat.client.ChatClient; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/ai/analysis") public class DataAnalysisController { private final ChatClient chatClient; public DataAnalysisController(ChatClient.Builder chatClientBuilder) { this.chatClient = chatClientBuilder .defaultFunctions("executeIsolatedCode") .build(); } @PostMapping("/query") public Map<String, Object> analyzeData(@RequestBody Map<String, String> request) { String userPrompt = request.get("prompt"); String systemPrompt = """ 你是一个资深数据分析师。当用户提出数学计算、聚合统计或复杂数据处理任务时: 1. 编写纯 Python 脚本完成计算。 2. 仅使用基础算术与标准数据结构,严禁进行网络请求或系统调用。 3. 将最终分析结果通过 print() 函数打印出来。 4. 调用 executeIsolatedCode 工具获取运行结果并整理回答。 """; String response = chatClient.prompt() .system(systemPrompt) .user(userPrompt) .call() .content(); return Map.of("code", 200, "data", response); } }生产加固清单与防护要点
在实际生产部署时,仅靠以上代码还不够,还需要在基础设施层面做好防御纵深:
- 宿主机 Docker Daemon 隔离:不要直接将业务 Pod 挂载宿主机的
/var/run/docker.sock。推荐在集群中部署独立的 Docker-in-Docker (DinD) 或专用的 Sandbox 节点池,业务应用通过受限的 mTLS gRPC 与沙箱管理服务通信。 - 只读挂载与内存文件系统:通过
withReadonlyRootfs(true)锁死根目录,仅为临时脚本创建tmpfs内存卷。即便攻击者生成了文件写入逻辑,容器销毁后内存数据也会彻底蒸发。 - PIDs 与 CPU/内存严格限额:大模型偶尔会写出无限递归代码。通过
withPidsLimit(32L)彻底防御fork炸弹;通过withMemorySwap限制内存与 Swap 总量,杜绝内存耗尽导致的节点雪崩。 - 输出截断保护:大模型执行
print("A" * 100000000)会导致网卡和 JVM 内存瞬间被打满。在logContainerCmd收集标准输出时,必须硬性截断最大读取字符数(例如限制为 64KB)。
大模型带来的自动化生产力极其诱人,但在代码执行这一高风险环节上,架构师必须保持防御性编程思维。将代码生成与实际执行边界明确隔离,用成熟的操作系统内核机制为 LLM 划定牢不可破的运行边界,才是业务长期稳定演进的基础。