安全沙箱需在findclass做预检、defineclass前用沙箱引擎校验字节码,选择性委派敏感包类加载,重写资源方法限制访问范围,并统一捕获异常防止策略泄露。

findClass 被调用时,类加载实际还没开始验证
很多人误以为重写 findClass 就能拦截并检查字节码——其实不然。findClass 只负责返回 byte[],后续的 defineClass 才真正触发校验(如签名、继承链、常量池合法性)。若只在 findClass 里做简单白名单过滤,攻击者仍可通过构造合法但危险的字节码绕过(比如反射调用 Runtime.exec 或读取 /etc/passwd)。
真正安全的做法是:在 findClass 中不直接返回原始字节,而是先交由沙箱策略引擎处理,再决定是否允许进入 defineClass 流程。常见实操建议如下:
- 把
findClass当作“字节码入口闸机”,只做路径/名称预检(如禁止加载java.lang.Runtime、sun.misc.Unsafe) - 对非核心类(如用户上传的
MyTask.class),先用ClassReader(来自 ASM)解析方法指令,扫描是否有INVOKESTATIC调用黑名单类的方法 - 避免在
findClass中执行耗时分析;可提前缓存已审核类的哈希值,命中即放行
ClassLoader 的双亲委派必须显式打破,但不能全盘关闭
在线沙箱要求用户代码与宿主环境完全隔离,所以必须让自定义 ClassLoader 不委托给 AppClassLoader 加载用户类。但直接重写 loadClass 并一律跳过 super.loadClass 是危险的——这会导致连 java.lang.Object 都找不到。
正确做法是选择性委派:只对 java.、javax.、sun.misc. 等敏感包保留父委派,其余全部由沙箱类加载器自己处理。示例逻辑如下:
protected Class> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (name.startsWith("java.") || name.startsWith("javax.") || name.startsWith("sun.misc.")) {
return super.loadClass(name, resolve); // 委托给系统类加载器
}
return findClass(name); // 全部走沙箱逻辑
}
注意:resolve 参数控制是否触发链接(包括验证),沙箱中通常设为 false,等所有类加载完毕再统一 resolve,便于集中做符号引用检查。
资源访问(如 openStream)必须和类加载器生命周期绑定
用户代码若通过 getClass().getResourceAsStream("config.json") 读取资源,其行为取决于当前类的 ClassLoader。如果沙箱类加载器没重写 getResourceAsStream,就会退化到父加载器,可能读到宿主应用的敏感配置文件。
因此,必须同步覆盖资源相关方法,并限制查找范围:
- 重写
getResourceAsStream,只允许从预设的沙箱资源目录(如/sandbox/resources/)或内存Map<string byte></string>中返回内容 - 禁用
getResource返回URL(防止通过URL.openStream()绕过检查) - 对
getResource返回的URL做协议检查,拒绝file://、jar:file://等本地路径
特别注意:JDK 9+ 的模块系统下,getResourceAsStream 还会受 Module::getResourceAsStream 影响,沙箱类加载器需确保其加载的类所属模块未导出敏感包。
defineClass 失败后,错误信息不能泄露堆栈或类名细节
当沙箱拒绝加载某个类时,defineClass 抛出的 ClassFormatError 或 NoClassDefFoundError 默认包含原始类名和失败原因(如 “Illegal field name”)。攻击者可利用这些信息反推沙箱策略边界。
推荐做法是在 findClass 到 defineClass 的链路末端统一捕获异常,并抛出泛化异常:
try {
return defineClass(name, bytecode, 0, bytecode.length);
} catch (ClassFormatError | UnsupportedClassVersionError e) {
throw new SecurityException("Class rejected: " + name); // 不带原异常 cause
}
更严格的做法是:所有异常都包装为 SecurityException,且不记录原始类名到日志(或仅记录哈希摘要),防止侧信道泄露。
沙箱最难的不是拦截,而是让拦截看起来像“自然失败”——类名不暴露、资源路径不回显、错误堆栈无上下文。这点稍不注意,整个隔离就形同虚设。











