一、我们的系统被薅羊毛了
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或账号的登录失败次数进行监控
解决:
- 登录失败超过5次,临时封禁IP 15分钟
- 登录失败超过10次,账号锁定,需要管理员解锁
- 实时告警:同一IP 1分钟内登录失败超过20次,立即告警
坑2:存储密码用了MD5导致用户数据泄露
2018年,我们的一个服务被拖库了。虽然后来发现是第三方SDK的漏洞,但更严重的问题是------我们的密码用的是MD5。
MD5早就被证明是不安全的,而且没有盐值。攻击者用彩虹表几分钟就能破解大部分密码。
血的教训:密码绝对不能只用MD5/SHA1这样的哈希算法。
解决:
- 使用BCrypt或Argon2等专用密码哈希算法
- 每个密码都有随机盐值
- 强制要求密码复杂度(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------有人在批量爬我们的数据。
解决:
- 所有API都必须有鉴权,哪怕是内部API
- 使用JWT或Session进行身份验证
- 对敏感接口进行IP白名单限制
- 定期检查接口访问日志
五、风控策略
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分钟内被领取了几千张,但转化率几乎为零。
攻击分析
我们立即展开了分析:
- 设备指纹分析:发现大部分领取来自相同的设备指纹
- IP地址分析:发现大量请求来自少量IP段
- 行为分析:用户行为非常规律,没有正常的浏览轨迹
- 账号分析:发现大量账号是同一天注册,且注册时间间隔只有几秒
应急处理
- 立即下线有问题的优惠券
- 封禁可疑账号和IP
- 冻结已发放的优惠券
- 人工审核异常订单
长期方案
- 设备指纹:采集设备指纹,识别同一设备上的多账号
- 行为验证:对关键操作加入行为验证(滑块、点选等)
- 风控引擎:建立实时风控系统,对异常行为进行拦截
- 情报共享:接入第三方风控情报服务
七、总结与思考
安全架构的关键要点:
- 纵深防御:不要依赖单一安全措施,多层防护
- 默认安全:安全应该是默认配置,不是可选功能
- 最小权限:只授予必要的权限,不过度授权
- 日志审计:所有操作都要有日志,方便溯源
- 定期渗透测试:请专业团队定期做渗透测试
- 安全培训:提高全员安全意识
血的教训:
不要觉得"这只是内部接口"就不做安全。攻击者永远比你想象的更执着。
给你的思考题:
- 你们系统有哪些接口可能存在安全漏洞?
- 如果发现系统在遭受攻击,你能在几分钟内止血?
个人观点,仅供参考