Java 運行時安全:輸入驗證、沙箱機制、安全反序列化
你的 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);
}
看起來沒問題,對吧?這是一場災難。攻擊者可以發送:
- 郵箱:
"admin@evil.com<script>alert('xss')</script>" - 年齡:
-1或999999 - 角色:
"ADMIN"(提升自己的權限)
你的應用程序會欣然接受所有這一切,因為你信任了輸入。
正確的輸入驗證
以下是正確的做法:
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 或其他僅包含數據的格式:
// 安全:使用 JSON
public 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();
}
}
開發人員認為這是安全的,因為該端點需要管理員身份驗證。他們遺漏的是:
- 攻擊者通過釣魚攻擊攻陷了一個低級別管理員賬戶
- 攻擊者使用 ysoserial 上傳了一個惡意的序列化載荷
- 在反序列化期間,載荷執行了系統命令
- 攻擊者獲得了應用程序服務器的 shell 訪問權限
- 從那里,他們橫向移動到數據庫并竊取了客戶數據
修復需要多次更改:
@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 時間、磁盤空間
- 清理臨時文件和資源
- 記錄所有沙箱違規行為
反序列化:
- 優先使用 JSON/Protocol Buffers 而非 Java 序列化
- 沒有過濾器的情況下切勿反序列化不受信任的數據
- 使用 ObjectInputFilter 將類加入白名單
- 可能時對序列化數據進行簽名
- 定期審計類路徑依賴項以查找已知的工具類
- 考慮使用需要注冊模式的 Kryo
通用實踐:
- 保持依賴項更新(漏洞利用針對特定版本)
- 使用靜態分析工具捕獲安全問題
- 記錄安全相關事件以進行監控
- 使用惡意輸入進行測試,而不僅僅是正常路徑
- 假設一切都可以被攻擊
有用的工具
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

浙公網安備 33010602011771號