你的 Java 应用程序刚刚被攻破了。攻击者发送了一个精心构造的 JSON 载荷,你的反序列化代码"尽职尽责"地执行了它,现在他们正在下载你的客户数据库。这并非假设场景——它曾在 Equifax、Apache 以及无数其他公司真实发生过。
运行时安全与防火墙或身份验证无关。它关注的是不受信任的数据进入你的应用程序之后会发生什么。攻击者能否诱使你的代码执行你从未打算做的事情?答案通常是"可以",除非你刻意提高了攻击难度。
Java 为你提供了自卫的工具。大多数开发者忽略了它们,因为这些工具看起来偏执或过于复杂。然后生产环境就遭到了入侵,突然间那些"偏执"的措施就显得相当合理了。
为何运行时安全被忽视
你专注于功能。安全评审即使有,也往往在后期进行。代码在测试中能工作,于是就发布了。然后有人发现你的公共 API 未经验证就接受了用户输入,或者发现你正在反序列化不受信任的数据,或者意识到你的插件系统以完全权限运行第三方代码。
问题在于,大多数漏洞在你编写它们时看起来并不危险。一个简单的 ObjectInputStream.readObject() 调用看似无害,直到有人解释它如何实现远程代码执行。跳过输入验证节省了五分钟的开发时间,却在六个月后让你付出安全事件的代价。
安全不吸引人,它不会在演示中体现,而且在出事之前很难量化。但运行时安全问题是在生产系统中最常被利用的漏洞之一。让我们来谈谈三大要点:输入验证、沙箱机制和反序列化。
输入验证:万物皆不可信
每一个从外部进入你应用程序的数据都是潜在的攻击向量。用户输入、API 请求、文件上传、来自共享数据库的数据库记录、配置文件——所有这些都是。
规则很简单:在边界验证一切。不要等到业务逻辑中再验证。不要假设前端已经验证过了。在数据进入你的系统时进行验证。
糟糕的验证示例
以下是我在生产环境中经常看到的代码:
@PostMapping("/users")public ResponseEntity<User> createUser(@RequestBody UserRequest request) { User user = new User(); user.setEmail(request.getEmail()); user.setAge(request.getAge()); user.setRole(request.getRole()); userRepository.save(user); return ResponseEntity.ok(user);}
复制代码
看起来没问题,对吧?这是一场灾难。攻击者可以发送:
你的应用程序会欣然接受所有这一切,因为你信任了输入。
正确的输入验证
以下是正确的做法:
public class UserRequest { @NotNull(message = "Email is required") @Email(message = "Must be a valid email") @Size(max = 255, message = "Email too long") private String email; @NotNull(message = "Age is required") @Min(value = 0, message = "Age must be positive") @Max(value = 150, message = "Age unrealistic") private Integer age; @NotNull(message = "Role is required") @Pattern(regexp = "^(USER|MODERATOR)$", message = "Invalid role") private String role;} @PostMapping("/users")public ResponseEntity<User> createUser(@Valid @RequestBody UserRequest request) { // 如果验证失败,Spring 自动返回 400 Bad Request User user = new User(); user.setEmail(sanitizeEmail(request.getEmail())); user.setAge(request.getAge()); user.setRole(request.getRole()); userRepository.save(user); return ResponseEntity.ok(user);} private String sanitizeEmail(String email) { // 额外防护层:清除任何 HTML/脚本标签以防万一 return email.replaceAll("<[^>]*>", "");}
复制代码
注意这种分层方法。Bean 验证注解捕获明显的问题。然后即使在验证之后,你还要对输入进行清理。这种深度防御方法意味着即使一层失效,你仍然受到保护。
验证复杂对象
真实的应用程序处理的是嵌套对象、列表和复杂结构:
public class OrderRequest { @NotNull @Valid // 这很关键 - 验证嵌套对象 private Customer customer; @NotEmpty(message = "Order must contain items") @Size(max = 100, message = "Too many items") @Valid private List<OrderItem> items; @NotNull @DecimalMin(value = "0.01", message = "Total must be positive") private BigDecimal total;} public class OrderItem { @NotBlank @Size(max = 50) private String productId; @Min(1) @Max(999) private Integer quantity; @DecimalMin("0.01") private BigDecimal price;}
复制代码
嵌套对象上的 @Valid 注解很容易被忘记,但至关重要。没有它,嵌套对象会完全绕过验证。
用于业务规则的自定义验证器
有时 Bean 验证还不够。你需要业务逻辑:
@Target({ElementType.FIELD})@Retention(RetentionPolicy.RUNTIME)@Constraint(validatedBy = SafeFilenameValidator.class)public @interface SafeFilename { String message() default "Unsafe filename"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {};} public class SafeFilenameValidator implements ConstraintValidator<SafeFilename, String> { private static final Pattern DANGEROUS_PATTERNS = Pattern.compile( "(\\.\\./|\\.\\.\\\\|[<>:\"|?*]|^\\.|\\.$)" ); @Override public boolean isValid(String filename, ConstraintValidatorContext context) { if (filename == null) { return true; // 单独使用 @NotNull } // 防止路径遍历攻击 if (DANGEROUS_PATTERNS.matcher(filename).find()) { return false; } // 白名单方法:只允许安全字符 if (!filename.matches("^[a-zA-Z0-9_.-]+$")) { return false; } return true; }}
复制代码
现在你可以在任何文件上传参数上使用 @SafeFilename。这可以捕获攻击者试图上传到 ../../../etc/passwd 的路径遍历攻击。
白名单与黑名单的陷阱
在验证输入时,开发者常常试图阻止"坏"字符。这是黑名单方法,而且几乎总是错误的:
// 不好:黑名单方法public boolean isValidUsername(String username) { return !username.contains("<") && !username.contains(">") && !username.contains("'") && !username.contains("\"") && !username.contains("script"); // 你永远无法列出所有危险模式}
复制代码
攻击者很有创造力。他们会使用 Unicode 字符、URL 编码、双重编码以及你没想到的技巧来绕过你的黑名单。
相反,应该对你允许的内容使用白名单:
// 好:白名单方法public boolean isValidUsername(String username) { return username.matches("^[a-zA-Z0-9_-]{3,20}$"); // 只允许字母数字、下划线、连字符,3-20个字符}
复制代码
如果不在明确允许的范围内,就拒绝。这样安全得多。
沙箱机制:限制损害
输入验证阻止坏数据进入。沙箱机制则限制代码即使攻击成功也能做的事情。如果你的应用程序运行不受信任的代码——插件、用户脚本、动态类加载——沙箱机制至关重要。
Java 安全管理器(传统方法)
多年来,Java 使用安全管理器进行沙箱处理。它在 Java 17 中已被弃用并将被移除,但理解它有助于掌握概念:
// 旧方法(已弃用)System.setSecurityManager(new SecurityManager()); // 在策略文件中定义权限grant codeBase "file:/path/to/untrusted/*" { permission java.io.FilePermission "/tmp/*", "read,write"; permission java.net.SocketPermission "example.com:80", "connect"; // 权限非常有限};
复制代码
安全管理器可以限制代码能做什么:文件访问、网络访问、系统属性访问等。它功能强大但复杂,并且有性能开销。
现代沙箱方法
没有安全管理器,你需要替代策略。
在独立进程中隔离。 最可靠的沙箱是进程边界:
public class PluginExecutor { public String executePlugin(String pluginPath, String input) throws Exception { ProcessBuilder pb = new ProcessBuilder( "java", "-Xmx256m", // 限制内存 "-classpath", pluginPath, "com.example.PluginRunner", input ); // 限制进程能做的事情 pb.environment().clear(); // 无环境变量 pb.directory(new File("/tmp/sandbox")); // 受限目录 Process process = pb.start(); // 超时保护 if (!process.waitFor(10, TimeUnit.SECONDS)) { process.destroyForcibly(); throw new TimeoutException("Plugin execution timeout"); } return new String(process.getInputStream().readAllBytes()); }}
复制代码
插件在它自己的、资源受限的进程中运行。如果它崩溃或行为不端,你的主应用程序不会受到影响。你可以使用容器或虚拟机实现更强的隔离。
使用带有限制的自定义 ClassLoader:
public class SandboxedClassLoader extends ClassLoader { private final Set<String> allowedPackages; public SandboxedClassLoader(Set<String> allowedPackages) { super(SandboxedClassLoader.class.getClassLoader()); this.allowedPackages = allowedPackages; } @Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 阻止危险的类 if (name.startsWith("java.lang.Runtime") || name.startsWith("java.lang.ProcessBuilder") || name.startsWith("sun.misc.Unsafe")) { throw new ClassNotFoundException("Access denied: " + name); } // 仅白名单特定的包 boolean allowed = allowedPackages.stream() .anyMatch(name::startsWith); if (!allowed) { throw new ClassNotFoundException("Package not whitelisted: " + name); } return super.loadClass(name, resolve); }} // 用法Set<String> allowed = Set.of("com.example.safe.", "org.apache.commons.lang3.");ClassLoader sandboxed = new SandboxedClassLoader(allowed);Class<?> pluginClass = sandboxed.loadClass("com.example.safe.UserPlugin");
复制代码
这可以防止插件加载危险的类。它并非无懈可击——坚定的攻击者可能会找到基于反射的变通方法——但它显著提高了攻击门槛。
限制资源消耗:
public class ResourceLimitedExecutor { private final ExecutorService executor = Executors.newFixedThreadPool(4); public <T> T executeWithLimits(Callable<T> task, long timeoutSeconds, long maxMemoryMB) throws Exception { // 通过超时限制 CPU/时间 Future<T> future = executor.submit(task); try { return future.get(timeoutSeconds, TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); throw new RuntimeException("Task exceeded time limit"); } // 内存限制更难——最好在 JVM 级别使用 -Xmx 处理 // 或者使用如前所示的进程隔离 }}
复制代码
如果你强制执行超时,即使是不受信任的代码也无法消耗无限的 CPU。内存更棘手——进程隔离或容器限制比尝试在 JVM 内强制执行效果更好。
真实世界的沙箱示例
假设你正在构建一个运行用户提交的数据转换脚本的系统:
public class ScriptSandbox { private static final long MAX_EXECUTION_TIME_MS = 5000; private static final String SANDBOX_DIR = "/tmp/script-sandbox"; public String executeScript(String script, String data) { // 1. 验证脚本没有明显的恶意 if (containsDangerousPatterns(script)) { throw new SecurityException("Script contains forbidden patterns"); } // 2. 将脚本写入隔离目录 Path scriptPath = Paths.get(SANDBOX_DIR, UUID.randomUUID().toString() + ".js"); Files.writeString(scriptPath, script); try { // 3. 在具有资源限制的独立进程中执行 ProcessBuilder pb = new ProcessBuilder( "timeout", String.valueOf(MAX_EXECUTION_TIME_MS / 1000), "node", "--max-old-space-size=100", // 100MB 内存限制 scriptPath.toString() ); pb.directory(new File(SANDBOX_DIR)); pb.redirectErrorStream(true); Process process = pb.start(); // 4. 通过 stdin 传递数据,从 stdout 读取结果 try (OutputStream os = process.getOutputStream()) { os.write(data.getBytes()); } String result = new String(process.getInputStream().readAllBytes()); int exitCode = process.waitFor(); if (exitCode != 0) { throw new RuntimeException("Script failed with exit code: " + exitCode); } return result; } finally { // 5. 清理 Files.deleteIfExists(scriptPath); } } private boolean containsDangerousPatterns(String script) { // 检查明显的攻击 return script.contains("require('child_process')") || script.contains("eval(") || script.contains("Function(") || script.matches(".*\\brequire\\s*\\(.*"); }}
复制代码
这个例子结合了多种防御措施:静态分析、进程隔离、资源限制和清理。没有单一的防御是完美的,但层层设防使得利用难度大大增加。
安全反序列化:最大的隐患
Java 反序列化漏洞是历史上一些最严重安全漏洞的罪魁祸首。问题在于其根本性质:反序列化可以在对象构造期间执行任意代码。
为何反序列化是危险的
当你反序列化一个对象时,Java 会调用构造函数、readObject 方法和其他代码。控制序列化数据的攻击者可以精心构造对象来执行任意命令:
// 危险代码 - 请勿在生产环境中使用public void loadUserSettings(byte[] data) { try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) { UserSettings settings = (UserSettings) ois.readObject(); applySettings(settings); }}
复制代码
这看起来无害。但攻击者可以发送包含你类路径上(如 Apache Commons Collections)库中对象的序列化数据,这些对象在反序列化期间会执行系统命令。他们甚至根本不需要接触你的 UserSettings 类。
臭名昭著的"工具链"就是利用这一点。通过以特定方式链式组合标准库类,攻击者实现了远程代码执行。像 ysoserial 这样的工具可以自动创建这些载荷。
切勿反序列化不受信任的数据
最安全的方法很简单:不要对来自不受信任来源的数据使用 Java 序列化。绝不。
改用 JSON、Protocol Buffers 或其他仅包含数据的格式:
// 安全:使用 JSONpublic UserSettings loadUserSettings(String json) { ObjectMapper mapper = new ObjectMapper(); return mapper.readValue(json, UserSettings.class);}
复制代码
像 Jackson 这样的 JSON 解析器在解析期间不会执行任意代码。它们只是填充字段。攻击面急剧缩小。
当你必须反序列化时
有时你无法摆脱 Java 序列化——遗留协议、缓存库或分布式计算框架。如果你绝对必须反序列化不受信任的数据,请使用防御措施。
使用 ObjectInputFilter (Java 9+):
public Object safeDeserialize(byte[] data) throws Exception { try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) { // 白名单允许的类 ObjectInputFilter filter = ObjectInputFilter.Config.createFilter( "com.example.UserSettings;" + "com.example.UserPreference;" + "java.util.ArrayList;" + "java.lang.String;" + "!*" // 拒绝其他所有类 ); ois.setObjectInputFilter(filter); return ois.readObject(); }}
复制代码
该过滤器明确地将安全的类加入白名单,并拒绝其他所有类。这阻止了依赖于意外可用类的工具链。
验证对象图:
public class SafeObjectInputStream extends ObjectInputStream { private final Set<String> allowedClasses; private int maxDepth = 10; private int currentDepth = 0; public SafeObjectInputStream(InputStream in, Set<String> allowedClasses) throws IOException { super(in); this.allowedClasses = allowedClasses; } @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { // 检查深度以防止深度嵌套的对象 if (++currentDepth > maxDepth) { throw new InvalidClassException("Max depth exceeded"); } String className = desc.getName(); // 白名单检查 if (!allowedClasses.contains(className)) { throw new InvalidClassException("Class not allowed: " + className); } return super.resolveClass(desc); } @Override protected ObjectStreamClass readClassDescriptor() throws IOException, ClassNotFoundException { ObjectStreamClass desc = super.readClassDescriptor(); currentDepth--; return desc; }}
复制代码
这个自定义实现通过跟踪反序列化深度和执行严格的白名单来增加另一层防御。
对序列化数据进行签名:
public class SignedSerializer { private final SecretKey signingKey; public byte[] serialize(Object obj) throws Exception { // 序列化对象 ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { oos.writeObject(obj); } byte[] data = baos.toByteArray(); // 创建签名 Mac mac = Mac.getInstance("HmacSHA256"); mac.init(signingKey); byte[] signature = mac.doFinal(data); // 合并签名和数据 ByteBuffer buffer = ByteBuffer.allocate(signature.length + data.length); buffer.put(signature); buffer.put(data); return buffer.array(); } public Object deserialize(byte[] signedData) throws Exception { ByteBuffer buffer = ByteBuffer.wrap(signedData); // 提取签名和数据 byte[] signature = new byte[32]; // HmacSHA256 产生 32 字节 buffer.get(signature); byte[] data = new byte[buffer.remaining()]; buffer.get(data); // 验证签名 Mac mac = Mac.getInstance("HmacSHA256"); mac.init(signingKey); byte[] expectedSignature = mac.doFinal(data); if (!MessageDigest.isEqual(signature, expectedSignature)) { throw new SecurityException("Signature verification failed"); } // 签名有效则反序列化 try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) { return ois.readObject(); } }}
复制代码
签名可以防止攻击者篡改序列化数据。没有签名密钥,他们无法注入恶意对象。这在数据可能暴露但不受攻击者直接控制时(如客户端存储或缓存系统)有效。
替代序列化库
有几个库提供了更安全的序列化:
Kryo 提供更好的性能,并且可以配置为使用白名单:
Kryo kryo = new Kryo();kryo.setRegistrationRequired(true); // 拒绝未注册的类kryo.register(UserSettings.class);kryo.register(ArrayList.class); // 序列化Output output = new Output(new FileOutputStream("file.bin"));kryo.writeObject(output, userSettings);output.close(); // 反序列化 - 只允许注册的类Input input = new Input(new FileInputStream("file.bin"));UserSettings settings = kryo.readObject(input, UserSettings.class);input.close();
复制代码
Protocol Buffers 或 Apache Avro 使用基于模式的序列化。它们设置起来比较繁琐,但完全避免了代码执行风险:
message UserSettings { string theme = 1; int32 fontSize = 2; repeated string favorites = 3;}
复制代码
这些格式只反序列化数据,从不反序列化代码。通过 protobuf 反序列化实现代码执行是不可能的。
真实世界安全事件:一个警示故事
我曾咨询过的一家公司有一个管理门户,用于接受文件上传以进行批处理。代码看起来像这样:
@PostMapping("/admin/import")public String importData(@RequestParam("file") MultipartFile file) { try { byte[] data = file.getBytes(); ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data)); DataImport importData = (DataImport) ois.readObject(); processImport(importData); return "Import successful"; } catch (Exception e) { return "Import failed: " + e.getMessage(); }}
复制代码
开发人员认为这是安全的,因为该端点需要管理员身份验证。他们遗漏的是:
修复需要多次更改:
@PostMapping("/admin/import")public String importData(@RequestParam("file") MultipartFile file) { // 验证文件类型 if (!file.getContentType().equals("application/json")) { return "Only JSON imports allowed"; } // 验证文件大小 if (file.getSize() > 10 * 1024 * 1024) { // 10MB 限制 return "File too large"; } try { // 使用 JSON 代替 Java 序列化 ObjectMapper mapper = new ObjectMapper(); mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); DataImport importData = mapper.readValue( file.getInputStream(), DataImport.class ); // 验证导入的数据 validateImportData(importData); // 在受限上下文中处理 processImportSafely(importData); return "Import successful"; } catch (Exception e) { log.error("Import failed", e); return "Import failed - check logs"; }}
复制代码
这次事件使他们付出了事件响应、法律费用和声誉损失方面的数百万代价。全都是因为一个不安全的反序列化调用。
实用安全检查清单
以下是你在每个 Java 应用程序中都应该做的事情:
输入验证:
在所有 DTO 上使用 Bean 验证注解
使用 @Valid 验证嵌套对象
白名单允许的模式,不要黑名单危险模式
即使在验证之后也要清理数据
验证文件上传:类型、大小、内容
绝不只依赖客户端验证
沙箱机制:
在独立进程或容器中运行不受信任的代码
使用自定义 ClassLoader 来限制类访问
强制执行资源限制:内存、CPU 时间、磁盘空间
清理临时文件和资源
记录所有沙箱违规行为
反序列化:
通用实践:
保持依赖项更新(漏洞利用针对特定版本)
使用静态分析工具捕获安全问题
记录安全相关事件以进行监控
使用恶意输入进行测试,而不仅仅是正常路径
假设一切都可以被攻击
有用的工具
SpotBugs 与 FindSecBugs 插件可在构建时捕获常见安全问题:
<plugin> <groupId>com.github.spotbugs</groupId> <artifactId>spotbugs-maven-plugin</artifactId> <configuration> <plugins> <plugin> <groupId>com.h3xstream.findsecbugs</groupId> <artifactId>findsecbugs-plugin</artifactId> <version>1.12.0</version> </plugin> </plugins> </configuration></plugin>
复制代码
OWASP Dependency-Check 识别易受攻击的依赖项:
<plugin> <groupId>org.owasp</groupId> <artifactId>dependency-check-maven</artifactId> <executions> <execution> <goals> <goal>check</goal> </goals> </execution> </executions></plugin>
复制代码
Snyk 或 Dependabot 在漏洞披露时自动更新依赖项。
思维模式的转变
安全不是你最后添加的功能。它是你从一开始就为之设计的约束。每次你接受外部输入时,问问自己:"攻击者利用这个能做的最坏的事情是什么?" 每次你反序列化数据时,问问:"我是否完全信任这个数据的来源?"
在代码审查中偏执是一种美德。当某人的 PR 包含反序列化或动态类加载时,积极地提出质疑。当缺少输入验证时,把它打回去。在代码审查中显得迂腐,也比在漏洞发生后显得疏忽要好。
运行时安全是关于减少信任。不要信任用户输入。不要信任插件。不要信任序列化数据。不要信任你的验证是完美的。层层设防,这样当一层失效时——它会的——其他层可以捕获攻击。
好消息是,一旦你内化了这些模式,它们就会成为第二天性。输入验证变得自动进行。你会本能地避免 Java 序列化。你会带着隔离的思想进行设计。安全成为你编码风格的一部分,而不是事后附加的东西。
有用资源
OWASP Top 10: https://owasp.org/www-project-top-ten/
Java 反序列化安全: https://owasp.org/www-community/vulnerabilities/Deserialization_of_untrusted_data
Bean 验证文档: https://beanvalidation.org/
ObjectInputFilter 指南: https://docs.oracle.com/en/java/javase/17/core/serialization-filtering1.html
FindSecBugs: https://find-sec-bugs.github.io/
OWASP Dependency-Check: https://owasp.org/www-project-dependency-check/
ysoserial (反序列化载荷生成器): https://github.com/frohoff/ysoserial
Kryo 序列化: https://github.com/EsotericSoftware/kryo
Protocol Buffers: https://developers.google.com/protocol-buffers
【注】本文译自:Runtime Security in Java: Input Validation, Sandboxing, Safe Deserialization
评论