【架构实战】安全架构设计:让攻击者无从下手

一、我们的系统被薅羊毛了

2019年的双十一,我们电商平台搞了个新用户首单1元购活动。结果,活动刚上线半小时,奖品就全被薅完了------不是被真实用户薅的,是被黑产用脚本薅的。

事后复盘,我们发现:

  • 同一个IP地址注册了几百个账号
  • 同一个设备指纹关联了几十个账号
  • 这些账号的行为非常规律:注册→领券→下单→注销

这一次,我们损失了将近10万的营销费用,而真实用户几乎没有享受到优惠。

从那以后,我开始认真研究安全架构。


二、Web安全防护:守住入口

2.1 XSS攻击与防御

XSS(跨站脚本攻击)是最常见的Web安全漏洞之一。攻击者在页面中注入恶意脚本,窃取用户Cookie或进行钓鱼。

java 复制代码
/**
 * XSS防护过滤器
 */
@Component
@WebFilter(urlPatterns = "/*")
public class XssFilter implements Filter {
    
    @Autowired
    private XssEscape xssEscape;
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                         FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        
        // 特殊路径跳过XSS过滤(如富文本编辑)
        String uri = httpRequest.getRequestURI();
        if (shouldSkip(uri)) {
            chain.doFilter(request, response);
            return;
        }
        
        // 包装请求,对参数进行XSS过滤
        chain.doFilter(new XssHttpServletRequestWrapper(httpRequest, xssEscape), response);
    }
}

/**
 * XSS过滤实现:使用HTML过滤器库
 */
@Component
public class XssEscape {
    
    private final PolicyFactory policy;
    
    public XssEscape() {
        // 配置只允许部分HTML标签
        this.policy = new PolicyFactory(
            TagWhiteList.from(TagName.DIV, TagName.P, TagName.BR, TagName.SPAN,
                             TagName.A, TagName.IMG, TagName.UL, TagName.LI)
                .allowAttributes("href").on(TagName.A)
                .allowAttributes("src").on(TagName.IMG)
                .allowAttributes("class").globally()
        );
    }
    
    public String escape(String input) {
        if (StringUtils.isEmpty(input)) {
            return input;
        }
        // HTML转义
        return HtmlUtils.htmlEscape(input, StandardCharsets.UTF_8.name());
    }
    
    public String escapeRichText(String input) {
        if (StringUtils.isEmpty(input)) {
            return input;
        }
        // 富文本使用更宽松的策略,但仍然防护XSS
        return policy.sanitize(input).toString();
    }
}

2.2 CSRF攻击与防御

CSRF(跨站请求伪造)利用用户已登录的身份,伪造请求执行操作。

java 复制代码
/**
 * CSRF Token验证
 */
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .ignoringAntMatchers("/api/public/**")  // 公开接口禁用CSRF
            )
            .addFilterAfter(new CsrfTokenFilter(), CsrfFilter.class);
    }
}

/**
 * CSRF Token校验过滤器
 */
public class CsrfTokenFilter extends OncePerRequestFilter {
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response,
                                   FilterChain filterChain) 
            throws ServletException, IOException {
        
        String method = request.getMethod();
        // GET/HEAD/OPTIONS不需要验证CSRF
        if ("GET".equals(method) || "HEAD".equals(method) || "OPTIONS".equals(method)) {
            filterChain.doFilter(request, response);
            return;
        }
        
        // 其他方法由Spring Security自动验证CSRF Token
        filterChain.doFilter(request, response);
    }
}

2.3 SQL注入防御

java 复制代码
/**
 * SQL注入防护:使用预编译SQL
 */
@Repository
public class UserDao {
    
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    /**
     * 正确做法:使用参数化查询
     */
    public User findByUsername(String username) {
        String sql = "SELECT * FROM users WHERE username = ?";
        return jdbcTemplate.queryForObject(sql, 
            new Object[]{username},  // 参数通过占位符传入
            new UserRowMapper());
    }
    
    /**
     * 错误做法:字符串拼接SQL(不要这样做!)
     */
    public User findByUsernameBad(String username) {
        String sql = "SELECT * FROM users WHERE username = '" + username + "'";
        // 攻击者输入: admin' OR '1'='1
        // 实际执行: SELECT * FROM users WHERE username = 'admin' OR '1'='1'
        return jdbcTemplate.queryForObject(sql, new UserRowMapper());
    }
}

/**
 * MyBatis使用#{}而不是${}
 */
@Mapper
public interface UserMapper {
    
    // 正确:使用#{},参数会被预编译
    @Select("SELECT * FROM users WHERE username = #{username}")
    User findByUsername(@Param("username") String username);
    
    // 错误:使用${},存在SQL注入风险
    @Select("SELECT * FROM users WHERE username = '${username}'")
    User findByUsernameBad(@Param("username") String username);
}

三、防爬虫策略

3.1 频率限制

java 复制代码
/**
 * 基于滑动窗口的请求频率限制
 */
