V4.0 安全扩展:29个新模块 + 60项API端点 + 164项新测试全部通过

新增模块(5组29个):

反作弊扩展(6个):
- AntiKillAuraService: 杀戮光环检测(攻击频率/目标切换/旋转角度)
- AntiReachService: 超距攻击检测(攻击距离/位置回溯验证)
- AntiSpeedService: 水平速度检测(移动速度/GroundSpoof异常)
- AntiJesusService: 水上行走检测(水面/岩浆面移动验证)
- AntiNoFallService: 无摔落伤害检测(落地检测/伤害验证)
- AntiScaffoldService: 自动搭路检测(方块放置频率/角度/速度)

玩家行为安全(6个):
- AntiNukerService: 快速破坏检测(挖矿速度/范围/模式识别)
- AntiAutoClickerService: 自动点击检测(CPS统计/点击间隔熵值分析)
- AntiChestStealService: 自动偷箱检测(开箱/取物速度模式识别)
- AntiAutoFishService: 自动钓鱼检测(钓鱼行为时序/反应速度)
- AntiInventoryManipulationService: 背包操作检测(非法丢弃/槽位异常)
- AntiBaritoneService: AI寻路机器人检测(路径平滑度/行为重复率)

服务器保护(6个):
- PacketFloodProtectionService: 数据包洪水防护(窗口/递增惩罚机制)
- AntiSignExploitService: 告示牌漏洞防护(超长JSON/NBT/命令注入)
- AntiBookBanService: 书与笔漏洞防护(超大页码/翻译键滥用/命令注入)
- AntiResourcePackExploitService: 资源包漏洞防护(内网SSRF/SHA-1黑名单)
- AntiTabCompleteCrashService: Tab补全崩溃防护(深度嵌套/洪水/无效UTF-8)
- AntiOfflineModeSpoofService: 离线UUID欺诈防护(格式校验/IP-UUID关联)

访问控制(6个):
- OPPrivilegeMonitorService: OP权限监控(权限变更/敏感命令审计)
- ConfigTamperDetectionService: 配置文件篡改检测(SHA-256基线监控)
- BackdoorPluginScannerService: 后门插件扫描(恶意类名/远程执行检测)
- AntiVPNProxyService: VPN代理检测(托管ASN/IP范围匹配)
- AntiAltAccountService: 小号检测(IP关联/行为相似度/封禁逃逸)
- AntiNameSpoofService: 名称冒充检测(Unicode同形字/空白字符/编辑距离)

聊天社交安全(5个):
- ChatFloodProtectionService: 聊天洪水防护(双窗口频率/相似度/递增静音)
- AntiAdvertisementService: 广告检测(IP/域名/QQ群/Discord邀请正则)
- AntiPhishingLinkService: 钓鱼链接检测(短链接/可疑域名/显示文本不匹配)
- AntiCommandAbuseService: 命令滥用检测(洪水/插件探测/Shell注入)
- PlayerPrivacyService: 玩家隐私保护(IP脱敏/GDPR合规/数据保留策略)

配置与API:
- ServerGuardConfig新增29个boolean开关(默认true),全部独立可控
- application.yml新增29行YAML配置(含中文注释说明功能)
- DashboardController整合29个新服务,新增60个REST API端点(总计108个端点)
- 所有模块使用双构造函数(无参+@Autowired)、静态结果类工厂方法

测试:新增5个测试类164项测试,全部通过(全项目278项/17测试类/159源文件)