@Component
public class RateLimitFilter {
    
    private final RedisTemplate<String, String> redisTemplate;
    private final RateLimitConfig config;
    
    public RateLimitFilter(RedisTemplate<String, String> redisTemplate, 
                          RateLimitConfig config) {
        this.redisTemplate = redisTemplate;
        this.config = config;
    }
    
    /**
     * 滑动窗口限流算法
     */
    public boolean isAllowed(String key, int maxRequests, int windowSeconds) {
        String redisKey = "rate_limit:" + key;
        long now = System.currentTimeMillis();
        long windowStart = now - windowSeconds * 1000L;
        
        // 使用Redis事务保证原子性
        redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                // 删除窗口外的请求记录
                operations.opsForZSet().removeRangeByScore(redisKey, 0, windowStart);
                
                // 统计当前窗口内请求数
                Long count = operations.opsForZSet().zCard(redisKey);
                
                if (count != null && count >= maxRequests) {
                    // 超过限制,拒绝请求
                    return false;
                }
                
                // 记录本次请求
                operations.opsForZSet().add(redisKey, String.valueOf(now), now);
                
                // 设置过期时间
                operations.expire(redisKey, Duration.ofSeconds(windowSeconds * 2L));
                
                return true;
            }
        });
        
        return true;
    }
    
    public Mono<Boolean> isAllowedReactive(String key, int maxRequests, int windowSeconds) {
        String redisKey = "rate_limit:" + key;
        long now = System.currentTimeMillis();
        long windowStart = now - windowSeconds * 1000L;
        
        return redisReactiveTemplate.execute(
            RedisReactiveCommands::multi
        ).flatMapMany(result -> {
            // 滑动窗口逻辑...
            return Flux.just(true);
        });
    }
}

3.2 设备指纹

javascript 复制代码
/**
 * 前端设备指纹采集
 */
function getDeviceFingerprint() {
    const components = [];
    
    // User Agent
    components.push(navigator.userAgent);
    
    // 屏幕分辨率
    components.push(`${screen.width}x${screen.height}x${screen.colorDepth}`);
    
    // 时区
    components.push(Intl.DateTimeFormat().resolvedOptions().timeZone);
    
    // 语言
    components.push(navigator.language);
    
    // Canvas指纹
    try {
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');
        ctx.textBaseline = 'top';
        ctx.font = "14px 'Arial'";
        ctx.fillStyle = '#f60';
        ctx.fillRect(125, 1, 62, 20);
        ctx.fillStyle = '#069';
        ctx.fillText('DeviceFP', 2, 15);
        ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
        ctx.fillText('DeviceFP', 4, 17);
        components.push(canvas.toDataURL());
    } catch (e) {
        components.push('canvas_not_available');
    }
    
    // WebGL指纹
    try {
        const canvas = document.createElement('canvas');
        const gl = canvas.getContext('webgl');
        if (gl) {
            const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
            if (debugInfo) {
                components.push(gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL));
                components.push(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL));
            }
        }
    } catch (e) {
        components.push('webgl_not_available');
    }
    
    // 计算哈希
    const fingerprint = components.join('|');
    return btoa(String.fromCharCode.apply(null, 
        new Uint8Array(
            crypto.subtle.digestSync('SHA-256', new TextEncoder().encode(fingerprint))
        )
    ));
}

四、踩坑实录

坑1:登录接口被暴力破解但没告警

有一天,我发现数据库里有大量异常的登录失败记录。仔细一看,我们的登录接口被暴力破解了------有人用字典攻击一直在试密码。

但问题是,我们竟然没有任何告警!

原因分析

  • 我们的登录失败记录只存在MySQL里,没人去看
  • 没有针对IP或账号的登录失败次数进行监控

解决

  1. 登录失败超过5次,临时封禁IP 15分钟
  2. 登录失败超过10次,账号锁定,需要管理员解锁
  3. 实时告警:同一IP 1分钟内登录失败超过20次,立即告警

坑2:存储密码用了MD5导致用户数据泄露

2018年,我们的一个服务被拖库了。虽然后来发现是第三方SDK的漏洞,但更严重的问题是------我们的密码用的是MD5。

MD5早就被证明是不安全的,而且没有盐值。攻击者用彩虹表几分钟就能破解大部分密码。

血的教训:密码绝对不能只用MD5/SHA1这样的哈希算法。

解决

  1. 使用BCrypt或Argon2等专用密码哈希算法
  2. 每个密码都有随机盐值
  3. 强制要求密码复杂度(8位以上,包含大小写字母、数字、特殊字符)
java 复制代码
/**
 * 密码加密工具类
 */
@Component
public class PasswordEncoder {
    
    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
    
    /**
     * 加密密码(自动加盐)
     */
    public String encode(String rawPassword) {
        return encoder.encode(rawPassword);
    }
    
    /**
     * 验证密码
     */
    public boolean matches(String rawPassword, String encodedPassword) {
        return encoder.matches(rawPassword, encodedPassword);
    }
}

坑3:API未鉴权暴露

有一次,我们开发了一个内部数据分析接口,因为是内部使用,就没有加鉴权。结果,这个接口被搜索引擎收录了,数据完全暴露。

更可怕的是,后来发现这个接口的访问日志里有大量的陌生IP------有人在批量爬我们的数据。

解决

  1. 所有API都必须有鉴权,哪怕是内部API
  2. 使用JWT或Session进行身份验证
  3. 对敏感接口进行IP白名单限制
  4. 定期检查接口访问日志

五、风控策略

5.1 设备指纹+行为分析识别黑产

java 复制代码
/**
 * 实时风控决策引擎
 */
@Service
public class RiskControlEngine {
    
    @Autowired
    private RiskRuleRepository ruleRepository;
    
    @Autowired
    private DeviceFingerprintService fingerprintService;
    
    /**
     * 风控决策
     */
    public RiskDecision evaluate(Long userId, String eventType, Map<String, Object> context) {
        List<RiskRule> rules = ruleRepository.findByEventTypeAndEnabled(eventType, true);
        
        int riskScore = 0;
        List<String> hitRules = new ArrayList<>();
        List<String> actions = new ArrayList<>();
        
        for (RiskRule rule : rules) {
            if (rule.evaluate(userId, context)) {
                riskScore += rule.getScore();
                hitRules.add(rule.getName());
                actions.addAll(rule.getActions());
            }
        }
        
        // 根据风险分决定处理方式
        RiskLevel level = calculateLevel(riskScore);
        
        return RiskDecision.builder()
            .level(level)
            .score(riskScore)
            .hitRules(hitRules)
            .actions(actions)
            .build();
    }
    
    private RiskLevel calculateLevel(int score) {
        if (score >= 80) return RiskLevel.HIGH;
        if (score >= 50) return RiskLevel.MEDIUM;
        return RiskLevel.LOW;
    }
}

/**
 * 风控规则示例
 */
@Component
public class AntiBrushRules {
    
    @Bean
    public RiskRule sameDeviceMultiAccountRule() {
        return RiskRule.builder()
            .name("同设备多账号")
            .eventType("USER_REGISTER")
            .score(60)
            .enabled(true)
            .condition(ctx -> {
                String deviceId = ctx.getDeviceFingerprint();
                int accountCount = deviceFingerprintService.countAccountsByDevice(deviceId);
                return accountCount > 3;
            })
            .actions(Arrays.asList("REQUIRE_SMS_VERIFY", "MANUAL_REVIEW"))
            .build();
    }
    
    @Bean
    public RiskRule rapidOperationRule() {
        return RiskRule.builder()
            .name("高频操作")
            .eventType("*")  // 所有事件类型
            .score(40)
            .enabled(true)
            .condition(ctx -> {
                String userId = ctx.getUserId().toString();
                String action = ctx.getEventType();
                String key = "action_count:" + userId + ":" + action;
                Long count = redisTemplate.opsForValue().increment(key);
                if (count == 1) {
                    redisTemplate.expire(key, Duration.ofSeconds(60));
                }
                return count != null && count > 100;  // 1分钟内超过100次
            })
            .actions(Collections.singletonList("BLOCK_TEMPORARILY"))
            .build();
    }
}

六、业务场景:某电商平台遭遇薅羊毛攻击的全过程及防护方案

攻击发现

某天晚上,运营同学发现某个优惠券的领取量异常:1分钟内被领取了几千张,但转化率几乎为零。

攻击分析

我们立即展开了分析:

  1. 设备指纹分析:发现大部分领取来自相同的设备指纹
  2. IP地址分析:发现大量请求来自少量IP段
  3. 行为分析:用户行为非常规律,没有正常的浏览轨迹
  4. 账号分析:发现大量账号是同一天注册,且注册时间间隔只有几秒

应急处理

  1. 立即下线有问题的优惠券
  2. 封禁可疑账号和IP
  3. 冻结已发放的优惠券
  4. 人工审核异常订单

长期方案

  1. 设备指纹:采集设备指纹,识别同一设备上的多账号
  2. 行为验证:对关键操作加入行为验证(滑块、点选等)
  3. 风控引擎:建立实时风控系统,对异常行为进行拦截
  4. 情报共享:接入第三方风控情报服务

七、总结与思考

安全架构的关键要点:

  1. 纵深防御:不要依赖单一安全措施,多层防护
  2. 默认安全:安全应该是默认配置,不是可选功能
  3. 最小权限:只授予必要的权限,不过度授权
  4. 日志审计:所有操作都要有日志,方便溯源
  5. 定期渗透测试:请专业团队定期做渗透测试
  6. 安全培训:提高全员安全意识

血的教训:

不要觉得"这只是内部接口"就不做安全。攻击者永远比你想象的更执着。

给你的思考题:

  • 你们系统有哪些接口可能存在安全漏洞?
  • 如果发现系统在遭受攻击,你能在几分钟内止血?

个人观点,仅供参考