修复:
- AntiBookBanService: helper方法改为返回int得分贡献,修复translate/score/clickEvent检测不生效
- AntiResourcePackExploitService: 修复Set.of()中.vbs重复导致ExceptionInInitializer
- AntiTabCompleteCrashService: 深度嵌套得分提高至75(触发blocked阈值)
- AntiOfflineModeSpoofService: 快速UUID切换得分提高至75(触发blocked阈值)
- PacketFloodProtectionService: 重构检测流程,移除提前返回导致的检测屏蔽
This commit is contained in:
Peijun Zhao
2026-05-16 11:47:59 +08:00
parent f82fb616cc
commit 1269d4dfa5
37 changed files with 13890 additions and 1 deletions
@@ -0,0 +1,350 @@
package com.aluer.security;
import com.aluer.config.ServerGuardConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 聊天洪水/垃圾消息防护服务 — V4.0 聊天社交安全模块
*
* 检测原理:
* 1. 频率追踪:3秒窗口内超过5条消息触发短时静音,10秒窗口内超过10条触发长静音
* 2. 相似度检测:连续3条消息编辑距离<3视为重复洪水
* 3. 长度激增检测:突然发送超长消息(>500字符)视为异常,正常聊天<200字符
* 4. Unicode洪水检测:消息中超过50%为特殊Unicode字符时拦截
* 5. 递增式静音策略:5秒→30秒→5分钟→永久,防止攻击者反复试探阈值
*
* 配置开关:serverguard.security.super-evolution.chat-flood-protection
*/
@Service
public class ChatFloodProtectionService {
private final ServerGuardConfig config;
private final Map<String, Deque<ChatEvent>> playerMessageHistory = new ConcurrentHashMap<>();
private final Map<String, Instant> mutedUntil = new ConcurrentHashMap<>();
private final Map<String, Integer> muteLevel = new ConcurrentHashMap<>();
private final AtomicLong totalMessages = new AtomicLong(0);
private final AtomicLong totalFloodEvents = new AtomicLong(0);
/** 递增静音时长(秒):5, 30, 300, 永久(-1) */
private static final long[] MUTE_ESCALATION_SECONDS = {5L, 30L, 300L, -1L};
/** 3秒窗口阈值 */
private static final int SHORT_WINDOW_THRESHOLD = 5;
/** 10秒窗口阈值 */
private static final int LONG_WINDOW_THRESHOLD = 10;
/** 3秒窗口大小(秒) */
private static final long SHORT_WINDOW_SECONDS = 3L;
/** 10秒窗口大小(秒) */
private static final long LONG_WINDOW_SECONDS = 10L;
/** 超长消息字符数阈值 */
private static final int LONG_MESSAGE_THRESHOLD = 500;
/** 正常聊天字符数上限 */
private static final int NORMAL_MESSAGE_MAX = 200;
/** 相似度检测的编辑距离阈值 */
private static final int SIMILARITY_EDIT_DISTANCE = 3;
/** Unicode洪水检测比例阈值 */
private static final double UNICODE_FLOOD_RATIO = 0.5;
/** 历史消息保留条数上限 */
private static final int MAX_HISTORY_PER_PLAYER = 50;
public ChatFloodProtectionService() {
this(new ServerGuardConfig());
}
@Autowired
public ChatFloodProtectionService(ServerGuardConfig config) {
this.config = config;
}
/**
* 检测玩家聊天消息是否存在洪水/垃圾行为
*
* 首先检查玩家是否处于静音期,然后从频率、相似度、长度、Unicode四个维度
* 逐层检测。每个维度独立判断,命中任一维度即返回blocked。
*
* @param player 玩家名
* @param message 待检测的聊天消息
* @return 检测结果,包含是否拦截及原因列表
*/
public CheckResult check(String player, String message) {
if (!config.getSecurity().getSuperEvolution().isChatFloodProtection()) {
return CheckResult.clean();
}
totalMessages.incrementAndGet();
// 0. 检查是否处于静音期
Instant muteEnd = mutedUntil.get(player);
if (muteEnd != null && Instant.now().isBefore(muteEnd)) {
totalFloodEvents.incrementAndGet();
return CheckResult.blocked(List.of("玩家处于静音期至 " + muteEnd));
}
// 获取或创建玩家消息历史
Deque<ChatEvent> history = playerMessageHistory.computeIfAbsent(player,
k -> new ArrayDeque<>());
Instant now = Instant.now();
ChatEvent currentEvent = new ChatEvent(now, message);
history.addLast(currentEvent);
// 限制历史记录长度,防止内存膨胀
while (history.size() > MAX_HISTORY_PER_PLAYER) {
history.removeFirst();
}
List<String> reasons = new ArrayList<>();
// 1. 短窗口频率检测(3秒窗口内>5条)
long shortCount = history.stream()
.filter(e -> e.time.isAfter(now.minusSeconds(SHORT_WINDOW_SECONDS)))
.count();
if (shortCount > SHORT_WINDOW_THRESHOLD) {
reasons.add("3秒内发送" + shortCount + "条消息(阈值" + SHORT_WINDOW_THRESHOLD + ")");
}
// 2. 长窗口频率检测(10秒窗口内>10条)
long longCount = history.stream()
.filter(e -> e.time.isAfter(now.minusSeconds(LONG_WINDOW_SECONDS)))
.count();
if (longCount > LONG_WINDOW_THRESHOLD) {
reasons.add("10秒内发送" + longCount + "条消息(阈值" + LONG_WINDOW_THRESHOLD + ")");
}
// 3. 消息相似度检测(连续3条编辑距离<3)
if (history.size() >= 3) {
List<ChatEvent> recent = new ArrayList<>(history);
int size = recent.size();
String msg1 = recent.get(size - 3).message;
String msg2 = recent.get(size - 2).message;
String msg3 = recent.get(size - 1).message;
if (editDistance(msg1, msg2) < SIMILARITY_EDIT_DISTANCE
&& editDistance(msg2, msg3) < SIMILARITY_EDIT_DISTANCE) {
reasons.add("连续3条消息高度相似(编辑距离<" + SIMILARITY_EDIT_DISTANCE + "),疑似重复洪水");
}
}
// 4. 消息长度激增检测(>500字符,正常聊天<200字符)
if (message.length() > LONG_MESSAGE_THRESHOLD) {
// 检查该玩家历史是否有正常长度的聊天记录
boolean hasNormalChat = history.stream()
.filter(e -> !e.message.equals(message))
.anyMatch(e -> e.message.length() < NORMAL_MESSAGE_MAX);
if (hasNormalChat) {
reasons.add("突然发送超长消息(" + message.length() + "字符),正常聊天<" + NORMAL_MESSAGE_MAX + "字符");
}
}
// 5. Unicode洪水检测(消息中>50%为特殊Unicode字符)
if (isUnicodeFlood(message)) {
reasons.add("消息中超过50%为特殊Unicode字符,疑似Unicode洪水攻击");
}
if (!reasons.isEmpty()) {
escalateMute(player);
totalFloodEvents.incrementAndGet();
return CheckResult.blocked(reasons);
}
return CheckResult.clean();
}
/**
* 递增式静音策略
*
* 每次洪水事件后递增静音级别,从5秒→30秒→5分钟→永久。
* 这样可以防止攻击者反复试探阈值,每次违规都会受到更严厉的惩罚。
*
* @param player 被静音的玩家名
*/
private void escalateMute(String player) {
int level = muteLevel.getOrDefault(player, 0);
long durationSeconds = MUTE_ESCALATION_SECONDS[Math.min(level, MUTE_ESCALATION_SECONDS.length - 1)];
if (durationSeconds == -1) {
// 永久静音:设为遥远的未来时间点
mutedUntil.put(player, Instant.MAX);
} else {
mutedUntil.put(player, Instant.now().plusSeconds(durationSeconds));
}
muteLevel.put(player, level + 1);
}
/**
* 计算两个字符串之间的编辑距离(Levenshtein距离)
*
* 使用动态规划算法计算将str1转换为str2所需的最少编辑操作次数。
* 编辑操作包括:插入、删除、替换一个字符。
* 空间复杂度O(min(m,n)),使用滚动数组优化。
*
* @param str1 字符串1
* @param str2 字符串2
* @return 编辑距离
*/
private int editDistance(String str1, String str2) {
// 确保str1是较短的字符串,节省空间
if (str1.length() > str2.length()) {
String temp = str1;
str1 = str2;
str2 = temp;
}
int m = str1.length();
int n = str2.length();
int[] prev = new int[m + 1];
int[] curr = new int[m + 1];
for (int i = 0; i <= m; i++) {
prev[i] = i;
}
for (int j = 1; j <= n; j++) {
curr[0] = j;
for (int i = 1; i <= m; i++) {
int cost = str1.charAt(i - 1) == str2.charAt(j - 1) ? 0 : 1;
curr[i] = Math.min(Math.min(
prev[i] + 1, // 删除
curr[i - 1] + 1), // 插入
prev[i - 1] + cost); // 替换
}
int[] temp = prev;
prev = curr;
curr = temp;
}
return prev[m];
}
/**
* 检测消息是否为Unicode洪水
*
* 统计消息中Unicode码点位于CJK统一表意文字、特殊符号、数学符号
* 等范围的字符占比。若超过50%且消息长度>10,视为Unicode洪水。
* 正常Minecraft聊天以ASCII字符为主。
*
* @param message 待检测的消息
* @return true表示疑似Unicode洪水
*/
private boolean isUnicodeFlood(String message) {
if (message.length() < 10) {
return false; // 短消息不检测,避免误判
}
int specialCount = 0;
for (int i = 0; i < message.length(); i++) {
int codePoint = message.codePointAt(i);
// 跳过代理对的高半部分,避免重复计数
if (Character.isSupplementaryCodePoint(codePoint)) {
i++;
}
// 检测范围:CJK统一表意文字(4E00-9FFF)、CJK扩展A(3400-4DBF)、
// 全角符号(FF00-FFEF)、数学符号(2200-22FF)、特殊符号(2600-26FF)、
// 装饰符号(2700-27BF)、零宽字符(200B-200F, 202A-202E, 2060)
if ((codePoint >= 0x4E00 && codePoint <= 0x9FFF)
|| (codePoint >= 0x3400 && codePoint <= 0x4DBF)
|| (codePoint >= 0xFF00 && codePoint <= 0xFFEF)
|| (codePoint >= 0x2200 && codePoint <= 0x22FF)
|| (codePoint >= 0x2600 && codePoint <= 0x26FF)
|| (codePoint >= 0x2700 && codePoint <= 0x27BF)
|| (codePoint >= 0x200B && codePoint <= 0x200F)
|| (codePoint >= 0x202A && codePoint <= 0x202E)
|| codePoint == 0x2060
|| codePoint == 0xFEFF) { // BOM/零宽不换行空格
specialCount++;
}
}
return (double) specialCount / message.length() > UNICODE_FLOOD_RATIO;
}
/**
* 获取服务运行状态
*
* 返回总处理消息数、当前静音玩家数、洪水事件数、启用的检测维度。
*
* @return 包含状态键值对的LinkedHashMap
*/
public Map<String, Object> getStatus() {
Map<String, Object> s = new LinkedHashMap<>();
s.put("enabled", config.getSecurity().getSuperEvolution().isChatFloodProtection());
s.put("totalMessages", totalMessages.get());
s.put("mutedPlayers", mutedUntil.size());
s.put("floodEvents", totalFloodEvents.get());
s.put("activeTrackers", playerMessageHistory.size());
s.put("shortWindowThreshold", SHORT_WINDOW_THRESHOLD);
s.put("longWindowThreshold", LONG_WINDOW_THRESHOLD);
s.put("unicodeFloodRatio", UNICODE_FLOOD_RATIO);
return s;
}
/**
* 解除指定玩家的静音状态
*
* @param player 玩家名
*/
public void unmutePlayer(String player) {
mutedUntil.remove(player);
muteLevel.remove(player);
}
/**
* 获取当前被静音的玩家列表及其剩余时间
*
* @return 玩家名->剩余秒数 的映射
*/
public Map<String, Long> getMutedPlayers() {
Map<String, Long> result = new LinkedHashMap<>();
Instant now = Instant.now();
for (Map.Entry<String, Instant> entry : mutedUntil.entrySet()) {
long remaining = entry.getValue().getEpochSecond() - now.getEpochSecond();
if (remaining > 0 || entry.getValue().equals(Instant.MAX)) {
result.put(entry.getKey(), entry.getValue().equals(Instant.MAX) ? -1L : remaining);
}
}
return result;
}
/**
* 聊天事件记录,存储时间戳和消息内容
*/
private static class ChatEvent {
final Instant time;
final String message;
ChatEvent(Instant time, String message) {
this.time = time;
this.message = message;
}
}
/**
* 检测结果类
*/
public static class CheckResult {
private final boolean blocked;
private final List<String> reasons;
private CheckResult(boolean blocked, List<String> reasons) {
this.blocked = blocked;
this.reasons = reasons;
}
public static CheckResult clean() {
return new CheckResult(false, List.of());
}
public static CheckResult blocked(List<String> reasons) {
return new CheckResult(true, reasons);
}
public boolean isBlocked() { return blocked; }
public boolean isClean() { return !blocked; }
public List<String> getReasons() { return reasons; }
}
}