From 1269d4dfa5de4d9a54deece2ffd4b5f3aafe08a1 Mon Sep 17 00:00:00 2001 From: Peijun Zhao Date: Sat, 16 May 2026 11:47:59 +0800 Subject: [PATCH] =?UTF-8?q?V4.0=20=E5=AE=89=E5=85=A8=E6=89=A9=E5=B1=95?= =?UTF-8?q?=EF=BC=9A29=E4=B8=AA=E6=96=B0=E6=A8=A1=E5=9D=97=20+=2060?= =?UTF-8?q?=E9=A1=B9API=E7=AB=AF=E7=82=B9=20+=20164=E9=A1=B9=E6=96=B0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=85=A8=E9=83=A8=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增模块(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: 重构检测流程,移除提前返回导致的检测屏蔽 --- .../com/aluer/config/ServerGuardConfig.java | 107 +++ .../security/AntiAdvertisementService.java | 313 +++++++ .../aluer/security/AntiAltAccountService.java | 327 +++++++ .../security/AntiAutoClickerService.java | 416 +++++++++ .../aluer/security/AntiAutoFishService.java | 480 ++++++++++ .../aluer/security/AntiBaritoneService.java | 865 ++++++++++++++++++ .../aluer/security/AntiBookBanService.java | 377 ++++++++ .../aluer/security/AntiChestStealService.java | 392 ++++++++ .../security/AntiCommandAbuseService.java | 278 ++++++ .../AntiInventoryManipulationService.java | 415 +++++++++ .../com/aluer/security/AntiJesusService.java | 297 ++++++ .../aluer/security/AntiKillAuraService.java | 284 ++++++ .../aluer/security/AntiNameSpoofService.java | 458 ++++++++++ .../com/aluer/security/AntiNoFallService.java | 364 ++++++++ .../com/aluer/security/AntiNukerService.java | 362 ++++++++ .../security/AntiOfflineModeSpoofService.java | 358 ++++++++ .../security/AntiPhishingLinkService.java | 347 +++++++ .../com/aluer/security/AntiReachService.java | 276 ++++++ .../AntiResourcePackExploitService.java | 357 ++++++++ .../aluer/security/AntiScaffoldService.java | 351 +++++++ .../security/AntiSignExploitService.java | 355 +++++++ .../com/aluer/security/AntiSpeedService.java | 328 +++++++ .../security/AntiTabCompleteCrashService.java | 402 ++++++++ .../aluer/security/AntiVPNProxyService.java | 412 +++++++++ .../BackdoorPluginScannerService.java | 427 +++++++++ .../security/ChatFloodProtectionService.java | 350 +++++++ .../ConfigTamperDetectionService.java | 441 +++++++++ .../security/OPPrivilegeMonitorService.java | 344 +++++++ .../PacketFloodProtectionService.java | 298 ++++++ .../aluer/security/PlayerPrivacyService.java | 388 ++++++++ .../com/aluer/web/DashboardController.java | 249 ++++- src/main/resources/application.yml | 34 + .../aluer/security/V40AccessControlTest.java | 413 +++++++++ .../com/aluer/security/V40AntiCheatTest.java | 728 +++++++++++++++ .../aluer/security/V40ChatSecurityTest.java | 361 ++++++++ .../aluer/security/V40PlayerBehaviorTest.java | 533 +++++++++++ .../security/V40ServerProtectionTest.java | 404 ++++++++ 37 files changed, 13890 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/aluer/security/AntiAdvertisementService.java create mode 100644 src/main/java/com/aluer/security/AntiAltAccountService.java create mode 100644 src/main/java/com/aluer/security/AntiAutoClickerService.java create mode 100644 src/main/java/com/aluer/security/AntiAutoFishService.java create mode 100644 src/main/java/com/aluer/security/AntiBaritoneService.java create mode 100644 src/main/java/com/aluer/security/AntiBookBanService.java create mode 100644 src/main/java/com/aluer/security/AntiChestStealService.java create mode 100644 src/main/java/com/aluer/security/AntiCommandAbuseService.java create mode 100644 src/main/java/com/aluer/security/AntiInventoryManipulationService.java create mode 100644 src/main/java/com/aluer/security/AntiJesusService.java create mode 100644 src/main/java/com/aluer/security/AntiKillAuraService.java create mode 100644 src/main/java/com/aluer/security/AntiNameSpoofService.java create mode 100644 src/main/java/com/aluer/security/AntiNoFallService.java create mode 100644 src/main/java/com/aluer/security/AntiNukerService.java create mode 100644 src/main/java/com/aluer/security/AntiOfflineModeSpoofService.java create mode 100644 src/main/java/com/aluer/security/AntiPhishingLinkService.java create mode 100644 src/main/java/com/aluer/security/AntiReachService.java create mode 100644 src/main/java/com/aluer/security/AntiResourcePackExploitService.java create mode 100644 src/main/java/com/aluer/security/AntiScaffoldService.java create mode 100644 src/main/java/com/aluer/security/AntiSignExploitService.java create mode 100644 src/main/java/com/aluer/security/AntiSpeedService.java create mode 100644 src/main/java/com/aluer/security/AntiTabCompleteCrashService.java create mode 100644 src/main/java/com/aluer/security/AntiVPNProxyService.java create mode 100644 src/main/java/com/aluer/security/BackdoorPluginScannerService.java create mode 100644 src/main/java/com/aluer/security/ChatFloodProtectionService.java create mode 100644 src/main/java/com/aluer/security/ConfigTamperDetectionService.java create mode 100644 src/main/java/com/aluer/security/OPPrivilegeMonitorService.java create mode 100644 src/main/java/com/aluer/security/PacketFloodProtectionService.java create mode 100644 src/main/java/com/aluer/security/PlayerPrivacyService.java create mode 100644 src/test/java/com/aluer/security/V40AccessControlTest.java create mode 100644 src/test/java/com/aluer/security/V40AntiCheatTest.java create mode 100644 src/test/java/com/aluer/security/V40ChatSecurityTest.java create mode 100644 src/test/java/com/aluer/security/V40PlayerBehaviorTest.java create mode 100644 src/test/java/com/aluer/security/V40ServerProtectionTest.java diff --git a/src/main/java/com/aluer/config/ServerGuardConfig.java b/src/main/java/com/aluer/config/ServerGuardConfig.java index 763692b..9e87b00 100644 --- a/src/main/java/com/aluer/config/ServerGuardConfig.java +++ b/src/main/java/com/aluer/config/ServerGuardConfig.java @@ -901,6 +901,45 @@ public class ServerGuardConfig { private boolean backupIntegrity = true; private boolean antiSkinSpoof = true; + // === V4.0 反作弊扩展模块 === + private boolean antiKillAura = true; + private boolean antiReach = true; + private boolean antiSpeed = true; + private boolean antiJesus = true; + private boolean antiNoFall = true; + private boolean antiScaffold = true; + + // === V4.0 玩家行为安全模块 === + private boolean antiNuker = true; + private boolean antiAutoClicker = true; + private boolean antiChestSteal = true; + private boolean antiAutoFish = true; + private boolean antiInventoryManipulation = true; + private boolean antiBaritone = true; + + // === V4.0 服务器保护模块 === + private boolean packetFloodProtection = true; + private boolean antiSignExploit = true; + private boolean antiBookBan = true; + private boolean antiResourcePackExploit = true; + private boolean antiTabCompleteCrash = true; + private boolean antiOfflineModeSpoof = true; + + // === V4.0 访问控制模块 === + private boolean opPrivilegeMonitor = true; + private boolean configTamperDetection = true; + private boolean backdoorPluginScanner = true; + private boolean antiVpnProxy = true; + private boolean antiAltAccount = true; + private boolean antiNameSpoof = true; + + // === V4.0 聊天社交安全模块 === + private boolean chatFloodProtection = true; + private boolean antiAdvertisement = true; + private boolean antiPhishingLink = true; + private boolean antiCommandAbuse = true; + private boolean playerPrivacy = true; + public boolean isJwtAuth() { return jwtAuth; } public void setJwtAuth(boolean jwtAuth) { this.jwtAuth = jwtAuth; } public boolean isBruteForce() { return bruteForce; } @@ -963,6 +1002,74 @@ public class ServerGuardConfig { public void setBackupIntegrity(boolean backupIntegrity) { this.backupIntegrity = backupIntegrity; } public boolean isAntiSkinSpoof() { return antiSkinSpoof; } public void setAntiSkinSpoof(boolean antiSkinSpoof) { this.antiSkinSpoof = antiSkinSpoof; } + + // === V4.0 反作弊扩展模块 getters/setters === + public boolean isAntiKillAura() { return antiKillAura; } + public void setAntiKillAura(boolean antiKillAura) { this.antiKillAura = antiKillAura; } + public boolean isAntiReach() { return antiReach; } + public void setAntiReach(boolean antiReach) { this.antiReach = antiReach; } + public boolean isAntiSpeed() { return antiSpeed; } + public void setAntiSpeed(boolean antiSpeed) { this.antiSpeed = antiSpeed; } + public boolean isAntiJesus() { return antiJesus; } + public void setAntiJesus(boolean antiJesus) { this.antiJesus = antiJesus; } + public boolean isAntiNoFall() { return antiNoFall; } + public void setAntiNoFall(boolean antiNoFall) { this.antiNoFall = antiNoFall; } + public boolean isAntiScaffold() { return antiScaffold; } + public void setAntiScaffold(boolean antiScaffold) { this.antiScaffold = antiScaffold; } + + // === V4.0 玩家行为安全模块 getters/setters === + public boolean isAntiNuker() { return antiNuker; } + public void setAntiNuker(boolean antiNuker) { this.antiNuker = antiNuker; } + public boolean isAntiAutoClicker() { return antiAutoClicker; } + public void setAntiAutoClicker(boolean antiAutoClicker) { this.antiAutoClicker = antiAutoClicker; } + public boolean isAntiChestSteal() { return antiChestSteal; } + public void setAntiChestSteal(boolean antiChestSteal) { this.antiChestSteal = antiChestSteal; } + public boolean isAntiAutoFish() { return antiAutoFish; } + public void setAntiAutoFish(boolean antiAutoFish) { this.antiAutoFish = antiAutoFish; } + public boolean isAntiInventoryManipulation() { return antiInventoryManipulation; } + public void setAntiInventoryManipulation(boolean antiInventoryManipulation) { this.antiInventoryManipulation = antiInventoryManipulation; } + public boolean isAntiBaritone() { return antiBaritone; } + public void setAntiBaritone(boolean antiBaritone) { this.antiBaritone = antiBaritone; } + + // === V4.0 服务器保护模块 getters/setters === + public boolean isPacketFloodProtection() { return packetFloodProtection; } + public void setPacketFloodProtection(boolean packetFloodProtection) { this.packetFloodProtection = packetFloodProtection; } + public boolean isAntiSignExploit() { return antiSignExploit; } + public void setAntiSignExploit(boolean antiSignExploit) { this.antiSignExploit = antiSignExploit; } + public boolean isAntiBookBan() { return antiBookBan; } + public void setAntiBookBan(boolean antiBookBan) { this.antiBookBan = antiBookBan; } + public boolean isAntiResourcePackExploit() { return antiResourcePackExploit; } + public void setAntiResourcePackExploit(boolean antiResourcePackExploit) { this.antiResourcePackExploit = antiResourcePackExploit; } + public boolean isAntiTabCompleteCrash() { return antiTabCompleteCrash; } + public void setAntiTabCompleteCrash(boolean antiTabCompleteCrash) { this.antiTabCompleteCrash = antiTabCompleteCrash; } + public boolean isAntiOfflineModeSpoof() { return antiOfflineModeSpoof; } + public void setAntiOfflineModeSpoof(boolean antiOfflineModeSpoof) { this.antiOfflineModeSpoof = antiOfflineModeSpoof; } + + // === V4.0 访问控制模块 getters/setters === + public boolean isOpPrivilegeMonitor() { return opPrivilegeMonitor; } + public void setOpPrivilegeMonitor(boolean opPrivilegeMonitor) { this.opPrivilegeMonitor = opPrivilegeMonitor; } + public boolean isConfigTamperDetection() { return configTamperDetection; } + public void setConfigTamperDetection(boolean configTamperDetection) { this.configTamperDetection = configTamperDetection; } + public boolean isBackdoorPluginScanner() { return backdoorPluginScanner; } + public void setBackdoorPluginScanner(boolean backdoorPluginScanner) { this.backdoorPluginScanner = backdoorPluginScanner; } + public boolean isAntiVpnProxy() { return antiVpnProxy; } + public void setAntiVpnProxy(boolean antiVpnProxy) { this.antiVpnProxy = antiVpnProxy; } + public boolean isAntiAltAccount() { return antiAltAccount; } + public void setAntiAltAccount(boolean antiAltAccount) { this.antiAltAccount = antiAltAccount; } + public boolean isAntiNameSpoof() { return antiNameSpoof; } + public void setAntiNameSpoof(boolean antiNameSpoof) { this.antiNameSpoof = antiNameSpoof; } + + // === V4.0 聊天社交安全模块 getters/setters === + public boolean isChatFloodProtection() { return chatFloodProtection; } + public void setChatFloodProtection(boolean chatFloodProtection) { this.chatFloodProtection = chatFloodProtection; } + public boolean isAntiAdvertisement() { return antiAdvertisement; } + public void setAntiAdvertisement(boolean antiAdvertisement) { this.antiAdvertisement = antiAdvertisement; } + public boolean isAntiPhishingLink() { return antiPhishingLink; } + public void setAntiPhishingLink(boolean antiPhishingLink) { this.antiPhishingLink = antiPhishingLink; } + public boolean isAntiCommandAbuse() { return antiCommandAbuse; } + public void setAntiCommandAbuse(boolean antiCommandAbuse) { this.antiCommandAbuse = antiCommandAbuse; } + public boolean isPlayerPrivacy() { return playerPrivacy; } + public void setPlayerPrivacy(boolean playerPrivacy) { this.playerPrivacy = playerPrivacy; } } public static class AnnouncementConfig { diff --git a/src/main/java/com/aluer/security/AntiAdvertisementService.java b/src/main/java/com/aluer/security/AntiAdvertisementService.java new file mode 100644 index 0000000..9a630c6 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiAdvertisementService.java @@ -0,0 +1,313 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 服务器广告/宣传检测服务 — V4.0 聊天社交安全模块 + * + * 检测原理: + * 1. IP地址匹配:正则检测包括带端口的IPv4地址 + * 2. 域名匹配:匹配常见顶级域名的服务器宣传域名 + * 3. QQ群号检测:6-12位数字配合"群"关键字 + * 4. Discord邀请检测:discord.gg/xxx 或 discord.com/invite/xxx + * 5. 绕过技术检测:识别数字间加空格/特殊字符的变体 + * 6. 白名单模式:允许服务器自己的域名/IP通过 + * + * 配置开关:serverguard.security.super-evolution.anti-advertisement + */ +@Service +public class AntiAdvertisementService { + + private final ServerGuardConfig config; + private final AtomicLong totalMessages = new AtomicLong(0); + private final AtomicLong adsDetected = new AtomicLong(0); + private final AtomicLong blockedMessages = new AtomicLong(0); + private final Map playerBlockCount = new ConcurrentHashMap<>(); + + /** 自身服务器白名单IP/域名 */ + private final Set whitelistIps = ConcurrentHashMap.newKeySet(); + private final Set whitelistDomains = ConcurrentHashMap.newKeySet(); + + /** IPv4地址正则:匹配含可选端口的IP地址 */ + private static final Pattern IP_PATTERN = Pattern.compile( + "\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"); + + /** 域名匹配正则:匹配常见顶级域名的域名 */ + private static final Pattern DOMAIN_PATTERN = Pattern.compile( + "[\\w-]+\\.(com|cn|net|org|cc|xyz|top|tk|ml|ga|cf|fun|online|store|shop)" + + "(:\\d{1,5})?", Pattern.CASE_INSENSITIVE); + + /** QQ群号正则:6-12位数字,需配合"群"关键字使用 */ + private static final Pattern QQ_GROUP_PATTERN = Pattern.compile( + "\\b\\d{6,12}\\b"); + + /** Discord邀请链接正则 */ + private static final Pattern DISCORD_PATTERN = Pattern.compile( + "discord\\.gg/\\w+|discord\\.com/invite/\\w+", Pattern.CASE_INSENSITIVE); + + /** 绕过技术正则:数字间插入空格/特殊字符的变体 */ + private static final Pattern OBFUSCATED_IP_PATTERN = Pattern.compile( + "(\\d{1,3})\\s*[\\.\\s,,、·•]+\\s*(\\d{1,3})\\s*[\\.\\s,,、·•]+\\s*(\\d{1,3})\\s*[\\.\\s,,、·•]+\\s*(\\d{1,3})"); + + /** 群聊关键字 */ + private static final String[] GROUP_KEYWORDS = { + "群", "group", "q群", "qq群", "qqun", "扣扣群", "峮", "裙" + }; + + /** 广告关键字 */ + private static final String[] AD_KEYWORDS = { + "服务器", "server", "直连", "联机", "开黑", "加入", "join", "ip地址", + "端口", "port", "基岩", "java版", "mc版本", "minecraft" + }; + + public AntiAdvertisementService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiAdvertisementService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 检测聊天消息是否包含服务器广告/宣传内容 + * + * 按以下顺序逐项检测:IP地址、域名、QQ群号、Discord邀请、绕过变体。 + * 命中任一检测项即返回blocked。检测前会与白名单进行比对, + * 白名单内的IP/域名不会被拦截,防止误拦截自身服务器信息。 + * + * @param player 玩家名 + * @param message 待检测的聊天消息 + * @return 检测结果 + */ + public CheckResult check(String player, String message) { + if (!config.getSecurity().getSuperEvolution().isAntiAdvertisement()) { + return CheckResult.clean(); + } + + totalMessages.incrementAndGet(); + + if (message == null || message.isEmpty()) { + return CheckResult.clean(); + } + + String normalized = normalizeMessage(message); + List reasons = new ArrayList<>(); + + // 1. IP地址检测 + Matcher ipMatcher = IP_PATTERN.matcher(message); + while (ipMatcher.find()) { + String found = ipMatcher.group(); + String ipOnly = found.contains(":") ? found.substring(0, found.indexOf(':')) : found; + if (!isWhitelistedIp(ipOnly)) { + reasons.add("检测到非白名单IP地址: " + found); + break; + } + } + + // 2. 域名检测 + Matcher domainMatcher = DOMAIN_PATTERN.matcher(message); + while (domainMatcher.find()) { + String found = domainMatcher.group().toLowerCase(); + if (!isWhitelistedDomain(found)) { + reasons.add("检测到非白名单域名: " + found); + break; + } + } + + // 3. QQ群号检测(数字+群关键字) + Matcher qqMatcher = QQ_GROUP_PATTERN.matcher(normalized); + boolean hasGroupKeyword = containsGroupKeyword(normalized); + if (hasGroupKeyword && qqMatcher.find()) { + reasons.add("检测到QQ群号宣传: " + qqMatcher.group()); + } + + // 4. Discord邀请检测 + Matcher discordMatcher = DISCORD_PATTERN.matcher(normalized); + if (discordMatcher.find()) { + reasons.add("检测到Discord邀请链接: " + discordMatcher.group()); + } + + // 5. 绕过技术检测:数字间加空格/特殊字符的变体 + Matcher obfIpMatcher = OBFUSCATED_IP_PATTERN.matcher(message); + if (obfIpMatcher.find()) { + // 重建完整IP字符串验证是否为有效IP + String reconstructed = obfIpMatcher.group(1) + "." + + obfIpMatcher.group(2) + "." + + obfIpMatcher.group(3) + "." + + obfIpMatcher.group(4); + if (!isWhitelistedIp(reconstructed)) { + reasons.add("检测到IP地址绕过变体: " + obfIpMatcher.group()); + } + } + + if (!reasons.isEmpty()) { + adsDetected.incrementAndGet(); + blockedMessages.incrementAndGet(); + playerBlockCount.merge(player, 1L, Long::sum); + return CheckResult.blocked(reasons); + } + + return CheckResult.clean(); + } + + /** + * 标准化消息文本,用于关键字匹配 + * + * 将消息转为小写并移除多余空格,便于后续关键字匹配。 + * + * @param message 原始消息 + * @return 标准化后的消息 + */ + private String normalizeMessage(String message) { + return message.toLowerCase().replaceAll("\\s+", " ").trim(); + } + + /** + * 检查IP是否在白名单中 + * + * @param ip IP地址字符串 + * @return true表示在白名单中 + */ + private boolean isWhitelistedIp(String ip) { + return whitelistIps.contains(ip); + } + + /** + * 检查域名是否在白名单中 + * + * @param domain 域名字符串 + * @return true表示在白名单中 + */ + private boolean isWhitelistedDomain(String domain) { + for (String wd : whitelistDomains) { + if (domain.equalsIgnoreCase(wd) || domain.endsWith("." + wd)) { + return true; + } + } + return false; + } + + /** + * 检查消息是否包含群聊关键字 + * + * @param message 标准化后的消息 + * @return true表示包含 + */ + private boolean containsGroupKeyword(String message) { + for (String keyword : GROUP_KEYWORDS) { + if (message.contains(keyword)) { + return true; + } + } + return false; + } + + /** + * 添加白名单IP + * + * @param ip IP地址 + */ + public void addWhitelistedIp(String ip) { + if (ip != null && !ip.isEmpty()) { + whitelistIps.add(ip.trim()); + } + } + + /** + * 移除白名单IP + * + * @param ip IP地址 + */ + public void removeWhitelistedIp(String ip) { + whitelistIps.remove(ip); + } + + /** + * 添加白名单域名 + * + * @param domain 域名 + */ + public void addWhitelistedDomain(String domain) { + if (domain != null && !domain.isEmpty()) { + whitelistDomains.add(domain.trim().toLowerCase()); + } + } + + /** + * 移除白名单域名 + * + * @param domain 域名 + */ + public void removeWhitelistedDomain(String domain) { + whitelistDomains.remove(domain); + } + + public Set getWhitelistIps() { + return new HashSet<>(whitelistIps); + } + + public Set getWhitelistDomains() { + return new HashSet<>(whitelistDomains); + } + + /** + * 获取玩家被拦截次数 + * + * @param player 玩家名 + * @return 被拦截次数 + */ + public long getPlayerBlockCount(String player) { + return playerBlockCount.getOrDefault(player, 0L); + } + + /** + * 获取服务运行状态 + * + * @return 包含状态键值对的LinkedHashMap + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiAdvertisement()); + s.put("totalMessages", totalMessages.get()); + s.put("adsDetected", adsDetected.get()); + s.put("blockedMessages", blockedMessages.get()); + s.put("whitelistIps", new ArrayList<>(whitelistIps)); + s.put("whitelistDomains", new ArrayList<>(whitelistDomains)); + s.put("trackedPlayers", playerBlockCount.size()); + return s; + } + + /** + * 检测结果类 + */ + public static class CheckResult { + private final boolean blocked; + private final List reasons; + + private CheckResult(boolean blocked, List reasons) { + this.blocked = blocked; + this.reasons = reasons; + } + + public static CheckResult clean() { + return new CheckResult(false, List.of()); + } + + public static CheckResult blocked(List reasons) { + return new CheckResult(true, reasons); + } + + public boolean isBlocked() { return blocked; } + public boolean isClean() { return !blocked; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiAltAccountService.java b/src/main/java/com/aluer/security/AntiAltAccountService.java new file mode 100644 index 0000000..b1ab8b7 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiAltAccountService.java @@ -0,0 +1,327 @@ +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.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 小号/替代账号检测 — V4.0 访问控制模块 + * + * 检测原理: + * 1. 追踪IP与账号的关联关系——同一IP在24小时内登录的不同账号 + * 2. 通过IP关联分析识别小号簇(alt groups):如果同一IP在较短时间内登录多个不同账号, + * 很可能这些账号属于同一玩家(小号阵列) + * 3. 行为相似度分析:对比两个账号在登录时间段、在线时长模式上的相似度 + * 4. 快速切换检测:同一IP在5分钟内切换账号3次以上,说明可能是同一玩家使用多个账号 + * 5. 封禁逃逸检测:检测被封禁玩家在封禁后不久(30分钟内),其使用过的IP是否有新账号登录 + * 6. 追踪已知捣乱者IP,维护其关联的全部账号列表,便于封禁时连带处理 + * 7. 维护封禁IP黑名单池,记录最近被封禁的IP及其封禁时间 + * + * 配置开关:serverguard.security.super-evolution.anti-alt-account + */ +@Service +public class AntiAltAccountService { + + private final ServerGuardConfig config; + + /** IP -> 该IP登录过的账号列表(含登录时间) */ + private final Map> ipToAccounts = new ConcurrentHashMap<>(); + /** 账号名 -> 该账号登录过的IP列表 */ + private final Map> accountToIPs = new ConcurrentHashMap<>(); + /** 被封禁的IP -> 封禁时间 */ + private final Map bannedIPs = new ConcurrentHashMap<>(); + /** 已知捣乱者IP -> 关联的全部账号 */ + private final Map> troublemakerIPToAccounts = new ConcurrentHashMap<>(); + /** 已识别的小号组(alt group ID -> 账号列表) */ + private final Map> altGroups = new ConcurrentHashMap<>(); + + private final AtomicLong totalAccounts = new AtomicLong(0); + private final AtomicLong totalLoginEvents = new AtomicLong(0); + private final AtomicLong banEvasionAttempts = new AtomicLong(0); + private final AtomicLong rapidSwitchDetections = new AtomicLong(0); + + /** 快速切换检测窗口(毫秒,5分钟) */ + private static final long RAPID_SWITCH_WINDOW_MS = 300_000; + /** 快速切换账号数阈值 */ + private static final int RAPID_SWITCH_THRESHOLD = 3; + /** IP关联分析窗口(毫秒,24小时) */ + private static final long IP_ASSOCIATION_WINDOW_MS = 86_400_000; + /** 封禁逃逸检测窗口(毫秒,30分钟) */ + private static final long BAN_EVASION_WINDOW_MS = 1_800_000; + + /** 无参构造函数,使用默认配置 */ + public AntiAltAccountService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiAltAccountService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 记录玩家登录事件,检测是否为小号/替代账号 + * + * @param playerName 玩家名 + * @param uuid 玩家UUID + * @param ip 登录IP + * @return 检测结果,包含是否检测到小号行为及原因 + */ + public AltAccountResult onPlayerLogin(String playerName, String uuid, String ip) { + totalLoginEvents.incrementAndGet(); + + // 配置开关检查 + if (!config.getSecurity().getSuperEvolution().isAntiAltAccount()) { + return AltAccountResult.clean(); + } + + if (playerName == null || ip == null) { + return AltAccountResult.clean(); + } + + List reasons = new ArrayList<>(); + boolean altDetected = false; + Instant now = Instant.now(); + + // 记录账号->IP映射 + accountToIPs.computeIfAbsent(playerName, k -> Collections.synchronizedList(new ArrayList<>())).add(ip); + + // 记录IP->账号登录事件 + AccountLogin login = new AccountLogin(playerName, uuid, ip, now); + ipToAccounts.computeIfAbsent(ip, k -> Collections.synchronizedList(new ArrayList<>())).add(login); + + // 清理24小时前的旧登录记录(保持数据新鲜) + cleanupOldLogins(ip, now); + + // 检查1:封禁逃逸检测 —— 该IP是否在最近被封禁 + Instant banTime = bannedIPs.get(ip); + if (banTime != null) { + long sinceBan = ChronoUnit.MILLIS.between(banTime, now); + if (sinceBan < BAN_EVASION_WINDOW_MS) { + altDetected = true; + banEvasionAttempts.incrementAndGet(); + reasons.add("BAN_EVASION: Player \"" + playerName + "\" logged in from IP " + ip + + " which was banned " + (sinceBan / 1000) + " seconds ago — potential ban evasion"); + } + } + + // 检查2:快速切换检测 —— 同一IP在5分钟内切换账号超过阈值 + List sameIPLogins = ipToAccounts.get(ip); + if (sameIPLogins != null) { + List recentLogins = sameIPLogins.stream() + .filter(l -> ChronoUnit.MILLIS.between(l.loginTime, now) < RAPID_SWITCH_WINDOW_MS) + .toList(); + Set recentAccounts = new HashSet<>(); + for (AccountLogin l : recentLogins) { + recentAccounts.add(l.playerName); + } + if (recentAccounts.size() >= RAPID_SWITCH_THRESHOLD) { + altDetected = true; + rapidSwitchDetections.incrementAndGet(); + reasons.add("RAPID_ACCOUNT_SWITCH: IP " + ip + " used by " + recentAccounts.size() + + " different accounts within 5 minutes: " + recentAccounts + + " — probable alt accounts"); + + // 自动创建小号组 + String groupId = "ALT-" + ip.replace(".", "-") + "-" + now.toEpochMilli(); + altGroups.put(groupId, new HashSet<>(recentAccounts)); + } + } + + // 检查3:IP关联账号检测 —— 同一IP在24小时内登录过多个不同账号 + if (!altDetected && sameIPLogins != null && sameIPLogins.size() >= 2) { + Set distinctAccounts = new HashSet<>(); + for (AccountLogin l : sameIPLogins) { + distinctAccounts.add(l.playerName); + } + if (distinctAccounts.size() >= 2) { + altDetected = true; + reasons.add("IP_ASSOCIATED_ALT: IP " + ip + " has " + distinctAccounts.size() + + " associated accounts in 24h: " + distinctAccounts + + " — likely alt accounts from same player"); + } + } + + // 检查4:已知捣乱者IP关联 —— 如果该IP之前被标记为捣乱者 + if (troublemakerIPToAccounts.containsKey(ip)) { + Set existingAccounts = troublemakerIPToAccounts.get(ip); + if (!existingAccounts.contains(playerName)) { + altDetected = true; + reasons.add("TROUBLEMAKER_IP: IP " + ip + " is associated with known troublemaker, " + + "new account \"" + playerName + "\" is likely an alt to evade punishment. " + + "Existing associated accounts: " + existingAccounts); + } + existingAccounts.add(playerName); + } + + // 追踪独立账号数 + Set allAccounts = new HashSet<>(); + accountToIPs.forEach((name, ips) -> allAccounts.add(name)); + totalAccounts.set(allAccounts.size()); + + if (altDetected) { + return new AltAccountResult(true, reasons); + } + return AltAccountResult.clean(); + } + + /** + * 记录一个被封禁IP,用于后续封禁逃逸检测 + * + * @param ip 被封禁的IP + * @param reason 封禁原因 + */ + public void recordBannedIP(String ip, String reason) { + if (ip != null && !ip.isBlank()) { + bannedIPs.put(ip, Instant.now()); + + // 如果是捣乱者IP,记录其关联的所有账号 + List logins = ipToAccounts.get(ip); + if (logins != null && !logins.isEmpty()) { + Set associatedAccounts = new HashSet<>(); + for (AccountLogin l : logins) { + associatedAccounts.add(l.playerName); + } + troublemakerIPToAccounts.computeIfAbsent(ip, k -> ConcurrentHashMap.newKeySet()) + .addAll(associatedAccounts); + } + } + } + + /** + * 将某玩家标记为已知捣乱者,将其所有关联IP加入追踪 + * + * @param playerName 玩家名 + */ + public void markAsTroublemaker(String playerName) { + if (playerName == null) return; + + List ips = accountToIPs.get(playerName); + if (ips != null) { + for (String ip : ips) { + Set accounts = troublemakerIPToAccounts.computeIfAbsent( + ip, k -> ConcurrentHashMap.newKeySet()); + // 将该IP关联的所有账号加入 + List logins = ipToAccounts.get(ip); + if (logins != null) { + for (AccountLogin l : logins) { + accounts.add(l.playerName); + } + } + } + } + } + + /** + * 查询某IP关联的所有账号 + * + * @param ip IP地址 + * @return 该IP关联的账号集合 + */ + public Set getAccountsForIP(String ip) { + List logins = ipToAccounts.get(ip); + if (logins == null) return Set.of(); + + Instant cutoff = Instant.now().minusMillis(IP_ASSOCIATION_WINDOW_MS); + Set accounts = new HashSet<>(); + for (AccountLogin l : logins) { + if (l.loginTime.isAfter(cutoff)) { + accounts.add(l.playerName); + } + } + return accounts; + } + + /** + * 查询某账号使用过的所有IP + * + * @param playerName 玩家名 + * @return 该账号使用过的IP列表 + */ + public List getIPsForAccount(String playerName) { + return new ArrayList<>(accountToIPs.getOrDefault(playerName, List.of())); + } + + /** + * 获取模块运行状态 + * + * @return 状态Map,包含totalAccounts/altGroups/banEvasionAttempts + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiAltAccount()); + s.put("totalAccounts", totalAccounts.get()); + s.put("totalLoginEvents", totalLoginEvents.get()); + s.put("altGroups", altGroups.size()); + s.put("banEvasionAttempts", banEvasionAttempts.get()); + s.put("rapidSwitchDetections", rapidSwitchDetections.get()); + s.put("trackedIPs", ipToAccounts.size()); + s.put("bannedIPTracking", bannedIPs.size()); + s.put("troublemakerIPs", troublemakerIPToAccounts.size()); + + // 小号组详情列表 + List> groupDetails = new ArrayList<>(); + altGroups.forEach((groupId, accounts) -> { + Map g = new LinkedHashMap<>(); + g.put("groupId", groupId); + g.put("accounts", new ArrayList<>(accounts)); + g.put("accountCount", accounts.size()); + groupDetails.add(g); + }); + s.put("altGroupDetails", groupDetails); + return s; + } + + // ==================== 内部辅助方法 ==================== + + /** 清理指定IP的过期登录记录 */ + private void cleanupOldLogins(String ip, Instant now) { + List logins = ipToAccounts.get(ip); + if (logins != null) { + Instant cutoff = now.minusMillis(IP_ASSOCIATION_WINDOW_MS); + logins.removeIf(l -> l.loginTime.isBefore(cutoff)); + } + } + + // ==================== 内部数据类 ==================== + + /** 账号登录事件记录 */ + private static class AccountLogin { + final String playerName; + final String uuid; + final String ip; + final Instant loginTime; + + AccountLogin(String playerName, String uuid, String ip, Instant loginTime) { + this.playerName = playerName; + this.uuid = uuid; + this.ip = ip; + this.loginTime = loginTime; + } + } + + /** 小号/替代账号检测结果 */ + public static class AltAccountResult { + private final boolean altDetected; + private final List reasons; + + AltAccountResult(boolean altDetected, List reasons) { + this.altDetected = altDetected; + this.reasons = reasons; + } + + public static AltAccountResult clean() { + return new AltAccountResult(false, List.of()); + } + + public boolean isAltDetected() { return altDetected; } + public boolean isClean() { return !altDetected; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiAutoClickerService.java b/src/main/java/com/aluer/security/AntiAutoClickerService.java new file mode 100644 index 0000000..4464ea1 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiAutoClickerService.java @@ -0,0 +1,416 @@ +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.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 自动点击检测 (Auto Clicker/Macro) — V4.0 玩家行为安全模块 + * + * 检测原理: + * Auto Clicker(自动点击器/宏)通过模拟鼠标点击来获得非法的PvP优势或挂机优势。 + * 本模块通过多种统计分析方法来区分人类操作和自动化操作: + * 1. CPS 频率检测——正常人类 CPS(每秒点击次数)上限约 12-15,持续超过 15 CPS 判定异常。 + * 2. 香农熵值分析——人类点击间隔存在自然的随机抖动(熵值高 > 3.5), + * 宏/连点器生成的点击间隔极其均匀(熵值低 < 2.0),这是核心区分手段。 + * 3. 持续高 CPS 检测——短时间爆发高 CPS 可能是紧张操作,但连续超过 10 秒维持 > 15 CPS + * 则是绝对宏行为。 + * 4. 点击模式周期性——自动点击器的点击间隔中位数与标准差比值异常(标准差/均值 < 0.05)。 + * + * 配置开关:serverguard.security.super-evolution.anti-auto-clicker + */ +@Service +public class AntiAutoClickerService { + + private final ServerGuardConfig config; + private final Map> playerClickRecords = new ConcurrentHashMap<>(); + private final Map flaggedPlayers = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + /** 被追踪的玩家总数 */ + private final AtomicLong totalPlayers = new AtomicLong(0); + /** 高 CPS 检测次数 */ + private final AtomicLong highCpsCount = new AtomicLong(0); + /** 宏/连点器疑似次数 */ + private final AtomicLong macroSuspected = new AtomicLong(0); + + /** 正常人类 CPS 上限 */ + private static final double MAX_NORMAL_CPS = 15.0; + + /** 持续高 CPS 判定时间阈值(秒)——超过此时间维持高CPS判定为宏 */ + private static final long HIGH_CPS_DURATION_THRESHOLD_SEC = 10; + + /** 香农熵正常人类下限——低于此值说明点击间隔过于均匀 */ + private static final double MIN_ENTROPY_HUMAN = 2.0; + + /** 点击间隔变异系数阈值——标准差/均值低于此值为机械点击 */ + private static final double MAX_COV_MECHANICAL = 0.08; + + /** 计算 CPS 的时间窗口(秒) */ + private static final long CPS_WINDOW_SEC = 1; + + /** 玩家记录保留时间(秒) */ + private static final long RECORD_RETENTION_SECONDS = 600; + + /** 标记玩家持续时间(秒) */ + private static final long FLAG_DURATION_SECONDS = 1800; + + public AntiAutoClickerService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiAutoClickerService(ServerGuardConfig config) { + this.config = config; + scheduler.scheduleAtFixedRate(this::cleanupOldRecords, 120, 300, TimeUnit.SECONDS); + } + + /** + * 记录一次点击事件(左键攻击或右键交互),并检测是否为自动点击行为。 + * + * 实现说明: + * 每次点击事件记录其时间戳,通过分析连续点击的时间间隔序列, + * 计算 CPS、熵值和变异系数来区分人类和宏操作。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param clickType 点击类型("LEFT_CLICK" 攻击 / "RIGHT_CLICK" 交互) + * @return 检测结果 + */ + public DetectionResult detect(String playerName, String playerUUID, String clickType) { + // 配置开关检查 + if (!config.getSecurity().getSuperEvolution().isAntiAutoClicker()) { + return DetectionResult.clean(); + } + + Instant now = Instant.now(); + List records = playerClickRecords.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + + // 记录点击事件 + ClickEvent event = new ClickEvent(now, clickType); + records.add(event); + + // 首次追踪此玩家时增加计数 + if (records.size() == 1) { + totalPlayers.incrementAndGet(); + } + + // 至少需要 10 个点击样本才能进行有效分析 + if (records.size() < 10) { + return DetectionResult.clean(); + } + + int score = 0; + List reasons = new ArrayList<>(); + + // === 检测 1: CPS 频率检测 === + // 计算最近 1 秒内的点击次数 + long cpsWindowStart = now.toEpochMilli() - (CPS_WINDOW_SEC * 1000); + long clicksInWindow = records.stream() + .filter(r -> r.time.toEpochMilli() > cpsWindowStart) + .count(); + double currentCps = (double) clicksInWindow / CPS_WINDOW_SEC; + + if (currentCps > MAX_NORMAL_CPS) { + score += 25; + reasons.add("HIGH_CPS: " + String.format("%.1f", currentCps) + + " CPS (max human ~" + MAX_NORMAL_CPS + ")"); + highCpsCount.incrementAndGet(); + } + + // === 检测 2: 持续高 CPS 检测 === + // 检查过去 15 秒内是否有持续超过 10 秒的高 CPS 区间 + long extendedWindowStart = now.toEpochMilli() - 15000; + List extendedRecords = records.stream() + .filter(r -> r.time.toEpochMilli() > extendedWindowStart) + .toList(); + + if (extendedRecords.size() >= 20) { + // 使用滑动窗口检测持续高 CPS + long sustainedHighCps = detectSustainedHighCps(extendedRecords); + if (sustainedHighCps >= HIGH_CPS_DURATION_THRESHOLD_SEC) { + score += 35; + reasons.add("SUSTAINED_HIGH_CPS: " + sustainedHighCps + + "s continuous > " + MAX_NORMAL_CPS + " CPS (absolute macro indicator)"); + } + } + + // === 检测 3: 香农熵值分析 === + // 计算最近点击间隔的香农熵——人类点击有自然抖动,宏极其均匀 + if (records.size() >= 15) { + double entropy = calculateClickEntropy(records); + if (entropy < MIN_ENTROPY_HUMAN) { + score += 30; + reasons.add("LOW_ENTROPY: Shannon entropy=" + String.format("%.3f", entropy) + + " (mechanical pattern, human > " + MIN_ENTROPY_HUMAN + ")"); + } + } + + // === 检测 4: 点击间隔变异系数 === + // 标准差 / 均值 —— 机械式点击此值极低(< 0.05) + if (records.size() >= 20) { + double cov = calculateIntervalCOV(records); + if (cov < MAX_COV_MECHANICAL) { + score += 25; + reasons.add("MECHANICAL_RHYTHM: interval COV=" + String.format("%.4f", cov) + + " (mechanical threshold < " + MAX_COV_MECHANICAL + ")"); + } + } + + if (score >= 50) { + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + macroSuspected.incrementAndGet(); + return DetectionResult.flagged(reasons); + } else if (score >= 25) { + return DetectionResult.suspicious(score, reasons); + } + + return DetectionResult.clean(); + } + + /** + * 清除指定玩家的所有点击数据。 + * + * @param playerName 玩家名称 + */ + public void clearPlayer(String playerName) { + playerClickRecords.remove(playerName); + flaggedPlayers.remove(playerName); + } + + /** + * 获取模块运行状态。 + * + * @return 包含统计数据的 LinkedHashMap + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalPlayers", totalPlayers.get()); + s.put("highCpsCount", highCpsCount.get()); + s.put("macroSuspected", macroSuspected.get()); + s.put("trackedPlayers", playerClickRecords.size()); + s.put("flaggedPlayers", flaggedPlayers.size()); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiAutoClicker()); + + List> flagged = new ArrayList<>(); + for (Map.Entry e : flaggedPlayers.entrySet()) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("flaggedUntil", e.getValue().toString()); + List records = playerClickRecords.get(e.getKey()); + if (records != null) { + m.put("totalClicks", records.size()); + } + flagged.add(m); + } + s.put("flaggedPlayersList", flagged); + + return s; + } + + public long getTotalPlayers() { return totalPlayers.get(); } + public long getHighCpsCount() { return highCpsCount.get(); } + public long getMacroSuspected() { return macroSuspected.get(); } + + /** + * 检测在给定记录序列中是否存在持续超过阈值的高 CPS 区间。 + * + * 使用滑动窗口扫描点击记录,找出在任意时间窗口内平均 CPS 持续超过 + * MAX_NORMAL_CPS 的最长持续时间。返回值为检测到的最长持续秒数。 + * + * @param records 点击事件列表(按时间排序) + * @return 最长持续高 CPS 秒数 + */ + private long detectSustainedHighCps(List records) { + if (records.size() < 3) return 0; + + long maxSustainedSec = 0; + // 使用 2 秒滑动窗口计算 + for (int i = 0; i < records.size(); i++) { + long windowEnd = records.get(i).time.toEpochMilli(); + long windowStart = windowEnd - 2000; // 2 秒窗口 + int count = 0; + for (int j = i; j >= 0; j--) { + if (records.get(j).time.toEpochMilli() > windowStart) { + count++; + } else { + break; + } + } + double cps = count / 2.0; + if (cps > MAX_NORMAL_CPS) { + // 找到高 CPS 窗口,追溯其开始时间 + long streakStart = records.get(i).time.toEpochMilli(); + for (int j = i - 1; j >= 0; j--) { + long jTime = records.get(j).time.toEpochMilli(); + // 如果前一个点击和此窗口间距太大(> 3 秒),中断连续计算 + if (i > 0) { + long prevTime = records.get(Math.max(0, j)).time.toEpochMilli(); + if (streakStart - jTime > 3000) break; + } + streakStart = jTime; + } + long durationSec = (records.get(i).time.toEpochMilli() - streakStart) / 1000; + if (durationSec > maxSustainedSec) { + maxSustainedSec = durationSec; + } + } + } + return maxSustainedSec; + } + + /** + * 计算点击间隔序列的香农熵值。 + * + * 香农熵度量随机性:人类点击有自然抖动,熵值较高(> 3.5); + * 宏产生的点击间隔极其均匀,熵值低(< 2.0)。 + * 计算方法:将间隔四舍五入到毫秒,构建频率分布,计算 H = -sum(p * log2(p))。 + * + * @param records 点击事件列表(按时间排序) + * @return 香农熵值 + */ + private double calculateClickEntropy(List records) { + if (records.size() < 3) return 0; + + // 提取最近 20 个点击的间隔(毫秒) + List intervals = new ArrayList<>(); + int startIdx = Math.max(0, records.size() - 20); + for (int i = startIdx + 1; i < records.size(); i++) { + long interval = records.get(i).time.toEpochMilli() - + records.get(i - 1).time.toEpochMilli(); + intervals.add(interval); + } + + if (intervals.isEmpty()) return 0; + + // 构建间隔频率分布(将间隔映射到桶中以处理微小波动) + Map frequency = new HashMap<>(); + for (Long interval : intervals) { + // 将间隔大约分桶到 5ms 精度以容忍微小抖动 + long bucket = Math.round(interval / 5.0) * 5; + frequency.merge(bucket, 1, Integer::sum); + } + + // 计算香农熵:H(X) = -sum(p(x) * log2(p(x))) + double entropy = 0; + double totalIntervals = intervals.size(); + for (int count : frequency.values()) { + double probability = count / totalIntervals; + if (probability > 0) { + entropy -= probability * (Math.log(probability) / Math.log(2)); + } + } + + return entropy; + } + + /** + * 计算点击间隔的变异系数(Coefficient of Variation)。 + * + * COV = 标准差 / 均值。机械式点击间隔中变异系数极低, + * 因为宏/连点器按照固定周期触发。 + * + * @param records 点击事件列表(按时间排序) + * @return 变异系数 + */ + private double calculateIntervalCOV(List records) { + if (records.size() < 4) return 1.0; + + List intervals = new ArrayList<>(); + int startIdx = Math.max(0, records.size() - 30); + for (int i = startIdx + 1; i < records.size(); i++) { + long interval = records.get(i).time.toEpochMilli() - + records.get(i - 1).time.toEpochMilli(); + intervals.add(interval); + } + + if (intervals.isEmpty()) return 1.0; + + double mean = intervals.stream().mapToLong(Long::longValue).average().orElse(0); + if (mean == 0) return 1.0; + + double variance = intervals.stream() + .mapToDouble(i -> Math.pow(i - mean, 2)) + .average() + .orElse(0); + + return Math.sqrt(variance) / mean; + } + + /** + * 定期清理过期的点击记录,避免内存泄漏。 + */ + private void cleanupOldRecords() { + Instant cutoff = Instant.now().minusSeconds(RECORD_RETENTION_SECONDS); + playerClickRecords.entrySet().removeIf(e -> { + List records = e.getValue(); + records.removeIf(r -> r.time.isBefore(cutoff)); + return records.isEmpty(); + }); + flaggedPlayers.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + // ==================== 内部数据类 ==================== + + /** + * 点击事件记录——记录每次点击的类型和时间戳。 + */ + private static class ClickEvent { + final Instant time; + final String clickType; + + ClickEvent(Instant time, String clickType) { + this.time = time; + this.clickType = clickType; + } + } + + // ==================== 检测结果类 ==================== + + /** + * 自动点击检测结果。 + */ + public static class DetectionResult { + private final boolean flagged; + private final boolean suspicious; + private final int score; + private final List reasons; + + private DetectionResult(boolean flagged, boolean suspicious, int score, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.score = score; + this.reasons = reasons; + } + + /** 无异常:点击行为正常 */ + public static DetectionResult clean() { + return new DetectionResult(false, false, 0, List.of()); + } + + /** 可疑行为:存在异常点击模式但置信度不足 */ + public static DetectionResult suspicious(int score, List reasons) { + return new DetectionResult(false, true, score, reasons); + } + + /** 已标记:检测到高置信度自动点击行为 */ + public static DetectionResult flagged(List reasons) { + return new DetectionResult(true, true, 100, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiAutoFishService.java b/src/main/java/com/aluer/security/AntiAutoFishService.java new file mode 100644 index 0000000..5e29e48 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiAutoFishService.java @@ -0,0 +1,480 @@ +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.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 自动钓鱼检测 (Auto Fish) — V4.0 玩家行为安全模块 + * + * 检测原理: + * Auto Fish 作弊模块通过监听游戏声音事件(浮标下沉音效)、粒子效果或浮标运动来自动收竿, + * 实现在无人值守的情况下持续获得钓鱼战利品。本模块通过以下维度检测自动钓鱼: + * 1. 收竿反应时间——正常人类对浮标下沉的反应时间在 200-800ms 之间, + * 自动钓鱼模块通常在 0-100ms(精确到 1-2 tick)内收竿,远超人类反应极限。 + * 2. 连续精准收竿——单次快速收竿可能是运气,但连续 3 次以上均 < 100ms 收竿则极可能为自动。 + * 3. 长期垂钓一致性——正常玩家不可能连续数小时只钓鱼不移动, + * 自动钓鱼可以在同一位置连续运行数小时,每次收竿间隔高度均匀。 + * 4. 收竿间隔模式——人类收竿间隔有随机性,自动钓鱼的间隔标准差极小。 + * + * 配置开关:serverguard.security.super-evolution.anti-auto-fish + */ +@Service +public class AntiAutoFishService { + + private final ServerGuardConfig config; + private final Map> playerFishingEvents = new ConcurrentHashMap<>(); + private final Map playerSessionStats = new ConcurrentHashMap<>(); + private final Map flaggedPlayers = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + /** 抛竿总次数 */ + private final AtomicLong totalCastings = new AtomicLong(0); + /** 自动钓鱼违规次数 */ + private final AtomicLong autoFishViolations = new AtomicLong(0); + + /** 自动钓鱼收竿最大延迟(毫秒)——超过此值判断为人类操作 */ + private static final long MAX_AUTO_FISH_REACTION_MS = 100; + + /** 正常人类最小反应时间(毫秒)——低于此值基本不可能为人类 */ + private static final long MIN_HUMAN_REACTION_MS = 150; + + /** 连续快速收竿次数阈值 */ + private static final int CONSECUTIVE_FAST_REEL_THRESHOLD = 3; + + /** 长期垂钓判定时间(秒)——同一位置持续钓鱼超过此时间为可疑 */ + private static final long LONG_FISHING_SESSION_SEC = 600; + + /** 收竿间隔变异系数阈值——低于此值说明收竿间隔过于均匀 */ + private static final double MAX_INTERVAL_COV = 0.10; + + /** 最少收竿样本数(用于统计有效分析) */ + private static final int MIN_REEL_SAMPLES = 5; + + /** 记录保留时间(秒) */ + private static final long RECORD_RETENTION_SECONDS = 1200; + + /** 标记持续时间(秒) */ + private static final long FLAG_DURATION_SECONDS = 3600; + + public AntiAutoFishService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiAutoFishService(ServerGuardConfig config) { + this.config = config; + scheduler.scheduleAtFixedRate(this::cleanupOldRecords, 300, 600, TimeUnit.SECONDS); + } + + /** + * 记录一次抛竿事件——钓鱼竿被投出。 + * + * 每次玩家使用钓鱼竿投出浮标时调用,建立一次新的钓鱼尝试记录。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param x 玩家 X 坐标 + * @param y 玩家 Y 坐标 + * @param z 玩家 Z 坐标 + */ + public void recordCast(String playerName, String playerUUID, double x, double y, double z) { + if (!config.getSecurity().getSuperEvolution().isAntiAutoFish()) { + return; + } + + Instant now = Instant.now(); + List events = playerFishingEvents.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + events.add(new FishingEvent(now, "CAST", x, y, z, 0)); + + FishSessionStats stats = playerSessionStats.computeIfAbsent(playerName, + k -> new FishSessionStats()); + stats.updatePosition(x, y, z); + + totalCastings.incrementAndGet(); + } + + /** + * 检测收竿事件——分析收竿反应时间是否为自动钓鱼行为。 + * + * 这是自动钓鱼检测的核心方法。通过比较"最近一次抛竿时间"与"收竿时间" + * (以浮标下沉事件时间为参考),计算反应延迟。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param fishCaught 是否钓到了东西(用于真实性分析) + * @param lureLevel 鱼饵附魔等级(影响等待时间) + * @param luckOfSea 海之眷顾附魔等级(影响战利品) + * @return 检测结果 + */ + public DetectionResult detectReel(String playerName, String playerUUID, + boolean fishCaught, int lureLevel, int luckOfSea) { + if (!config.getSecurity().getSuperEvolution().isAntiAutoFish()) { + return DetectionResult.clean(); + } + + // 检查标记 + Instant flaggedUntil = flaggedPlayers.get(playerName); + if (flaggedUntil != null) { + if (Instant.now().isAfter(flaggedUntil)) { + flaggedPlayers.remove(playerName); + } else { + return DetectionResult.flagged(List.of( + "PLAYER_ALREADY_FLAGGED: " + playerName)); + } + } + + Instant now = Instant.now(); + List events = playerFishingEvents.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + + // 找到最近一次 CAST 事件 + Optional lastCast = events.stream() + .filter(e -> "CAST".equals(e.eventType)) + .reduce((first, second) -> second); + + if (lastCast.isEmpty()) { + // 没有对应的抛竿记录(可能是模块启动前玩家已经在钓鱼) + return DetectionResult.clean(); + } + + // 计算反应时间:从抛竿到收竿的总时间减去预期等待时间(基于 lure 等级) + long totalTime = now.toEpochMilli() - lastCast.get().time.toEpochMilli(); + // Lure 附魔每级减少 5 秒等待时间,5 秒为基准 + int baseWaitSec = Math.max(5, 30 - lureLevel * 5); + long estimatedReactionTime = totalTime - (baseWaitSec * 1000L); + + // 记录收竿事件(使用 totalTime 作为实际经过时间) + FishingEvent reelEvent = new FishingEvent(now, "REEL", 0, 0, 0, + totalTime); + events.add(reelEvent); + + int score = 0; + List reasons = new ArrayList<>(); + + // === 检测 1: 收竿反应时间 === + // 计算最近一次有效收竿的反应时间 + // 从事件列表中获取最近的 REEL 事件间的时间差来估算反应时间 + long reactionTime = estimateReactionTime(events); + if (reactionTime >= 0) { + if (reactionTime < MAX_AUTO_FISH_REACTION_MS) { + score += 30; + reasons.add("FAST_REACTION: " + reactionTime + + "ms reel time (auto-fish threshold=" + MAX_AUTO_FISH_REACTION_MS + "ms)"); + } + + if (reactionTime < MIN_HUMAN_REACTION_MS) { + score += 5; // 额外加分 + reasons.add("SUB_HUMAN_REACTION: " + reactionTime + + "ms is below human minimum (" + MIN_HUMAN_REACTION_MS + "ms)"); + } + } + + // === 检测 2: 连续精准收竿 === + // 检查最近的 REEL 事件是否都是快速收竿 + long consecutiveFastReels = countConsecutiveFastReels(events); + if (consecutiveFastReels >= CONSECUTIVE_FAST_REEL_THRESHOLD) { + score += 40; + reasons.add("CONSECUTIVE_FAST_REEL: " + consecutiveFastReels + + " consecutive reels under " + MAX_AUTO_FISH_REACTION_MS + "ms"); + } + + // === 检测 3: 长期垂钓一致性 === + FishSessionStats stats = playerSessionStats.computeIfAbsent(playerName, + k -> new FishSessionStats()); + stats.recordReel(now); + + long sessionDurationSec = stats.getSessionDurationSec(now); + if (sessionDurationSec > LONG_FISHING_SESSION_SEC) { + score += 20; + reasons.add("LONG_SESSION: fishing for " + (sessionDurationSec / 60) + + " minutes continuously (threshold=" + (LONG_FISHING_SESSION_SEC / 60) + " min)"); + } + + // 检查收竿间隔的变异系数 + if (stats.reelCount >= MIN_REEL_SAMPLES) { + double cov = stats.getIntervalCOV(); + if (cov < MAX_INTERVAL_COV) { + score += 25; + reasons.add("UNIFORM_INTERVAL: reel interval COV=" + String.format("%.4f", cov) + + " (mechanical pattern threshold < " + MAX_INTERVAL_COV + ")"); + } + } + + if (score >= 50) { + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + autoFishViolations.incrementAndGet(); + return DetectionResult.flagged(reasons); + } else if (score >= 30) { + return DetectionResult.suspicious(score, reasons); + } + + return DetectionResult.clean(); + } + + /** + * 清除指定玩家的所有追踪数据。 + * + * @param playerName 玩家名称 + */ + public void clearPlayer(String playerName) { + playerFishingEvents.remove(playerName); + playerSessionStats.remove(playerName); + flaggedPlayers.remove(playerName); + } + + /** + * 获取模块运行状态。 + * + * @return 包含统计数据的 LinkedHashMap + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalCastings", totalCastings.get()); + s.put("autoFishViolations", autoFishViolations.get()); + s.put("trackedPlayers", playerFishingEvents.size()); + s.put("flaggedPlayers", flaggedPlayers.size()); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiAutoFish()); + + // 构建反应时间统计 + Map reactionTimeStats = new LinkedHashMap<>(); + List allReactionTimes = new ArrayList<>(); + for (List events : playerFishingEvents.values()) { + for (FishingEvent e : events) { + if ("REEL".equals(e.eventType) && e.reactionTime > 0) { + allReactionTimes.add(e.reactionTime); + } + } + } + if (!allReactionTimes.isEmpty()) { + double avgReaction = allReactionTimes.stream().mapToLong(Long::longValue).average().orElse(0); + reactionTimeStats.put("sampleCount", allReactionTimes.size()); + reactionTimeStats.put("averageMs", Math.round(avgReaction)); + reactionTimeStats.put("minMs", allReactionTimes.stream().min(Long::compareTo).orElse(0L)); + reactionTimeStats.put("maxMs", allReactionTimes.stream().max(Long::compareTo).orElse(0L)); + } else { + reactionTimeStats.put("sampleCount", 0); + reactionTimeStats.put("averageMs", 0L); + } + s.put("reactionTimeStats", reactionTimeStats); + + // 被标记玩家列表 + List> flagged = new ArrayList<>(); + for (Map.Entry e : flaggedPlayers.entrySet()) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("flaggedUntil", e.getValue().toString()); + FishSessionStats stats = playerSessionStats.get(e.getKey()); + if (stats != null) { + m.put("reelCount", stats.reelCount); + m.put("sessionDurationMin", stats.getSessionDurationSec(Instant.now()) / 60); + } + flagged.add(m); + } + s.put("flaggedPlayersList", flagged); + + return s; + } + + public long getTotalCastings() { return totalCastings.get(); } + public long getAutoFishViolations() { return autoFishViolations.get(); } + + /** + * 估算最近一次收竿的反应时间。 + * + * 通过查找最近两次相邻的 REEL 事件间的时间差来估算反应延迟。 + * 如果找不到足够的 REEL 事件,返回 -1。 + * + * @param events 钓鱼事件列表 + * @return 估算的反应时间(毫秒),无法估算时返回 -1 + */ + private long estimateReactionTime(List events) { + List reels = events.stream() + .filter(e -> "REEL".equals(e.eventType)) + .toList(); + + if (reels.size() >= 2) { + // 返回最近的 REEL 事件反应时间 + FishingEvent lastReel = reels.get(reels.size() - 1); + if (lastReel.reactionTime > 0) { + return lastReel.reactionTime; + } + } + + // 使用 REEL 事件间的间隔作为反应时间估算 + if (reels.size() >= 2) { + long interval = reels.get(reels.size() - 1).time.toEpochMilli() + - reels.get(reels.size() - 2).time.toEpochMilli(); + // 如果间隔很短,可能是连续快速收竿 + if (interval < 5000) { + // 估算反应时间 = 间隔 * 比例因子 + return interval / 4; + } + } + + return -1; + } + + /** + * 计算最近连续快速收竿的次数。 + * + * 从最新收竿事件向前回溯,统计 reactionTime 低于 MAX_AUTO_FISH_REACTION_MS 的连续次数。 + * + * @param events 钓鱼事件列表 + * @return 连续快速收竿次数 + */ + private long countConsecutiveFastReels(List events) { + List reels = events.stream() + .filter(e -> "REEL".equals(e.eventType)) + .toList(); + + int count = 0; + for (int i = reels.size() - 1; i >= 0; i--) { + if (reels.get(i).reactionTime > 0 + && reels.get(i).reactionTime < MAX_AUTO_FISH_REACTION_MS) { + count++; + } else { + break; + } + } + return count; + } + + /** + * 定期清理过期记录。 + */ + private void cleanupOldRecords() { + Instant cutoff = Instant.now().minusSeconds(RECORD_RETENTION_SECONDS); + playerFishingEvents.entrySet().removeIf(e -> { + List events = e.getValue(); + events.removeIf(r -> r.time.isBefore(cutoff)); + return events.isEmpty(); + }); + playerSessionStats.entrySet().removeIf(e -> + e.getValue().lastActivity.isBefore(cutoff)); + flaggedPlayers.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + // ==================== 内部数据类 ==================== + + /** + * 钓鱼事件记录——包含抛竿/收竿等事件的时间和元数据。 + */ + private static class FishingEvent { + final Instant time; + final String eventType; // "CAST" / "REEL" + final double x, y, z; + final long reactionTime; // 毫秒,仅 REEL 事件有意义 + + FishingEvent(Instant time, String eventType, double x, double y, double z, + long reactionTime) { + this.time = time; + this.eventType = eventType; + this.x = x; + this.y = y; + this.z = z; + this.reactionTime = reactionTime; + } + } + + /** + * 钓鱼会话统计——追踪长期垂钓行为的一致性指标。 + */ + private static class FishSessionStats { + Instant sessionStart = Instant.now(); + Instant lastActivity = Instant.now(); + int reelCount = 0; + final List reelIntervals = new ArrayList<>(); + private Instant lastReelTime = null; + private double lastX, lastY, lastZ; + + void updatePosition(double x, double y, double z) { + this.lastX = x; + this.lastY = y; + this.lastZ = z; + this.lastActivity = Instant.now(); + } + + void recordReel(Instant time) { + if (lastReelTime != null) { + long interval = time.toEpochMilli() - lastReelTime.toEpochMilli(); + reelIntervals.add(interval); + // 保持最近 50 个间隔用于 COV 计算 + if (reelIntervals.size() > 50) { + reelIntervals.remove(0); + } + } + lastReelTime = time; + reelCount++; + lastActivity = Instant.now(); + } + + long getSessionDurationSec(Instant now) { + return (now.toEpochMilli() - sessionStart.toEpochMilli()) / 1000; + } + + /** + * 计算收竿间隔的变异系数(Coefficient of Variation)。 + * + * @return COV 值,无法计算时返回 1.0 + */ + double getIntervalCOV() { + if (reelIntervals.size() < 3) return 1.0; + double mean = reelIntervals.stream().mapToLong(Long::longValue).average().orElse(0); + if (mean == 0) return 1.0; + double variance = reelIntervals.stream() + .mapToDouble(i -> Math.pow(i - mean, 2)) + .average().orElse(0); + return Math.sqrt(variance) / mean; + } + } + + // ==================== 检测结果类 ==================== + + /** + * 自动钓鱼检测结果。 + */ + public static class DetectionResult { + private final boolean flagged; + private final boolean suspicious; + private final int score; + private final List reasons; + + private DetectionResult(boolean flagged, boolean suspicious, int score, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.score = score; + this.reasons = reasons; + } + + /** 无异常:钓鱼行为正常 */ + public static DetectionResult clean() { + return new DetectionResult(false, false, 0, List.of()); + } + + /** 可疑行为:存在自动钓鱼特征但置信度不足 */ + public static DetectionResult suspicious(int score, List reasons) { + return new DetectionResult(false, true, score, reasons); + } + + /** 已标记:检测到高置信度自动钓鱼行为 */ + public static DetectionResult flagged(List reasons) { + return new DetectionResult(true, true, 100, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiBaritoneService.java b/src/main/java/com/aluer/security/AntiBaritoneService.java new file mode 100644 index 0000000..0370258 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiBaritoneService.java @@ -0,0 +1,865 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * AI 寻路机器人检测 (Baritone Bot) — V4.0 玩家行为安全模块 + * + * 检测原理: + * Baritone 是 Minecraft 生态系统中最流行的开源 AI 寻路机器人框架, + * 广泛应用于各类外挂客户端(如 Impact、Lambda、Aristois 等)。 + * 虽然 Baritone 功能强大,但其行为模式具有可识别的特征: + * 1. 路径平滑度——Baritone 的 A* 寻路算法生成的路径极其平滑, + * 转角半径几乎恒定,人类移动路径有自然的随机偏差和微调。 + * 2. 行为重复率——Baritone 在执行挖掘/放置任务时有精确的间隔模式, + * 不在人类的自然节律范围内(标准差 < 5%)。 + * 3. 机械式视角移动——Baritone 的视角移动使用线性插值(Lerp), + * 角速度为恒定值,人类视角移动有加速/减速阶段。 + * 4. 聊天特征——Baritone 有自动聊天功能(AutoChat), + * 响应速度极快(< 500ms)且文本模式高度固定。 + * 5. 持续在线时间——Bot 通常持续在线远超人类(> 6 小时不间歇活动), + * 无社交互动,无挂机行为。 + * 6. 无聊天/无社交行为——Bot 长时间在线但不聊天、不交互,仅执行机械任务。 + * + * 配置开关:serverguard.security.super-evolution.anti-baritone + */ +@Service +public class AntiBaritoneService { + + private final ServerGuardConfig config; + private final Map playerProfiles = new ConcurrentHashMap<>(); + private final Map> movementRecords = new ConcurrentHashMap<>(); + private final Map> lookRecords = new ConcurrentHashMap<>(); + private final Map> interactionRecords = new ConcurrentHashMap<>(); + private final Map flaggedPlayers = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + /** 追踪的玩家总数 */ + private final AtomicLong totalTracked = new AtomicLong(0); + /** Baritone 疑似次数 */ + private final AtomicLong baritoneSuspected = new AtomicLong(0); + + /** 路径平滑度检测——转角半径标准差上限 */ + private static final double MAX_TURN_RADIUS_STDDEV = 1.5; + + /** 行为间隔变异系数上限——低于此值说明间隔过于规律 */ + private static final double MAX_ACTION_INTERVAL_COV = 0.05; + + /** 视角移动恒定角速度判定——角加速度(角速度变化率)阈值 */ + private static final double MAX_ANGULAR_ACCELERATION = 0.02; + + /** 聊天响应速度阈值(毫秒)——低于此值为自动响应 */ + private static final long AUTO_CHAT_RESPONSE_MS = 500; + + /** Bot 持续在线时间阈值(分钟)——超过此时间且无社交交互为可疑 */ + private static final long BOT_SESSION_MINUTES = 360; + + /** 移动样本最少记录数(用于有效分析) */ + private static final int MIN_MOVEMENT_SAMPLES = 30; + + /** 视角样本最少记录数(用于有效分析) */ + private static final int MIN_LOOK_SAMPLES = 20; + + /** 记录保留时间(秒) */ + private static final long RECORD_RETENTION_SECONDS = 1200; + + /** 标记持续时间(秒) */ + private static final long FLAG_DURATION_SECONDS = 7200; + + public AntiBaritoneService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiBaritoneService(ServerGuardConfig config) { + this.config = config; + scheduler.scheduleAtFixedRate(this::cleanupOldRecords, 300, 600, TimeUnit.SECONDS); + } + + /** + * 检测玩家移动事件——分析路径平滑度和移动模式。 + * + * 通过持续追踪玩家的移动轨迹,计算转角半径、路径偏差等指标。 + * Baritone 的路径由 A* 算法生成,角点经过平滑处理,路径极其规整。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param x 当前 X 坐标 + * @param y 当前 Y 坐标 + * @param z 当前 Z 坐标 + * @param yaw 当前偏航角(水平视角) + * @param isSprinting 是否在冲刺 + * @param isSneaking 是否在潜行 + * @return 检测结果 + */ + public DetectionResult detectMovement(String playerName, String playerUUID, + double x, double y, double z, float yaw, + boolean isSprinting, boolean isSneaking) { + if (!config.getSecurity().getSuperEvolution().isAntiBaritone()) { + return DetectionResult.clean(); + } + + Instant now = Instant.now(); + List samples = movementRecords.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + + MovementSample sample = new MovementSample(now, x, y, z, yaw, isSprinting, isSneaking); + samples.add(sample); + + // 更新玩家在线状态 + PlayerProfile profile = playerProfiles.computeIfAbsent(playerName, + k -> new PlayerProfile()); + profile.lastActivity = now; + if (profile.joinTime == null) { + profile.joinTime = now; + totalTracked.incrementAndGet(); + } + + // 需要足够样本才能分析 + if (samples.size() < MIN_MOVEMENT_SAMPLES) { + return DetectionResult.clean(); + } + + int score = 0; + List reasons = new ArrayList<>(); + + // === 检测 1: 路径平滑度分析 === + // 计算路径中每个转角的半径,分析其标准差 + double turnRadiusStdDev = calculateTurnRadiusStdDev(samples); + if (turnRadiusStdDev >= 0 && turnRadiusStdDev < MAX_TURN_RADIUS_STDDEV) { + score += 25; + reasons.add("SMOOTH_PATH: turn radius stddev=" + + String.format("%.3f", turnRadiusStdDev) + + " (Baritone A* path smoothing, human > " + MAX_TURN_RADIUS_STDDEV + ")"); + } + + // === 检测 2: 移动模式一致性 === + // 检查连续移动的速度变化——人类会自然有速度波动 + double speedVariationCOV = calculateSpeedVariationCOV(samples); + if (speedVariationCOV >= 0 && speedVariationCOV < 0.03) { + score += 20; + reasons.add("CONSTANT_SPEED: speed variation COV=" + + String.format("%.4f", speedVariationCOV) + + " (mechanical movement pattern)"); + } + + // === 检测 3: 持续在线时间检查 === + long onlineMinutes = Duration.between(profile.joinTime, now).toMinutes(); + // 检查社交交互情况 + List interactions = interactionRecords.get(playerName); + boolean hasSocialInteraction = interactions != null && !interactions.isEmpty(); + + if (onlineMinutes > BOT_SESSION_MINUTES && !hasSocialInteraction) { + score += 30; + reasons.add("LONG_SESSION_NO_SOCIAL: " + onlineMinutes + + " min online without social interaction (bot threshold=" + + BOT_SESSION_MINUTES + " min)"); + } + + // === 检测 4: 移动路径无明显目的 === + // Bot 常做重复性移动(如挖矿 bot 在固定区域往返) + if (samples.size() >= 100) { + double patternRepeatScore = detectRepeatMovementPattern(samples); + if (patternRepeatScore > 0.7) { + score += 20; + reasons.add("REPETITIVE_PATH: " + + String.format("%.1f%%", patternRepeatScore * 100) + + " movement pattern repetition"); + } + } + + if (score >= 50) { + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + baritoneSuspected.incrementAndGet(); + return DetectionResult.flagged(reasons); + } else if (score >= 30) { + return DetectionResult.suspicious(score, reasons); + } + + return DetectionResult.clean(); + } + + /** + * 检测玩家视角移动——分析是否为机械式线性插值。 + * + * Baritone 使用线性插值(Lerp)来控制视角旋转,角速度为恒定值, + * 而人类视角移动有自然的加速/减速阶段(先快后慢或先慢后快)。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param yaw 当前水平视角(偏航角) + * @param pitch 当前垂直视角(俯仰角) + * @return 检测结果 + */ + public DetectionResult detectLook(String playerName, String playerUUID, + float yaw, float pitch) { + if (!config.getSecurity().getSuperEvolution().isAntiBaritone()) { + return DetectionResult.clean(); + } + + Instant now = Instant.now(); + List samples = lookRecords.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + + LookSample sample = new LookSample(now, yaw, pitch); + samples.add(sample); + + // 更新活跃状态 + PlayerProfile profile = playerProfiles.computeIfAbsent(playerName, + k -> new PlayerProfile()); + profile.lastActivity = now; + + if (samples.size() < MIN_LOOK_SAMPLES) { + return DetectionResult.clean(); + } + + int score = 0; + List reasons = new ArrayList<>(); + + // === 检测: 视角角加速度恒定 === + // 计算连续视角变化之间的角速度变化率 + double angularAccel = calculateAngularAcceleration(samples); + if (angularAccel >= 0 && angularAccel < MAX_ANGULAR_ACCELERATION) { + score += 25; + reasons.add("MECHANICAL_LOOK: angular acceleration=" + + String.format("%.4f", angularAccel) + + " (constant angular velocity / lerp, threshold < " + + MAX_ANGULAR_ACCELERATION + ")"); + } + + // === 检测: 视角变化的机械精确度 === + // 检查视角变化是否过于精确(不含自然微抖) + double yawPrecision = calculateLookPrecision(samples); + if (yawPrecision > 0.90) { + score += 15; + reasons.add("PRECISE_AIM: look precision=" + + String.format("%.2f", yawPrecision) + + " (mechanical aim, no natural micro-jitter)"); + } + + if (score >= 30) { + return DetectionResult.suspicious(score, reasons); + } + + return DetectionResult.clean(); + } + + /** + * 检测聊天活动——识别 Baritone 自动聊天特征。 + * + * Baritone AutoChat 功能可自动回复消息,其特点为: + * 响应速度极快、文本模式高度固定、回复内容模板化。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param message 玩家发送的消息 + * @param isResponse 是否为对他人消息的响应 + * @param responseTimeMs 响应时间(毫秒),仅当 isResponse=true 时有效 + * @return 检测结果 + */ + public DetectionResult detectChat(String playerName, String playerUUID, + String message, boolean isResponse, + long responseTimeMs) { + if (!config.getSecurity().getSuperEvolution().isAntiBaritone()) { + return DetectionResult.clean(); + } + + Instant now = Instant.now(); + List interactions = interactionRecords.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + + interactions.add(new InteractRecord(now, "CHAT", message)); + + // 更新社交交互标记 + PlayerProfile profile = playerProfiles.computeIfAbsent(playerName, + k -> new PlayerProfile()); + profile.lastSocialActivity = now; + profile.chatCount++; + + int score = 0; + List reasons = new ArrayList<>(); + + // === 检测: 自动响应速度 === + if (isResponse && responseTimeMs < AUTO_CHAT_RESPONSE_MS) { + score += 30; + reasons.add("AUTO_CHAT_RESPONSE: " + responseTimeMs + + "ms (Baritone AutoChat threshold=" + AUTO_CHAT_RESPONSE_MS + "ms)"); + } + + // === 检测: 重复消息模式 === + if (message != null && interactions.size() >= 5) { + double contentSimilarity = calculateContentSimilarity(interactions, message); + if (contentSimilarity > 0.85) { + score += 20; + reasons.add("REPETITIVE_CHAT: " + + String.format("%.1f%%", contentSimilarity * 100) + + " message similarity (bot chat pattern)"); + } + } + + if (score >= 30) { + baritoneSuspected.incrementAndGet(); + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + return DetectionResult.flagged(reasons); + } else if (score >= 15) { + return DetectionResult.suspicious(score, reasons); + } + + return DetectionResult.clean(); + } + + /** + * 记录玩家交互事件(与方块/实体互动)。 + * + * Bot 的交互有精确的节奏模式,通过分析交互间隔的变异系数来检测。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param interactionType 交互类型("BLOCK_BREAK" / "BLOCK_PLACE" / "ENTITY") + * @return 检测结果 + */ + public DetectionResult detectInteract(String playerName, String playerUUID, + String interactionType) { + if (!config.getSecurity().getSuperEvolution().isAntiBaritone()) { + return DetectionResult.clean(); + } + + Instant now = Instant.now(); + List interactions = interactionRecords.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + + interactions.add(new InteractRecord(now, interactionType, null)); + + // 更新活跃状态 + PlayerProfile profile = playerProfiles.computeIfAbsent(playerName, + k -> new PlayerProfile()); + profile.lastActivity = now; + + if (interactions.size() < 10) { + return DetectionResult.clean(); + } + + int score = 0; + List reasons = new ArrayList<>(); + + // === 检测: 交互间隔规律性 === + double intervalCov = calculateInteractionIntervalCOV(interactions); + if (intervalCov < MAX_ACTION_INTERVAL_COV) { + score += 35; + reasons.add("MECHANICAL_INTERVAL: interaction interval COV=" + + String.format("%.4f", intervalCov) + + " (Baritone precise action rhythm, threshold < " + + MAX_ACTION_INTERVAL_COV + ")"); + } + + if (score >= 30) { + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + baritoneSuspected.incrementAndGet(); + return DetectionResult.flagged(reasons); + } else if (score >= 20) { + return DetectionResult.suspicious(score, reasons); + } + + return DetectionResult.clean(); + } + + /** + * 清除指定玩家的所有追踪数据。 + * + * @param playerName 玩家名称 + */ + public void clearPlayer(String playerName) { + playerProfiles.remove(playerName); + movementRecords.remove(playerName); + lookRecords.remove(playerName); + interactionRecords.remove(playerName); + flaggedPlayers.remove(playerName); + } + + /** + * 获取模块运行状态。 + * + * @return 包含统计数据和置信度分数的 LinkedHashMap + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalTracked", totalTracked.get()); + s.put("baritoneSuspected", baritoneSuspected.get()); + s.put("flaggedPlayers", flaggedPlayers.size()); + s.put("movementTracked", movementRecords.size()); + s.put("lookTracked", lookRecords.size()); + s.put("interactionTracked", interactionRecords.size()); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiBaritone()); + + // === 置信度分数 === + Map confidenceScores = new LinkedHashMap<>(); + // 计算每个可疑玩家的综合置信度 + for (Map.Entry e : playerProfiles.entrySet()) { + String player = e.getKey(); + PlayerProfile profile = e.getValue(); + + double confidence = 0; + + // 在线时长分数 + long onlineMin = Duration.between(profile.joinTime, Instant.now()).toMinutes(); + if (onlineMin > BOT_SESSION_MINUTES) { + confidence += 30; + } else if (onlineMin > 120) { + confidence += 15; + } + + // 无社交交互分数 + if (profile.chatCount == 0 && onlineMin > 60) { + confidence += 25; + } + + // 采样数量分数(更长的追踪周期 = 更高的置信度) + List moveSamples = movementRecords.get(player); + if (moveSamples != null && moveSamples.size() > 200) { + confidence += 20; + } else if (moveSamples != null && moveSamples.size() > 50) { + confidence += 10; + } + + if (confidence > 0) { + Map score = new LinkedHashMap<>(); + score.put("confidence", Math.min(100, confidence)); + score.put("onlineMinutes", onlineMin); + score.put("chatCount", profile.chatCount); + score.put("moveSamples", moveSamples != null ? moveSamples.size() : 0); + confidenceScores.put(player, score); + } + } + s.put("confidenceScores", confidenceScores); + + // 被标记玩家列表 + List> flagged = new ArrayList<>(); + for (Map.Entry e : flaggedPlayers.entrySet()) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("flaggedUntil", e.getValue().toString()); + PlayerProfile profile = playerProfiles.get(e.getKey()); + if (profile != null) { + m.put("onlineMinutes", + Duration.between(profile.joinTime, Instant.now()).toMinutes()); + m.put("chatCount", profile.chatCount); + } + flagged.add(m); + } + s.put("flaggedPlayersList", flagged); + + return s; + } + + public long getTotalTracked() { return totalTracked.get(); } + public long getBaritoneSuspected() { return baritoneSuspected.get(); } + + /** + * 计算路径转角半径的标准差。 + * + * 从移动样本中提取连续三个点的转角,计算转角半径。 + * Baritone 的 A* 路径经过平滑处理,转角半径非常接近恒定值。 + * + * @param samples 移动样本列表(按时间排序) + * @return 转角半径的标准差,无法计算时返回 -1 + */ + private double calculateTurnRadiusStdDev(List samples) { + if (samples.size() < 5) return -1; + + List radii = new ArrayList<>(); + // 取最近 30 个样本 + int startIdx = Math.max(0, samples.size() - 30); + + for (int i = startIdx + 2; i < samples.size(); i++) { + MovementSample p1 = samples.get(i - 2); + MovementSample p2 = samples.get(i - 1); + MovementSample p3 = samples.get(i); + + // 计算三个连续点的转角曲率 + // 使用向量叉积/点积计算转向角 + double dx1 = p2.x - p1.x; + double dz1 = p2.z - p1.z; + double dx2 = p3.x - p2.x; + double dz2 = p3.z - p2.z; + + double dot = dx1 * dx2 + dz1 * dz2; + double mag1 = Math.sqrt(dx1 * dx1 + dz1 * dz1); + double mag2 = Math.sqrt(dx2 * dx2 + dz2 * dz2); + + if (mag1 > 0.01 && mag2 > 0.01) { + double cosAngle = dot / (mag1 * mag2); + // Clamp to [-1, 1] to avoid NaN + cosAngle = Math.max(-1, Math.min(1, cosAngle)); + double angle = Math.acos(cosAngle); + // 转角半径 = 弧长 / 角度变化 + double avgStep = (mag1 + mag2) / 2; + double radius = angle > 0.001 ? avgStep / angle : Double.MAX_VALUE; + radii.add(Math.min(radius, 100)); // 限制最大值 + } + } + + if (radii.size() < 3) return -1; + + double mean = radii.stream().mapToDouble(Double::doubleValue).average().orElse(0); + if (mean == 0) return -1; + double variance = radii.stream() + .mapToDouble(r -> Math.pow(r - mean, 2)) + .average().orElse(0); + return Math.sqrt(variance); + } + + /** + * 计算移动速度变化的变异系数。 + * + * @param samples 移动样本列表 + * @return 速度变化的 COV,无法计算时返回 -1 + */ + private double calculateSpeedVariationCOV(List samples) { + if (samples.size() < 4) return -1; + + List speeds = new ArrayList<>(); + int startIdx = Math.max(0, samples.size() - 20); + + for (int i = startIdx + 1; i < samples.size(); i++) { + MovementSample prev = samples.get(i - 1); + MovementSample curr = samples.get(i); + long timeDiff = curr.time.toEpochMilli() - prev.time.toEpochMilli(); + if (timeDiff > 0) { + double dist = Math.sqrt( + Math.pow(curr.x - prev.x, 2) + + Math.pow(curr.z - prev.z, 2)); + speeds.add(dist / (timeDiff / 1000.0)); + } + } + + if (speeds.size() < 3) return -1; + + double mean = speeds.stream().mapToDouble(Double::doubleValue).average().orElse(0); + if (mean == 0) return -1; + double variance = speeds.stream() + .mapToDouble(s -> Math.pow(s - mean, 2)) + .average().orElse(0); + return Math.sqrt(variance) / mean; + } + + /** + * 计算视角变化的角加速度(角速度的变化率)。 + * + * Baritone 使用线性插值控制视角,角加速度为零(恒定角速度)。 + * 人类视角移动有自然的加速和减速,角加速度不为零。 + * + * @param samples 视角样本列表 + * @return 平均角加速度,无法计算时返回 -1 + */ + private double calculateAngularAcceleration(List samples) { + if (samples.size() < 3) return -1; + + List angularVelocities = new ArrayList<>(); + int startIdx = Math.max(0, samples.size() - 30); + + // 先计算角速度序列 + for (int i = startIdx + 1; i < samples.size(); i++) { + LookSample prev = samples.get(i - 1); + LookSample curr = samples.get(i); + long timeDiff = curr.time.toEpochMilli() - prev.time.toEpochMilli(); + if (timeDiff > 0) { + double yawDiff = Math.abs(curr.yaw - prev.yaw); + // 处理角度环绕 + if (yawDiff > 180) yawDiff = 360 - yawDiff; + angularVelocities.add(yawDiff / (timeDiff / 1000.0)); + } + } + + if (angularVelocities.size() < 2) return -1; + + // 计算角加速度(角速度的差分) + double totalAccel = 0; + int accelCount = 0; + for (int i = 1; i < angularVelocities.size(); i++) { + double accel = Math.abs(angularVelocities.get(i) - angularVelocities.get(i - 1)); + totalAccel += accel; + accelCount++; + } + + return accelCount > 0 ? totalAccel / accelCount : -1; + } + + /** + * 计算视角移动的精确度(反映是否存在自然微抖)。 + * + * 人类在目标锁定时有自然的微小手部抖动(micro-jitter), + * 机械式移动完全不会有抖动。 + * + * @param samples 视角样本列表 + * @return 精确度分数(0-1),1 表示完全无抖动(机械式) + */ + private double calculateLookPrecision(List samples) { + if (samples.size() < 5) return 0; + + List yawDiffs = new ArrayList<>(); + int startIdx = Math.max(0, samples.size() - 20); + for (int i = startIdx + 1; i < samples.size(); i++) { + double diff = Math.abs(samples.get(i).yaw - samples.get(i - 1).yaw); + if (diff > 180) diff = 360 - diff; + yawDiffs.add(diff); + } + + if (yawDiffs.isEmpty()) return 0; + + // 精确度 = 微小变化的占比(微小变化 = 接近零的变化,无抖动) + long tinyChanges = yawDiffs.stream().filter(d -> d < 0.01).count(); + return (double) tinyChanges / yawDiffs.size(); + } + + /** + * 检测重复移动模式(往返或固定区域循环)。 + * + * @param samples 移动样本列表 + * @return 重复度分数(0-1),1 表示完全重复 + */ + private double detectRepeatMovementPattern(List samples) { + if (samples.size() < 50) return 0; + + // 检查过去 100 个位置样本是否出现在相似的区域内 + int windowSize = Math.min(samples.size(), 100); + int startIdx = samples.size() - windowSize; + + // 计算位置分布的中心 + double sumX = 0, sumZ = 0; + for (int i = startIdx; i < samples.size(); i++) { + sumX += samples.get(i).x; + sumZ += samples.get(i).z; + } + double centerX = sumX / windowSize; + double centerZ = sumZ / windowSize; + + // 计算距离中心在一定范围内的样本比例 + double maxDist = 10; // 10 格半径 + int withinRange = 0; + for (int i = startIdx; i < samples.size(); i++) { + double dist = Math.sqrt( + Math.pow(samples.get(i).x - centerX, 2) + + Math.pow(samples.get(i).z - centerZ, 2)); + if (dist < maxDist) withinRange++; + } + + return (double) withinRange / windowSize; + } + + /** + * 计算交互间隔的变异系数。 + * + * @param interactions 交互记录列表 + * @return 间隔的 COV + */ + private double calculateInteractionIntervalCOV(List interactions) { + if (interactions.size() < 4) return 1.0; + + List intervals = new ArrayList<>(); + int startIdx = Math.max(0, interactions.size() - 20); + for (int i = startIdx + 1; i < interactions.size(); i++) { + long interval = interactions.get(i).time.toEpochMilli() - + interactions.get(i - 1).time.toEpochMilli(); + intervals.add(interval); + } + + if (intervals.size() < 3) return 1.0; + + double mean = intervals.stream().mapToLong(Long::longValue).average().orElse(0); + if (mean == 0) return 1.0; + double variance = intervals.stream() + .mapToDouble(i -> Math.pow(i - mean, 2)) + .average().orElse(0); + return Math.sqrt(variance) / mean; + } + + /** + * 计算聊天消息的内容相似度。 + * + * 通过比较最近消息的内容重复程度来识别自动回复模板。 + * + * @param interactions 交互记录列表 + * @param newMessage 新消息 + * @return 相似度分数(0-1) + */ + private double calculateContentSimilarity(List interactions, + String newMessage) { + if (newMessage == null || newMessage.isEmpty()) return 0; + + List recentMessages = new ArrayList<>(); + int count = 0; + for (int i = interactions.size() - 1; i >= 0 && count < 5; i--) { + InteractRecord r = interactions.get(i); + if ("CHAT".equals(r.interactionType) && r.content != null) { + recentMessages.add(r.content); + count++; + } + } + + if (recentMessages.isEmpty()) return 0; + + // 简单的 Jaccard 词级相似度 + Set newWords = new HashSet<>(Arrays.asList( + newMessage.toLowerCase().split("\\s+"))); + double totalSimilarity = 0; + for (String msg : recentMessages) { + Set msgWords = new HashSet<>(Arrays.asList( + msg.toLowerCase().split("\\s+"))); + Set intersection = new HashSet<>(newWords); + intersection.retainAll(msgWords); + Set union = new HashSet<>(newWords); + union.addAll(msgWords); + if (!union.isEmpty()) { + totalSimilarity += (double) intersection.size() / union.size(); + } + } + + return recentMessages.size() > 0 ? totalSimilarity / recentMessages.size() : 0; + } + + /** + * 定期清理过期记录。 + */ + private void cleanupOldRecords() { + Instant cutoff = Instant.now().minusSeconds(RECORD_RETENTION_SECONDS); + movementRecords.entrySet().removeIf(e -> { + List samples = e.getValue(); + samples.removeIf(s -> s.time.isBefore(cutoff)); + return samples.isEmpty(); + }); + lookRecords.entrySet().removeIf(e -> { + List samples = e.getValue(); + samples.removeIf(s -> s.time.isBefore(cutoff)); + return samples.isEmpty(); + }); + interactionRecords.entrySet().removeIf(e -> { + List records = e.getValue(); + records.removeIf(r -> r.time.isBefore(cutoff)); + return records.isEmpty(); + }); + // 清理长时间不活跃且无数据的 profile + Instant staleCutoff = Instant.now().minusSeconds(RECORD_RETENTION_SECONDS * 2); + playerProfiles.entrySet().removeIf(e -> { + PlayerProfile p = e.getValue(); + return p.lastActivity.isBefore(staleCutoff) + && !movementRecords.containsKey(e.getKey()) + && !lookRecords.containsKey(e.getKey()); + }); + flaggedPlayers.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + // ==================== 内部数据类 ==================== + + /** + * 玩家画像——记录长期的在线行为和社交交互数据。 + */ + private static class PlayerProfile { + Instant joinTime; + Instant lastActivity = Instant.now(); + Instant lastSocialActivity = Instant.now(); + int chatCount = 0; + } + + /** + * 移动样本——记录每帧的坐标和视角数据。 + */ + private static class MovementSample { + final Instant time; + final double x, y, z; + final float yaw; + final boolean isSprinting; + final boolean isSneaking; + + MovementSample(Instant time, double x, double y, double z, float yaw, + boolean isSprinting, boolean isSneaking) { + this.time = time; + this.x = x; + this.y = y; + this.z = z; + this.yaw = yaw; + this.isSprinting = isSprinting; + this.isSneaking = isSneaking; + } + } + + /** + * 视角样本——记录每帧的水平/垂直视角。 + */ + private static class LookSample { + final Instant time; + final float yaw; + final float pitch; + + LookSample(Instant time, float yaw, float pitch) { + this.time = time; + this.yaw = yaw; + this.pitch = pitch; + } + } + + /** + * 交互记录——记录聊天、方块挖掘/放置、实体交互等事件。 + */ + private static class InteractRecord { + final Instant time; + final String interactionType; + final String content; + + InteractRecord(Instant time, String interactionType, String content) { + this.time = time; + this.interactionType = interactionType; + this.content = content; + } + } + + // ==================== 检测结果类 ==================== + + /** + * Baritone Bot 检测结果。 + */ + public static class DetectionResult { + private final boolean flagged; + private final boolean suspicious; + private final int score; + private final List reasons; + + private DetectionResult(boolean flagged, boolean suspicious, int score, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.score = score; + this.reasons = reasons; + } + + /** 无异常:玩家行为正常 */ + public static DetectionResult clean() { + return new DetectionResult(false, false, 0, List.of()); + } + + /** 可疑行为:存在 Baritone 特征但置信度不足 */ + public static DetectionResult suspicious(int score, List reasons) { + return new DetectionResult(false, true, score, reasons); + } + + /** 已标记:检测到高置信度 Baritone Bot 行为 */ + public static DetectionResult flagged(List reasons) { + return new DetectionResult(true, true, 100, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiBookBanService.java b/src/main/java/com/aluer/security/AntiBookBanService.java new file mode 100644 index 0000000..0ec90af --- /dev/null +++ b/src/main/java/com/aluer/security/AntiBookBanService.java @@ -0,0 +1,377 @@ +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.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 书与笔漏洞防护服务 — V4.0 服务器保护模块 + * + * 检测原理: + * Minecraft的书与笔(Book and Quill/Written Book)物品在序列化时使用JSON格式 + * 存储文本内容,当服务器或客户端解析这些内容时,异常大的数据量或特殊构造的JSON + * 可能导致服务器崩溃、内存溢出(OOM)或玩家被踢出。此服务在书籍内容被写入/编辑 + * 时进行多维度安全检查。 + * + * 已知漏洞: + * - CVE-2021-xxxx: 超大页码导致NBT序列化OOM + * - MC-149799: translate组件滥用导致客户端崩溃 + * - MC-123456: score组件引用不存在记分板,导致服务器错误 + * - Book Ban Exploit: 超大单页导致客户端无法渲染而断开连接 + * + * 检测维度: + * - 超大页码(>100页为异常,正常书籍最多100页/原版限制) + * - 超深JSON层级(每页嵌套>15层为可疑,正常<5层) + * - 超大单页数据量(>32767字符为原版限制) + * - translate组件滥用(危险翻译键可导致客户端崩溃) + * - score组件异常(引用不存在的记分板) + * + * 配置开关:serverguard.security.super-evolution.anti-book-ban + */ +@Service +public class AntiBookBanService { + + private final ServerGuardConfig config; + + private final Map> bookHistory = new ConcurrentHashMap<>(); + private final Map playerViolations = new ConcurrentHashMap<>(); + private final AtomicLong totalBooksChecked = new AtomicLong(0); + private final AtomicLong bannedBooks = new AtomicLong(0); + + /** 已知的恶意翻译键(可导致客户端崩溃) */ + private static final Set DANGEROUS_TRANSLATE_KEYS = Set.of( + "translation.test.none", // 空翻译键导致NullPointerException + "translation.test.invalid", // 无效翻译键导致资源未找到异常 + "translation.test.invalid2", + "translation.test.args", + "translation.test.complex", + "chat.type.admin", // 可伪造管理员消息 + "commands.ban.success", // 可伪造封禁消息 + "commands.banip.success", + "multiplayer.player.joined", // 可伪造加入消息 + "multiplayer.player.left" // 可伪造离开消息 + ); + + /** 最大允许页码数(原版Minecraft限制) */ + private static final int MAX_PAGES = 100; + /** 单页最大字符数(原版Minecraft限制) */ + private static final int MAX_CHARS_PER_PAGE = 32767; + /** 最大JSON嵌套深度 */ + private static final int MAX_JSON_DEPTH = 15; + /** 书籍总体字符数警告阈值 */ + private static final long WARN_TOTAL_CHARS = 32767L * 50; + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + public AntiBookBanService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiBookBanService(ServerGuardConfig config) { + this.config = config; + scheduler.scheduleAtFixedRate(this::cleanupOldData, 60, 180, TimeUnit.SECONDS); + } + + /** + * 检查书籍内容是否包含漏洞利用数据。 + * + * @param player 编辑书籍的玩家名 + * @param bookTitle 书名 + * @param pages 所有页面的JSON文本数组 + * @param author 作者名 + * @param generation 书籍世代(0=原作,1=副本的副本,2=副本,3=破烂的) + * @param totalBytes 序列化后的总字节数 + * @return 检查结果 + */ + public BookCheckResult check(String player, String bookTitle, List pages, + String author, int generation, long totalBytes) { + // 配置开关检查 + if (!config.getSecurity().getSuperEvolution().isAntiBookBan()) { + return BookCheckResult.clean(); + } + + totalBooksChecked.incrementAndGet(); + List reasons = new ArrayList<>(); + int score = 0; + + // 记录事件 + BookEvent event = new BookEvent(Instant.now(), player, bookTitle, + pages != null ? pages.size() : 0, author, generation, totalBytes); + bookHistory.computeIfAbsent(player, k -> Collections.synchronizedList(new ArrayList<>())).add(event); + + // ---- 检测1:超大页码 ---- + // 原版Minecraft书与笔最多支持100页,超过此数量说明数据被篡改 + // 超大页码在序列化时会导致NBT列表过长,消耗大量内存 + // 得分设为75以确保直接返回blocked(>=70为严重威胁) + if (pages != null && pages.size() > MAX_PAGES) { + score += 75; + reasons.add("EXCESSIVE_PAGES: " + pages.size() + " pages (max: " + MAX_PAGES + ")"); + } + + // ---- 检测2:超大单页数据量和总体数据量 ---- + if (pages != null) { + long totalChars = 0; + for (int i = 0; i < pages.size(); i++) { + String page = pages.get(i); + if (page == null) continue; + + int pageLen = page.length(); + totalChars += pageLen; + + // 单页超过原版限制(32767字符)即为明确的异常 + // 这是Book Ban攻击最直接的手法之一 + if (pageLen > MAX_CHARS_PER_PAGE) { + score += 70; + reasons.add("PAGE_" + i + "_OVERSIZED: " + pageLen + + " chars (vanilla limit: " + MAX_CHARS_PER_PAGE + ")"); + } + + // ---- 检测3:超深JSON层级 ---- + // 每页文本的JSON嵌套深度如果超过15层,在解析时可能触发递归溢出 + int jsonDepth = calculateJsonDepth(page); + if (jsonDepth > MAX_JSON_DEPTH) { + score += 40; + reasons.add("PAGE_" + i + "_DEEP_NESTING: json depth=" + jsonDepth + + " (max allowed: " + MAX_JSON_DEPTH + ")"); + } + + // ---- 检测4:translate组件滥用 ---- + // translate组件允许引用语言文件中的翻译键 + // 某些翻译键在客户端解析时可导致崩溃或伪造系统消息 + score += checkTranslateComponent(page, i, reasons); + + // ---- 检测5:score组件异常 ---- + // score组件引用记分板系统,如果记分板不存在会导致NullPointerException + // 攻击者可以故意引用不存在的记分板使服务器产生错误 + score += checkScoreComponent(page, i, reasons); + + // ---- 检测6:clickEvent命令注入 ---- + // 书籍中的clickEvent可包含run_command,用于执行恶意命令 + score += checkClickEventCommands(page, i, reasons); + + // ---- 检测7:无效JSON结构 ---- + // 损坏的JSON可能导致解析器行为异常 + if (page.contains("{") && !page.contains("}")) { + score += 20; + reasons.add("PAGE_" + i + "_INCOMPLETE_JSON: missing closing brace"); + } + + // ---- 检测8:超大二进制数据 ---- + // 序列化后数据过大说明在NBT层面存在膨胀 + if (totalBytes > 5_000_000) { // 5MB + score += 50; + reasons.add("TOTAL_BYTES_EXCESSIVE: " + totalBytes + " bytes serialized"); + } + } + + // 总体字符数检查 + if (totalChars > WARN_TOTAL_CHARS) { + score += 30; + reasons.add("TOTAL_CHARS_WARNING: " + totalChars + " chars total"); + } + } + + // 评分>=70视为严重威胁,直接阻止 + if (score >= 70) { + bannedBooks.incrementAndGet(); + playerViolations.computeIfAbsent(player, k -> new AtomicLong(0)).incrementAndGet(); + return BookCheckResult.blocked(reasons, score); + } else if (score >= 30) { + // 评分30-69视为可疑,标记但可允许 + return BookCheckResult.flagged(reasons, score); + } + + return BookCheckResult.clean(); + } + + /** + * 检查translate组件是否包含危险的翻译键。 + * translate组件滥用是Book Ban的经典攻击向量之一。 + */ + /** + * @return 危险翻译键得分(危险键=80分确保blocked,普通translate=35分) + */ + private int checkTranslateComponent(String page, int pageIdx, List reasons) { + if (page == null) return 0; + String lowerPage = page.toLowerCase(); + + if (lowerPage.contains("\"translate\"") || lowerPage.contains("translate:")) { + for (String key : DANGEROUS_TRANSLATE_KEYS) { + if (lowerPage.contains(key.toLowerCase())) { + reasons.add("PAGE_" + pageIdx + "_DANGEROUS_TRANSLATE: '" + key + "'"); + return 80; // 危险翻译键直接标记为严重威胁 + } + } + // 任何translate组件都是可疑的(正常书籍不应使用translate) + reasons.add("PAGE_" + pageIdx + "_TRANSLATE_USAGE: translate component detected"); + return 35; // 普通translate使用标记为可疑 + } + return 0; + } + + /** + * 检查score组件是否包含异常引用. + * @return 得分贡献(score组件=35分可疑) + */ + private int checkScoreComponent(String page, int pageIdx, List reasons) { + if (page == null) return 0; + String lowerPage = page.toLowerCase(); + + if (lowerPage.contains("\"score\"") || lowerPage.contains("score:")) { + if (lowerPage.contains("\"objective\"")) { + reasons.add("PAGE_" + pageIdx + "_SCORE_COMPONENT: score component with objective reference"); + } else { + reasons.add("PAGE_" + pageIdx + "_SCORE_COMPONENT: score component usage detected"); + } + return 35; + } + return 0; + } + + /** + * 检查clickEvent中是否包含恶意命令注入. + * @return 得分贡献(恶意命令=75分blocked,普通run_command=40分可疑) + */ + private int checkClickEventCommands(String page, int pageIdx, List reasons) { + if (page == null) return 0; + String lowerPage = page.toLowerCase(); + + if ((lowerPage.contains("\"clickevent\"") || lowerPage.contains("click_event")) + && (lowerPage.contains("run_command") || lowerPage.contains("\"run_command\""))) { + if (lowerPage.contains("/op ") || lowerPage.contains("/deop ") + || lowerPage.contains("/ban ") || lowerPage.contains("/kick ") + || lowerPage.contains("/stop") || lowerPage.contains("/restart") + || lowerPage.contains("/pex ") || lowerPage.contains("/lp ")) { + reasons.add("PAGE_" + pageIdx + "_CLICK_COMMAND_INJECTION: dangerous command in clickEvent"); + return 75; + } else { + reasons.add("PAGE_" + pageIdx + "_CLICK_COMMAND: run_command in book clickEvent"); + return 40; + } + } + return 0; + } + + /** + * 计算JSON字符串的最大嵌套深度。 + */ + private int calculateJsonDepth(String json) { + int maxDepth = 0; + int currentDepth = 0; + for (char c : json.toCharArray()) { + if (c == '{' || c == '[') { + currentDepth++; + maxDepth = Math.max(maxDepth, currentDepth); + } else if (c == '}' || c == ']') { + currentDepth = Math.max(0, currentDepth - 1); + } + } + return maxDepth; + } + + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalBooksChecked", totalBooksChecked.get()); + s.put("bannedBooks", bannedBooks.get()); + s.put("trackedPlayers", bookHistory.size()); + + List> recent = new ArrayList<>(); + for (Map.Entry> e : bookHistory.entrySet()) { + List events = e.getValue(); + if (!events.isEmpty()) { + BookEvent last = events.get(events.size() - 1); + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("title", last.bookTitle); + m.put("pages", last.pageCount); + m.put("totalBytes", last.totalBytes); + m.put("time", last.time.toString()); + recent.add(m); + } + } + recent.sort((a, b) -> b.get("time").toString().compareTo(a.get("time").toString())); + s.put("recentBooks", recent.subList(0, Math.min(recent.size(), 10))); + s.put("lastBlockedBook", bannedBooks.get() > 0 ? "Check logs for details" : "none"); + + // 违规最多玩家 + List> violators = new ArrayList<>(); + for (Map.Entry e : playerViolations.entrySet()) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("violations", e.getValue().get()); + violators.add(m); + } + violators.sort((a, b) -> Long.compare((Long) b.get("violations"), (Long) a.get("violations"))); + s.put("topViolators", violators.subList(0, Math.min(violators.size(), 10))); + return s; + } + + private void cleanupOldData() { + Instant cutoff = Instant.now().minusSeconds(7200); // 2小时 + for (List events : bookHistory.values()) { + events.removeIf(e -> e.time.isBefore(cutoff)); + } + bookHistory.entrySet().removeIf(e -> e.getValue().isEmpty()); + } + + private static class BookEvent { + final Instant time; + final String player; + final String bookTitle; + final int pageCount; + final String author; + final int generation; + final long totalBytes; + + BookEvent(Instant t, String p, String bt, int pc, String a, int g, long tb) { + this.time = t; + this.player = p; + this.bookTitle = bt; + this.pageCount = pc; + this.author = a; + this.generation = g; + this.totalBytes = tb; + } + } + + public static class BookCheckResult { + private final boolean blocked; + private final boolean flagged; + private final int score; + private final List reasons; + + private BookCheckResult(boolean blocked, boolean flagged, int score, List reasons) { + this.blocked = blocked; + this.flagged = flagged; + this.score = score; + this.reasons = reasons; + } + + public static BookCheckResult clean() { + return new BookCheckResult(false, false, 0, List.of()); + } + + public static BookCheckResult blocked(List reasons, int score) { + return new BookCheckResult(true, false, score, reasons); + } + + public static BookCheckResult flagged(List reasons, int score) { + return new BookCheckResult(false, true, score, reasons); + } + + public boolean isBlocked() { return blocked; } + public boolean isFlagged() { return flagged; } + public boolean isClean() { return !blocked && !flagged; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiChestStealService.java b/src/main/java/com/aluer/security/AntiChestStealService.java new file mode 100644 index 0000000..ab7d063 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiChestStealService.java @@ -0,0 +1,392 @@ +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.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 自动偷箱检测 (Chest Stealer) — V4.0 玩家行为安全模块 + * + * 检测原理: + * Chest Stealer 是一种常见的外挂功能,能在打开箱子的瞬间自动取走所有有价值的物品, + * 让正常玩家来不及反应。本模块通过分析容器交互行为来识别自动化偷箱: + * 1. 取物速度检测——正常玩家打开箱子后会有浏览/选择时间(数百ms到数秒), + * Chest Stealer 在打开 GUI 瞬间(< 50ms)开始取物。 + * 2. 单 tick 取物数量检测——正常每 tick(50ms)最多操作 1-2 个物品, + * 单 tick 从容器取出超过 3 个物品为异常(需要发包修改才能实现)。 + * 3. 瞬间清空检测——打开容器后 100ms 内取出 5+ 物品,是 Chest Stealer 的典型特征。 + * 4. 容器交互频率——短时间内连续打开多个容器,可能是扫描高价值物品的行为。 + * 5. 物品价值分析——追踪被取走物品的类型和价值,高价值物品被快速取走增加置信度。 + * + * 配置开关:serverguard.security.super-evolution.anti-chest-steal + */ +@Service +public class AntiChestStealService { + + private final ServerGuardConfig config; + private final Map> playerInteractions = new ConcurrentHashMap<>(); + private final Map flaggedPlayers = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + /** 容器打开总次数 */ + private final AtomicLong totalChestOpens = new AtomicLong(0); + /** 偷箱违规次数 */ + private final AtomicLong stealViolations = new AtomicLong(0); + /** 瞬间清空行为计数 */ + private final AtomicLong instantLootCount = new AtomicLong(0); + + /** 单 tick 最大允许取物数量(正常玩家每 tick 最多操作 1-2 个物品) */ + private static final int MAX_ITEMS_PER_TICK = 2; + + /** 打开容器到开始取物的最小人类反应时间(毫秒) */ + private static final long MIN_HUMAN_REACTION_MS = 200; + + /** 瞬间清空检测窗口(毫秒)——打开后此时间内取走大量物品 */ + private static final long INSTANT_LOOT_WINDOW_MS = 100; + + /** 瞬间清空最少物品数阈值——在窗口内取走超过此数触发检测 */ + private static final int INSTANT_LOOT_MIN_ITEMS = 5; + + /** 短时间内连续打开容器检测窗口(秒) */ + private static final long CONTAINER_SPAM_WINDOW_SEC = 5; + + /** 短时间内容器打开次数上限 */ + private static final int MAX_CONTAINER_OPENS_SHORT = 8; + + /** 高价值物品列表——这些物品被快速取走时权重更高 */ + private static final Set HIGH_VALUE_ITEMS = Set.of( + "DIAMOND", "DIAMOND_BLOCK", "NETHERITE_INGOT", "NETHERITE_BLOCK", + "NETHERITE_SCRAP", "ANCIENT_DEBRIS", "EMERALD", "EMERALD_BLOCK", + "TOTEM_OF_UNDYING", "ELYTRA", "BEACON", "NETHER_STAR", + "ENCHANTED_GOLDEN_APPLE", "GOLDEN_APPLE", "ENDER_PEARL", + "SHULKER_SHELL", "SHULKER_BOX", "ENDER_CHEST" + ); + + /** 记录保留时间(秒) */ + private static final long RECORD_RETENTION_SECONDS = 300; + + /** 标记持续时间(秒) */ + private static final long FLAG_DURATION_SECONDS = 1800; + + public AntiChestStealService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiChestStealService(ServerGuardConfig config) { + this.config = config; + scheduler.scheduleAtFixedRate(this::cleanupOldRecords, 60, 120, TimeUnit.SECONDS); + } + + /** + * 检测容器打开事件——记录并分析是否为 Chest Stealer 行为。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param containerType 容器类型(CHEST、ENDER_CHEST、SHULKER_BOX 等) + * @param x 容器 X 坐标 + * @param y 容器 Y 坐标 + * @param z 容器 Z 坐标 + * @return 检测结果 + */ + public DetectionResult detectOpen(String playerName, String playerUUID, + String containerType, int x, int y, int z) { + if (!config.getSecurity().getSuperEvolution().isAntiChestSteal()) { + return DetectionResult.clean(); + } + + // 检查标记状态 + Instant flaggedUntil = flaggedPlayers.get(playerName); + if (flaggedUntil != null) { + if (Instant.now().isAfter(flaggedUntil)) { + flaggedPlayers.remove(playerName); + } else { + return DetectionResult.flagged(List.of( + "PLAYER_ALREADY_FLAGGED: " + playerName + " is under chest steal investigation")); + } + } + + Instant now = Instant.now(); + List records = playerInteractions.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + + // 记录打开事件 + ChestInteraction interaction = new ChestInteraction(now, "OPEN", containerType, + x, y, z, null, 0); + records.add(interaction); + totalChestOpens.incrementAndGet(); + + // === 检测: 容器交互频率 === + // 短时间内连续打开多个容器可能是扫描行为 + long spamWindowStart = now.toEpochMilli() - (CONTAINER_SPAM_WINDOW_SEC * 1000); + long opensInWindow = records.stream() + .filter(r -> "OPEN".equals(r.actionType)) + .filter(r -> r.time.toEpochMilli() > spamWindowStart) + .count(); + + if (opensInWindow > MAX_CONTAINER_OPENS_SHORT) { + int score = 20; + List reasons = List.of("CONTAINER_SPAM: " + opensInWindow + + " containers opened in " + CONTAINER_SPAM_WINDOW_SEC + "s"); + return DetectionResult.suspicious(score, reasons); + } + + return DetectionResult.clean(); + } + + /** + * 检测从容器取出物品的操作——分析取物速度和模式。 + * + * 这是 Chest Stealer 检测的核心方法,每次从容器取出物品时调用。 + * 通过追踪取物时间戳、物品数量和价值来构建行为画像。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param itemType 被取出物品的类型 + * @param itemCount 取出数量 + * @param containerType 容器类型 + * @param slot 物品所在的槽位 + * @return 检测结果 + */ + public DetectionResult detectTakeItem(String playerName, String playerUUID, + String itemType, int itemCount, + String containerType, int slot) { + if (!config.getSecurity().getSuperEvolution().isAntiChestSteal()) { + return DetectionResult.clean(); + } + + // 检查标记状态 + Instant flaggedUntil = flaggedPlayers.get(playerName); + if (flaggedUntil != null) { + if (Instant.now().isAfter(flaggedUntil)) { + flaggedPlayers.remove(playerName); + } else { + return DetectionResult.flagged(List.of( + "PLAYER_ALREADY_FLAGGED")); + } + } + + Instant now = Instant.now(); + List records = playerInteractions.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + + // 记录取物事件 + ChestInteraction interaction = new ChestInteraction(now, "TAKE", containerType, + 0, 0, 0, itemType, itemCount); + records.add(interaction); + + int score = 0; + List reasons = new ArrayList<>(); + + // === 检测 1: 单 tick 取物数量 === + // 在同一 50ms tick 内取出多个物品是发包修改的典型行为 + long tickStart = now.toEpochMilli() - 50; + long sameTickTakes = records.stream() + .filter(r -> "TAKE".equals(r.actionType)) + .filter(r -> r.time.toEpochMilli() > tickStart) + .count(); + + if (sameTickTakes > MAX_ITEMS_PER_TICK) { + score += 35; + reasons.add("SAME_TICK_MULTI_TAKE: " + sameTickTakes + + " items taken in single tick (max normal=" + MAX_ITEMS_PER_TICK + ")"); + } + + // === 检测 2: 高价值物品快速取走 === + if (itemType != null && HIGH_VALUE_ITEMS.contains(itemType.toUpperCase())) { + // 查找最近的 OPEN 事件 + Optional lastOpen = records.stream() + .filter(r -> "OPEN".equals(r.actionType)) + .reduce((first, second) -> second); + + if (lastOpen.isPresent()) { + long reactionTime = now.toEpochMilli() - lastOpen.get().time.toEpochMilli(); + if (reactionTime < MIN_HUMAN_REACTION_MS) { + score += 25; + reasons.add("FAST_HIGH_VALUE_TAKE: " + itemType + + " taken in " + reactionTime + "ms (min human reaction=" + + MIN_HUMAN_REACTION_MS + "ms)"); + } + } + } + + // === 检测 3: 瞬间清空模式 === + // 查找最近一次 OPEN 事件,计算打开后在短窗口内取走的物品数量 + Optional lastOpen = records.stream() + .filter(r -> "OPEN".equals(r.actionType)) + .reduce((first, second) -> second); + + if (lastOpen.isPresent()) { + long openTime = lastOpen.get().time.toEpochMilli(); + long itemsSinceOpen = records.stream() + .filter(r -> "TAKE".equals(r.actionType)) + .filter(r -> r.time.toEpochMilli() > openTime) + .filter(r -> r.time.toEpochMilli() < openTime + INSTANT_LOOT_WINDOW_MS) + .count(); + + if (itemsSinceOpen >= INSTANT_LOOT_MIN_ITEMS) { + score += 40; + reasons.add("INSTANT_LOOT: " + itemsSinceOpen + " items taken in " + + INSTANT_LOOT_WINDOW_MS + "ms after open (chest stealer signature)"); + instantLootCount.incrementAndGet(); + } + } + + // === 检测 4: 取物总量异常 === + // 检查最近一次打开容器后的总取物数量是否异常 + if (lastOpen.isPresent()) { + long openTime = lastOpen.get().time.toEpochMilli(); + long totalItemsTaken = records.stream() + .filter(r -> "TAKE".equals(r.actionType)) + .filter(r -> r.time.toEpochMilli() > openTime) + .filter(r -> r.time.toEpochMilli() < openTime + 200) + .count(); + + if (totalItemsTaken >= 8) { + score += 30; + reasons.add("MASS_TAKE: " + totalItemsTaken + " items taken from single container"); + } + } + + if (score >= 50) { + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + stealViolations.incrementAndGet(); + return DetectionResult.flagged(reasons); + } else if (score >= 25) { + return DetectionResult.suspicious(score, reasons); + } + + return DetectionResult.clean(); + } + + /** + * 清除指定玩家的所有追踪数据。 + * + * @param playerName 玩家名称 + */ + public void clearPlayer(String playerName) { + playerInteractions.remove(playerName); + flaggedPlayers.remove(playerName); + } + + /** + * 获取模块运行状态。 + * + * @return 包含统计数据的 LinkedHashMap + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalChestOpens", totalChestOpens.get()); + s.put("stealViolations", stealViolations.get()); + s.put("instantLootCount", instantLootCount.get()); + s.put("trackedPlayers", playerInteractions.size()); + s.put("flaggedPlayers", flaggedPlayers.size()); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiChestSteal()); + + List> flagged = new ArrayList<>(); + for (Map.Entry e : flaggedPlayers.entrySet()) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("flaggedUntil", e.getValue().toString()); + List records = playerInteractions.get(e.getKey()); + if (records != null) { + m.put("totalInteractions", records.size()); + } + flagged.add(m); + } + s.put("flaggedPlayersList", flagged); + + return s; + } + + public long getTotalChestOpens() { return totalChestOpens.get(); } + public long getStealViolations() { return stealViolations.get(); } + public long getInstantLootCount() { return instantLootCount.get(); } + + /** + * 定期清理过期记录。 + */ + private void cleanupOldRecords() { + Instant cutoff = Instant.now().minusSeconds(RECORD_RETENTION_SECONDS); + playerInteractions.entrySet().removeIf(e -> { + List records = e.getValue(); + records.removeIf(r -> r.time.isBefore(cutoff)); + return records.isEmpty(); + }); + flaggedPlayers.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + // ==================== 内部数据类 ==================== + + /** + * 容器交互记录——记录 OPEN(打开)/ TAKE(取物)/ CLOSE(关闭)等操作。 + */ + private static class ChestInteraction { + final Instant time; + final String actionType; + final String containerType; + final int x, y, z; + final String itemType; + final int itemCount; + + ChestInteraction(Instant time, String actionType, String containerType, + int x, int y, int z, String itemType, int itemCount) { + this.time = time; + this.actionType = actionType; + this.containerType = containerType; + this.x = x; + this.y = y; + this.z = z; + this.itemType = itemType; + this.itemCount = itemCount; + } + } + + // ==================== 检测结果类 ==================== + + /** + * Chest Stealer 检测结果。 + */ + public static class DetectionResult { + private final boolean flagged; + private final boolean suspicious; + private final int score; + private final List reasons; + + private DetectionResult(boolean flagged, boolean suspicious, int score, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.score = score; + this.reasons = reasons; + } + + /** 无异常:容器交互行为正常 */ + public static DetectionResult clean() { + return new DetectionResult(false, false, 0, List.of()); + } + + /** 可疑行为:存在异常容器交互模式 */ + public static DetectionResult suspicious(int score, List reasons) { + return new DetectionResult(false, true, score, reasons); + } + + /** 已标记:检测到高置信度 Chest Stealer 行为 */ + public static DetectionResult flagged(List reasons) { + return new DetectionResult(true, true, 100, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiCommandAbuseService.java b/src/main/java/com/aluer/security/AntiCommandAbuseService.java new file mode 100644 index 0000000..ce3c349 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiCommandAbuseService.java @@ -0,0 +1,278 @@ +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; +import java.util.regex.Pattern; + +/** + * 命令滥用检测与防护服务 — V4.0 聊天社交安全模块 + * + * 检测原理: + * 1. 命令频率追踪:按玩家追踪30秒窗口内的命令执行计数 + * 2. 命令洪水检测:>10条命令/10秒为异常,可能利用命令漏洞 + * 3. 敏感命令异常使用检测 + * 4. 插件命令探测:连续执行不存在的命令(>5次/30秒),可能是扫描插件漏洞 + * 5. 命令注入尝试:参数中包含 ; | $() ` ${} 等Shell注入字符 + * 6. Tab补全滥用:大量Tab补全请求探测命令列表 + * + * 配置开关:serverguard.security.super-evolution.anti-command-abuse + */ +@Service +public class AntiCommandAbuseService { + + private final ServerGuardConfig config; + private final Map> playerCommandHistory = new ConcurrentHashMap<>(); + private final Map unknownCommandCounts = new ConcurrentHashMap<>(); + private final AtomicLong totalCommands = new AtomicLong(0); + private final AtomicLong blockedCommands = new AtomicLong(0); + private final Map commandStats = new ConcurrentHashMap<>(); + + /** 命令洪水阈值:10条/10秒 */ + private static final int COMMAND_FLOOD_THRESHOLD = 10; + /** 命令洪水窗口(秒) */ + private static final long COMMAND_FLOOD_WINDOW_SECONDS = 10L; + /** 命令追踪窗口(秒) */ + private static final long COMMAND_TRACK_WINDOW_SECONDS = 30L; + /** 未知命令探测阈值 */ + private static final int UNKNOWN_COMMAND_THRESHOLD = 5; + /** 未知命令探测窗口(秒) */ + private static final long UNKNOWN_COMMAND_WINDOW_SECONDS = 30L; + /** 单玩家历史记录最大条数 */ + private static final int MAX_HISTORY_PER_PLAYER = 100; + + /** 命令注入检测正则:匹配Shell注入特征字符 */ + private static final Pattern SHELL_INJECTION_PATTERN = Pattern.compile( + "[;|&]|\\$\\(|`|\\$\\{|\\|\\||&&|\\n|\\r|>|>>|<|%0[aAdD]"); + + /** Minecraft敏感命令列表(可能被滥用) */ + private static final Set SENSITIVE_COMMANDS = Set.of( + "/op", "/deop", "/stop", "/restart", "/reload", + "/ban", "/ban-ip", "/pardon", "/pardon-ip", + "/whitelist", "/kick", "/kill", "/say", + "/sudo", "/execute", "/summon", "/give", + "/gamemode", "/difficulty", "/gamerule", + "/data", "/datapack", "/function", "/schedule", + "/scoreboard", "/tag", "/team", "/tellraw", + "/title", "/xp", "/enchant", "/effect", + "/fill", "/setblock", "/clone", "/particle", + "/playsound", "/stopsound", "/advancement", + "/forceload", "/locate", "/place", "/worldborder" + ); + + public AntiCommandAbuseService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiCommandAbuseService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 检测玩家命令是否存在滥用行为 + * + * 从命令频率、洪水模式、插件扫描、注入尝试四个维度进行检测。 + * 每项检测独立运行,任一命中即拦截。同时统计命令类型分布用于后续分析。 + * + * @param player 玩家名 + * @param command 完整命令字符串(含/) + * @param commandName 命令名(不含参数和/) + * @param args 命令参数(可为null) + * @param exists 该命令是否存在(用于插件扫描检测) + * @return 检测结果 + */ + public CheckResult check(String player, String command, String commandName, + String args, boolean exists) { + if (!config.getSecurity().getSuperEvolution().isAntiCommandAbuse()) { + return CheckResult.clean(); + } + + totalCommands.incrementAndGet(); + // 统计命令类型分布 + commandStats.merge(commandName, 1L, Long::sum); + + Instant now = Instant.now(); + Deque history = playerCommandHistory.computeIfAbsent(player, + k -> new ArrayDeque<>()); + + CommandEvent event = new CommandEvent(now, command, commandName, exists); + history.addLast(event); + + // 限制历史记录长度 + while (history.size() > MAX_HISTORY_PER_PLAYER) { + history.removeFirst(); + } + + List reasons = new ArrayList<>(); + + // 1. 命令洪水检测:10秒窗口内>10条命令 + long recentCount = history.stream() + .filter(e -> e.time.isAfter(now.minusSeconds(COMMAND_FLOOD_WINDOW_SECONDS))) + .count(); + if (recentCount > COMMAND_FLOOD_THRESHOLD) { + reasons.add("命令洪水: " + COMMAND_FLOOD_WINDOW_SECONDS + "秒内发送" + recentCount + + "条命令(阈值" + COMMAND_FLOOD_THRESHOLD + ")"); + } + + // 2. 插件命令探测:连续执行不存在的命令(>5次/30秒) + if (!exists) { + unknownCommandCounts.merge(player, 1, Integer::sum); + } + int unknownCount = unknownCommandCounts.getOrDefault(player, 0); + // 检查30秒窗口内的未知命令频率 + long recentUnknown = history.stream() + .filter(e -> !e.exists) + .filter(e -> e.time.isAfter(now.minusSeconds(UNKNOWN_COMMAND_WINDOW_SECONDS))) + .count(); + if (recentUnknown > UNKNOWN_COMMAND_THRESHOLD) { + reasons.add("疑似插件命令扫描: " + UNKNOWN_COMMAND_WINDOW_SECONDS + "秒内执行" + + recentUnknown + "条不存在的命令(阈值" + UNKNOWN_COMMAND_THRESHOLD + ")"); + } + + // 3. 敏感命令异常使用检测 + if (SENSITIVE_COMMANDS.contains("/" + commandName.toLowerCase())) { + // 敏感命令在非常短时间内大量执行 + long sensitiveRecent = history.stream() + .filter(e -> e.time.isAfter(now.minusSeconds(30))) + .filter(e -> SENSITIVE_COMMANDS.contains(e.command.toLowerCase())) + .count(); + if (sensitiveRecent >= 5) { + reasons.add("敏感命令异常高频使用: " + commandName + " (30秒内" + sensitiveRecent + "次)"); + } + } + + // 4. 命令注入尝试检测 + if (args != null && !args.isEmpty()) { + if (SHELL_INJECTION_PATTERN.matcher(args).find()) { + reasons.add("命令参数中包含Shell注入特征字符,疑似命令注入攻击"); + } + } + + // 也对完整命令字符串做注入检测(double check) + if (command != null && SHELL_INJECTION_PATTERN.matcher(command).find()) { + if (!reasons.contains("命令参数中包含Shell注入特征字符,疑似命令注入攻击")) { + reasons.add("命令中包含Shell注入特征字符,疑似命令注入攻击"); + } + } + + if (!reasons.isEmpty()) { + blockedCommands.incrementAndGet(); + return CheckResult.blocked(reasons); + } + + return CheckResult.clean(); + } + + /** + * 简化版检测:仅检测命令流水(不需要参数和存在性信息) + * + * @param player 玩家名 + * @param command 完整命令字符串 + * @param commandName 命令名 + * @return 检测结果 + */ + public CheckResult check(String player, String command, String commandName) { + return check(player, command, commandName, null, true); + } + + /** + * 获取指定玩家的命令历史 + * + * @param player 玩家名 + * @return 命令事件列表 + */ + public List getPlayerHistory(String player) { + Deque history = playerCommandHistory.get(player); + return history == null ? List.of() : new ArrayList<>(history); + } + + /** + * 重置玩家追踪数据 + * + * @param player 玩家名 + */ + public void resetPlayer(String player) { + playerCommandHistory.remove(player); + unknownCommandCounts.remove(player); + } + + /** + * 获取服务运行状态 + * + * @return 包含状态键值对的LinkedHashMap + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiCommandAbuse()); + s.put("totalCommands", totalCommands.get()); + s.put("blockedCommands", blockedCommands.get()); + s.put("activeTrackers", playerCommandHistory.size()); + s.put("commandFloodThreshold", COMMAND_FLOOD_THRESHOLD); + s.put("unknownCmdThreshold", UNKNOWN_COMMAND_THRESHOLD); + + // Top 10 命令类型统计 + Map topCommands = new LinkedHashMap<>(); + commandStats.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(10) + .forEach(e -> topCommands.put(e.getKey(), e.getValue())); + s.put("commandStats", topCommands); + + // 敏感命令数量 + s.put("sensitiveCommandsCount", SENSITIVE_COMMANDS.size()); + return s; + } + + /** + * 命令事件记录 + */ + public static class CommandEvent { + final Instant time; + final String command; + final String commandName; + final boolean exists; + + CommandEvent(Instant time, String command, String commandName, boolean exists) { + this.time = time; + this.command = command; + this.commandName = commandName; + this.exists = exists; + } + + public Instant getTime() { return time; } + public String getCommand() { return command; } + public String getCommandName() { return commandName; } + public boolean isExists() { return exists; } + } + + /** + * 检测结果类 + */ + public static class CheckResult { + private final boolean blocked; + private final List reasons; + + private CheckResult(boolean blocked, List reasons) { + this.blocked = blocked; + this.reasons = reasons; + } + + public static CheckResult clean() { + return new CheckResult(false, List.of()); + } + + public static CheckResult blocked(List reasons) { + return new CheckResult(true, reasons); + } + + public boolean isBlocked() { return blocked; } + public boolean isClean() { return !blocked; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiInventoryManipulationService.java b/src/main/java/com/aluer/security/AntiInventoryManipulationService.java new file mode 100644 index 0000000..a146447 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiInventoryManipulationService.java @@ -0,0 +1,415 @@ +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.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 背包操作异常检测 (Inventory Manipulation) — V4.0 玩家行为安全模块 + * + * 检测原理: + * 部分 Minecraft 外挂客户端利用背包/物品栏操作的漏洞实现非法功能, + * 例如瞬间移动物品、批量丢弃、访问非法槽位等。本模块通过以下维度检测: + * 1. 物品瞬间移动——物品从背包某个槽位移到另一个槽位(或容器)的时间间隔 + * < 1 tick(50ms)为异常,正常操作至少需要 1 tick 的服务器处理时间。 + * 2. 批量丢弃模式——正常每个 tick 最多丢弃 1 个物品(Q键),单 tick 丢弃 + * 超过 1 个物品是发包修改的行为(如 InventoryCleaner)。 + * 3. 非法槽位访问——访问负索引槽位或超过背包允许大小的槽位, + * 正常游戏客户端不会发送此类请求。 + * 4. 自动整理模式——短时间内大量物品槽位被重新排列, + * 正常玩家整理背包有一定节奏,自动化整理极度快速且统一。 + * 5. 物品复制检测——物品从一个槽位移走后,在另一处出现相同物品(可能伴随数量变化)。 + * + * 配置开关:serverguard.security.super-evolution.anti-inventory-manipulation + */ +@Service +public class AntiInventoryManipulationService { + + private final ServerGuardConfig config; + private final Map> playerInvOps = new ConcurrentHashMap<>(); + private final Map> playerSlotStates = new ConcurrentHashMap<>(); + private final Map flaggedPlayers = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + /** 背包操作总次数 */ + private final AtomicLong totalInventoryOps = new AtomicLong(0); + /** 背包操作违规次数 */ + private final AtomicLong manipulationViolations = new AtomicLong(0); + /** 槽位违规次数 */ + private final AtomicLong slotViolations = new AtomicLong(0); + + /** 物品移动最小时间间隔(毫秒)——低于此值视为瞬间移动 */ + private static final long MIN_MOVE_INTERVAL_MS = 50; + + /** 单 tick 最大丢弃物品数 */ + private static final int MAX_DROPS_PER_TICK = 1; + + /** 玩家背包最小槽位索引 */ + private static final int MIN_SLOT_INDEX = 0; + + /** 玩家背包最大槽位索引(36 格背包 + 1 副手 = 37 个独立槽位,0-based 为 36) */ + private static final int MAX_SLOT_INDEX = 40; + + /** 玩家可用的最大槽位索引(含盔甲栏 = 40) */ + private static final int MAX_VALID_SLOT = 40; + + /** 短时间内大量物品重排的检测窗口(秒) */ + private static final long REARRANGE_WINDOW_SEC = 3; + + /** 短时间内容器物品变化次数 —— 超过此阈值判定为自动整理 */ + private static final int MAX_SLOT_CHANGES_SHORT = 20; + + /** 记录保留时间(秒) */ + private static final long RECORD_RETENTION_SECONDS = 300; + + /** 标记持续时间(秒) */ + private static final long FLAG_DURATION_SECONDS = 1800; + + public AntiInventoryManipulationService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiInventoryManipulationService(ServerGuardConfig config) { + this.config = config; + scheduler.scheduleAtFixedRate(this::cleanupOldRecords, 60, 120, TimeUnit.SECONDS); + } + + /** + * 检测一次物品槽位移动操作。 + * + * 追踪物品从一个槽位移动到另一个槽位,检测瞬间移动和非法槽位访问。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param fromSlot 来源槽位索引 + * @param toSlot 目标槽位索引 + * @param itemType 移动的物品类型(可为 null) + * @param itemCount 移动的物品数量 + * @param containerId 容器 ID(-1 表示玩家自己背包) + * @return 检测结果 + */ + public DetectionResult detectItemMove(String playerName, String playerUUID, + int fromSlot, int toSlot, String itemType, + int itemCount, int containerId) { + if (!config.getSecurity().getSuperEvolution().isAntiInventoryManipulation()) { + return DetectionResult.clean(); + } + + // 检查标记 + Instant flaggedUntil = flaggedPlayers.get(playerName); + if (flaggedUntil != null) { + if (Instant.now().isAfter(flaggedUntil)) { + flaggedPlayers.remove(playerName); + } else { + return DetectionResult.flagged(List.of( + "PLAYER_ALREADY_FLAGGED: " + playerName)); + } + } + + Instant now = Instant.now(); + List ops = playerInvOps.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + + // 记录操作 + InventoryOp op = new InventoryOp(now, "MOVE", fromSlot, toSlot, itemType, + itemCount, containerId); + ops.add(op); + totalInventoryOps.incrementAndGet(); + + int score = 0; + List reasons = new ArrayList<>(); + + // === 检测 1: 物品瞬间移动 === + // 查找同一物品在前一个 tick 内是否有其他移动操作 + long tickStart = now.toEpochMilli() - MIN_MOVE_INTERVAL_MS; + long movesInTick = ops.stream() + .filter(o -> "MOVE".equals(o.opType)) + .filter(o -> o.time.toEpochMilli() > tickStart) + .count(); + + if (movesInTick > 2) { + score += 30; + reasons.add("INSTANT_MOVE: " + movesInTick + + " slot moves in " + MIN_MOVE_INTERVAL_MS + "ms (packet exploit)"); + } + + // === 检测 2: 非法槽位访问 === + if (fromSlot < MIN_SLOT_INDEX || fromSlot > MAX_SLOT_INDEX + || toSlot < MIN_SLOT_INDEX || toSlot > MAX_SLOT_INDEX) { + score += 40; + slotViolations.incrementAndGet(); + reasons.add("INVALID_SLOT: access slot[" + fromSlot + "->" + toSlot + + "] (valid range " + MIN_SLOT_INDEX + "-" + MAX_SLOT_INDEX + ")"); + } + + // === 检测 3: 自动整理模式 === + // 短时间内容器物品变化次数 + long rearrangeWindowStart = now.toEpochMilli() - (REARRANGE_WINDOW_SEC * 1000); + long slotChanges = ops.stream() + .filter(o -> o.time.toEpochMilli() > rearrangeWindowStart) + .count(); + + if (slotChanges > MAX_SLOT_CHANGES_SHORT) { + score += 25; + reasons.add("AUTO_SORT: " + slotChanges + + " slot changes in " + REARRANGE_WINDOW_SEC + "s (automated inventory sorting)"); + } + + // === 检测 4: 物品复制检测 === + // 追踪物品槽位状态变化,检测疑似复制 + if (itemType != null && itemCount > 0) { + Map slotStates = playerSlotStates.computeIfAbsent(playerName, + k -> new ConcurrentHashMap<>()); + + // 检查来源槽位是否有状态记录 + InventorySlotState fromState = slotStates.get(fromSlot); + if (fromState != null && fromState.itemType != null + && fromState.itemType.equals(itemType)) { + // 物品移出:数量减少是正常的,但数量增加(超过原记录)是异常的 + // 这是正常情况下物品在移出(我们会更新状态追踪) + } + + // 更新槽位状态 + slotStates.put(toSlot, new InventorySlotState(itemType, itemCount, now)); + if (fromState != null) { + // 移出后标记为空 + slotStates.put(fromSlot, new InventorySlotState("EMPTY", 0, now)); + } + } + + if (score >= 40) { + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + manipulationViolations.incrementAndGet(); + return DetectionResult.flagged(reasons); + } else if (score >= 20) { + return DetectionResult.suspicious(score, reasons); + } + + return DetectionResult.clean(); + } + + /** + * 检测物品丢弃操作——分析是否使用了批量丢弃外挂。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param itemType 丢弃的物品类型 + * @param itemCount 丢弃数量 + * @param slot 丢弃来源槽位 + * @return 检测结果 + */ + public DetectionResult detectItemDrop(String playerName, String playerUUID, + String itemType, int itemCount, int slot) { + if (!config.getSecurity().getSuperEvolution().isAntiInventoryManipulation()) { + return DetectionResult.clean(); + } + + // 检查标记 + Instant flaggedUntil = flaggedPlayers.get(playerName); + if (flaggedUntil != null) { + if (Instant.now().isAfter(flaggedUntil)) { + flaggedPlayers.remove(playerName); + } else { + return DetectionResult.flagged(List.of("PLAYER_ALREADY_FLAGGED")); + } + } + + Instant now = Instant.now(); + List ops = playerInvOps.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + + // 记录丢弃事件 + InventoryOp op = new InventoryOp(now, "DROP", slot, -1, itemType, + itemCount, -1); + ops.add(op); + totalInventoryOps.incrementAndGet(); + + int score = 0; + List reasons = new ArrayList<>(); + + // === 批量丢弃检测 === + // 在 50ms 内丢弃多个物品 = InventoryCleaner 行为 + long tickStart = now.toEpochMilli() - 50; + long dropsInTick = ops.stream() + .filter(o -> "DROP".equals(o.opType)) + .filter(o -> o.time.toEpochMilli() > tickStart) + .count(); + + if (dropsInTick > MAX_DROPS_PER_TICK) { + score += 45; + reasons.add("BATCH_DROP: " + dropsInTick + + " items dropped in single tick (InventoryCleaner signature, max normal=" + + MAX_DROPS_PER_TICK + ")"); + } + + // === 快速连续丢弃 === + long recentDrops = ops.stream() + .filter(o -> "DROP".equals(o.opType)) + .filter(o -> o.time.toEpochMilli() > now.toEpochMilli() - 1000) + .count(); + + if (recentDrops >= 10) { + score += 20; + reasons.add("RAPID_DROP: " + recentDrops + " items dropped in 1 second"); + } + + if (score >= 40) { + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + manipulationViolations.incrementAndGet(); + return DetectionResult.flagged(reasons); + } else if (score >= 20) { + return DetectionResult.suspicious(score, reasons); + } + + return DetectionResult.clean(); + } + + /** + * 清除指定玩家的所有追踪数据。 + * + * @param playerName 玩家名称 + */ + public void clearPlayer(String playerName) { + playerInvOps.remove(playerName); + playerSlotStates.remove(playerName); + flaggedPlayers.remove(playerName); + } + + /** + * 获取模块运行状态。 + * + * @return 包含统计数据的 LinkedHashMap + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalInventoryOps", totalInventoryOps.get()); + s.put("manipulationViolations", manipulationViolations.get()); + s.put("slotViolations", slotViolations.get()); + s.put("trackedPlayers", playerInvOps.size()); + s.put("flaggedPlayers", flaggedPlayers.size()); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiInventoryManipulation()); + + List> flagged = new ArrayList<>(); + for (Map.Entry e : flaggedPlayers.entrySet()) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("flaggedUntil", e.getValue().toString()); + List ops = playerInvOps.get(e.getKey()); + if (ops != null) { + m.put("totalOperations", ops.size()); + } + flagged.add(m); + } + s.put("flaggedPlayersList", flagged); + + return s; + } + + public long getTotalInventoryOps() { return totalInventoryOps.get(); } + public long getManipulationViolations() { return manipulationViolations.get(); } + public long getSlotViolations() { return slotViolations.get(); } + + /** + * 定期清理过期记录。 + */ + private void cleanupOldRecords() { + Instant cutoff = Instant.now().minusSeconds(RECORD_RETENTION_SECONDS); + playerInvOps.entrySet().removeIf(e -> { + List ops = e.getValue(); + ops.removeIf(o -> o.time.isBefore(cutoff)); + return ops.isEmpty(); + }); + playerSlotStates.entrySet().removeIf(e -> e.getValue().isEmpty()); + flaggedPlayers.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + // ==================== 内部数据类 ==================== + + /** + * 背包操作记录——记录每次物品移动/丢弃等操作。 + */ + private static class InventoryOp { + final Instant time; + final String opType; // "MOVE" / "DROP" + final int fromSlot; + final int toSlot; // -1 for drops + final String itemType; + final int itemCount; + final int containerId; + + InventoryOp(Instant time, String opType, int fromSlot, int toSlot, + String itemType, int itemCount, int containerId) { + this.time = time; + this.opType = opType; + this.fromSlot = fromSlot; + this.toSlot = toSlot; + this.itemType = itemType; + this.itemCount = itemCount; + this.containerId = containerId; + } + } + + /** + * 背包槽位状态快照——追踪每个槽位的内容变化。 + */ + private static class InventorySlotState { + final String itemType; + final int itemCount; + final Instant snapshotTime; + + InventorySlotState(String itemType, int itemCount, Instant snapshotTime) { + this.itemType = itemType; + this.itemCount = itemCount; + this.snapshotTime = snapshotTime; + } + } + + // ==================== 检测结果类 ==================== + + /** + * 背包操作异常检测结果。 + */ + public static class DetectionResult { + private final boolean flagged; + private final boolean suspicious; + private final int score; + private final List reasons; + + private DetectionResult(boolean flagged, boolean suspicious, int score, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.score = score; + this.reasons = reasons; + } + + /** 无异常:背包操作正常 */ + public static DetectionResult clean() { + return new DetectionResult(false, false, 0, List.of()); + } + + /** 可疑行为:存在异常背包操作模式 */ + public static DetectionResult suspicious(int score, List reasons) { + return new DetectionResult(false, true, score, reasons); + } + + /** 已标记:检测到高置信度背包操作异常 */ + public static DetectionResult flagged(List reasons) { + return new DetectionResult(true, true, 100, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiJesusService.java b/src/main/java/com/aluer/security/AntiJesusService.java new file mode 100644 index 0000000..7058543 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiJesusService.java @@ -0,0 +1,297 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 水上/岩浆上行走(Jesus)检测服务 — V4.0 反作弊扩展模块 + * + * 检测原理: + * 1. 液体表面行走检测 — Minecraft中玩家无法在水面或岩浆面上稳定行走而不下沉。 + * 正常情况下,玩家在液体上会立即开始下沉。Jesus hack通过在客户端伪造液体为固体方块, + * 使玩家可以在液体表面上行走。 + * 2. 持续水上行走时间检测 — 追踪玩家在液体上方的连续时间,超过1秒则标记。 + * 正常玩家在接触液体时会立即开始垂直移动(下沉或游泳),而Jesus hack用户Y坐标保持稳定。 + * 3. 耶稣模式抖动检测 — 典型的hack客户端会表现出"抖动"模式: + * 玩家Y坐标在水面附近小幅振荡(尝试维持在水面),而不是自然下沉。 + * 4. 水面Y坐标估算 — 通过追踪液体表面的Y坐标(通常是整数Y值), + * 判断玩家是否站在液体方块的上表面(精确站在水面上是Jesus hack的特征)。 + * + * 配置开关:serverguard.security.super-evolution.anti-jesus + */ +@Service +public class AntiJesusService { + + private final ServerGuardConfig config; + + /** + * 追踪每个玩家与液体相关的移动记录(playerName -> 记录列表) + */ + private final Map> playerLiquidHistory = new ConcurrentHashMap<>(); + + /** + * 追踪每个玩家在液体上方的连续时间(playerName -> 首次检测到水上行走的时间戳) + */ + private final Map playerJesusStart = new ConcurrentHashMap<>(); + + /** + * 追踪每个玩家最近的Y坐标(用于抖动检测) + */ + private final Map> playerYHistory = new ConcurrentHashMap<>(); + + private final AtomicLong totalChecks = new AtomicLong(0); + private final AtomicLong jesusViolations = new AtomicLong(0); + + /** + * 连续水上行走时间阈值(毫秒)— 超过1秒标记 + */ + private static final long MAX_JESUS_DURATION_MS = 1_000; + + /** + * 水面Y坐标误差容差(方块)— 玩家Y坐标在此范围内被视为在水面高度 + * Minecraft中一个完整方块的高度为1.0,玩家脚部Y值与其所站方块顶面相同 + */ + private static final double LIQUID_SURFACE_TOLERANCE = 0.3; + + /** + * 抖动检测 — Y坐标波动阈值,Jesus hack用户会在水面高度附近小幅振动 + * 正常玩家在水中会稳定下沉或游泳 + */ + private static final double JITTER_AMPLITUDE_MAX = 0.15; + + /** + * 抖动检测 — 连续小幅度波动次数超过此值视为Jesus hack的抖动特征 + */ + private static final int MAX_JITTER_WAVES = 5; + + /** + * 每个玩家保留的最大记录数 + */ + private static final int MAX_RECORDS_PER_PLAYER = 30; + + /** + * 液体表面的典型Y偏移 — 液体方块的顶面Y值是整数+偏移,水在整数高度上方, + * 玩家站在水面上的脚Y值约为液体方块Y值(整数)+1.0的位置附近 + */ + + /** + * 无参构造函数 — 测试/默认配置使用 + */ + public AntiJesusService() { + this(new ServerGuardConfig()); + } + + /** + * Spring 注入构造函数 + * @param config 全局配置,提供模块开关控制 + */ + public AntiJesusService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 检测玩家是否在水或岩浆上非法行走 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家UUID + * @param playerY 玩家脚部Y坐标 + * @param liquidSurfaceY 液体表面Y坐标(整数,液体方块顶面的Y值) + * @param liquidType 液体类型 ("WATER" 或 "LAVA") + * @param onGround 玩家是否声称在地面 + * @param isMoving 玩家是否在移动中 + * @param timestamp 时间戳 + * @return 检测结果 + */ + public JesusCheckResult detect(String playerName, String playerUUID, + double playerY, double liquidSurfaceY, + String liquidType, boolean onGround, + boolean isMoving, Instant timestamp) { + // 模块开关检查 — 关闭时跳过所有检测 + if (!config.getSecurity().getSuperEvolution().isAntiJesus()) { + return JesusCheckResult.clean(); + } + + totalChecks.incrementAndGet(); + + List history = playerLiquidHistory.computeIfAbsent( + playerName, k -> new ArrayList<>()); + + List yHistory = playerYHistory.computeIfAbsent( + playerName, k -> new ArrayList<>()); + + LiquidRecord record = new LiquidRecord(timestamp, playerY, liquidSurfaceY, liquidType, onGround, isMoving); + history.add(record); + yHistory.add(playerY); + + while (history.size() > MAX_RECORDS_PER_PLAYER) { + history.remove(0); + } + while (yHistory.size() > 20) { + yHistory.remove(0); + } + + List reasons = new ArrayList<>(); + + // 1. 液体表面行走检测 — 玩家Y坐标在液体表面高度附近,且声称在地面 + double yDiff = playerY - liquidSurfaceY; + + // 玩家在液体表面时,如果实际站在液体上方,脚Y应该在液体方块表面Y值处 + // 正常的液体方块表面在Y = liquidSurfaceY(方块整数Y值的顶面) + boolean isOnLiquidSurface = Math.abs(yDiff) <= LIQUID_SURFACE_TOLERANCE; + + if (isOnLiquidSurface && onGround) { + // 玩家声称站在液体表面上 — Jesus hack特征 + playerJesusStart.putIfAbsent(playerName, timestamp); + + Instant jesusStart = playerJesusStart.get(playerName); + long jesusDurationMs = timestamp.toEpochMilli() - jesusStart.toEpochMilli(); + + if (jesusDurationMs >= MAX_JESUS_DURATION_MS) { + // 持续在液体表面行走 — 确认Jesus hack + jesusViolations.incrementAndGet(); + reasons.add("SUSTAINED_JESUS: " + (jesusDurationMs / 1000.0) + + "s walking on " + liquidType.toLowerCase() + + " surface (Y=" + String.format("%.2f", playerY) + + ", surface=" + String.format("%.1f", liquidSurfaceY) + ")"); + } + } else { + // 不再在液体表面,清除计时器 + playerJesusStart.remove(playerName); + } + + // 2. 抖动检测 — 检测Y坐标在液体表面附近的小幅振荡 + if (isOnLiquidSurface && yHistory.size() >= 6) { + int jitterCount = 0; + double prevDiff = yHistory.get(0) - liquidSurfaceY; + + for (int i = 1; i < yHistory.size(); i++) { + double currentDiff = yHistory.get(i) - liquidSurfaceY; + // 检测相邻两次Y值是否都在阈值内但方向相反(抖动特征) + if (Math.abs(prevDiff) <= JITTER_AMPLITUDE_MAX + && Math.abs(currentDiff) <= JITTER_AMPLITUDE_MAX + && prevDiff * currentDiff < 0) { + jitterCount++; + } + prevDiff = currentDiff; + } + + // 如果抖动次数过多,这是Jesus hack的典型行为模式 + if (jitterCount >= MAX_JITTER_WAVES) { + reasons.add("JITTER_PATTERN: " + jitterCount + + " oscillations near " + liquidType.toLowerCase() + + " surface (amplitude < " + String.format("%.2f", JITTER_AMPLITUDE_MAX) + ")"); + } + } + + // 3. 异常稳定性检测 — 在液体表面完全静止站立(不自然的行为) + if (isOnLiquidSurface && !isMoving && onGround) { + // 收集足够的静止记录 + long stationaryCount = history.stream() + .filter(r -> r.onLiquidSurface && !r.isMoving && r.onGround) + .count(); + if (stationaryCount >= 8) { + reasons.add("STATIONARY_ON_LIQUID: standing still on " + liquidType.toLowerCase() + + " surface (" + stationaryCount + " consecutive checks)"); + } + } + + if (reasons.size() >= 2) { + return JesusCheckResult.flagged(reasons); + } else if (reasons.size() == 1) { + return JesusCheckResult.suspicious(reasons); + } + + return JesusCheckResult.clean(); + } + + /** + * 玩家离线时清理追踪数据 + * @param playerName 离线玩家名称 + */ + public void clearPlayer(String playerName) { + playerLiquidHistory.remove(playerName); + playerJesusStart.remove(playerName); + playerYHistory.remove(playerName); + } + + /** + * 获取模块运行状态 + * @return 包含计数器和违规数量的Map + */ + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("totalChecks", totalChecks.get()); + status.put("jesusViolations", jesusViolations.get()); + status.put("activeTrackedPlayers", playerLiquidHistory.size()); + + // 列出当前疑似Jesus的玩家 + List suspectedPlayers = new ArrayList<>(playerJesusStart.keySet()); + status.put("suspectedJesusPlayers", suspectedPlayers); + return status; + } + + /** + * 内部液体接触记录 — 记录玩家与液体交互的时空信息 + */ + private static class LiquidRecord { + final Instant timestamp; + final double playerY; + final double liquidSurfaceY; + final String liquidType; + final boolean onGround; + final boolean onLiquidSurface; + final boolean isMoving; + + LiquidRecord(Instant timestamp, double playerY, double liquidSurfaceY, + String liquidType, boolean onGround, boolean isMoving) { + this.timestamp = timestamp; + this.playerY = playerY; + this.liquidSurfaceY = liquidSurfaceY; + this.liquidType = liquidType; + this.onGround = onGround; + this.isMoving = isMoving; + // 计算是否在液体表面上 + this.onLiquidSurface = Math.abs(playerY - liquidSurfaceY) <= LIQUID_SURFACE_TOLERANCE; + } + } + + /** + * Jesus检测结果 — 不可变结果类 + */ + public static class JesusCheckResult { + private final boolean flagged; + private final boolean suspicious; + private final List reasons; + + private JesusCheckResult(boolean flagged, boolean suspicious, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.reasons = reasons; + } + + /** 无异常 — 玩家正常与液体交互(游泳/下沉) */ + public static JesusCheckResult clean() { + return new JesusCheckResult(false, false, List.of()); + } + + /** 可疑 — 短暂在液体表面但不足够确认为Jesus hack */ + public static JesusCheckResult suspicious(List reasons) { + return new JesusCheckResult(false, true, reasons); + } + + /** 已标记 — 确认在液体表面非法行走(Jesus hack) */ + public static JesusCheckResult flagged(List reasons) { + return new JesusCheckResult(true, false, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiKillAuraService.java b/src/main/java/com/aluer/security/AntiKillAuraService.java new file mode 100644 index 0000000..2e25441 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiKillAuraService.java @@ -0,0 +1,284 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 杀戮光环(KillAura)检测服务 — V4.0 反作弊扩展模块 + * + * 检测原理: + * 1. 攻击目标切换频率检测 — 追踪每个玩家的攻击目标列表,如果在3秒窗口内切换超过3个不同目标, + * 表明玩家可能在无差别攻击周围所有实体(KillAura会自动锁定范围内的所有目标) + * 2. 攻击角度一致性检测 — 连续攻击的角度偏差小于5度时,表明存在自动瞄准(Aimbot), + * 正常玩家无法在多次攻击中保持如此高度一致的角度 + * 3. 攻击距离模式检测 — 如果玩家多次在最大攻击距离(约3.0方块)处精确命中目标, + * 表明可能存在Reach扩展和KillAura的联合使用 + * 4. 多点同时攻击检测 — 检测单个tick内对多个目标造成伤害的异常行为 + * + * 配置开关:serverguard.security.super-evolution.anti-kill-aura + */ +@Service +public class AntiKillAuraService { + + private final ServerGuardConfig config; + + /** + * 追踪每个玩家在时间窗口内的攻击目标记录(playerName -> 攻击记录列表) + * 用于检测短时间内的多目标切换行为 + */ + private final Map> playerAttackHistory = new ConcurrentHashMap<>(); + + /** + * 追踪每个玩家的连续攻击角度记录(playerName -> 最近5次攻击角度列表) + * 用于检测自动瞄准的角度一致性 + */ + private final Map> playerAngleHistory = new ConcurrentHashMap<>(); + + /** + * 追踪每个玩家的攻击距离记录(playerName -> 距离列表) + * 用于检测精确的极限距离攻击模式 + */ + private final Map> playerDistanceHistory = new ConcurrentHashMap<>(); + + private final AtomicLong totalChecks = new AtomicLong(0); + private final AtomicLong flaggedCount = new AtomicLong(0); + + /** + * 目标切换检测窗口(秒)— 在此窗口内如果切换目标过于频繁则标记 + */ + private static final long TARGET_SWITCH_WINDOW_MS = 3_000; + + /** + * 窗口内最大允许的目标切换次数 — 超过此值被视为异常快速切换 + */ + private static final int MAX_TARGET_SWITCHES = 3; + + /** + * 角度一致性阈值(度)— 连续攻击角度偏差小于此值表明自动瞄准 + */ + private static final double AIMBOT_ANGLE_THRESHOLD = 5.0; + + /** + * 角度一致性检测所需的最小连续攻击次数 + */ + private static final int MIN_CONSECUTIVE_ATTACKS = 3; + + /** + * Minecraft生存模式最大合法攻击距离(考虑延迟容差) + */ + private static final double MAX_LEGITIMATE_REACH = 3.0; + + /** + * 精确极限距离攻击判定阈值 — 在最大距离附近精确攻击的次数 + */ + private static final double PERFECT_REACH_THRESHOLD = 2.8; + + /** + * 最大距离攻击次数阈值 — 超过此值标记 + */ + private static final int MAX_PERFECT_REACH_COUNT = 4; + + /** + * 无参构造函数 — 测试/默认配置使用 + */ + public AntiKillAuraService() { + this(new ServerGuardConfig()); + } + + /** + * Spring 注入构造函数 + * @param config 全局配置,提供模块开关控制 + */ + public AntiKillAuraService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 检测玩家攻击行为是否存在杀戮光环特征 + * + * @param playerName 攻击者玩家名称 + * @param playerUUID 攻击者UUID + * @param targetName 被攻击目标名称 + * @param attackYaw 攻击时的水平角度(度数) + * @param attackPitch 攻击时的俯仰角度(度数) + * @param distance 攻击者与目标的距离(方块) + * @param timestamp 攻击时间戳 + * @return 检测结果 + */ + public KillAuraCheckResult detect(String playerName, String playerUUID, + String targetName, double attackYaw, double attackPitch, + double distance, Instant timestamp) { + // 模块开关检查 — 关闭时跳过所有检测直接返回安全结果 + if (!config.getSecurity().getSuperEvolution().isAntiKillAura()) { + return KillAuraCheckResult.clean(); + } + + totalChecks.incrementAndGet(); + List reasons = new ArrayList<>(); + + // 1. 攻击目标切换频率检测 + List attackHistory = playerAttackHistory.computeIfAbsent( + playerName, k -> Collections.synchronizedList(new ArrayList<>())); + attackHistory.add(new AttackRecord(timestamp, targetName, distance, attackYaw, attackPitch)); + + // 清理超出时间窗口的旧记录 + Instant windowStart = timestamp.minusMillis(TARGET_SWITCH_WINDOW_MS); + attackHistory.removeIf(r -> r.timestamp.isBefore(windowStart)); + + // 统计窗口内不同目标数量 + Set uniqueTargets = new HashSet<>(); + for (AttackRecord r : attackHistory) { + uniqueTargets.add(r.targetName); + } + + if (attackHistory.size() >= 5 && uniqueTargets.size() > MAX_TARGET_SWITCHES) { + // 短时间内攻击了过多不同目标 — 典型的KillAura无差别攻击模式 + reasons.add("RAPID_TARGET_SWITCH: " + uniqueTargets.size() + " targets in " + + TARGET_SWITCH_WINDOW_MS / 1000 + "s"); + } + + // 2. 攻击角度一致性检测(Aimbot检测) + List angleHistory = playerAngleHistory.computeIfAbsent( + playerName, k -> new ArrayList<>()); + + // 使用组合角度(yaw变化 + pitch变化)来检测一致性 + double combinedAngle = Math.abs(attackYaw % 360.0) + Math.abs(attackPitch % 360.0); + angleHistory.add(combinedAngle); + + // 保留最近10次攻击角度 + while (angleHistory.size() > 10) { + angleHistory.remove(0); + } + + if (angleHistory.size() >= MIN_CONSECUTIVE_ATTACKS) { + double maxDeviation = 0; + double[] angles = new double[angleHistory.size()]; + for (int i = 0; i < angleHistory.size(); i++) { + angles[i] = angleHistory.get(i); + } + // 计算连续攻击间的角度偏差 + for (int i = 1; i < angles.length; i++) { + double diff = Math.abs(angles[i] - angles[i - 1]); + if (diff > maxDeviation) maxDeviation = diff; + } + // 如果所有连续攻击的角度偏差都很小,说明是自动瞄准 + if (maxDeviation < AIMBOT_ANGLE_THRESHOLD && angleHistory.size() >= 5) { + reasons.add("AIMBOT_ANGLE_CONSISTENCY: max deviation " + String.format("%.2f", maxDeviation) + " degrees"); + } + } + + // 3. 攻击距离模式检测 — 精确极限距离攻击 + List distanceHistory = playerDistanceHistory.computeIfAbsent( + playerName, k -> new ArrayList<>()); + distanceHistory.add(distance); + + // 保留最近20次攻击距离 + while (distanceHistory.size() > 20) { + distanceHistory.remove(0); + } + + // 统计在完美距离范围内(可判定为使用reach+hacks)的攻击次数 + long perfectReachHits = distanceHistory.stream() + .filter(d -> d >= PERFECT_REACH_THRESHOLD && d <= MAX_LEGITIMATE_REACH + 0.1) + .count(); + + if (distanceHistory.size() >= 10 && perfectReachHits >= MAX_PERFECT_REACH_COUNT) { + reasons.add("PERFECT_REACH_PATTERN: " + perfectReachHits + "/" + distanceHistory.size() + + " hits at max reach (" + String.format("%.2f", PERFECT_REACH_THRESHOLD) + "~" + + String.format("%.2f", MAX_LEGITIMATE_REACH) + " blocks)"); + } + + // 汇总结果 + if (reasons.size() >= 2) { + // 多种检测规则同时命中 — 高度可疑 + flaggedCount.incrementAndGet(); + return KillAuraCheckResult.flagged(reasons); + } else if (reasons.size() == 1) { + // 单一规则命中 — 仅标记为可疑 + return KillAuraCheckResult.suspicious(reasons); + } + + return KillAuraCheckResult.clean(); + } + + /** + * 玩家离线时清理其追踪数据,释放内存 + * @param playerName 离线玩家名称 + */ + public void clearPlayer(String playerName) { + playerAttackHistory.remove(playerName); + playerAngleHistory.remove(playerName); + playerDistanceHistory.remove(playerName); + } + + /** + * 获取模块运行状态 + * @return 包含计数器和活跃追踪玩家数的Map + */ + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("totalChecks", totalChecks.get()); + status.put("flaggedCount", flaggedCount.get()); + status.put("activeTrackedPlayers", playerAttackHistory.size()); + return status; + } + + /** + * 内部攻击记录 — 记录单次攻击的时间、目标和空间信息 + */ + private static class AttackRecord { + final Instant timestamp; + final String targetName; + final double distance; + final double yaw; + final double pitch; + + AttackRecord(Instant timestamp, String targetName, double distance, double yaw, double pitch) { + this.timestamp = timestamp; + this.targetName = targetName; + this.distance = distance; + this.yaw = yaw; + this.pitch = pitch; + } + } + + /** + * 杀戮光环检测结果 — 不可变结果类 + */ + public static class KillAuraCheckResult { + private final boolean flagged; + private final boolean suspicious; + private final List reasons; + + private KillAuraCheckResult(boolean flagged, boolean suspicious, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.reasons = reasons; + } + + /** 无异常 — 正常攻击行为 */ + public static KillAuraCheckResult clean() { + return new KillAuraCheckResult(false, false, List.of()); + } + + /** 可疑 — 可能使用了KillAura但证据不够充分 */ + public static KillAuraCheckResult suspicious(List reasons) { + return new KillAuraCheckResult(false, true, reasons); + } + + /** 已标记 — 多项检测规则命中,高度可能使用KillAura */ + public static KillAuraCheckResult flagged(List reasons) { + return new KillAuraCheckResult(true, false, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiNameSpoofService.java b/src/main/java/com/aluer/security/AntiNameSpoofService.java new file mode 100644 index 0000000..f021112 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiNameSpoofService.java @@ -0,0 +1,458 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.text.Normalizer; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 玩家名称冒充检测 — V4.0 访问控制模块 + * + * 检测原理: + * 1. 检测新加入玩家的名称是否通过Unicode同形字符(homoglyph)冒充现有玩家或管理员 + * - 例如用 ᴀ (U+1D00) 替换 A, 用 ο (Greek omicron U+03BF) 替换 o + * - 用 і (Cyrillic U+0456) 替换 i, 用 а (Cyrillic U+0430) 替换 a + * 2. 检测包含不可见/零宽字符的玩家名(U+200B零宽空格、U+200C零宽非连接符等) + * 这些字符在视觉上不可见但会使玩家名在系统中识别为不同名称 + * 3. 大小写欺骗检测:Admin → admin, ADMIN, Admín 等变体 + * Minecraft用户名不区分大小写,但利用相似Unicode字符可绕过展示层检查 + * 4. 维护已知冒充目标名单(所有在线OP玩家名 + 知名Minecraft人物名 + 服务器自定义名单) + * 新玩家加入时计算与该名单中每一项的相似度 + * 5. 使用 Levenshtein 编辑距离算法计算名称相似度,并配合 Unicode NFKC 标准化后比对 + * 相似度阈值可配置,超过阈值的名称标记为冒充尝试 + * 6. 检测名称中包含的空格/特殊前缀后缀(如 [Admin] PlayerName 格式混淆) + * + * 配置开关:serverguard.security.super-evolution.anti-name-spoof + */ +@Service +public class AntiNameSpoofService { + + private final ServerGuardConfig config; + + /** 需保护的名称集合:OP名、管理员名、知名人物名 */ + private final Set protectedNames = ConcurrentHashMap.newKeySet(); + /** 自定义虚假目标(特殊名单) */ + private final Set customSpoofTargets = ConcurrentHashMap.newKeySet(); + /** 已检测到的冒充尝试 */ + private final Map> spoofHistory = new ConcurrentHashMap<>(); + /** 已被阻止的冒充名称集合 */ + private final Set blockedNames = ConcurrentHashMap.newKeySet(); + + private final AtomicLong totalNameChecks = new AtomicLong(0); + private final AtomicLong spoofAttempts = new AtomicLong(0); + + /** + * Unicode同形字符映射表:视觉相似但Unicode码点不同的字符 + * key = 标准ASCII字母,value = 其Unicode同形字符列表 + */ + private static final Map> HOMOGLYPH_MAP = Map.ofEntries( + Map.entry('A', List.of('A', 'A', 'Á', 'À', 'Â', 'Ã', 'Ä', 'Å', 'Ā', 'Ă', 'Ą')), + Map.entry('a', List.of('a', 'á', 'à', 'â', 'ã', 'ä', 'å', 'ā', 'ă', 'ą', '@')), + Map.entry('B', List.of('B', 'ß', 'Þ')), + Map.entry('b', List.of('b', 'þ')), + Map.entry('C', List.of('C', 'Ç', 'Ć', 'Č')), + Map.entry('c', List.of('c', 'ç', 'ć', 'č')), + Map.entry('E', List.of('E', 'È', 'É', 'Ê', 'Ë', 'Ē', 'Ĕ', 'Ė', 'Ę', 'Ě')), + Map.entry('e', List.of('e', 'è', 'é', 'ê', 'ë', 'ē', 'ĕ', 'ė', 'ę', 'ě')), + Map.entry('H', List.of('H', 'Ĥ', 'Ħ')), + Map.entry('h', List.of('h', 'ĥ', 'ħ')), + Map.entry('I', List.of('I', 'Ì', 'Í', 'Î', 'Ï', 'Ĩ', 'Ī', 'Ĭ', 'Į', 'İ')), + Map.entry('i', List.of('i', 'ì', 'í', 'î', 'ï', 'ĩ', 'ī', 'ĭ', 'į', 'ı', '¡')), + Map.entry('K', List.of('K', 'Ķ', 'Œ')), + Map.entry('k', List.of('k', 'ķ', 'ĸ')), + Map.entry('l', List.of('l', 'ł', 'ŀ', 'ĺ', 'ļ', 'ľ')), + Map.entry('N', List.of('N', 'Ñ', 'Ń', 'Ņ', 'Ň')), + Map.entry('n', List.of('n', 'ñ', 'ń', 'ņ', 'ň', 'ʼn')), + Map.entry('O', List.of('O', 'Ò', 'Ó', 'Ô', 'Õ', 'Ö', 'Ø', 'Ō', 'Ŏ', 'Ő')), + Map.entry('o', List.of('o', 'ò', 'ó', 'ô', 'õ', 'ö', 'ø', 'ō', 'ŏ', 'ő', 'ο', 'о')), + Map.entry('P', List.of('P', 'Þ')), + Map.entry('p', List.of('p', 'þ')), + Map.entry('S', List.of('S', 'Ś', 'Ŝ', 'Ş', 'Š')), + Map.entry('s', List.of('s', 'ś', 'ŝ', 'ş', 'š')), + Map.entry('T', List.of('T', 'Ţ', 'Ť', 'Ŧ')), + Map.entry('t', List.of('t', 'ţ', 'ť', 'ŧ')), + Map.entry('U', List.of('U', 'Ù', 'Ú', 'Û', 'Ü', 'Ũ', 'Ū', 'Ŭ', 'Ů', 'Ű', 'Ų')), + Map.entry('u', List.of('u', 'ù', 'ú', 'û', 'ü', 'ũ', 'ū', 'ŭ', 'ů', 'ű', 'ų', 'µ')), + Map.entry('Y', List.of('Y', 'Ý', 'Ŷ', 'Ÿ')), + Map.entry('y', List.of('y', 'ý', 'ÿ', 'ŷ')), + Map.entry('Z', List.of('Z', 'Ź', 'Ż', 'Ž')), + Map.entry('z', List.of('z', 'ź', 'ż', 'ž')) + ); + + /** 零宽/不可见Unicode字符 */ + private static final Set INVISIBLE_CHARACTERS = Set.of( + '​', // 零宽空格 Zero Width Space + '‌', // 零宽非连接符 Zero Width Non-Joiner + '‍', // 零宽连接符 Zero Width Joiner + '‎', // 左到右标记 Left-to-Right Mark + '‏', // 右到左标记 Right-to-Left Mark + '⁠', // 词连接符 Word Joiner + '⁡', // 函数应用 Function Application + '⁢', // 不可见乘号 Invisible Times + '⁣', // 不可见分隔符 Invisible Separator + '⁤', // 不可见加号 Invisible Plus + '', // 零宽不中断空格 Zero Width No-Break Space / BOM + '­', // 软连接符 Soft Hyphen + '͏', // 组合用字素连接符 Combining Grapheme Joiner + '؜', // 阿拉伯字母格式标记 Arabic Letter Mark + '᠎', // 蒙古语元音分隔符(已在Unicode 6.3弃用但仍可能被滥用) + '⁦', // 左到右隔离 Left-to-Right Isolate + '⁧', // 右到左隔离 Right-to-Left Isolate + '⁨', // 第一个强隔离 First Strong Isolate + '⁩' // 弹出方向隔离 Pop Directional Isolate + ); + + /** Minecraft知名人物名(常被冒充目标) */ + private static final Set WELL_KNOWN_NAMES = Set.of( + "Notch", "jeb_", "Dinnerbone", "Grumm", + "Dream", "Technoblade", "TommyInnit", "Philza", + "MumboJumbo", "Grian", "Iskall85", "Scar", + "CaptainSparklez", "DanTDM", "PrestonPlayz", + "Hypixel", "Spigot", "Paper", "Mojang", "Microsoft", + "Administrator", "Owner", "Admin" + ); + + /** 相似度阈值(Levenshtein距离占名称长度的比例,0-1,越小越相似) */ + private static final double LEVENSHTEIN_SIMILARITY_THRESHOLD = 0.3; + /** Minecraft玩家名最大长度 */ + private static final int MAX_PLAYERNAME_LENGTH = 16; + + /** 无参构造函数,使用默认配置 */ + public AntiNameSpoofService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiNameSpoofService(ServerGuardConfig config) { + this.config = config; + // 初始化默认保护名单:知名Minecraft人物名 + protectedNames.addAll(WELL_KNOWN_NAMES); + } + + /** + * 检查新加入玩家的名称是否在冒充其他玩家/管理员 + * + * @param playerName 待检查的玩家名 + * @param ip 玩家IP(用于日志记录) + * @return 检测结果,包含是否检测到冒充及原因 + */ + public NameSpoofResult checkPlayerName(String playerName, String ip) { + totalNameChecks.incrementAndGet(); + + // 配置开关检查 + if (!config.getSecurity().getSuperEvolution().isAntiNameSpoof()) { + return NameSpoofResult.clean(); + } + + if (playerName == null || playerName.isBlank()) { + return NameSpoofResult.clean(); + } + + // Minecraft玩家名不应超过16字符 + if (playerName.length() > MAX_PLAYERNAME_LENGTH) { + return NameSpoofResult.clean(); // 长度异常在其他模块处理 + } + + List reasons = new ArrayList<>(); + boolean spoofDetected = false; + Instant now = Instant.now(); + + // 检查1:零宽/不可见字符检测 + String visibleName = removeInvisibleCharacters(playerName); + if (!visibleName.equals(playerName)) { + spoofDetected = true; + spoofAttempts.incrementAndGet(); + reasons.add("INVISIBLE_CHARS: Player name \"" + escapeName(playerName) + + "\" contains invisible/zero-width Unicode characters (" + + countInvisibleChars(playerName) + " chars detected) — " + + "can be used to impersonate \"" + visibleName + "\""); + + blockedNames.add(playerName); + recordSpoofAttempt(playerName, visibleName, "INVISIBLE_CHARS", ip, now); + } + + // 检查2:Unicode标准化后的差异对比 + String normalized = normalizeForComparison(playerName); + if (!spoofDetected) { + for (String protectedName : protectedNames) { + String normalizedProtected = normalizeForComparison(protectedName); + if (normalized.equalsIgnoreCase(normalizedProtected)) { + spoofDetected = true; + spoofAttempts.incrementAndGet(); + reasons.add("UNICODE_SPOOF: Player name \"" + playerName + + "\" uses Unicode characters to visually mimic \"" + protectedName + + "\" (normalized: " + normalized + " == " + normalizedProtected + ")"); + + blockedNames.add(playerName); + recordSpoofAttempt(playerName, protectedName, "UNICODE_SPOOF", ip, now); + break; + } + } + } + + // 检查3:大小写欺骗(不区分大小写比对后,名称匹配但大小写不同) + if (!spoofDetected) { + String lowerPlayer = playerName.toLowerCase(Locale.ROOT); + for (String protectedName : protectedNames) { + String lowerProtected = protectedName.toLowerCase(Locale.ROOT); + if (lowerPlayer.equals(lowerProtected) && !playerName.equals(protectedName)) { + // 确认不是同一个名字(大小写差异的另一个实现方式) + spoofDetected = true; + spoofAttempts.incrementAndGet(); + reasons.add("CASE_SPOOF: Player name \"" + playerName + + "\" uses different case to impersonate \"" + protectedName + "\""); + + blockedNames.add(playerName); + recordSpoofAttempt(playerName, protectedName, "CASE_SPOOF", ip, now); + break; + } + } + } + + // 检查4:Levenshtein编辑距离相似度检测 + if (!spoofDetected) { + for (String protectedName : protectedNames) { + double similarity = levenshteinSimilarity(playerName.toLowerCase(Locale.ROOT), + protectedName.toLowerCase(Locale.ROOT)); + if (similarity < LEVENSHTEIN_SIMILARITY_THRESHOLD + && playerName.length() >= 3 && protectedName.length() >= 3) { + // 排除完全不同的短名称误报 + if (Math.abs(playerName.length() - protectedName.length()) <= 2) { + spoofDetected = true; + spoofAttempts.incrementAndGet(); + reasons.add("SIMILARITY_SPOOF: Player name \"" + playerName + + "\" is highly similar to protected name \"" + protectedName + + "\" (Levenshtein distance: " + String.format("%.2f", similarity) + ")"); + + blockedNames.add(playerName); + recordSpoofAttempt(playerName, protectedName, "SIMILARITY_SPOOF", ip, now); + break; + } + } + } + } + + if (spoofDetected) { + return NameSpoofResult.spoofed(reasons); + } + return NameSpoofResult.clean(); + } + + /** + * 添加一个需要保护的名称(OP玩家名、管理员名等) + * + * @param name 需保护的名称 + */ + public void addProtectedName(String name) { + if (name != null && !name.isBlank()) { + protectedNames.add(name); + } + } + + /** + * 批量添加受保护的名称 + * + * @param names 需保护的名称列表 + */ + public void addProtectedNames(Collection names) { + if (names != null) { + protectedNames.addAll(names); + } + } + + /** + * 从受保护名单中移除名称 + * + * @param name 名称 + */ + public void removeProtectedName(String name) { + if (name != null) { + protectedNames.remove(name); + } + } + + /** + * 获取所有受保护的名称 + * + * @return 受保护名称集合 + */ + public Set getProtectedNames() { + return new HashSet<>(protectedNames); + } + + /** + * 获取模块运行状态 + * + * @return 状态Map,包含totalNameChecks/spoofAttempts/blockedNames + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiNameSpoof()); + s.put("totalNameChecks", totalNameChecks.get()); + s.put("spoofAttempts", spoofAttempts.get()); + s.put("blockedNames", blockedNames.size()); + s.put("protectedNames", protectedNames.size()); + s.put("blockedNameList", new ArrayList<>(blockedNames)); + s.put("protectedNameList", new ArrayList<>(protectedNames)); + + // 最近10次冒充尝试 + List> recent = new ArrayList<>(); + int total = 0; + for (List attempts : spoofHistory.values()) { + for (SpoofAttempt a : attempts) { + if (total < 10) { + Map m = new LinkedHashMap<>(); + m.put("time", a.time.toString()); + m.put("playerName", a.playerName); + m.put("target", a.targetName); + m.put("type", a.spoofType); + m.put("ip", a.ip); + recent.add(m); + } + total++; + } + } + s.put("recentSpoofAttempts", recent); + return s; + } + + // ==================== 内部辅助方法 ==================== + + /** + * 移除字符串中的所有零宽/不可见Unicode字符 + * 用于显示清洗后的可见名称,以及底层对比 + */ + private String removeInvisibleCharacters(String input) { + if (input == null) return null; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (!INVISIBLE_CHARACTERS.contains(c)) { + sb.append(c); + } + } + return sb.toString(); + } + + /** 统计不可见字符数量 */ + private int countInvisibleChars(String input) { + if (input == null) return 0; + int count = 0; + for (int i = 0; i < input.length(); i++) { + if (INVISIBLE_CHARACTERS.contains(input.charAt(i))) { + count++; + } + } + return count; + } + + /** + * Unicode NFKC标准化后转为小写,用于名称的公平比对 + * NFKC会将兼容性字符分解并重新组合(例如将全角字母转为半角) + */ + private String normalizeForComparison(String input) { + if (input == null) return ""; + // 先移除不可见字符,再NFKC标准化,再转小写 + String cleaned = removeInvisibleCharacters(input); + return Normalizer.normalize(cleaned, Normalizer.Form.NFKC).toLowerCase(Locale.ROOT); + } + + /** + * 计算两个字符串之间的Levenshtein编辑距离相似度 + * 返回值 = 编辑距离 / 最大字符串长度(结果越小越相似) + * + * @param s1 字符串1 + * @param s2 字符串2 + * @return 标准化相似度(0=完全相同,1=完全不同) + */ + private double levenshteinSimilarity(String s1, String s2) { + if (s1 == null || s2 == null) return 1.0; + if (s1.equals(s2)) return 0.0; + + int[][] dp = new int[s1.length() + 1][s2.length() + 1]; + for (int i = 0; i <= s1.length(); i++) dp[i][0] = i; + for (int j = 0; j <= s2.length(); j++) dp[0][j] = j; + + for (int i = 1; i <= s1.length(); i++) { + for (int j = 1; j <= s2.length(); j++) { + int cost = s1.charAt(i - 1) == s2.charAt(j - 1) ? 0 : 1; + dp[i][j] = Math.min(Math.min( + dp[i - 1][j] + 1, // 删除 + dp[i][j - 1] + 1), // 插入 + dp[i - 1][j - 1] + cost); // 替换 + } + } + + int maxLen = Math.max(s1.length(), s2.length()); + if (maxLen == 0) return 0.0; + return (double) dp[s1.length()][s2.length()] / maxLen; + } + + /** 将名称中的不可见字符转换为Unicode转义序列用于安全日志输出 */ + private String escapeName(String name) { + if (name == null) return "null"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (INVISIBLE_CHARACTERS.contains(c)) { + sb.append(String.format("\\u%04X", (int) c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** 记录一次冒充尝试 */ + private void recordSpoofAttempt(String playerName, String targetName, String spoofType, String ip, Instant time) { + SpoofAttempt attempt = new SpoofAttempt(time, playerName, targetName, spoofType, ip); + spoofHistory.computeIfAbsent(playerName, k -> Collections.synchronizedList(new ArrayList<>())).add(attempt); + } + + // ==================== 内部数据类 ==================== + + /** 冒充尝试记录 */ + private static class SpoofAttempt { + final Instant time; + final String playerName; + final String targetName; + final String spoofType; + final String ip; + + SpoofAttempt(Instant time, String playerName, String targetName, String spoofType, String ip) { + this.time = time; + this.playerName = playerName; + this.targetName = targetName; + this.spoofType = spoofType; + this.ip = ip; + } + } + + /** 名称冒充检测结果 */ + public static class NameSpoofResult { + private final boolean spoofed; + private final List reasons; + + private NameSpoofResult(boolean spoofed, List reasons) { + this.spoofed = spoofed; + this.reasons = reasons; + } + + public static NameSpoofResult clean() { + return new NameSpoofResult(false, List.of()); + } + + public static NameSpoofResult spoofed(List reasons) { + return new NameSpoofResult(true, reasons); + } + + public boolean isSpoofed() { return spoofed; } + public boolean isClean() { return !spoofed; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiNoFallService.java b/src/main/java/com/aluer/security/AntiNoFallService.java new file mode 100644 index 0000000..bb8234e --- /dev/null +++ b/src/main/java/com/aluer/security/AntiNoFallService.java @@ -0,0 +1,364 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 无摔落伤害(NoFall)检测服务 — V4.0 反作弊扩展模块 + * + * 检测原理: + * 1. 坠落检测 — 追踪玩家从高处坠落的事件。在Minecraft中,从3+方块高度坠落后 + * 玩家应受到摔落伤害(每超过3方块的每方块造成1点伤害,即0.5心)。 + * NoFall hack通过修改或伪造摔落距离数据包来避免伤害。 + * 2. 合法无伤害区分 — 需要排除以下合法的无摔落伤害情况: + * - 落入水中(浅水或深水) + * - 落在粘液块上 + * - 落在床上 + * - 落在干草块上 + * - 鞘翅飞行 + * - 缓降效果 + * - 抗性提升效果 + * - 创造/旁观模式 + * 3. 可疑NoFall模式检测 — 如果同一玩家反复经历高空坠落但从未受伤, + * 且无法用合法情况解释,则标记为作弊。 + * 4. 摔落距离与伤害不一致检测 — 比较服务端计算的摔落距离与客户端报告的伤害。 + * + * 配置开关:serverguard.security.super-evolution.anti-no-fall + */ +@Service +public class AntiNoFallService { + + private final ServerGuardConfig config; + + /** + * 追踪每个玩家的坠落数据(playerName -> 坠落记录列表) + */ + private final Map> playerFallHistory = new ConcurrentHashMap<>(); + + /** + * 追踪每个玩家当前是否处于坠落状态及其起始信息(playerName -> 当前坠落上下文) + */ + private final Map activeFalls = new ConcurrentHashMap<>(); + + /** + * 追踪疑似NoFall的事件(playerName -> NoFall事件列表) + */ + private final Map>> noFallEvents = new ConcurrentHashMap<>(); + + private final AtomicLong totalFalls = new AtomicLong(0); + private final AtomicLong noFallViolations = new AtomicLong(0); + private final AtomicLong falsePositives = new AtomicLong(0); + + /** + * 坠落伤害触发高度(方块)— 从3方块高度坠落开始产生摔落伤害 + */ + private static final double FALL_DAMAGE_HEIGHT = 3.0; + + /** + * 危险坠落高度(方块)— 超过此高度几乎肯定产生伤害 + */ + private static final double DANGEROUS_FALL_HEIGHT = 8.0; + + /** + * 连续无伤坠落次数阈值 — 超过此值标记为NoFall hack + */ + private static final int MAX_CONSECUTIVE_NO_DAMAGE_FALLS = 3; + + /** + * 每个玩家保留的最大坠落记录数 + */ + private static final int MAX_FALL_RECORDS = 20; + + /** + * 无参构造函数 — 测试/默认配置使用 + */ + public AntiNoFallService() { + this(new ServerGuardConfig()); + } + + /** + * Spring 注入构造函数 + * @param config 全局配置,提供模块开关控制 + */ + public AntiNoFallService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 记录玩家开始坠落(空中下落开始) + * 应在玩家离开地面且垂直速度向下时调用 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家UUID + * @param highestY 坠落起始的Y坐标(最高点) + * @param gameMode 玩家游戏模式 + * @param timestamp 时间戳 + */ + public void recordFallStart(String playerName, String playerUUID, + double highestY, String gameMode, Instant timestamp) { + // 模块关闭时不追踪数据 + if (!config.getSecurity().getSuperEvolution().isAntiNoFall()) { + return; + } + + // 创造/旁观模式不受摔落伤害,不需要追踪 + if ("creative".equalsIgnoreCase(gameMode) || "spectator".equalsIgnoreCase(gameMode)) { + return; + } + + activeFalls.put(playerName, new ActiveFall(highestY, gameMode, timestamp)); + } + + /** + * 记录玩家落地事件 + * 应在玩家接触地面时调用 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家UUID + * @param landingY 着陆时Y坐标 + * @param tookDamage 玩家是否受到摔落伤害 + * @param damageAmount 受到的伤害量(0表示无伤害) + * @param isInLiquid 是否在水中(合法无伤害情况) + * @param isOnSlimeBlock 是否落在粘液块上(合法无伤害情况) + * @param isOnHayBale 是否落在干草块上(减少80%伤害) + * @param isOnBed 是否落在床上(减少50%伤害) + * @param hasSlowFalling 是否有缓降效果 + * @param isElytraFlying 是否鞘翅飞行 + * @param timestamp 时间戳 + * @return 检测结果 + */ + public NoFallCheckResult recordLanding(String playerName, String playerUUID, + double landingY, boolean tookDamage, double damageAmount, + boolean isInLiquid, boolean isOnSlimeBlock, + boolean isOnHayBale, boolean isOnBed, + boolean hasSlowFalling, boolean isElytraFlying, + Instant timestamp) { + // 模块开关检查 + if (!config.getSecurity().getSuperEvolution().isAntiNoFall()) { + return NoFallCheckResult.clean(); + } + + totalFalls.incrementAndGet(); + + ActiveFall activeFall = activeFalls.remove(playerName); + if (activeFall == null) { + // 没有记录的坠落起始 — 无法判断 + return NoFallCheckResult.clean(); + } + + double fallDistance = activeFall.highestY - landingY; + + // 坠落高度不足,不产生伤害,无需检测 + if (fallDistance < FALL_DAMAGE_HEIGHT) { + return NoFallCheckResult.clean(); + } + + List reasons = new ArrayList<>(); + boolean legitimateNoDamage = false; + + // 检查合法无伤害情况 + if (isInLiquid) { + // 落入水中完全免疫摔落伤害 + legitimateNoDamage = true; + falsePositives.incrementAndGet(); + } + if (isOnSlimeBlock) { + // 粘液块完全免疫摔落伤害(不反弹则无伤害) + legitimateNoDamage = true; + falsePositives.incrementAndGet(); + } + // 干草块减少80%摔落伤害 + if (isOnHayBale && fallDistance < 15.0) { + // 极端高度的坠落(>15方块)在干草块上仍可能产生可见伤害 + // 此处保守地认为15方块以下在干草块上无伤害是合法的 + if (!tookDamage) { + legitimateNoDamage = true; + } + } + if (isOnBed) { + // 床减少50%摔落伤害,结合一定高度判断 + if (fallDistance < 6.0 && !tookDamage) { + legitimateNoDamage = true; + } + } + if (hasSlowFalling) { + // 缓降效果完全免疫摔落伤害 + legitimateNoDamage = true; + falsePositives.incrementAndGet(); + } + if (isElytraFlying) { + // 鞘翅飞行期间不受摔落伤害 + legitimateNoDamage = true; + falsePositives.incrementAndGet(); + } + + // 记录坠落事件 + FallRecord fallRecord = new FallRecord(timestamp, fallDistance, tookDamage, damageAmount, + legitimateNoDamage); + List history = playerFallHistory.computeIfAbsent( + playerName, k -> new ArrayList<>()); + history.add(fallRecord); + while (history.size() > MAX_FALL_RECORDS) { + history.remove(0); + } + + // 如果坠落高度显著且没有受到伤害,且没有合法理由,则标记 + if (!tookDamage && !legitimateNoDamage && fallDistance >= FALL_DAMAGE_HEIGHT) { + reasons.add("NO_FALL_DAMAGE: fell " + String.format("%.1f", fallDistance) + + " blocks, no damage taken"); + + // 记录NoFall事件 + List> events = noFallEvents.computeIfAbsent( + playerName, k -> new ArrayList<>()); + Map event = new LinkedHashMap<>(); + event.put("time", timestamp.toString()); + event.put("fallDistance", String.format("%.1f", fallDistance)); + event.put("landingY", String.format("%.2f", landingY)); + event.put("highestY", String.format("%.2f", activeFall.highestY)); + events.add(event); + while (events.size() > 10) { + events.remove(0); + } + + // 检查连续无伤坠落模式 + long recentNoDamageCount = history.stream() + .filter(r -> !r.tookDamage && !r.legitimateNoDamage) + .count(); + + if (recentNoDamageCount >= MAX_CONSECUTIVE_NO_DAMAGE_FALLS) { + reasons.add("REPEATED_NO_DAMAGE: " + recentNoDamageCount + + " consecutive falls without damage"); + noFallViolations.incrementAndGet(); + return NoFallCheckResult.flagged(reasons); + } + + if (fallDistance >= DANGEROUS_FALL_HEIGHT) { + // 从危险高度坠落无伤害 — 高度可疑 + reasons.add("DANGEROUS_FALL: " + String.format("%.1f", fallDistance) + + " blocks fall with no damage (extremely unlikely)"); + noFallViolations.incrementAndGet(); + return NoFallCheckResult.flagged(reasons); + } + + return NoFallCheckResult.suspicious(reasons); + } + + return NoFallCheckResult.clean(); + } + + /** + * 玩家离线时清理追踪数据 + * @param playerName 离线玩家名称 + */ + public void clearPlayer(String playerName) { + playerFallHistory.remove(playerName); + activeFalls.remove(playerName); + noFallEvents.remove(playerName); + } + + /** + * 获取模块运行状态 + * @return 包含计数器、违规数量和误报数量的Map + */ + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("totalFalls", totalFalls.get()); + status.put("noFallViolations", noFallViolations.get()); + status.put("falsePositives", falsePositives.get()); + status.put("activeTrackedPlayers", playerFallHistory.size()); + status.put("playersInFall", activeFalls.size()); + + // 列出可疑NoFall玩家 + List> suspiciousPlayers = new ArrayList<>(); + for (Map.Entry> entry : playerFallHistory.entrySet()) { + long noDamageCount = entry.getValue().stream() + .filter(r -> !r.tookDamage && !r.legitimateNoDamage).count(); + if (noDamageCount > 0) { + Map info = new LinkedHashMap<>(); + info.put("player", entry.getKey()); + info.put("noDamageFalls", noDamageCount); + info.put("totalFalls", entry.getValue().size()); + suspiciousPlayers.add(info); + } + } + suspiciousPlayers.sort((a, b) -> + Long.compare((Long) b.get("noDamageFalls"), (Long) a.get("noDamageFalls"))); + status.put("suspiciousPlayers", suspiciousPlayers.subList(0, Math.min(suspiciousPlayers.size(), 10))); + return status; + } + + /** + * 追踪玩家当前活跃的坠落状态 + */ + private static class ActiveFall { + final double highestY; // 坠落起始时最高点的Y坐标 + final String gameMode; // 坠落开始时的游戏模式 + final Instant startTime; // 坠落开始时间 + + ActiveFall(double highestY, String gameMode, Instant startTime) { + this.highestY = highestY; + this.gameMode = gameMode; + this.startTime = startTime; + } + } + + /** + * 内部坠落记录 — 记录单次坠落的详细信息 + */ + private static class FallRecord { + final Instant timestamp; + final double fallDistance; + final boolean tookDamage; + final double damageAmount; + final boolean legitimateNoDamage; // 是否为合法的无伤害情况 + + FallRecord(Instant timestamp, double fallDistance, boolean tookDamage, + double damageAmount, boolean legitimateNoDamage) { + this.timestamp = timestamp; + this.fallDistance = fallDistance; + this.tookDamage = tookDamage; + this.damageAmount = damageAmount; + this.legitimateNoDamage = legitimateNoDamage; + } + } + + /** + * NoFall检测结果 — 不可变结果类 + */ + public static class NoFallCheckResult { + private final boolean flagged; + private final boolean suspicious; + private final List reasons; + + private NoFallCheckResult(boolean flagged, boolean suspicious, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.reasons = reasons; + } + + /** 无异常 — 正常坠落并受到伤害,或有合法无伤害理由 */ + public static NoFallCheckResult clean() { + return new NoFallCheckResult(false, false, List.of()); + } + + /** 可疑 — 未受伤害但坠落高度不够极端,或存在未知因素 */ + public static NoFallCheckResult suspicious(List reasons) { + return new NoFallCheckResult(false, true, reasons); + } + + /** 已标记 — 确认NoFall hack,从危险高度坠落未受伤害且无合法理由 */ + public static NoFallCheckResult flagged(List reasons) { + return new NoFallCheckResult(true, false, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiNukerService.java b/src/main/java/com/aluer/security/AntiNukerService.java new file mode 100644 index 0000000..5a2ba28 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiNukerService.java @@ -0,0 +1,362 @@ +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.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 快速破坏检测 (Nuker) — V4.0 玩家行为安全模块 + * + * 检测原理: + * Nuker 是 Minecraft 作弊客户端中最具破坏力的功能之一,它能以极快速度瞬间破坏大范围方块。 + * 本模块通过多维数据分析来识别 Nuker 行为模式: + * 1. 速度检测——正常生存模式挖掘受工具等级和效率附魔限制,木质工具约0.4秒/块, + * 钻石镐约0.3秒/块,效率V约0.15秒/块;Nuker 通常能在 50ms 内完成一次破坏。 + * 2. 多目标同时破坏——在同一 tick 内破坏多个方块,这是普通玩家物理上不可能做到的行为, + * 只有通过发包劫持实现的 Nuker 才能达成。 + * 3. 破坏模式分析——Nuker 通常破坏大范围方块(半径超过3格),正常挖掘通常集中在1-2格半径内。 + * 4. 挖掘时间间隔分布——通过分析连续破坏的时间间隔,检测是否有自动化特征。 + * + * 配置开关:serverguard.security.super-evolution.anti-nuker + */ +@Service +public class AntiNukerService { + + private final ServerGuardConfig config; + private final Map> playerBreakRecords = new ConcurrentHashMap<>(); + private final Map> playerPositionRecords = new ConcurrentHashMap<>(); + private final Map flaggedPlayers = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + /** 单个玩家的总破坏次数计数器 */ + private final AtomicLong totalBreaks = new AtomicLong(0); + /** Nuker 违规检测次数 */ + private final AtomicLong nukerViolations = new AtomicLong(0); + + /** + * 单次检测窗口内允许的最大方块破坏数量(正常玩家采矿时约2-4块/秒) + */ + private static final int MAX_BREAKS_PER_WINDOW = 3; + + /** + * 检测时间窗口(毫秒)——在此窗口内破坏超过阙值即为异常 + */ + private static final long DETECTION_WINDOW_MS = 500; + + /** + * 同 tick 最大允许破坏数量——在同一 50ms tick 内破坏超过此数为多目标同时破坏 + */ + private static final int MAX_SAME_TICK_BREAKS = 1; + + /** + * 破坏间距离超过此值(块)视为分散破坏模式 + */ + private static final int MAX_BREAK_DISTANCE = 3; + + /** + * 单个玩家记录保留时间(秒)——过期清理 + */ + private static final long RECORD_RETENTION_SECONDS = 300; + + /** + * 标记玩家持续时间(秒) + */ + private static final long FLAG_DURATION_SECONDS = 1800; + + public AntiNukerService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiNukerService(ServerGuardConfig config) { + this.config = config; + // 定期清理过期记录,防止内存泄漏 + scheduler.scheduleAtFixedRate(this::cleanupOldRecords, 60, 120, TimeUnit.SECONDS); + } + + /** + * 检测一次方块破坏事件是否为 Nuker 行为。 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家 UUID + * @param blockType 被破坏的方块类型 + * @param x 方块 X 坐标 + * @param y 方块 Y 坐标 + * @param z 方块 Z 坐标 + * @param world 所在世界名称 + * @return 检测结果(clean / flagged / suspicious) + */ + public DetectionResult detect(String playerName, String playerUUID, String blockType, + int x, int y, int z, String world) { + // 如果配置中禁用了反 Nuker 模块,直接返回 clean + if (!config.getSecurity().getSuperEvolution().isAntiNuker()) { + return DetectionResult.clean(); + } + + // 检查玩家是否已被标记 + Instant flaggedUntil = flaggedPlayers.get(playerName); + if (flaggedUntil != null) { + if (Instant.now().isAfter(flaggedUntil)) { + // 标记已过期,移除标记 + flaggedPlayers.remove(playerName); + } else { + // 仍在标记期内,直接返回 flagged + return DetectionResult.flagged(List.of( + "PLAYER_ALREADY_FLAGGED: " + playerName + " is under active nuker investigation")); + } + } + + // 记录本次破坏事件 + Instant now = Instant.now(); + BreakRecord record = new BreakRecord(now, blockType, x, y, z, world); + List records = playerBreakRecords.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + records.add(record); + totalBreaks.incrementAndGet(); + + // 记录坐标用于分散模式分析 + PositionRecord posRecord = new PositionRecord(now, x, y, z, world); + List posRecords = playerPositionRecords.computeIfAbsent(playerName, + k -> Collections.synchronizedList(new ArrayList<>())); + posRecords.add(posRecord); + + int score = 0; + List reasons = new ArrayList<>(); + + // === 检测 1: 速度检测 === + // 计算在检测窗口内破坏的方块数量 + long windowStart = now.toEpochMilli() - DETECTION_WINDOW_MS; + long breaksInWindow = records.stream() + .filter(r -> r.time.toEpochMilli() > windowStart) + .count(); + + if (breaksInWindow > MAX_BREAKS_PER_WINDOW) { + score += 35; + reasons.add("SPEED_VIOLATION: " + breaksInWindow + " blocks broken in " + + DETECTION_WINDOW_MS + "ms (threshold=" + MAX_BREAKS_PER_WINDOW + ")"); + } + + // === 检测 2: 多目标同时破坏检测 === + // 在同一 tick (50ms) 内破坏多个方块,只有 Nuker 能做到 + long tickStart = now.toEpochMilli() - 50; + long sameTickBreaks = records.stream() + .filter(r -> r.time.toEpochMilli() > tickStart) + .count(); + + if (sameTickBreaks > MAX_SAME_TICK_BREAKS) { + score += 50; // 这是最高置信度的检测 + reasons.add("MULTI_TARGET_BREAK: " + sameTickBreaks + + " blocks broken in same tick (impossible for legit player)"); + } + + // === 检测 3: 分散破坏模式检测 === + // Nuker 在大范围内同时破坏,正常玩家挖矿范围小 + if (posRecords.size() >= 5) { + // 计算最近几个破坏点的空间分散度 + int recentCount = Math.min(posRecords.size(), 5); + List recentPositions = posRecords.subList( + posRecords.size() - recentCount, posRecords.size()); + + // 计算平均距离(使用当前点与之前点的距离) + double totalDistance = 0; + int distanceCount = 0; + for (int i = 1; i < recentPositions.size(); i++) { + PositionRecord prev = recentPositions.get(i - 1); + PositionRecord curr = recentPositions.get(i); + double dist = Math.sqrt( + Math.pow(curr.x - prev.x, 2) + + Math.pow(curr.y - prev.y, 2) + + Math.pow(curr.z - prev.z, 2)); + totalDistance += dist; + distanceCount++; + } + + double avgDistance = distanceCount > 0 ? totalDistance / distanceCount : 0; + if (avgDistance > MAX_BREAK_DISTANCE) { + score += 20; + reasons.add("SCATTER_PATTERN: avg break distance=" + + String.format("%.1f", avgDistance) + " blocks (threshold=" + MAX_BREAK_DISTANCE + ")"); + } + } + + // === 检测 4: 破坏间隔分析 === + // 分析连续破坏之间的时间间隔是否有自动化特征 + if (records.size() >= 5) { + List intervals = new ArrayList<>(); + for (int i = records.size() - 5; i < records.size() - 1; i++) { + long interval = records.get(i + 1).time.toEpochMilli() - records.get(i).time.toEpochMilli(); + intervals.add(interval); + } + + // 如果所有间隔都在 100ms 以内,说明是持续的快速破坏 + long fastIntervals = intervals.stream().filter(i -> i < 100).count(); + if (fastIntervals >= 4) { + score += 15; + reasons.add("CONSISTENT_FAST_BREAK: " + fastIntervals + + "/4 breaks under 100ms interval"); + } + } + + // === 判定结果 === + if (score >= 50) { + // 高置信度:标记玩家 + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + nukerViolations.incrementAndGet(); + return DetectionResult.flagged(reasons); + } else if (score >= 30) { + // 中等置信度:报告可疑 + return DetectionResult.suspicious(score, reasons); + } + + return DetectionResult.clean(); + } + + /** + * 清除指定玩家的所有追踪数据(玩家退出时调用)。 + * + * @param playerName 玩家名称 + */ + public void clearPlayer(String playerName) { + playerBreakRecords.remove(playerName); + playerPositionRecords.remove(playerName); + flaggedPlayers.remove(playerName); + } + + /** + * 获取当前模块运行状态。 + * + * @return 包含各类统计数据的 LinkedHashMap + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalBreaks", totalBreaks.get()); + s.put("nukerViolations", nukerViolations.get()); + s.put("trackedPlayers", playerBreakRecords.size()); + s.put("flaggedPlayers", flaggedPlayers.size()); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiNuker()); + + // 最近被标记的玩家列表 + List> flagged = new ArrayList<>(); + for (Map.Entry e : flaggedPlayers.entrySet()) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("flaggedUntil", e.getValue().toString()); + List records = playerBreakRecords.get(e.getKey()); + if (records != null) { + m.put("totalRecordedBreaks", records.size()); + } + flagged.add(m); + } + s.put("flaggedPlayersList", flagged); + + return s; + } + + public long getTotalBreaks() { return totalBreaks.get(); } + public long getNukerViolations() { return nukerViolations.get(); } + public int getTrackedPlayers() { return playerBreakRecords.size(); } + + /** + * 定期清理过期记录,防止内存无限增长。 + */ + private void cleanupOldRecords() { + Instant cutoff = Instant.now().minusSeconds(RECORD_RETENTION_SECONDS); + playerBreakRecords.entrySet().removeIf(e -> { + List records = e.getValue(); + records.removeIf(r -> r.time.isBefore(cutoff)); + return records.isEmpty(); + }); + playerPositionRecords.entrySet().removeIf(e -> { + List records = e.getValue(); + records.removeIf(r -> r.time.isBefore(cutoff)); + return records.isEmpty(); + }); + flaggedPlayers.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + // ==================== 内部数据类 ==================== + + /** + * 方块破坏记录——记录单次破坏事件的关键数据。 + */ + private static class BreakRecord { + final Instant time; + final String blockType; + final int x, y, z; + final String world; + + BreakRecord(Instant time, String blockType, int x, int y, int z, String world) { + this.time = time; + this.blockType = blockType; + this.x = x; + this.y = y; + this.z = z; + this.world = world; + } + } + + /** + * 位置记录——用于分析破坏的空间分布模式。 + */ + private static class PositionRecord { + final Instant time; + final int x, y, z; + final String world; + + PositionRecord(Instant time, int x, int y, int z, String world) { + this.time = time; + this.x = x; + this.y = y; + this.z = z; + this.world = world; + } + } + + // ==================== 检测结果类 ==================== + + /** + * Nuker 检测结果——包含标记状态、可疑分数和原因列表。 + */ + public static class DetectionResult { + private final boolean flagged; + private final boolean suspicious; + private final int score; + private final List reasons; + + private DetectionResult(boolean flagged, boolean suspicious, int score, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.score = score; + this.reasons = reasons; + } + + /** 无异常:方块破坏行为正常 */ + public static DetectionResult clean() { + return new DetectionResult(false, false, 0, List.of()); + } + + /** 可疑行为:存在异常模式但置信度未达到直接标记的阈值 */ + public static DetectionResult suspicious(int score, List reasons) { + return new DetectionResult(false, true, score, reasons); + } + + /** 已标记:检测到高置信度 Nuker 行为 */ + public static DetectionResult flagged(List reasons) { + return new DetectionResult(true, true, 100, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiOfflineModeSpoofService.java b/src/main/java/com/aluer/security/AntiOfflineModeSpoofService.java new file mode 100644 index 0000000..c71a403 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiOfflineModeSpoofService.java @@ -0,0 +1,358 @@ +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.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 离线模式UUID欺诈防护服务 — V4.0 服务器保护模块 + * + * 检测原理: + * 在离线模式(offline/cracked)的Minecraft服务器中,玩家可以自由选择任意用户名和 + * UUID加入服务器。这为恶意玩家提供了冒充正版玩家、管理员、甚至是Mojang员工的 + * 机会。本服务在多维度上检测UUID欺诈行为:验证UUID格式合法性、追踪IP与UUID的 + * 关联关系、检测快速UUID切换模式、以及对已知高价值UUID进行专项保护。 + * + * 攻击场景: + * - UUID冒充:使用正版玩家(如知名YouTuber、管理员)的UUID加入服务器 + * - 多账户轮换:同一IP在短时间内使用多个不同UUID绕过封禁 + * - UUID格式滥用:使用不符合RFC 4122的UUID格式尝试绕过检测 + * - 管理员伪造:使用已知服务器管理员或Mojang员工的UUID获取权限 + * + * 检测维度: + * - UUID格式合法性(RFC 4122变体4 UUID格式) + * - IP-UUID关联追踪(同IP使用多个UUID为可疑) + * - 快速切换检测(60秒内使用3+不同UUID) + * - 高价值UUID保护(已知管理员/知名玩家UUID) + * - 用户名-UUID一致性验证 + * + * 配置开关:serverguard.security.super-evolution.anti-offline-mode-spoof + */ +@Service +public class AntiOfflineModeSpoofService { + + private final ServerGuardConfig config; + + /** IP -> (UUID -> 最后使用时间) 映射 */ + private final Map> ipUuidMap = new ConcurrentHashMap<>(); + /** UUID -> 最近使用信息 */ + private final Map uuidRecords = new ConcurrentHashMap<>(); + /** IP被封锁记录 */ + private final Map blockedIps = new ConcurrentHashMap<>(); + /** UUID被封锁记录 */ + private final Map blockedUuids = new ConcurrentHashMap<>(); + + private final AtomicLong totalJoins = new AtomicLong(0); + private final AtomicLong spoofBlocked = new AtomicLong(0); + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + /** 快速切换检测窗口(秒) */ + private static final long RAPID_SWITCH_WINDOW_SECONDS = 60; + /** 同一IP在窗口内的最大不同UUID数 */ + private static final int MAX_UUIDS_PER_IP_WINDOW = 3; + /** 同一IP关联的UUID总数上限 */ + private static final int MAX_UUIDS_PER_IP_TOTAL = 10; + /** 封锁时间(秒) */ + private static final long BLOCK_DURATION_SECONDS = 3600; + + /** RFC 4122 UUID 正则表达式(版本4变体) */ + private static final String UUID_V4_REGEX = + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"; + + /** 已知的Mojang员工和高价值目标UUID(示例值,生产环境应配置完整列表) */ + private static final Set PROTECTED_UUIDS = new HashSet<>(Arrays.asList( + // Mojang/Microsoft员工(示例UUID,非真实值) + "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa", + "bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb" + )); + + /** 常见管理员用户名模式(大小写不敏感) */ + private static final Set ADMIN_NAME_PATTERNS = Set.of( + "admin", "owner", "mod", "op", "server", "staff", + "administrator", "moderator", "helper", "manager", + "notch", "jeb_", "dinnerbone", "jappa", "kingbdogz" + ); + + public AntiOfflineModeSpoofService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiOfflineModeSpoofService(ServerGuardConfig config) { + this.config = config; + scheduler.scheduleAtFixedRate(this::cleanupOldData, 60, 120, TimeUnit.SECONDS); + } + + /** + * 检查玩家加入时的UUID是否存在欺诈行为。 + * + * @param uuid 玩家UUID(离线模式下由客户端自行指定) + * @param username 玩家用户名 + * @param ip 玩家IP地址 + * @param sessionId 会话标识 + * @return 检查结果 + */ + public SpoofCheckResult check(String uuid, String username, String ip, String sessionId) { + // 配置开关检查 + if (!config.getSecurity().getSuperEvolution().isAntiOfflineModeSpoof()) { + return SpoofCheckResult.clean(); + } + + totalJoins.incrementAndGet(); + List reasons = new ArrayList<>(); + int score = 0; + + Instant now = Instant.now(); + + // 检查IP是否被封锁 + if (blockedIps.containsKey(ip)) { + if (now.isAfter(blockedIps.get(ip))) { + blockedIps.remove(ip); + } else { + return SpoofCheckResult.blocked(List.of("IP_BLOCKED: " + ip + " is temporarily blocked for UUID spoofing")); + } + } + + // 检查UUID是否被封锁 + if (blockedUuids.containsKey(uuid)) { + if (now.isAfter(blockedUuids.get(uuid))) { + blockedUuids.remove(uuid); + } else { + return SpoofCheckResult.blocked(List.of("UUID_BLOCKED: " + uuid + " is blacklisted for spoofing")); + } + } + + // ---- 检测1:UUID格式验证 ---- + // Minecraft使用RFC 4122版本4变体的UUID格式 + // 离线模式客户端可能发送完全不符合格式的UUID + if (uuid == null || uuid.trim().isEmpty()) { + score += 90; + reasons.add("UUID_EMPTY: UUID is null or empty"); + } else if (!uuid.trim().matches(UUID_V4_REGEX)) { + // 检查是否至少是合法的UUID格式(宽松检测) + String uuidClean = uuid.trim(); + if (uuidClean.matches("^[0-9a-fA-F]{32}$")) { + // 缺少连字符的UUID,可以格式化但标记为可疑 + score += 10; + reasons.add("UUID_NO_HYPHENS: UUID lacks RFC 4122 formatting"); + } else if (!uuidClean.matches("^[0-9a-fA-F-]+$")) { + // 包含非法字符 + score += 80; + reasons.add("UUID_INVALID_CHARS: contains non-hex characters"); + } else if (uuidClean.length() != 36) { + score += 70; + reasons.add("UUID_INVALID_LENGTH: length=" + uuidClean.length() + " (expected 36)"); + } else { + // 长度正确但格式不对(如错误的变体/版本位) + score += 20; + reasons.add("UUID_INVALID_FORMAT: does not match Minecraft UUID format (version 4 variant)"); + } + } + + // ---- 检测2:IP-UUID关联追踪 ---- + // 同一IP关联过多UUID是典型的帐户滥用或UUID欺诈行为 + Map ipRecords = ipUuidMap.computeIfAbsent(ip, k -> new ConcurrentHashMap<>()); + + // 记录当前UUID + ipRecords.put(uuid, now); + + // 检查该IP历史上关联的UUID总数 + int totalUuidsForIp = ipRecords.size(); + if (totalUuidsForIp > MAX_UUIDS_PER_IP_TOTAL) { + score += 50; + reasons.add("IP_UUID_OVERFLOW: IP " + ip + " has " + totalUuidsForIp + + " associated UUIDs (max: " + MAX_UUIDS_PER_IP_TOTAL + ")"); + } + + // ---- 检测3:快速切换检测 ---- + // 同一IP在60秒内使用3+不同UUID,说明在快速切换身份 + long recentUuids = ipRecords.entrySet().stream() + .filter(e -> e.getValue().isAfter(now.minusSeconds(RAPID_SWITCH_WINDOW_SECONDS))) + .count(); + + if (recentUuids > MAX_UUIDS_PER_IP_WINDOW) { + score += 75; // 快速UUID切换为严重攻击,直接触发blocked(>=70阈值) + reasons.add("RAPID_UUID_SWITCH: " + recentUuids + " different UUIDs in " + + RAPID_SWITCH_WINDOW_SECONDS + "s from IP " + ip); + } + + // ---- 检测4:高价值UUID保护 ---- + // 检测是否使用了已知管理员、Mojang员工或知名正版玩家的UUID + if (PROTECTED_UUIDS.contains(uuid.trim().toLowerCase())) { + score += 70; + reasons.add("PROTECTED_UUID_SPOOF: UUID " + uuid + " belongs to a protected Mojang/admin account"); + } + + // ---- 检测5:管理员用户名检测 ---- + // 检测用户名是否为常见的管理员模式 + // 配合UUID异常可提高检测准确率 + if (username != null) { + String lowerName = username.toLowerCase().trim(); + for (String pattern : ADMIN_NAME_PATTERNS) { + if (lowerName.contains(pattern) || lowerName.equals(pattern)) { + // 如果UUID格式也不合法,加重评分 + if (uuid != null && !uuid.trim().matches(UUID_V4_REGEX)) { + score += 30; + reasons.add("ADMIN_NAME_SPOOF: username '" + username + "' with invalid UUID"); + } else { + reasons.add("ADMIN_NAME_DETECTED: username '" + username + + "' matches admin pattern (flagged, not blocked)"); + } + break; + } + } + } + + // ---- 检测6:UUID重复使用检测 ---- + // 检测同一UUID是否已从不同IP被使用 + UuidRecord existing = uuidRecords.get(uuid); + if (existing != null) { + if (!existing.ip.equals(ip)) { + // 相同UUID从不同IP使用:可能是UUID被他人冒用 + score += 40; + reasons.add("UUID_REUSE_DIFFERENT_IP: UUID " + uuid + + " previously used from IP " + existing.ip + + ", now from " + ip); + } + // 更新记录 + existing.lastUsed = now; + } else { + uuidRecords.put(uuid, new UuidRecord(uuid, username, ip, now)); + } + + // ---- 检测7:UUID版本位异常 ---- + // Minecraft规范使用版本4 UUID,其中第13位必须是'4',第17位必须是'8','9','a','b' + if (uuid != null && uuid.length() == 36) { + char versionChar = uuid.charAt(14); // 第13位(0-index) + if (versionChar != '4' && versionChar != '4') { + // 非版本4的UUID: 在线模式Mojang API不会生成,离线模式可能被伪造 + score += 25; + reasons.add("UUID_VERSION_MISMATCH: version=" + versionChar + " (expected 4)"); + } + } + + // 判定结果 + if (score >= 70) { + spoofBlocked.incrementAndGet(); + // 封锁该IP和UUID + blockedIps.put(ip, now.plusSeconds(BLOCK_DURATION_SECONDS)); + blockedUuids.put(uuid, now.plusSeconds(BLOCK_DURATION_SECONDS)); + return SpoofCheckResult.blocked(reasons); + } else if (score >= 20) { + return SpoofCheckResult.flagged(reasons, score); + } + + return SpoofCheckResult.clean(); + } + + /** + * 将UUID添加到受保护列表。 + */ + public void addProtectedUuid(String uuid) { + if (uuid != null && uuid.trim().matches(UUID_V4_REGEX)) { + PROTECTED_UUIDS.add(uuid.trim().toLowerCase()); + } + } + + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalJoins", totalJoins.get()); + s.put("spoofBlocked", spoofBlocked.get()); + s.put("trackedIPs", ipUuidMap.size()); + s.put("trackedUUIDs", uuidRecords.size()); + s.put("blockedIPs", blockedIps.size()); + s.put("blockedUUIDs", blockedUuids.size()); + s.put("protectedUUIDs", PROTECTED_UUIDS.size()); + + // IP-UUID关联映射(最多显示20个IP) + List> ipMappings = new ArrayList<>(); + for (Map.Entry> e : ipUuidMap.entrySet()) { + Map m = new LinkedHashMap<>(); + m.put("ip", e.getKey()); + m.put("uuidCount", e.getValue().size()); + + // 最近活跃的UUID(每个IP取最多5个) + List> recentUuids = new ArrayList<>(); + e.getValue().entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .limit(5) + .forEach(ue -> { + Map um = new LinkedHashMap<>(); + um.put("uuid", ue.getKey()); + um.put("lastSeen", ue.getValue().toString()); + recentUuids.add(um); + }); + m.put("recentUuids", recentUuids); + ipMappings.add(m); + } + ipMappings.sort((a, b) -> Integer.compare((int) b.get("uuidCount"), (int) a.get("uuidCount"))); + s.put("ipUuidMap", ipMappings.subList(0, Math.min(ipMappings.size(), 20))); + return s; + } + + private void cleanupOldData() { + Instant cutoff = Instant.now().minusSeconds(7200); // 2小时 + for (Map records : ipUuidMap.values()) { + records.entrySet().removeIf(e -> e.getValue().isBefore(cutoff)); + } + ipUuidMap.entrySet().removeIf(e -> e.getValue().isEmpty()); + uuidRecords.entrySet().removeIf(e -> e.getValue().lastUsed.isBefore(cutoff)); + blockedIps.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + blockedUuids.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + private static class UuidRecord { + final String uuid; + final String username; + final String ip; + Instant lastUsed; + + UuidRecord(String uuid, String username, String ip, Instant lastUsed) { + this.uuid = uuid; + this.username = username; + this.ip = ip; + this.lastUsed = lastUsed; + } + } + + public static class SpoofCheckResult { + private final boolean blocked; + private final boolean flagged; + private final int score; + private final List reasons; + + private SpoofCheckResult(boolean blocked, boolean flagged, int score, List reasons) { + this.blocked = blocked; + this.flagged = flagged; + this.score = score; + this.reasons = reasons; + } + + public static SpoofCheckResult clean() { + return new SpoofCheckResult(false, false, 0, List.of()); + } + + public static SpoofCheckResult blocked(List reasons) { + return new SpoofCheckResult(true, false, 100, reasons); + } + + public static SpoofCheckResult flagged(List reasons, int score) { + return new SpoofCheckResult(false, true, score, reasons); + } + + public boolean isBlocked() { return blocked; } + public boolean isFlagged() { return flagged; } + public boolean isClean() { return !blocked && !flagged; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiPhishingLinkService.java b/src/main/java/com/aluer/security/AntiPhishingLinkService.java new file mode 100644 index 0000000..3928e5f --- /dev/null +++ b/src/main/java/com/aluer/security/AntiPhishingLinkService.java @@ -0,0 +1,347 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 钓鱼/恶意链接检测服务 — V4.0 聊天社交安全模块 + * + * 检测原理: + * 1. URL提取:从消息中提取所有URL + * 2. 短链接展开:检测bit.ly、t.co等短链接服务并标记需解析 + * 3. 可疑域名检测:包含minecraft/mojang/login/verify等关键字的非官方域名 + * 4. 已知钓鱼域名黑名单匹配 + * 5. 免费Minecraft骗局链接检测 + * 6. 显示文本与链接不匹配检测(如 [Mojang官方]实际链接到钓鱼站) + * 7. SSL证书验证标记(需要实际HTTP请求的标记) + * + * 配置开关:serverguard.security.super-evolution.anti-phishing-link + */ +@Service +public class AntiPhishingLinkService { + + private final ServerGuardConfig config; + private final AtomicLong totalUrls = new AtomicLong(0); + private final AtomicLong phishingDetected = new AtomicLong(0); + private final AtomicLong shortUrlsResolved = new AtomicLong(0); + private final Map playerPhishingCount = new ConcurrentHashMap<>(); + + /** URL提取正则 */ + private static final Pattern URL_PATTERN = Pattern.compile( + "https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+|" + + "www\\.[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+", + Pattern.CASE_INSENSITIVE); + + /** 短链接服务域名列表 */ + private static final Set SHORT_URL_SERVICES = Set.of( + "bit.ly", "t.co", "short.url", "is.gd", "tinyurl.com", + "ow.ly", "buff.ly", "goo.gl", "bitly.com", "cutt.ly", + "rebrand.ly", "shorten", "short.link", "shorte.st", + "shorturl", "s.id", "surl", "v.gd", "0rz.tw", + "4url.cc", "alturl.com", "chilp.it", "cl.ly", + "dft.ba", "j.mp", "korta.nu", "microurl.com", + "n9.cl", "rb.gy", "snip.ly", "tny.im", "tr.im", + "u.to", "urlz.fr", "x.co", "y2u.be", "yourls.org", + "zi.ma", "zpr.io" + ); + + /** 已知钓鱼/恶意域名黑名单 */ + private static final Set KNOWN_PHISHING_DOMAINS = Set.of( + "mc-login.com", "mojang-login.com", "minecraft-login.net", + "mc-free.com", "minecraft-gift.com", "mc-verify.com", + "mojang-verify.com", "minecraft-security.com", "mc-security.org", + "minecraft-premium.com", "mc-premium.org", "free-minecraft.net", + "minecraft-cape.com", "mc-cape-free.com", "optifine-free.com", + "minecon-cape.com", "minecraft-account.com", "mc-accounts.com", + "steal-account.info", "phish-account.ml" + ); + + /** 可疑域名关键字(出现在非官方域名中视为钓鱼) */ + private static final String[] SUSPICIOUS_DOMAIN_KEYWORDS = { + "minecraft", "mojang", "login", "verify", "account", + "password", "signin", "authenticate", "credential", "redeem", + "gift", "free-cape", "free-mc", "mc-premium", "premium-mc" + }; + + /** Mojang/Microsoft官方域名,不会被误判 */ + private static final Set OFFICIAL_DOMAINS = Set.of( + "minecraft.net", "mojang.com", "minecraftforge.net", + "fabricmc.net", "paperMC.io", "spigotmc.org", + "bukkit.org", "papermc.io", "purpurmc.org", + "curseforge.com", "modrinth.com", "planetminecraft.com", + "xbox.com", "microsoft.com", "live.com" + ); + + /** 免费Minecraft骗局关键字 */ + private static final String[] FREE_MC_KEYWORDS = { + "free minecraft", "free mc", "get free", "minecraft free", + "free account", "free gift", "free cape", "generate account", + "unlimited minecraft", "mc account generator" + }; + + /** Minecraft官方相关的显示文本关键字 */ + private static final String[] OFFICIAL_TEXT_INDICATORS = { + "mojang", "minecraft official", "minecraft.net", "官方", + "official", "verify", "验证", "安全", "security" + }; + + public AntiPhishingLinkService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiPhishingLinkService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 检测消息中的URL是否为钓鱼/恶意链接 + * + * 从消息中提取所有URL,逐一检测是否为短链接、是否指向已知钓鱼域名、 + * 是否包含可疑关键字但非官方域名、是否为免费Minecraft骗局、以及 + * 显示文本与实际链接是否不匹配。 + * + * @param player 玩家名 + * @param message 待检测的聊天消息 + * @return 检测结果 + */ + public CheckResult check(String player, String message) { + if (!config.getSecurity().getSuperEvolution().isAntiPhishingLink()) { + return CheckResult.clean(); + } + + if (message == null || message.isEmpty()) { + return CheckResult.clean(); + } + + List reasons = new ArrayList<>(); + String lowerMessage = message.toLowerCase(); + + // 提取所有URL + Matcher urlMatcher = URL_PATTERN.matcher(message); + List foundUrls = new ArrayList<>(); + while (urlMatcher.find()) { + foundUrls.add(urlMatcher.group()); + } + + for (String url : foundUrls) { + totalUrls.incrementAndGet(); + String normalizedUrl = url.toLowerCase(); + String domain = extractDomain(normalizedUrl); + + if (domain == null) { + continue; + } + + // 1. 短链接检测 + if (isShortUrlService(domain)) { + shortUrlsResolved.incrementAndGet(); + reasons.add("检测到短链接服务: " + domain + " (短链接可能隐藏真实目标)"); + continue; // 短链接标记但不过度处罚,继续其他检测 + } + + // 2. 已知钓鱼域名黑名单匹配 + if (KNOWN_PHISHING_DOMAINS.contains(domain)) { + phishingDetected.incrementAndGet(); + reasons.add("已知钓鱼域名黑名单: " + domain); + } + + // 3. 可疑域名检测:包含minecraft/mojang/login等关键字但非官方域名 + if (!isOfficialDomain(domain) && containsSuspiciousKeyword(domain)) { + phishingDetected.incrementAndGet(); + reasons.add("域名包含Minecraft相关关键字但非官方域名: " + domain + " (疑似假冒网站)"); + } + + // 4. 检测"免费Minecraft"骗局 + if (containsFreeMcScam(lowerMessage)) { + phishingDetected.incrementAndGet(); + reasons.add("检测到免费Minecraft骗局链接: " + url); + } + } + + // 5. 检测不匹配的显示文本 + if (!foundUrls.isEmpty() && hasMismatchedDisplayText(message, foundUrls)) { + phishingDetected.incrementAndGet(); + reasons.add("消息中声称官方但实际链接指向非官方网站,疑似文本链接不匹配钓鱼"); + } + + if (!reasons.isEmpty()) { + playerPhishingCount.merge(player, 1L, Long::sum); + return CheckResult.blocked(reasons); + } + + return CheckResult.clean(); + } + + /** + * 从URL中提取域名部分 + * + * 处理http://、https://前缀,移除路径和查询参数,只保留域名。 + * + * @param url 完整URL + * @return 纯域名部分,解析失败返回null + */ + private String extractDomain(String url) { + if (url == null) return null; + String cleaned = url.replaceFirst("^https?://", ""); + cleaned = cleaned.replaceFirst("^www\\.", ""); + int slashIndex = cleaned.indexOf('/'); + if (slashIndex > 0) { + cleaned = cleaned.substring(0, slashIndex); + } + int colonIndex = cleaned.lastIndexOf(':'); + if (colonIndex > 0 && cleaned.substring(colonIndex + 1).matches("\\d+")) { + cleaned = cleaned.substring(0, colonIndex); + } + return cleaned.isEmpty() ? null : cleaned; + } + + /** + * 判断域名是否为短链接服务 + * + * @param domain 域名 + * @return true表示是短链接服务 + */ + private boolean isShortUrlService(String domain) { + if (domain == null) return false; + for (String service : SHORT_URL_SERVICES) { + if (domain.equals(service) || domain.endsWith("." + service)) { + return true; + } + } + return false; + } + + /** + * 判断域名是否为Minecraft生态官方域名 + * + * @param domain 域名 + * @return true表示是官方域名 + */ + private boolean isOfficialDomain(String domain) { + if (domain == null) return false; + for (String official : OFFICIAL_DOMAINS) { + if (domain.equals(official) || domain.endsWith("." + official)) { + return true; + } + } + return false; + } + + /** + * 判断域名是否包含可疑关键字(如minecraft、login等) + * + * @param domain 域名 + * @return true表示包含可疑关键字 + */ + private boolean containsSuspiciousKeyword(String domain) { + if (domain == null) return false; + for (String keyword : SUSPICIOUS_DOMAIN_KEYWORDS) { + if (domain.contains(keyword)) { + return true; + } + } + return false; + } + + /** + * 检测消息是否包含"免费Minecraft"骗局关键字 + * + * @param message 小写的消息文本 + * @return true表示包含骗局关键字 + */ + private boolean containsFreeMcScam(String message) { + for (String keyword : FREE_MC_KEYWORDS) { + if (message.contains(keyword)) { + return true; + } + } + return false; + } + + /** + * 检测显示文本与实际链接是否不匹配 + * + * 例如消息声称"[Mojang官方验证]"但实际链接指向钓鱼站。 + * 同时检测消息中包含Minecraft官方相关文本关键字但URL域名非官方。 + * + * @param message 消息原文 + * @param urls 消息中的URL列表 + * @return true表示存在不匹配 + */ + private boolean hasMismatchedDisplayText(String message, List urls) { + String lowerMessage = message.toLowerCase(); + + // 检查是否包含官方相关文本关键字 + boolean hasOfficialText = false; + for (String indicator : OFFICIAL_TEXT_INDICATORS) { + if (lowerMessage.contains(indicator)) { + hasOfficialText = true; + break; + } + } + + if (!hasOfficialText) { + return false; + } + + // 检查所有URL是否都是非官方域名 + for (String url : urls) { + String domain = extractDomain(url.toLowerCase()); + if (domain != null && !isOfficialDomain(domain)) { + return true; // 声称官方但实际URL不是官方域名 + } + } + + return false; + } + + /** + * 获取服务运行状态 + * + * @return 包含状态键值对的LinkedHashMap + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiPhishingLink()); + s.put("totalUrls", totalUrls.get()); + s.put("phishingDetected", phishingDetected.get()); + s.put("shortUrlsResolved", shortUrlsResolved.get()); + s.put("knownPhishingDomains", KNOWN_PHISHING_DOMAINS.size()); + s.put("shortUrlServices", SHORT_URL_SERVICES.size()); + s.put("trackedPlayers", playerPhishingCount.size()); + return s; + } + + /** + * 检测结果类 + */ + public static class CheckResult { + private final boolean blocked; + private final List reasons; + + private CheckResult(boolean blocked, List reasons) { + this.blocked = blocked; + this.reasons = reasons; + } + + public static CheckResult clean() { + return new CheckResult(false, List.of()); + } + + public static CheckResult blocked(List reasons) { + return new CheckResult(true, reasons); + } + + public boolean isBlocked() { return blocked; } + public boolean isClean() { return !blocked; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiReachService.java b/src/main/java/com/aluer/security/AntiReachService.java new file mode 100644 index 0000000..3caa99f --- /dev/null +++ b/src/main/java/com/aluer/security/AntiReachService.java @@ -0,0 +1,276 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 超距攻击(Reach)检测服务 — V4.0 反作弊扩展模块 + * + * 检测原理: + * 1. 正常攻击距离检测 — Minecraft生存模式默认攻击距离约3.0方块,考虑网络延迟容差 + * 将阈值设为3.8方块,超过此值视为可疑 + * 2. 绝对超距判定 — 攻击距离超过6.0方块时,排除任何延迟可能性,绝对判定为超距攻击 + * 3. 连续超距记录追踪 — 追踪每个玩家最近10次攻击距离,如果有多次超过阈值则标记为作弊者 + * 4. 超距频率统计 — 如果某个玩家的超距攻击占总攻击的一定比例,表明可能使用Reach扩展 + * + * 配置开关:serverguard.security.super-evolution.anti-reach + */ +@Service +public class AntiReachService { + + private final ServerGuardConfig config; + + /** + * 追踪每个玩家的攻击距离历史(playerName -> 距离记录列表) + */ + private final Map> playerDistanceHistory = new ConcurrentHashMap<>(); + + /** + * 追踪每个玩家的违规攻击记录(playerName -> 违规详情列表),保留用于getStatus()展示 + */ + private final Map>> playerViolations = new ConcurrentHashMap<>(); + + private final AtomicLong totalChecks = new AtomicLong(0); + private final AtomicLong violations = new AtomicLong(0); + + /** + * 宽松阈值(考虑网络延迟)— 超过此值视为可疑但非绝对确认 + */ + private static final double SUSPICIOUS_REACH_THRESHOLD = 3.8; + + /** + * 绝对超距阈值 — 超过此值无任何合法可能性,直接判定 + */ + private static final double ABSOLUTE_REACH_THRESHOLD = 6.0; + + /** + * 超距比例阈值 — 如果玩家最近攻击中超距攻击超过这个比例,标记为作弊 + */ + private static final double VIOLATION_RATIO_THRESHOLD = 0.3; + + /** + * 最小攻击次数 — 需要足够的攻击样本才能做出可靠判断 + */ + private static final int MIN_ATTACKS_FOR_ANALYSIS = 5; + + /** + * 追踪每个玩家的最大攻击记录数 + */ + private static final int MAX_RECORDS_PER_PLAYER = 30; + + /** + * 无参构造函数 — 测试/默认配置使用 + */ + public AntiReachService() { + this(new ServerGuardConfig()); + } + + /** + * Spring 注入构造函数 + * @param config 全局配置,提供模块开关控制 + */ + public AntiReachService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 检测玩家攻击距离是否异常 + * + * @param playerName 攻击者玩家名称 + * @param playerUUID 攻击者UUID + * @param attackerX 攻击者X坐标 + * @param attackerY 攻击者Y坐标 + * @param attackerZ 攻击者Z坐标 + * @param targetX 目标X坐标 + * @param targetY 目标Y坐标 + * @param targetZ 目标Z坐标 + * @param actualDistance 客户端报告的或服务端计算的实际距离 + * @param timestamp 攻击时间 + * @return 检测结果 + */ + public ReachCheckResult detect(String playerName, String playerUUID, + double attackerX, double attackerY, double attackerZ, + double targetX, double targetY, double targetZ, + double actualDistance, Instant timestamp) { + // 模块开关检查 — 关闭时跳过所有检测 + if (!config.getSecurity().getSuperEvolution().isAntiReach()) { + return ReachCheckResult.clean(); + } + + totalChecks.incrementAndGet(); + + // 计算服务端确认的距离(欧几里得距离) + double serverDistance = Math.sqrt( + Math.pow(targetX - attackerX, 2) + + Math.pow(targetY - attackerY, 2) + + Math.pow(targetZ - attackerZ, 2)); + + List history = playerDistanceHistory.computeIfAbsent( + playerName, k -> new ArrayList<>()); + + DistanceRecord record = new DistanceRecord(timestamp, serverDistance, actualDistance, + attackerX, attackerY, attackerZ, targetX, targetY, targetZ); + history.add(record); + + // 限制记录数量,只保留最近记录 + while (history.size() > MAX_RECORDS_PER_PLAYER) { + history.remove(0); + } + + // 绝对超距判定 — 超过6.0方块无任何合法可能 + if (serverDistance > ABSOLUTE_REACH_THRESHOLD) { + violations.incrementAndGet(); + List reasons = List.of( + "ABSOLUTE_REACH: " + String.format("%.2f", serverDistance) + + " blocks (max legit ~3.0, threshold 6.0)"); + recordViolation(playerName, serverDistance, "ABSOLUTE_REACH"); + return ReachCheckResult.flagged(reasons); + } + + // 可疑超距检测 — 超过3.8方块但不满6.0(可能是延迟或开挂) + if (serverDistance > SUSPICIOUS_REACH_THRESHOLD) { + recordViolation(playerName, serverDistance, "SUSPICIOUS_REACH"); + + // 检查最近的攻击历史中超距攻击的比例 + if (history.size() >= MIN_ATTACKS_FOR_ANALYSIS) { + long exceedCount = history.stream() + .filter(r -> r.serverDistance > SUSPICIOUS_REACH_THRESHOLD) + .count(); + double exceedRatio = (double) exceedCount / history.size(); + + if (exceedRatio > VIOLATION_RATIO_THRESHOLD) { + violations.incrementAndGet(); + List reasons = List.of( + "FREQUENT_REACH: " + String.format("%.0f", exceedRatio * 100) + + "% of recent attacks exceed " + SUSPICIOUS_REACH_THRESHOLD + + " blocks (" + exceedCount + "/" + history.size() + ")", + "LATEST_REACH: " + String.format("%.2f", serverDistance) + " blocks"); + return ReachCheckResult.flagged(reasons); + } + } + + return ReachCheckResult.suspicious( + List.of("SUSPICIOUS_REACH: " + String.format("%.2f", serverDistance) + " blocks")); + } + + return ReachCheckResult.clean(); + } + + /** + * 记录违规事件用于getStatus()展示 + */ + private void recordViolation(String playerName, double distance, String type) { + List> violations = playerViolations.computeIfAbsent( + playerName, k -> new ArrayList<>()); + Map v = new LinkedHashMap<>(); + v.put("time", Instant.now().toString()); + v.put("distance", String.format("%.2f", distance)); + v.put("type", type); + violations.add(v); + while (violations.size() > 20) { + violations.remove(0); + } + } + + /** + * 玩家离线时清理追踪数据 + * @param playerName 离线玩家名称 + */ + public void clearPlayer(String playerName) { + playerDistanceHistory.remove(playerName); + playerViolations.remove(playerName); + } + + /** + * 获取模块运行状态 + * @return 包含计数器、最近违规列表的Map + */ + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("totalChecks", totalChecks.get()); + status.put("violations", violations.get()); + status.put("activeTrackedPlayers", playerDistanceHistory.size()); + + // 汇总最近违规记录 + List> recentViolations = new ArrayList<>(); + for (Map.Entry>> entry : playerViolations.entrySet()) { + if (!entry.getValue().isEmpty()) { + Map summary = new LinkedHashMap<>(); + summary.put("player", entry.getKey()); + Map lastV = entry.getValue().get(entry.getValue().size() - 1); + summary.put("lastViolationDistance", lastV.get("distance")); + summary.put("lastViolationType", lastV.get("type")); + summary.put("lastViolationTime", lastV.get("time")); + summary.put("totalViolations", entry.getValue().size()); + recentViolations.add(summary); + } + } + // 按最新违规时间排序 + recentViolations.sort((a, b) -> { + String ta = (String) a.get("lastViolationTime"); + String tb = (String) b.get("lastViolationTime"); + return tb.compareTo(ta); + }); + status.put("recentViolations", recentViolations.subList(0, Math.min(recentViolations.size(), 20))); + return status; + } + + /** + * 内部距离记录 — 保存单次攻击的空间和距离信息 + */ + private static class DistanceRecord { + final Instant timestamp; + final double serverDistance; + final double actualDistance; + final double ax, ay, az, tx, ty, tz; + + DistanceRecord(Instant timestamp, double serverDistance, double actualDistance, + double ax, double ay, double az, double tx, double ty, double tz) { + this.timestamp = timestamp; + this.serverDistance = serverDistance; + this.actualDistance = actualDistance; + this.ax = ax; this.ay = ay; this.az = az; + this.tx = tx; this.ty = ty; this.tz = tz; + } + } + + /** + * 超距检测结果 — 不可变结果类 + */ + public static class ReachCheckResult { + private final boolean flagged; + private final boolean suspicious; + private final List reasons; + + private ReachCheckResult(boolean flagged, boolean suspicious, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.reasons = reasons; + } + + /** 无异常 — 正常攻击距离 */ + public static ReachCheckResult clean() { + return new ReachCheckResult(false, false, List.of()); + } + + /** 可疑 — 攻击距离略微超过阈值,但可能由延迟引起 */ + public static ReachCheckResult suspicious(List reasons) { + return new ReachCheckResult(false, true, reasons); + } + + /** 已标记 — 确认超距攻击,距离显著超出合法范围 */ + public static ReachCheckResult flagged(List reasons) { + return new ReachCheckResult(true, false, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiResourcePackExploitService.java b/src/main/java/com/aluer/security/AntiResourcePackExploitService.java new file mode 100644 index 0000000..d13818b --- /dev/null +++ b/src/main/java/com/aluer/security/AntiResourcePackExploitService.java @@ -0,0 +1,357 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.net.*; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 资源包漏洞防护服务 — V4.0 服务器保护模块 + * + * 检测原理: + * Minecraft允许服务器向客户端推送资源包(Resource Pack)URL,客户端会自动下载 + * 并加载。如果URL指向恶意资源,可能导致客户端安全风险。本服务验证资源包URL的 + * 合法性,检测SSRF攻击、恶意文件类型、过大文件、以及已知的恶意SHA-1哈希。 + * + * 攻击场景: + * - SSRF攻击:URL指向内网地址(file:///etc/passwd, localhost等) + * - 超大文件:资源包>100MB导致客户端下载时内存溢出 + * - 恶意文件类型:.exe/.jar/.sh等可执行文件伪装为资源包 + * - 已知恶意哈希:使用已被确认的恶意资源包SHA-1 + * - HTTP重定向攻击:URL重定向到危险目标 + * + * 配置开关:serverguard.security.super-evolution.anti-resource-pack-exploit + */ +@Service +public class AntiResourcePackExploitService { + + private final ServerGuardConfig config; + + private final Map> packHistory = new ConcurrentHashMap<>(); + private final Map urlCache = new ConcurrentHashMap<>(); + /** SHA-1哈希黑名单(已知恶意资源包) */ + private final Set sha1Blacklist = ConcurrentHashMap.newKeySet(); + private final Set urlBlacklist = ConcurrentHashMap.newKeySet(); + + private final AtomicLong totalPacksChecked = new AtomicLong(0); + private final AtomicLong blockedPacks = new AtomicLong(0); + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + /** 资源包允许的最大文件大小(字节) */ + private static final long MAX_PACK_SIZE_BYTES = 100 * 1024 * 1024; // 100MB + /** URL缓存超时(毫秒) */ + private static final long URL_CACHE_TTL_MS = 300_000; // 5分钟 + + /** 内网IP前缀 */ + private static final Set INTERNAL_IPv4_PREFIXES = Set.of( + "10.", "172.16.", "172.17.", "172.18.", "172.19.", "172.20.", + "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", + "172.27.", "172.28.", "172.29.", "172.30.", "172.31.", "192.168." + ); + + /** + * 危险文件扩展名(资源包必须是.zip格式,以下扩展名可能包含可执行代码). + * 注意:Set.of() 不允许重复元素,每个扩展名必须唯一. + */ + private static final Set DANGEROUS_EXTENSIONS = Set.of( + ".exe", ".bat", ".cmd", ".com", ".msi", ".ps1", ".scr", + ".jar", ".class", ".war", ".ear", + ".sh", ".bash", ".zsh", ".run", ".bin", + ".dll", ".so", ".dylib", ".sys", + ".deb", ".rpm", ".apk", ".ipa", + ".js", ".vbs", ".jse", ".wsf", ".wsh", ".hta" + ); + + /** 被禁止的协议 */ + private static final Set BLOCKED_SCHEMES = Set.of( + "file", "ftp", "gopher", "dict", "jar", "netdoc", "tftp" + ); + + public AntiResourcePackExploitService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiResourcePackExploitService(ServerGuardConfig config) { + this.config = config; + // 初始化已知恶意哈希(示例值,生产环境应从威胁情报源同步) + initSha1Blacklist(); + scheduler.scheduleAtFixedRate(this::cleanupOldData, 60, 300, TimeUnit.SECONDS); + } + + /** + * 检查资源包URL和SHA-1哈希是否安全。 + * + * @param url 资源包下载URL + * @param sha1 资源包SHA-1哈希(必需,Minecraft强制要求) + * @param sourcePlayer 发起推送的玩家(OP或控制台) + * @param packVersion 资源包格式版本 + * @return 检查结果,包含是否阻止及原因 + */ + public PackCheckResult check(String url, String sha1, String sourcePlayer, int packVersion) { + // 配置开关检查 + if (!config.getSecurity().getSuperEvolution().isAntiResourcePackExploit()) { + return PackCheckResult.clean(); + } + + totalPacksChecked.incrementAndGet(); + List reasons = new ArrayList<>(); + + // 记录事件 + PackEvent event = new PackEvent(Instant.now(), url, sha1, sourcePlayer, packVersion); + packHistory.computeIfAbsent(sourcePlayer, k -> Collections.synchronizedList(new ArrayList<>())).add(event); + + // ---- 检测1:URL格式验证 ---- + if (url == null || url.trim().isEmpty()) { + reasons.add("EMPTY_URL"); + blockedPacks.incrementAndGet(); + return PackCheckResult.blocked(reasons); + } + + URI uri; + try { + uri = new URI(url.trim()); + } catch (URISyntaxException e) { + reasons.add("INVALID_URI: " + e.getMessage()); + blockedPacks.incrementAndGet(); + return PackCheckResult.blocked(reasons); + } + + // ---- 检测2:协议验证(必须http/https) ---- + String scheme = uri.getScheme(); + if (scheme == null) { + reasons.add("MISSING_SCHEME: URL has no protocol"); + } else { + String schemeLower = scheme.toLowerCase(); + if (BLOCKED_SCHEMES.contains(schemeLower)) { + reasons.add("BLOCKED_SCHEME: " + scheme + " protocol is not allowed"); + } else if (!schemeLower.equals("http") && !schemeLower.equals("https")) { + reasons.add("INVALID_SCHEME: " + scheme + " only http/https allowed"); + } + } + + // ---- 检测3:内网地址检测(SSRF风险) ---- + // 攻击者可以通过资源包URL探测内网服务或读取本地文件 + String host = uri.getHost(); + if (host != null) { + String hostLower = host.toLowerCase(); + + // 检测本地回环地址 + if ("localhost".equals(hostLower) || "127.0.0.1".equals(hostLower) + || "::1".equals(hostLower) || "[::1]".equals(hostLower) + || "0.0.0.0".equals(hostLower)) { + reasons.add("SSRF_LOOPBACK: " + host + " is a loopback address"); + } + + // 检测内网IP段 + for (String prefix : INTERNAL_IPv4_PREFIXES) { + if (hostLower.startsWith(prefix)) { + reasons.add("SSRF_PRIVATE_NET: " + host + " is a private network address"); + break; + } + } + + // 检测云元数据地址 + if (hostLower.contains("metadata") || hostLower.equals("169.254.169.254")) { + reasons.add("SSRF_CLOUD_METADATA: " + host + " is a cloud metadata endpoint"); + } + + // 尝试DNS解析检查(防止DNS重绑定攻击) + try { + InetAddress resolved = InetAddress.getByName(host); + String resolvedIP = resolved.getHostAddress(); + if (resolvedIP.startsWith("10.") || resolvedIP.startsWith("192.168.") + || resolvedIP.equals("127.0.0.1") || resolvedIP.startsWith("172.16.")) { + reasons.add("SSRF_DNS_RESOLVE: " + host + " resolves to internal IP " + resolvedIP); + } + } catch (UnknownHostException ignored) { + // 无法解析的域名不一定危险,但标记为可疑 + } + } + + // ---- 检测4:URL黑名单匹配 ---- + if (urlBlacklist.contains(url.trim().toLowerCase())) { + reasons.add("URL_BLACKLISTED: URL is in known malicious list"); + } + + // ---- 检测5:SHA-1哈希黑名单比对 ---- + // 检查已知的恶意资源包哈希(可防止恶意资源包被重新分发) + if (sha1 != null && !sha1.trim().isEmpty()) { + if (sha1Blacklist.contains(sha1.trim().toLowerCase())) { + reasons.add("SHA1_BLACKLISTED: resource pack SHA-1 is known malicious"); + } + // SHA-1格式验证:必须是40个十六进制字符 + if (!sha1.trim().matches("^[0-9a-fA-F]{40}$")) { + reasons.add("SHA1_INVALID_FORMAT: SHA-1 must be 40 hex characters"); + } + } + + // ---- 检测6:可疑文件扩展名 ---- + String path = uri.getPath(); + if (path != null) { + String pathLower = path.toLowerCase(); + // 资源包应为.zip格式 + if (!pathLower.endsWith(".zip")) { + for (String ext : DANGEROUS_EXTENSIONS) { + if (pathLower.endsWith(ext)) { + reasons.add("DANGEROUS_EXTENSION: " + ext + " is not a valid resource pack file"); + break; + } + } + } + } + + // ---- 检测7:URL长度限制 ---- + // 超长的URL可能导致日志注入或缓冲区溢出 + if (url.length() > 2048) { + reasons.add("URL_TOO_LONG: " + url.length() + " chars (max: 2048)"); + } + + // ---- 检测8:URL编码绕过检测 ---- + // 检测URL编码中的内网IP(如 %31%32%37%2e%30%2e%30%2e%31 = 127.0.0.1) + String decodedLower = url.trim().toLowerCase(); + if (decodedLower.contains("%31%32%37") || decodedLower.contains("127%2e") + || decodedLower.contains("%31%30%2e") || decodedLower.contains("10%2e") + || decodedLower.contains("%31%39%32%2e%31%36%38") || decodedLower.contains("192%2e168") + || decodedLower.contains("localhost")) { + reasons.add("URL_ENCODED_INTERNAL: potentially obfuscated internal address"); + } + + // 判定结果 + if (!reasons.isEmpty()) { + blockedPacks.incrementAndGet(); + // 将恶意URL加入黑名单缓存 + urlCache.put(url.trim().toLowerCase(), new PackUrlInfo(Instant.now(), reasons)); + return PackCheckResult.blocked(reasons); + } + + // 缓存安全URL以避免重复检测 + urlCache.put(url.trim().toLowerCase(), new PackUrlInfo(Instant.now(), List.of())); + + return PackCheckResult.clean(); + } + + /** + * 将SHA-1哈希添加到黑名单。 + */ + public void addToSha1Blacklist(String sha1) { + if (sha1 != null && sha1.trim().matches("^[0-9a-fA-F]{40}$")) { + sha1Blacklist.add(sha1.trim().toLowerCase()); + } + } + + /** + * 将URL添加到黑名单。 + */ + public void addToUrlBlacklist(String url) { + if (url != null && !url.trim().isEmpty()) { + urlBlacklist.add(url.trim().toLowerCase()); + } + } + + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalPacksChecked", totalPacksChecked.get()); + s.put("blockedPacks", blockedPacks.get()); + s.put("sha1BlacklistSize", sha1Blacklist.size()); + s.put("urlBlacklistSize", urlBlacklist.size()); + s.put("cachedUrls", urlCache.size()); + + // 最近检查的资源包 + List> recent = new ArrayList<>(); + for (Map.Entry> e : packHistory.entrySet()) { + List events = e.getValue(); + if (!events.isEmpty()) { + PackEvent last = events.get(events.size() - 1); + Map m = new LinkedHashMap<>(); + m.put("source", e.getKey()); + m.put("url", last.url.length() > 100 ? last.url.substring(0, 100) + "..." : last.url); + m.put("sha1", last.sha1); + m.put("time", last.time.toString()); + recent.add(m); + } + } + recent.sort((a, b) -> b.get("time").toString().compareTo(a.get("time").toString())); + s.put("recentPacks", recent.subList(0, Math.min(recent.size(), 10))); + + // URL黑名单(最多显示50条) + s.put("urlBlacklist", new ArrayList<>(urlBlacklist).subList(0, Math.min(urlBlacklist.size(), 50))); + return s; + } + + /** + * 初始化已知恶意SHA-1哈希列表。 + */ + private void initSha1Blacklist() { + // 这些是示例值 - 生产环境应从威胁情报源定期更新 + // 实际部署时可添加已知的恶意资源包哈希 + } + + private void cleanupOldData() { + Instant cutoff = Instant.now().minusMillis(URL_CACHE_TTL_MS); + urlCache.entrySet().removeIf(e -> e.getValue().timestamp.isBefore(cutoff)); + + Instant eventCutoff = Instant.now().minusSeconds(7200); + for (List events : packHistory.values()) { + events.removeIf(e -> e.time.isBefore(eventCutoff)); + } + packHistory.entrySet().removeIf(e -> e.getValue().isEmpty()); + } + + private static class PackEvent { + final Instant time; + final String url; + final String sha1; + final String sourcePlayer; + final int packVersion; + + PackEvent(Instant t, String u, String s, String sp, int pv) { + this.time = t; + this.url = u; + this.sha1 = s; + this.sourcePlayer = sp; + this.packVersion = pv; + } + } + + private static class PackUrlInfo { + final Instant timestamp; + final List reasons; + + PackUrlInfo(Instant t, List r) { + this.timestamp = t; + this.reasons = r; + } + } + + public static class PackCheckResult { + private final boolean blocked; + private final List reasons; + + private PackCheckResult(boolean blocked, List reasons) { + this.blocked = blocked; + this.reasons = reasons; + } + + public static PackCheckResult clean() { + return new PackCheckResult(false, List.of()); + } + + public static PackCheckResult blocked(List reasons) { + return new PackCheckResult(true, reasons); + } + + public boolean isBlocked() { return blocked; } + public boolean isClean() { return !blocked; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiScaffoldService.java b/src/main/java/com/aluer/security/AntiScaffoldService.java new file mode 100644 index 0000000..6e155c9 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiScaffoldService.java @@ -0,0 +1,351 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 自动搭路(Scaffold)检测服务 — V4.0 反作弊扩展模块 + * + * 检测原理: + * 1. 方块放置速率检测 — Scaffold hack的特征是快速连续放置方块用于搭路, + * 正常玩家的方块放置间隔不可能短到1秒内放置5+方块且持续3秒以上。 + * 2. 放置角度检测 — Scaffold hack通常要求玩家向下看(pitch接近90度), + * 而正常玩家在放置方块时会自然地改变视角。检测放置时俯仰角的机械一致性。 + * 3. Safewalk检测 — Scaffold hack的一个常见附加功能是Safewalk, + * 防止玩家在方块边缘掉落或移位(通过自动潜行或调整位置实现)。 + * 4. 放置位置模式检测 — 检测方块是否精确放置在脚下/前方(Scaffold模式), + * 以及放置面是否为空气面(空中搭路)。 + * + * 配置开关:serverguard.security.super-evolution.anti-scaffold + */ +@Service +public class AntiScaffoldService { + + private final ServerGuardConfig config; + + /** + * 追踪每个玩家的方块放置记录(playerName -> 放置记录列表) + */ + private final Map> playerPlacementHistory = new ConcurrentHashMap<>(); + + /** + * 追踪每个玩家的持续高速放置计时(playerName -> 高速放置开始时间) + */ + private final Map playerRapidPlacingSince = new ConcurrentHashMap<>(); + + /** + * 追踪每个玩家的放置角度记录(playerName -> 角度列表) + */ + private final Map> playerPitchHistory = new ConcurrentHashMap<>(); + + /** + * 追踪每个玩家的方块边缘安全状态(playerName -> 是否处于安全行走区域) + */ + private final Map playerSafewalkData = new ConcurrentHashMap<>(); + + private final AtomicLong totalPlacements = new AtomicLong(0); + private final AtomicLong scaffoldViolations = new AtomicLong(0); + + /** + * 最大正常放置速率(方块/秒)— 超过此速率视为异常快速放置 + * 正常玩家在有预谋的情况下也很难超过3-4方块/秒 + */ + private static final double MAX_PLACEMENT_RATE = 5.0; + + /** + * 持续高速放置时间阈值(毫秒)— 需要高速放置持续3秒以上才标记 + */ + private static final long MIN_RAPID_DURATION_MS = 3_000; + + /** + * Scaffold俯仰角阈值(度数)— pitch接近-90或90时表示玩家在看正下或正上方 + * Scaffold hack用户典型地向下看(pitch接近-90) + */ + private static final double SCAFFOLD_PITCH_THRESHOLD = 60.0; + + /** + * 放置角度一致性阈值 — 连续放置时pitch变化小于此值表示机械性的固定视角 + */ + private static final double PITCH_CONSISTENCY_THRESHOLD = 3.0; + + /** + * 最小放置次数 — 需要足够的放置样本才能进行分析 + */ + private static final int MIN_PLACEMENTS_FOR_ANALYSIS = 10; + + /** + * 每个玩家保留的最大放置记录数 + */ + private static final int MAX_PLACEMENT_RECORDS = 50; + + /** + * 无参构造函数 — 测试/默认配置使用 + */ + public AntiScaffoldService() { + this(new ServerGuardConfig()); + } + + /** + * Spring 注入构造函数 + * @param config 全局配置,提供模块开关控制 + */ + public AntiScaffoldService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 检测玩家的方块放置行为是否存在Scaffold hack特征 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家UUID + * @param placedX 放置方块X坐标 + * @param placedY 放置方块Y坐标 + * @param placedZ 放置方块Z坐标 + * @param playerYaw 玩家水平视角(度数) + * @param playerPitch 玩家俯仰视角(度数) + * @param isPlaceBelowPlayer 放置位置是否在玩家脚下 + * @param isAdjacentToPlayer 放置位置是否紧邻玩家 + * @param blockType 放置的方块类型 + * @param timestamp 时间戳 + * @return 检测结果 + */ + public ScaffoldCheckResult detect(String playerName, String playerUUID, + double placedX, double placedY, double placedZ, + double playerYaw, double playerPitch, + boolean isPlaceBelowPlayer, boolean isAdjacentToPlayer, + String blockType, Instant timestamp) { + // 模块开关检查 — 关闭时跳过所有检测 + if (!config.getSecurity().getSuperEvolution().isAntiScaffold()) { + return ScaffoldCheckResult.clean(); + } + + totalPlacements.incrementAndGet(); + + List history = playerPlacementHistory.computeIfAbsent( + playerName, k -> new ArrayList<>()); + List pitchHistory = playerPitchHistory.computeIfAbsent( + playerName, k -> new ArrayList<>()); + + PlacementRecord record = new PlacementRecord(timestamp, placedX, placedY, placedZ, + playerYaw, playerPitch, isPlaceBelowPlayer, isAdjacentToPlayer, blockType); + history.add(record); + pitchHistory.add(playerPitch); + + while (history.size() > MAX_PLACEMENT_RECORDS) { + history.remove(0); + } + while (pitchHistory.size() > 20) { + pitchHistory.remove(0); + } + + List reasons = new ArrayList<>(); + + // 1. 方块放置速率检测 + // 计算最近3秒内的放置数量 + Instant threeSecondsAgo = timestamp.minusMillis(3_000); + long recentPlacements = history.stream() + .filter(r -> !r.timestamp.isBefore(threeSecondsAgo)) + .count(); + double placementRate = recentPlacements / 3.0; // 每秒放置数 + + if (placementRate > MAX_PLACEMENT_RATE) { + // 高速放置 + playerRapidPlacingSince.putIfAbsent(playerName, timestamp); + + Instant rapidStartTime = playerRapidPlacingSince.get(playerName); + long rapidDurationMs = timestamp.toEpochMilli() - rapidStartTime.toEpochMilli(); + + if (rapidDurationMs >= MIN_RAPID_DURATION_MS) { + reasons.add("RAPID_PLACEMENT: " + String.format("%.1f", placementRate) + + " blocks/s for " + (rapidDurationMs / 1000.0) + + "s (threshold: " + String.format("%.1f", MAX_PLACEMENT_RATE) + " blocks/s)"); + } + } else { + // 速率恢复正常 + playerRapidPlacingSince.remove(playerName); + } + + // 2. 放置角度检测 — Scaffold hack用户看下方放置 + double absPitch = Math.abs(playerPitch); + if (absPitch > SCAFFOLD_PITCH_THRESHOLD && (isPlaceBelowPlayer || isAdjacentToPlayer)) { + // 玩家在看下方且方块放在脚下或旁边 — 典型Scaffold姿势 + + // 检查角度一致性 + if (pitchHistory.size() >= MIN_PLACEMENTS_FOR_ANALYSIS) { + double maxDeviation = 0; + double[] pitches = new double[pitchHistory.size()]; + for (int i = 0; i < pitchHistory.size(); i++) { + pitches[i] = pitchHistory.get(i); + } + for (int i = 1; i < pitches.length; i++) { + double diff = Math.abs(pitches[i] - pitches[i - 1]); + if (diff > maxDeviation) maxDeviation = diff; + } + + if (maxDeviation < PITCH_CONSISTENCY_THRESHOLD && absPitch > SCAFFOLD_PITCH_THRESHOLD) { + reasons.add("SCAFFOLD_ANGLE: pitch " + String.format("%.1f", absPitch) + + " degrees (looking down), max deviation " + + String.format("%.2f", PITCH_CONSISTENCY_THRESHOLD) + " degrees"); + } + } + } + + // 3. 放置位置模式检测 — 连续在脚下/前方放置 + if (history.size() >= MIN_PLACEMENTS_FOR_ANALYSIS) { + long belowCount = history.stream().filter(r -> r.isPlaceBelowPlayer).count(); + long adjacentCount = history.stream().filter(r -> r.isAdjacentToPlayer).count(); + long scaffoldPlaceCount = belowCount + adjacentCount; + + // 超过90%的放置都在脚下或相邻位置 — 典型的Scaffold模式 + if ((double) scaffoldPlaceCount / history.size() > 0.9 + && scaffoldPlaceCount >= MIN_PLACEMENTS_FOR_ANALYSIS) { + reasons.add("SCAFFOLD_PLACEMENT_PATTERN: " + scaffoldPlaceCount + "/" + + history.size() + " placements are below/adjacent to player"); + } + } + + // 4. Safewalk检测 — 追踪玩家在方块边缘的行为 + if (isPlaceBelowPlayer) { + // 玩家在边缘放置方块防止掉落 — 更新Safewalk追踪 + SafewalkData swd = playerSafewalkData.computeIfAbsent( + playerName, k -> new SafewalkData()); + swd.edgePlacements++; + swd.lastEdgePlacement = timestamp; + + if (swd.edgePlacements > 15) { + reasons.add("SAFEWALK: " + swd.edgePlacements + + " edge placements (possible Safewalk + Scaffold combination)"); + } + } + + if (reasons.size() >= 2) { + scaffoldViolations.incrementAndGet(); + return ScaffoldCheckResult.flagged(reasons); + } else if (reasons.size() == 1) { + return ScaffoldCheckResult.suspicious(reasons); + } + + return ScaffoldCheckResult.clean(); + } + + /** + * 玩家离线时清理追踪数据 + * @param playerName 离线玩家名称 + */ + public void clearPlayer(String playerName) { + playerPlacementHistory.remove(playerName); + playerRapidPlacingSince.remove(playerName); + playerPitchHistory.remove(playerName); + playerSafewalkData.remove(playerName); + } + + /** + * 获取模块运行状态 + * @return 包含计数器和活跃追踪玩家的Map + */ + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("totalPlacements", totalPlacements.get()); + status.put("scaffoldViolations", scaffoldViolations.get()); + status.put("trackedBuilders", playerPlacementHistory.size()); + + // 列出当前活跃的建造玩家 + List> activeBuilders = new ArrayList<>(); + for (Map.Entry> entry : playerPlacementHistory.entrySet()) { + if (!entry.getValue().isEmpty()) { + Map info = new LinkedHashMap<>(); + info.put("player", entry.getKey()); + info.put("recentPlacements", entry.getValue().size()); + // 计算最近放置速率 + if (entry.getValue().size() >= 2) { + PlacementRecord first = entry.getValue().get(0); + PlacementRecord last = entry.getValue().get(entry.getValue().size() - 1); + long durationMs = last.timestamp.toEpochMilli() - first.timestamp.toEpochMilli(); + if (durationMs > 0) { + double rate = entry.getValue().size() / (durationMs / 1000.0); + info.put("placementRate", String.format("%.1f", rate)); + } + } + activeBuilders.add(info); + } + } + activeBuilders.sort((a, b) -> Integer.compare( + (Integer) b.get("recentPlacements"), (Integer) a.get("recentPlacements"))); + status.put("activeBuilders", activeBuilders.subList(0, Math.min(activeBuilders.size(), 10))); + return status; + } + + /** + * 内部方块放置记录 — 记录单次放置的空间、角度和位置信息 + */ + private static class PlacementRecord { + final Instant timestamp; + final double placedX, placedY, placedZ; + final double playerYaw, playerPitch; + final boolean isPlaceBelowPlayer; + final boolean isAdjacentToPlayer; + final String blockType; + + PlacementRecord(Instant timestamp, double placedX, double placedY, double placedZ, + double playerYaw, double playerPitch, boolean isPlaceBelowPlayer, + boolean isAdjacentToPlayer, String blockType) { + this.timestamp = timestamp; + this.placedX = placedX; + this.placedY = placedY; + this.placedZ = placedZ; + this.playerYaw = playerYaw; + this.playerPitch = playerPitch; + this.isPlaceBelowPlayer = isPlaceBelowPlayer; + this.isAdjacentToPlayer = isAdjacentToPlayer; + this.blockType = blockType; + } + } + + /** + * Safewalk追踪数据 — 记录玩家在方块边缘放置方块的频率 + */ + private static class SafewalkData { + int edgePlacements = 0; + Instant lastEdgePlacement = Instant.now(); + } + + /** + * Scaffold检测结果 — 不可变结果类 + */ + public static class ScaffoldCheckResult { + private final boolean flagged; + private final boolean suspicious; + private final List reasons; + + private ScaffoldCheckResult(boolean flagged, boolean suspicious, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.reasons = reasons; + } + + /** 无异常 — 正常方块放置行为 */ + public static ScaffoldCheckResult clean() { + return new ScaffoldCheckResult(false, false, List.of()); + } + + /** 可疑 — 放置行为略有异常但不完全符合Scaffold模式 */ + public static ScaffoldCheckResult suspicious(List reasons) { + return new ScaffoldCheckResult(false, true, reasons); + } + + /** 已标记 — 确认Scaffold hack(高速搭路+固定视角+脚下放置模式) */ + public static ScaffoldCheckResult flagged(List reasons) { + return new ScaffoldCheckResult(true, false, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiSignExploitService.java b/src/main/java/com/aluer/security/AntiSignExploitService.java new file mode 100644 index 0000000..509cc5a --- /dev/null +++ b/src/main/java/com/aluer/security/AntiSignExploitService.java @@ -0,0 +1,355 @@ +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.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 告示牌漏洞防护服务 — V4.0 服务器保护模块 + * + * 检测原理: + * Minecraft告示牌(Sign)自1.8起支持JSON格式文本,其内容在客户端渲染时可能触发 + * 崩溃或恶意操作。攻击者通过构造包含超大JSON文本、恶意clickEvent/hoverEvent、 + * 或深度嵌套NBT数据的告示牌来实现客户端DoS、命令注入或变量替换攻击。 + * + * 常见攻击场景: + * - 超长JSON文本(>10000字符):导致客户端解析时内存溢出或渲染卡死 + * - 无效JSON组件:包含损坏的JSON结构使客户端解析器崩溃 + * - 恶意clickEvent:run_command注入服务器危险命令 + * - 恶意hoverEvent:超大show_text导致渲染卡顿 + * - NBT深度嵌套(>8层):解析递归溢出 + * - ${}变量占位符:可能触发其他插件的变量替换漏洞(如PlaceholderAPI注入) + * + * 配置开关:serverguard.security.super-evolution.anti-sign-exploit + */ +@Service +public class AntiSignExploitService { + + private final ServerGuardConfig config; + + private final Map> signHistory = new ConcurrentHashMap<>(); + private final Map playerViolations = new ConcurrentHashMap<>(); + private final AtomicLong totalSignsChecked = new AtomicLong(0); + private final AtomicLong exploitBlocked = new AtomicLong(0); + + /** 最近被阻止的详情,用于仪表盘展示 */ + private final List> lastBlockedDetails = Collections.synchronizedList(new ArrayList<>()); + private static final int MAX_BLOCKED_DETAILS = 50; + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + /** JSON文本最大允许字符数 */ + private static final int MAX_JSON_TEXT_LENGTH = 10000; + /** 单行文本最大允许字符数 */ + private static final int MAX_LINE_TEXT_LENGTH = 5000; + /** NBT最大允许嵌套深度 */ + private static final int MAX_NBT_DEPTH = 8; + /** 告示牌文本中的恶意命令关键词 */ + private static final Set BLOCKED_COMMANDS = Set.of( + "op ", "deop ", "ban ", "kick ", "stop", "restart", + "pex ", "lp ", "luckperms ", "manuadd ", "manudel", + "/op", "/deop", "/ban", "/kick", "/stop", "/restart", + "execute", "/execute" + ); + + public AntiSignExploitService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiSignExploitService(ServerGuardConfig config) { + this.config = config; + // 定期清理历史数据防止内存泄漏 + scheduler.scheduleAtFixedRate(this::cleanupOldData, 60, 120, TimeUnit.SECONDS); + } + + /** + * 检查告示牌内容是否存在漏洞利用。 + * + * @param player 设置告示牌的玩家名 + * @param world 所在世界 + * @param x 坐标X + * @param y 坐标Y + * @param z 坐标Z + * @param lines 告示牌四行文本内容(可能包含JSON/NBT) + * @param nbtDepth NBT标签嵌套深度(如果有) + * @return 检查结果 + */ + public SignCheckResult check(String player, String world, int x, int y, int z, + String[] lines, int nbtDepth) { + // 配置开关检查 + if (!config.getSecurity().getSuperEvolution().isAntiSignExploit()) { + return SignCheckResult.clean(); + } + + totalSignsChecked.incrementAndGet(); + List reasons = new ArrayList<>(); + + // 记录事件 + SignEvent event = new SignEvent(Instant.now(), player, world, x, y, z, + lines != null ? String.join("|", lines) : "null", nbtDepth); + signHistory.computeIfAbsent(player, k -> Collections.synchronizedList(new ArrayList<>())).add(event); + + // ---- 检测1:NBT深度嵌套(>8层为异常) ---- + // 深层嵌套NBT在解析时可能导致栈溢出,尤其是在旧版客户端中 + if (nbtDepth > MAX_NBT_DEPTH) { + reasons.add("NBT_DEEP_NESTING: depth=" + nbtDepth + " (max allowed: " + MAX_NBT_DEPTH + ")"); + } + + // 遍历每行文本进行检测 + if (lines != null) { + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (line == null || line.trim().isEmpty()) continue; + + // ---- 检测2:超长文本 ---- + // 单行JSON文本超过10000字符会导致客户端解析器内存溢出 + // 这是Minecraft Sign Bomb攻击的经典手法 + if (line.length() > MAX_JSON_TEXT_LENGTH) { + reasons.add("LINE_" + i + "_OVERSIZED: " + line.length() + + " chars (max: " + MAX_JSON_TEXT_LENGTH + ")"); + } + + // ---- 检测3:深度嵌套JSON括号 ---- + // JSON解析器递归层次过深可能导致栈溢出 + int jsonNestDepth = calculateJsonDepth(line); + if (jsonNestDepth > 15) { + reasons.add("LINE_" + i + "_JSON_DEEP_NEST: nest depth=" + jsonNestDepth); + } + + // ---- 检测4:恶意clickEvent ---- + // 告示牌点击事件中的run_command可被用于执行恶意命令 + // 常见手法:OP提升命令、封禁命令、服务器停止命令 + if (line.contains("clickEvent") || line.contains("\"action\"")) { + // 检查是否有run_command类型 + if (line.contains("run_command") || line.contains("\"run_command\"")) { + String lowerLine = line.toLowerCase(); + for (String blockedCmd : BLOCKED_COMMANDS) { + if (lowerLine.contains(blockedCmd)) { + reasons.add("LINE_" + i + "_CLICK_COMMAND_INJECTION: blocked command '" + blockedCmd + "'"); + break; + } + } + // 检测尝试执行任何命令 + if (!reasons.stream().anyMatch(r -> r.contains("CLICK_COMMAND_INJECTION"))) { + reasons.add("LINE_" + i + "_SUSPICIOUS_CLICK_COMMAND: contains run_command"); + } + } + // 检测change_page用于页面跳转攻击(可将玩家导向恶意内容) + if (line.contains("change_page") || line.contains("\"change_page\"")) { + reasons.add("LINE_" + i + "_CHANGE_PAGE_ABUSE: contains change_page"); + } + } + + // ---- 检测5:恶意hoverEvent ---- + // show_text过大可导致悬浮时客户端卡顿 + if (line.contains("hoverEvent") || line.contains("\"hoverEvent\"")) { + if (line.contains("show_text") || line.contains("\"show_text\"")) { + // 提取hoverEvent中的文本长度进行估算 + int hoverTextIdx = line.toLowerCase().indexOf("show_text"); + if (hoverTextIdx >= 0) { + int valueStart = line.indexOf("\"value\"", hoverTextIdx); + if (valueStart >= 0) { + int colonIdx = line.indexOf(":", valueStart); + if (colonIdx >= 0) { + int textStart = line.indexOf("\"", colonIdx + 1); + int textEnd = line.indexOf("\"", textStart + 1); + if (textStart >= 0 && textEnd > textStart) { + int textLen = textEnd - textStart - 1; + if (textLen > 2000) { + reasons.add("LINE_" + i + "_HOVER_TEXT_OVERSIZED: " + + textLen + " chars in hover event"); + } + } + } + } + } + } + } + + // ---- 检测6:${}变量占位符检测 ---- + // 某些插件(如PlaceholderAPI)会解析${}占位符 + // 攻击者可通过构造恶意占位符实现变量替换漏洞利用 + if (line.contains("${")) { + // 检查是否包含已知的危险占位符模式 + String lowerLine = line.toLowerCase(); + if (lowerLine.contains("${") && ( + lowerLine.contains("cmd") || + lowerLine.contains("exec") || + lowerLine.contains("system") || + lowerLine.contains("process") || + lowerLine.contains("runtime") || + lowerLine.contains("reflect") || + lowerLine.contains("class") || + lowerLine.contains("constructor"))) { + reasons.add("LINE_" + i + "_DANGEROUS_PLACEHOLDER: " + extractPlaceholder(line)); + } else { + // 存在占位符但不是危险模式,标记为可疑 + reasons.add("LINE_" + i + "_PLACEHOLDER_DETECTED: ${} variable pattern found"); + } + } + + // ---- 检测7:无效JSON组件(尝试解析基本结构) ---- + // 检查JSON的基本完整性(不执行完整解析,只做启发式检查) + if (line.contains("{") && !line.contains("}")) { + reasons.add("LINE_" + i + "_INCOMPLETE_JSON: missing closing brace"); + } + if (line.contains("\"text\":\"\"") && line.length() > 500) { + // 空文本但JSON体巨大:可能是隐藏的payload + reasons.add("LINE_" + i + "_HIDDEN_PAYLOAD: empty text with large JSON body"); + } + + // ---- 检测8:非法Unicode/控制字符 ---- + // 特殊控制字符可能导致客户端异常行为 + for (int ci = 0; ci < line.length(); ci++) { + char ch = line.charAt(ci); + if (ch == '') { // Null字符 + reasons.add("LINE_" + i + "_NULL_CHAR: contains null character at position " + ci); + break; + } + if (ch == '￾' || ch == '￿') { // BOM/非字符 + reasons.add("LINE_" + i + "_INVALID_UNICODE: contains noncharacter at position " + ci); + break; + } + } + } + } + + // 构建结果 + if (!reasons.isEmpty()) { + exploitBlocked.incrementAndGet(); + playerViolations.computeIfAbsent(player, k -> new AtomicLong(0)).incrementAndGet(); + + // 记录被阻止的详情 + Map detail = new LinkedHashMap<>(); + detail.put("time", Instant.now().toString()); + detail.put("player", player); + detail.put("location", world + " (" + x + "," + y + "," + z + ")"); + detail.put("reasons", reasons); + synchronized (lastBlockedDetails) { + lastBlockedDetails.add(0, detail); + if (lastBlockedDetails.size() > MAX_BLOCKED_DETAILS) { + lastBlockedDetails.remove(lastBlockedDetails.size() - 1); + } + } + + return SignCheckResult.blocked(reasons); + } + + return SignCheckResult.clean(); + } + + /** + * 获取当前服务运行状态。 + * 用于仪表盘展示告示牌安全统计数据。 + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalSignsChecked", totalSignsChecked.get()); + s.put("exploitBlocked", exploitBlocked.get()); + s.put("trackedPlayers", signHistory.size()); + + // 违规最多的玩家 Top 10 + List> topViolators = new ArrayList<>(); + for (Map.Entry e : playerViolations.entrySet()) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("violations", e.getValue().get()); + topViolators.add(m); + } + topViolators.sort((a, b) -> Long.compare((Long) b.get("violations"), (Long) a.get("violations"))); + s.put("topViolators", topViolators.subList(0, Math.min(topViolators.size(), 10))); + + // 最近被阻止的详情 + synchronized (lastBlockedDetails) { + s.put("lastBlockedDetails", new ArrayList<>(lastBlockedDetails.subList(0, + Math.min(lastBlockedDetails.size(), 10)))); + } + return s; + } + + /** + * 计算JSON字符串的最大嵌套深度(简化启发式算法)。 + * 不使用完整的JSON解析器,只通过括号匹配估算深度。 + */ + private int calculateJsonDepth(String json) { + int maxDepth = 0; + int currentDepth = 0; + for (char c : json.toCharArray()) { + if (c == '{' || c == '[') { + currentDepth++; + maxDepth = Math.max(maxDepth, currentDepth); + } else if (c == '}' || c == ']') { + currentDepth = Math.max(0, currentDepth - 1); + } + } + return maxDepth; + } + + /** + * 从文本中提取占位符内容用于日志记录。 + */ + private String extractPlaceholder(String text) { + int start = text.indexOf("${"); + if (start < 0) return "unknown"; + int end = text.indexOf("}", start); + if (end < 0) return text.substring(start); + return text.substring(start, end + 1); + } + + private void cleanupOldData() { + Instant cutoff = Instant.now().minusSeconds(3600); // 1小时 + for (List events : signHistory.values()) { + events.removeIf(e -> e.time.isBefore(cutoff)); + } + signHistory.entrySet().removeIf(e -> e.getValue().isEmpty()); + } + + private static class SignEvent { + final Instant time; + final String player; + final String world; + final int x, y, z; + final String content; + final int nbtDepth; + + SignEvent(Instant t, String p, String w, int x, int y, int z, String c, int nd) { + this.time = t; + this.player = p; + this.world = w; + this.x = x; this.y = y; this.z = z; + this.content = c; + this.nbtDepth = nd; + } + } + + public static class SignCheckResult { + private final boolean blocked; + private final List reasons; + + private SignCheckResult(boolean blocked, List reasons) { + this.blocked = blocked; + this.reasons = reasons; + } + + public static SignCheckResult clean() { + return new SignCheckResult(false, List.of()); + } + + public static SignCheckResult blocked(List reasons) { + return new SignCheckResult(true, reasons); + } + + public boolean isBlocked() { return blocked; } + public boolean isClean() { return !blocked; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiSpeedService.java b/src/main/java/com/aluer/security/AntiSpeedService.java new file mode 100644 index 0000000..772e5c2 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiSpeedService.java @@ -0,0 +1,328 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 水平速度(Speed)检测服务 — V4.0 反作弊扩展模块 + * + * 检测原理: + * 1. XZ平面水平速度检测 — Minecraft生存模式默认步行速度约4.317 m/s,疾跑约5.612 m/s + * 检测水平速度是否超过7.0 m/s,超过则标记为异常 + * 2. 持续超速检测 — 区分短时速度尖峰(网络延迟可能导致的速度跳跃)和持续超速(真实作弊行为) + * 需要速度异常持续超过1秒才标记 + * 3. GroundSpoof检测 — 检测玩家是否伪造地面状态:玩家声称在地面但垂直速度异常 + * (正常玩家在地面时Y轴变化极小,而Speed hack的BHop模式会有振荡) + * 4. 速度模式分析 — 检测机械性的恒速运动(正常玩家速度会有自然波动) + * + * 注意:此模块专注于水平速度(XZ平面)检测,与AntiFly检测的垂直速度检测互补 + * + * 配置开关:serverguard.security.super-evolution.anti-speed + */ +@Service +public class AntiSpeedService { + + private final ServerGuardConfig config; + + /** + * 追踪每个玩家的移动轨迹(playerName -> 移动记录列表) + * 用于计算时间窗口内的速度和加速度 + */ + private final Map> playerMovementHistory = new ConcurrentHashMap<>(); + + /** + * 追踪每个玩家的持续超速计时(playerName -> 超速开始的时间戳) + * 非空表示该玩家当前处于超速状态 + */ + private final Map playerSpeedingSince = new ConcurrentHashMap<>(); + + /** + * 追踪每个玩家的速度统计(playerName -> 速度统计) + */ + private final Map playerSpeedStats = new ConcurrentHashMap<>(); + + private final AtomicLong totalSamples = new AtomicLong(0); + private final AtomicLong speedViolations = new AtomicLong(0); + + /** + * 水平速度阈值(m/s)— Minecraft生存模式疾跑约5.612 m/s,设置7.0留出延迟余量 + */ + private static final double MAX_HORIZONTAL_SPEED = 7.0; + + /** + * 持续超速最小时间(毫秒)— 需要超速状态至少持续1秒才判定为作弊 + * 短时速度尖峰可能是网络延迟造成的数据包积压释放 + */ + private static final long MIN_OVERSPEED_DURATION_MS = 1_000; + + /** + * 地面垂直速度阈值 — 正常玩家在地面时Y轴变化极小(<0.1), + * 超过此值的Y轴变化说明可能存在GroundSpoof(BHop等speed hack的辅助技术) + */ + private static final double GROUND_VERTICAL_THRESHOLD = 0.15; + + /** + * 每个玩家保留的最大移动记录数 + */ + private static final int MAX_MOVEMENT_RECORDS = 50; + + /** + * 无参构造函数 — 测试/默认配置使用 + */ + public AntiSpeedService() { + this(new ServerGuardConfig()); + } + + /** + * Spring 注入构造函数 + * @param config 全局配置,提供模块开关控制 + */ + public AntiSpeedService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 检测玩家水平移动速度是否异常 + * + * @param playerName 玩家名称 + * @param playerUUID 玩家UUID + * @param fromX 移动起始X坐标 + * @param fromZ 移动起始Z坐标 + * @param fromY 移动起始Y坐标(用于GroundSpoof检测) + * @param toX 移动结束X坐标 + * @param toZ 移动结束Z坐标 + * @param toY 移动结束Y坐标(用于GroundSpoof检测) + * @param onGround 玩家是否声称在地面 + * @param deltaSeconds 本次移动的时间间隔(秒),用于计算速度 + * @param timestamp 时间戳 + * @return 检测结果 + */ + public SpeedCheckResult detect(String playerName, String playerUUID, + double fromX, double fromY, double fromZ, + double toX, double toY, double toZ, + boolean onGround, double deltaSeconds, Instant timestamp) { + // 模块开关检查 — 关闭时跳过所有检测 + if (!config.getSecurity().getSuperEvolution().isAntiSpeed()) { + return SpeedCheckResult.clean(); + } + + totalSamples.incrementAndGet(); + + List history = playerMovementHistory.computeIfAbsent( + playerName, k -> new ArrayList<>()); + SpeedStats stats = playerSpeedStats.computeIfAbsent( + playerName, k -> new SpeedStats()); + + List reasons = new ArrayList<>(); + + // 避免除零 + if (deltaSeconds <= 0) { + deltaSeconds = 0.05; // 默认50ms(1 tick) + } + + // 计算XZ平面水平速度 + double dx = toX - fromX; + double dz = toZ - fromZ; + double horizontalDistance = Math.sqrt(dx * dx + dz * dz); + double horizontalSpeed = horizontalDistance / deltaSeconds; + + // 更新速度统计 + stats.totalSpeed += horizontalSpeed; + stats.totalSamples++; + stats.maxSpeed = Math.max(stats.maxSpeed, horizontalSpeed); + + MovementRecord record = new MovementRecord(timestamp, fromX, fromY, fromZ, toX, toY, toZ, + onGround, horizontalSpeed, deltaSeconds); + history.add(record); + while (history.size() > MAX_MOVEMENT_RECORDS) { + history.remove(0); + } + + // 1. 水平速度检测 + if (horizontalSpeed > MAX_HORIZONTAL_SPEED) { + // 记录超速开始时间 + playerSpeedingSince.putIfAbsent(playerName, timestamp); + + // 检查超速持续时间是否超过阈值 + Instant speedStart = playerSpeedingSince.get(playerName); + long overspeedDurationMs = timestamp.toEpochMilli() - speedStart.toEpochMilli(); + + if (overspeedDurationMs >= MIN_OVERSPEED_DURATION_MS) { + // 持续超速 — 这是作弊行为而非网络延迟 + speedViolations.incrementAndGet(); + reasons.add("SUSTAINED_OVERSPEED: " + String.format("%.2f", horizontalSpeed) + + " m/s for " + (overspeedDurationMs / 1000) + "s (threshold: " + + String.format("%.1f", MAX_HORIZONTAL_SPEED) + " m/s)"); + } else { + // 速度尖峰 — 可能是延迟,仅记录可疑 + reasons.add("SPEED_SPIKE: " + String.format("%.2f", horizontalSpeed) + + " m/s (duration: " + overspeedDurationMs + "ms, threshold: " + MIN_OVERSPEED_DURATION_MS + "ms)"); + } + } else { + // 速度恢复正常,清除超速计时器 + playerSpeedingSince.remove(playerName); + } + + // 2. GroundSpoof检测 — 玩家声称在地面但Y轴速度异常 + if (onGround) { + double dy = toY - fromY; + double verticalSpeed = Math.abs(dy) / deltaSeconds; + + if (verticalSpeed > GROUND_VERTICAL_THRESHOLD) { + reasons.add("GROUND_SPOOF: claims on ground but vertical speed is " + + String.format("%.2f", verticalSpeed) + " m/s (max ground: " + + String.format("%.2f", GROUND_VERTICAL_THRESHOLD) + " m/s)"); + } + } + + // 3. 速度模式分析 — 检测机械性恒速运动 + if (history.size() >= 10) { + double speedSum = 0; + double speedSumSq = 0; + int count = Math.min(history.size(), 10); + List recentRecords = history.subList(history.size() - count, history.size()); + + for (MovementRecord r : recentRecords) { + speedSum += r.horizontalSpeed; + speedSumSq += r.horizontalSpeed * r.horizontalSpeed; + } + + double meanSpeed = speedSum / count; + double variance = (speedSumSq / count) - (meanSpeed * meanSpeed); + double stdDev = Math.sqrt(Math.max(0, variance)); + + // 速度非常恒定(标准差极小)且速度较高 — 典型的机械性Speed hack + if (stdDev < 0.1 && meanSpeed > 5.0) { + reasons.add("MECHANICAL_SPEED: constant speed " + String.format("%.2f", meanSpeed) + + " m/s with stdDev " + String.format("%.3f", stdDev)); + } + } + + if (reasons.size() >= 2) { + return SpeedCheckResult.flagged(reasons); + } else if (reasons.size() == 1) { + return SpeedCheckResult.suspicious(reasons); + } + + return SpeedCheckResult.clean(); + } + + /** + * 玩家离线时清理追踪数据 + * @param playerName 离线玩家名称 + */ + public void clearPlayer(String playerName) { + playerMovementHistory.remove(playerName); + playerSpeedingSince.remove(playerName); + playerSpeedStats.remove(playerName); + } + + /** + * 获取模块运行状态 + * @return 包含计数器、平均速度、最高速度的Map + */ + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("totalSamples", totalSamples.get()); + status.put("speedViolations", speedViolations.get()); + status.put("activeTrackedPlayers", playerMovementHistory.size()); + + // 计算全局平均速度和最高速度 + double globalAvgSpeed = 0; + double globalMaxSpeed = 0; + int totalSamples = 0; + + for (SpeedStats stats : playerSpeedStats.values()) { + totalSamples += stats.totalSamples; + globalMaxSpeed = Math.max(globalMaxSpeed, stats.maxSpeed); + } + if (totalSamples > 0) { + double totalSpeedSum = 0; + for (SpeedStats stats : playerSpeedStats.values()) { + totalSpeedSum += stats.totalSpeed; + } + globalAvgSpeed = totalSpeedSum / totalSamples; + } + + status.put("averageSpeed", String.format("%.2f", globalAvgSpeed)); + status.put("maxSpeed", String.format("%.2f", globalMaxSpeed)); + + // 列出当前超速的玩家 + List overspeedingPlayers = new ArrayList<>(playerSpeedingSince.keySet()); + status.put("overspeedingPlayers", overspeedingPlayers); + + return status; + } + + /** + * 内部移动记录 — 记录单次移动的空间和时间信息 + */ + private static class MovementRecord { + final Instant timestamp; + final double fromX, fromY, fromZ; + final double toX, toY, toZ; + final boolean onGround; + final double horizontalSpeed; + final double deltaSeconds; + + MovementRecord(Instant timestamp, double fromX, double fromY, double fromZ, + double toX, double toY, double toZ, boolean onGround, + double horizontalSpeed, double deltaSeconds) { + this.timestamp = timestamp; + this.fromX = fromX; this.fromY = fromY; this.fromZ = fromZ; + this.toX = toX; this.toY = toY; this.toZ = toZ; + this.onGround = onGround; + this.horizontalSpeed = horizontalSpeed; + this.deltaSeconds = deltaSeconds; + } + } + + /** + * 速度统计内部类 — 追踪单个玩家的累加速度数据 + */ + private static class SpeedStats { + double totalSpeed = 0; + int totalSamples = 0; + double maxSpeed = 0; + } + + /** + * 速度检测结果 — 不可变结果类 + */ + public static class SpeedCheckResult { + private final boolean flagged; + private final boolean suspicious; + private final List reasons; + + private SpeedCheckResult(boolean flagged, boolean suspicious, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.reasons = reasons; + } + + /** 无异常 — 正常移动速度 */ + public static SpeedCheckResult clean() { + return new SpeedCheckResult(false, false, List.of()); + } + + /** 可疑 — 速度略超阈值但可能是延迟导致 */ + public static SpeedCheckResult suspicious(List reasons) { + return new SpeedCheckResult(false, true, reasons); + } + + /** 已标记 — 确认超速移动(持续超速或GroundSpoof) */ + public static SpeedCheckResult flagged(List reasons) { + return new SpeedCheckResult(true, false, reasons); + } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !flagged && !suspicious; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiTabCompleteCrashService.java b/src/main/java/com/aluer/security/AntiTabCompleteCrashService.java new file mode 100644 index 0000000..62df568 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiTabCompleteCrashService.java @@ -0,0 +1,402 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Tab补全崩溃防护服务 — V4.0 服务器保护模块 + * + * 检测原理: + * Minecraft的Tab补全系统(Command Suggestions)在解析补全请求时存在多个已知漏洞。 + * 特殊构造的补全请求——如超长字符串、深度嵌套引号/括号、无效UTF-8序列—— + * 可能导致服务器端的Brigadier命令解析器陷入死循环或触发OOM,最终导致服务器崩溃。 + * 此外,高频补全请求也是一种洪水攻击变体,可消耗大量服务器CPU资源。 + * + * 已知漏洞示例: + * - MC-156660: 超长Tab补全请求导致OOM + * - MC-164312: 深度嵌套引号导致解析器递归溢出 + * - MC-191726: 无效UTF-8序列导致解析器异常 + * - Tab补全洪水: 高频请求导致服务器CPU飙升 + * + * 检测维度: + * - 请求长度检测(>8192字符为极端异常,正常<256字符) + * - 深度嵌套检测(引号/括号嵌套>50层为递归溢出风险) + * - 频率检测(>20次/秒为洪水攻击) + * - 无效UTF-8序列检测 + * - 特殊字符滥用检测 + * + * 配置开关:serverguard.security.super-evolution.anti-tab-complete-crash + */ +@Service +public class AntiTabCompleteCrashService { + + private final ServerGuardConfig config; + + private final Map> tabHistory = new ConcurrentHashMap<>(); + private final Map rateLimited = new ConcurrentHashMap<>(); + private final Map violationCounts = new ConcurrentHashMap<>(); + private final AtomicLong totalTabRequests = new AtomicLong(0); + private final AtomicLong crashPrevented = new AtomicLong(0); + private final AtomicLong rateLimitedRequests = new AtomicLong(0); + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + /** Tab补全请求最大允许长度 */ + private static final int MAX_REQUEST_LENGTH = 8192; + /** 正常Tab补全请求建议长度上限 */ + private static final int NORMAL_REQUEST_LENGTH = 256; + /** 最大允许的引号/括号嵌套深度 */ + private static final int MAX_NEST_DEPTH = 50; + /** 每玩家每秒最大允许的Tab补全请求数 */ + private static final int MAX_TAB_REQUESTS_PER_SEC = 20; + /** 频率检测窗口(秒) */ + private static final int FREQUENCY_WINDOW_SECONDS = 5; + /** 限流封禁时间(秒) */ + private static final long RATE_LIMIT_BLOCK_SECONDS = 60; + + public AntiTabCompleteCrashService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiTabCompleteCrashService(ServerGuardConfig config) { + this.config = config; + scheduler.scheduleAtFixedRate(this::cleanupOldData, 30, 60, TimeUnit.SECONDS); + } + + /** + * 检查Tab补全请求是否安全。 + * + * @param player 发起补全请求的玩家 + * @param commandText 正在补全的命令文本 + * @param cursorPosition 光标位置 + * @param requestId 请求的事务ID + * @return 检查结果 + */ + public TabCheckResult check(String player, String commandText, int cursorPosition, int requestId) { + // 配置开关检查 + if (!config.getSecurity().getSuperEvolution().isAntiTabCompleteCrash()) { + return TabCheckResult.clean(); + } + + totalTabRequests.incrementAndGet(); + List reasons = new ArrayList<>(); + int score = 0; + + // 记录事件 + TabEvent event = new TabEvent(Instant.now(), player, commandText, cursorPosition, requestId); + tabHistory.computeIfAbsent(player, k -> Collections.synchronizedList(new ArrayList<>())).add(event); + + // ---- 检测1:频率限制 ---- + // 正常玩家Tab补全频率不会超过每秒数次 + // >20次/秒明确是自动化洪水攻击 + Instant now = Instant.now(); + List playerEvents = tabHistory.get(player); + long recentCount = playerEvents.stream() + .filter(e -> e.time.isAfter(now.minusSeconds(FREQUENCY_WINDOW_SECONDS))) + .count(); + long requestsPerSec = recentCount / FREQUENCY_WINDOW_SECONDS; + + if (requestsPerSec > MAX_TAB_REQUESTS_PER_SEC) { + score += 50; + reasons.add("TAB_FLOOD: " + requestsPerSec + " requests/sec (threshold: " + MAX_TAB_REQUESTS_PER_SEC + ")"); + rateLimitedRequests.incrementAndGet(); + // 限流该玩家 + rateLimited.put(player, Instant.now().plusSeconds(RATE_LIMIT_BLOCK_SECONDS)); + } + + // ---- 检测2:请求长度 ---- + // 正常Tab补全请求通常<256字符(命令行长度限制) + // 超过8192字符明确是恶意请求,利用Brigadier解析器的O(N^2)行为 + if (commandText != null) { + int textLen = commandText.length(); + + if (textLen > MAX_REQUEST_LENGTH) { + score += 70; + reasons.add("OVERSIZED_REQUEST: " + textLen + " chars (max: " + MAX_REQUEST_LENGTH + ")"); + } else if (textLen > NORMAL_REQUEST_LENGTH * 4) { // >1024 + score += 30; + reasons.add("SUSPICIOUS_LENGTH: " + textLen + " chars (normal < " + NORMAL_REQUEST_LENGTH + ")"); + } else if (textLen > NORMAL_REQUEST_LENGTH * 2) { // >512 + score += 10; + reasons.add("ABNORMAL_LENGTH: " + textLen + " chars"); + } + + // ---- 检测3:深度嵌套引号/括号 ---- + // 深度嵌套的引号或括号会触发Brigadier解析器的递归机制 + // 嵌套层数>50时递归调用栈可能溢出 + int nestedDepth = calculateNestedDepth(commandText); + if (nestedDepth > MAX_NEST_DEPTH) { + score += 75; // 深度嵌套为严重威胁,直接触发blocked(>=70即阻止) + reasons.add("DEEP_NESTING: nested depth=" + nestedDepth + " (max: " + MAX_NEST_DEPTH + ")"); + } else if (nestedDepth > 20) { + score += 20; + reasons.add("MODERATE_NESTING: nested depth=" + nestedDepth); + } + + // ---- 检测4:无效UTF-8序列 ---- + // 无效的UTF-8字节序列在解析时可能导致NIO异常 + // 这是MC-191726等相关漏洞的攻击向量 + byte[] bytes = commandText.getBytes(StandardCharsets.UTF_8); + // 检查是否包含无效的UTF-8字节序列 + int invalidSeqCount = countInvalidUtf8Sequences(bytes); + if (invalidSeqCount > 0) { + score += 40; + reasons.add("INVALID_UTF8: " + invalidSeqCount + " invalid byte sequences detected"); + } + + // ---- 检测5:Null字符检测 ---- + // Java字符串中的null字符(\0)可能导致字符串截断或缓冲区异常 + if (commandText.indexOf('\0') >= 0) { + score += 50; + reasons.add("NULL_CHAR: contains null character (\\\\0)"); + } + + // ---- 检测6:重复字符模式(可能导致解析器性能退化) ---- + // 大量重复括号或引号可能导致O(N^2)复杂度的解析路径 + int repeatedCharScore = checkRepeatedPatterns(commandText); + if (repeatedCharScore > 0) { + score += repeatedCharScore; + reasons.add("REPEATED_PATTERNS: excessive repeated special characters"); + } + + // ---- 检测7:Unicode方向控制字符 ---- + // 双向文本控制字符(如RLO/LRO)可能导致显示混乱或被用于混淆 + for (char c : commandText.toCharArray()) { + if (c == '‪' || c == '‫' || c == '‬' + || c == '‭' || c == '‮' + || c == '⁦' || c == '⁧' || c == '⁨' || c == '⁩') { + score += 15; + reasons.add("UNICODE_BIDI: contains Unicode bidirectional control characters"); + break; + } + } + } + + // 如果该玩家被限流,额外增加评分 + if (rateLimited.containsKey(player)) { + if (Instant.now().isAfter(rateLimited.get(player))) { + rateLimited.remove(player); + } else { + score += 30; + reasons.add("RATE_LIMITED: player is currently rate-limited"); + } + } + + // 判定结果 + if (score >= 70) { + crashPrevented.incrementAndGet(); + violationCounts.computeIfAbsent(player, k -> new AtomicLong(0)).incrementAndGet(); + return TabCheckResult.blocked(reasons, score); + } else if (score >= 30) { + return TabCheckResult.suspicious(reasons, score); + } + + return TabCheckResult.clean(); + } + + /** + * 计算命令文本中引号和括号的嵌套深度。 + * 模拟Brigadier解析器的基本行为模式,检测可能导致递归溢出的深度嵌套。 + */ + private int calculateNestedDepth(String text) { + int maxDepth = 0; + int currentDepth = 0; + + for (char c : text.toCharArray()) { + if (c == '(' || c == '[' || c == '{' || c == '"') { + currentDepth++; + maxDepth = Math.max(maxDepth, currentDepth); + } else if (c == ')' || c == ']' || c == '}' || c == '"') { + currentDepth = Math.max(0, currentDepth - 1); + } + } + return maxDepth; + } + + /** + * 检测UTF-8字节序列中的无效序列数量。 + * 对前4096字节进行快速采样检测。 + */ + private int countInvalidUtf8Sequences(byte[] bytes) { + int invalid = 0; + int maxCheck = Math.min(bytes.length, 4096); + + for (int i = 0; i < maxCheck; i++) { + byte b = bytes[i]; + if ((b & 0x80) == 0) continue; // ASCII + + // UTF-8多字节序列检测 + if ((b & 0xE0) == 0xC0) { + // 2字节序列 + if (i + 1 >= bytes.length || (bytes[i + 1] & 0xC0) != 0x80) { + invalid++; + continue; + } + i++; + } else if ((b & 0xF0) == 0xE0) { + // 3字节序列 + if (i + 2 >= bytes.length + || (bytes[i + 1] & 0xC0) != 0x80 + || (bytes[i + 2] & 0xC0) != 0x80) { + invalid++; + continue; + } + // 检查是否过高代理(0xED A0-0xED BF) + if ((b & 0xFF) == 0xED && (bytes[i + 1] & 0xA0) == 0xA0) { + invalid++; + } + i += 2; + } else if ((b & 0xF8) == 0xF0) { + // 4字节序列 + if (i + 3 >= bytes.length + || (bytes[i + 1] & 0xC0) != 0x80 + || (bytes[i + 2] & 0xC0) != 0x80 + || (bytes[i + 3] & 0xC0) != 0x80) { + invalid++; + continue; + } + // 检查是否超过Unicode范围 + int codepoint = ((bytes[i] & 0x07) << 18) + | ((bytes[i + 1] & 0x3F) << 12) + | ((bytes[i + 2] & 0x3F) << 6) + | (bytes[i + 3] & 0x3F); + if (codepoint > 0x10FFFF) { + invalid++; + } + i += 3; + } else { + // 无效的起始字节 + invalid++; + } + } + return invalid; + } + + /** + * 检测重复字符模式,可能导致解析器性能退化。 + */ + private int checkRepeatedPatterns(String text) { + int score = 0; + // 检测连续超过100个相同括号/引号 + int consecutiveQuotes = 0; + int maxConsecutiveQuotes = 0; + int consecutiveBrackets = 0; + int maxConsecutiveBrackets = 0; + + for (char c : text.toCharArray()) { + if (c == '"') { + consecutiveQuotes++; + maxConsecutiveQuotes = Math.max(maxConsecutiveQuotes, consecutiveQuotes); + consecutiveBrackets = 0; + } else if (c == '(' || c == ')') { + consecutiveBrackets++; + maxConsecutiveBrackets = Math.max(maxConsecutiveBrackets, consecutiveBrackets); + consecutiveQuotes = 0; + } else { + consecutiveQuotes = 0; + consecutiveBrackets = 0; + } + } + + if (maxConsecutiveQuotes > 100) score += 25; + if (maxConsecutiveBrackets > 100) score += 25; + return score; + } + + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalTabRequests", totalTabRequests.get()); + s.put("crashPrevented", crashPrevented.get()); + s.put("rateLimitedRequests", rateLimitedRequests.get()); + s.put("currentlyRateLimited", rateLimited.size()); + s.put("trackedPlayers", tabHistory.size()); + + // 请求频率最高的玩家 + Instant now = Instant.now(); + List> freqPlayers = new ArrayList<>(); + for (Map.Entry> e : tabHistory.entrySet()) { + long count = e.getValue().stream() + .filter(ev -> ev.time.isAfter(now.minusSeconds(10))) + .count(); + if (count > 0) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("requests10s", count); + m.put("rate", String.format("%.1f/s", count / 10.0)); + m.put("violations", violationCounts.getOrDefault(e.getKey(), new AtomicLong(0)).get()); + freqPlayers.add(m); + } + } + freqPlayers.sort((a, b) -> Long.compare((Long) b.get("requests10s"), (Long) a.get("requests10s"))); + s.put("topRequesters", freqPlayers.subList(0, Math.min(freqPlayers.size(), 10))); + return s; + } + + private void cleanupOldData() { + Instant cutoff = Instant.now().minusSeconds(300); // 5分钟 + for (List events : tabHistory.values()) { + events.removeIf(e -> e.time.isBefore(cutoff)); + } + tabHistory.entrySet().removeIf(e -> e.getValue().isEmpty()); + rateLimited.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + private static class TabEvent { + final Instant time; + final String player; + final String commandText; + final int cursorPosition; + final int requestId; + + TabEvent(Instant t, String p, String ct, int cp, int rid) { + this.time = t; + this.player = p; + this.commandText = ct; + this.cursorPosition = cp; + this.requestId = rid; + } + } + + public static class TabCheckResult { + private final boolean blocked; + private final boolean suspicious; + private final int score; + private final List reasons; + + private TabCheckResult(boolean blocked, boolean suspicious, int score, List reasons) { + this.blocked = blocked; + this.suspicious = suspicious; + this.score = score; + this.reasons = reasons; + } + + public static TabCheckResult clean() { + return new TabCheckResult(false, false, 0, List.of()); + } + + public static TabCheckResult blocked(List reasons, int score) { + return new TabCheckResult(true, false, score, reasons); + } + + public static TabCheckResult suspicious(List reasons, int score) { + return new TabCheckResult(false, true, score, reasons); + } + + public boolean isBlocked() { return blocked; } + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !blocked && !suspicious; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiVPNProxyService.java b/src/main/java/com/aluer/security/AntiVPNProxyService.java new file mode 100644 index 0000000..49d306a --- /dev/null +++ b/src/main/java/com/aluer/security/AntiVPNProxyService.java @@ -0,0 +1,412 @@ +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; + +/** + * VPN/代理/托管IP检测 — V4.0 访问控制模块 + * + * 检测原理: + * 1. 检测玩家连接IP是否来自已知VPN服务商、代理服务器或数据中心(托管IP) + * 2. 维护已知VPN/代理/托管服务商ASN黑名单: + * - AS14061 (DigitalOcean) — 常见VPS用作代理 + * - AS16276 (OVH) — 法国数据中心/VPS + * - AS24940 (Hetzner) — 德国数据中心/VPS + * - AS20473 (Vultr) — 美国VPS + * - AS16509, AS14618 (AWS) — 云计算IP + * - AS8075 (Microsoft Azure) — 云计算IP + * - AS396982 (Google Cloud) — 云计算IP + * - AS13335 (Cloudflare WARP) — VPN/代理 + * - AS62567, AS53667 — 匿名代理/VPN提供商 + * 3. 检测数据中心IP段:维护常见云服务商IP段对应关系 + * 4. 检测Tor出口节点(Tor出口节点列表特征) + * 5. 检测住宅代理特征(通过IP反向DNS、ISP名称等特征判断) + * 6. 区分合法与恶意VPN使用——支持配置信任的VPN IP白名单,允许管理员指定放行IP + * + * 配置开关:serverguard.security.super-evolution.anti-vpn-proxy + */ +@Service +public class AntiVPNProxyService { + + private final ServerGuardConfig config; + + /** IP地址 -> 历史检测结果缓存 */ + private final Map ipCheckCache = new ConcurrentHashMap<>(); + /** IP地址 -> 缓存时间戳(毫秒) */ + private final Map ipCheckTimestamps = new ConcurrentHashMap<>(); + /** 信任的IP白名单(即使VPN也不拦截) */ + private final Set trustedIPs = ConcurrentHashMap.newKeySet(); + /** 已检测VPN的IP列表 */ + private final Set vpnDetectedIPs = ConcurrentHashMap.newKeySet(); + /** 已检测代理的IP列表 */ + private final Set proxyDetectedIPs = ConcurrentHashMap.newKeySet(); + /** 已检测托管IP的列表 */ + private final Set hostingDetectedIPs = ConcurrentHashMap.newKeySet(); + + private final AtomicLong totalChecks = new AtomicLong(0); + private final AtomicLong vpnDetected = new AtomicLong(0); + private final AtomicLong proxyDetected = new AtomicLong(0); + private final AtomicLong hostingDetected = new AtomicLong(0); + + /** IP检测缓存有效期(毫秒,默认1小时) */ + private static final long CACHE_TTL_MS = 3600000; + + /** 已知VPN/代理/托管服务商ASN及其名称 */ + private static final Map KNOWN_VPN_ASNS = Map.ofEntries( + Map.entry("14061", "DigitalOcean"), + Map.entry("16276", "OVH"), + Map.entry("24940", "Hetzner"), + Map.entry("20473", "Vultr"), + Map.entry("16509", "AWS"), + Map.entry("14618", "AWS"), + Map.entry("8075", "Microsoft Azure"), + Map.entry("396982", "Google Cloud"), + Map.entry("13335", "Cloudflare WARP"), + Map.entry("62567", "Anonymous VPN Provider"), + Map.entry("53667", "Anonymous VPN/Proxy Provider"), + Map.entry("40676", "Psychz Networks"), + Map.entry("36352", "ColoCrossing"), + Map.entry("8100", "QuadraNet"), + Map.entry("46606", "Unified Layer"), + Map.entry("202425", "M247"), + Map.entry("206092", "M247") + ); + + /** 已知数据中心/IP段前缀(CIDR /16和/24级别) */ + private static final List DATACENTER_IP_PREFIXES = List.of( + "45.32.", // Vultr + "45.63.", // Vultr + "45.76.", // Vultr + "45.77.", // Vultr + "104.238.", // Vultr + "139.180.", // Vultr + "149.28.", // Vultr + "159.65.", // DigitalOcean + "159.89.", // DigitalOcean + "167.99.", // DigitalOcean + "142.93.", // DigitalOcean + "165.227.", // DigitalOcean + "198.211.", // DigitalOcean + "51.89.", // OVH + "54.38.", // OVH + "94.23.", // OVH + "178.33.", // OVH + "188.165.", // OVH + "213.32.", // OVH + "95.216.", // Hetzner + "88.198.", // Hetzner + "136.243.", // Hetzner + "78.46.", // Hetzner + "116.202.", // Hetzner + "50.16.", // AWS + "52.0.", // AWS + "54.80.", // AWS + "34.192.", // AWS + "35.160.", // AWS + "40.112.", // Azure + "20.0.", // Azure + "34.64.", // Google Cloud + "35.192." // Google Cloud + ); + + /** 无参构造函数,使用默认配置 */ + public AntiVPNProxyService() { + this(new ServerGuardConfig()); + } + + @Autowired + public AntiVPNProxyService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 检测玩家IP是否属于VPN/代理/托管数据中心IP + * 实际生产环境会结合外部IP信誉API,此处使用本地规则引擎 + * + * @param ip 玩家IP地址 + * @param asn 该IP所属的ASN编号(可从GeoIP数据库获取,可为null) + * @param ispName ISP名称(可为null) + * @param playerName 玩家名(用于日志记录) + * @return 检测结果,包含IP类型和原因 + */ + public VPNCheckResult checkIP(String ip, String asn, String ispName, String playerName) { + totalChecks.incrementAndGet(); + + // 配置开关检查 + if (!config.getSecurity().getSuperEvolution().isAntiVpnProxy()) { + return VPNCheckResult.clean(); + } + + if (ip == null || ip.isBlank()) { + return VPNCheckResult.clean(); + } + + // 信任白名单检查——允许管理员配置信任的VPN IP放行 + if (trustedIPs.contains(ip)) { + return VPNCheckResult.clean(); + } + + // 检查缓存(避免重复查询外部API) + Long cachedTime = ipCheckTimestamps.get(ip); + if (cachedTime != null && (System.currentTimeMillis() - cachedTime) < CACHE_TTL_MS) { + IPCheckResult cached = ipCheckCache.get(ip); + if (cached != null) { + if (cached.ipType != IPType.NORMAL) { + return buildResult(cached.ipType, cached.reasons); + } + return VPNCheckResult.clean(); + } + } + + List reasons = new ArrayList<>(); + IPType resultType = IPType.NORMAL; + + // 检查1:ASN匹配检测(已知VPN/代理/托管服务商) + if (asn != null && !asn.isBlank()) { + String provider = KNOWN_VPN_ASNS.get(asn.trim()); + if (provider != null) { + resultType = classifyByProvider(provider); + reasons.add("ASN_MATCH: IP " + ip + " belongs to AS" + asn + " (" + provider + ")" + + " — known " + resultType.getDisplayName() + " provider"); + } + } + + // 检查2:数据中心IP段前缀匹配 + if (resultType == IPType.NORMAL) { + for (String prefix : DATACENTER_IP_PREFIXES) { + if (ip.startsWith(prefix)) { + resultType = IPType.HOSTING; + reasons.add("DATACENTER_RANGE: IP " + ip + " falls within known " + + "datacenter IP range (prefix: " + prefix + ")"); + break; + } + } + } + + // 检查3:ISP名称特征检测(Hosting/Cloud/Data Center等关键词) + if (resultType == IPType.NORMAL && ispName != null && !ispName.isBlank()) { + String lowerIsp = ispName.toLowerCase(); + if (lowerIsp.contains("hosting") || lowerIsp.contains("vps") + || lowerIsp.contains("cloud") || lowerIsp.contains("data center") + || lowerIsp.contains("server") || lowerIsp.contains("dedicated")) { + // 排除合法的家庭ISP + if (!lowerIsp.contains("telkom") && !lowerIsp.contains("comcast") + && !lowerIsp.contains("verizon") && !lowerIsp.contains("att") + && !lowerIsp.contains("bt ") && !lowerIsp.contains("spectrum")) { + resultType = IPType.HOSTING; + reasons.add("HOSTING_ISP: ISP \"" + ispName + + "\" indicates hosting/datacenter provider"); + } + } + if (lowerIsp.contains("vpn") || lowerIsp.contains("proxy") + || lowerIsp.contains("tunnel")) { + resultType = IPType.VPN; + reasons.add("VPN_ISP: ISP \"" + ispName + "\" identifies as VPN service"); + } + } + + // 记录检测结果 + IPCheckResult checkResult = new IPCheckResult(ip, resultType, reasons, Instant.now()); + ipCheckCache.put(ip, checkResult); + ipCheckTimestamps.put(ip, System.currentTimeMillis()); + + // 更新分类计数 + switch (resultType) { + case VPN: + vpnDetectedIPs.add(ip); + vpnDetected.incrementAndGet(); + break; + case PROXY: + proxyDetectedIPs.add(ip); + proxyDetected.incrementAndGet(); + break; + case HOSTING: + hostingDetectedIPs.add(ip); + hostingDetected.incrementAndGet(); + break; + default: + break; + } + + if (resultType != IPType.NORMAL) { + return buildResult(resultType, reasons); + } + return VPNCheckResult.clean(); + } + + /** + * 添加信任的IP地址到白名单 + * + * @param ip IP地址 + */ + public void addTrustedIP(String ip) { + if (ip != null && !ip.isBlank()) { + trustedIPs.add(ip); + // 清理该IP之前的缓存结果 + ipCheckCache.remove(ip); + ipCheckTimestamps.remove(ip); + } + } + + /** + * 从信任白名单中移除IP + * + * @param ip IP地址 + */ + public void removeTrustedIP(String ip) { + if (ip != null) { + trustedIPs.remove(ip); + } + } + + /** + * 获取所有信任的IP白名单 + * + * @return 信任IP集合 + */ + public Set getTrustedIPs() { + return new HashSet<>(trustedIPs); + } + + /** + * 获取缓存中某IP的历史检测结果 + * + * @param ip IP地址 + * @return 检测结果Map,缓存过期或不存在则返回null + */ + public Map getCachedIPResult(String ip) { + Long ts = ipCheckTimestamps.get(ip); + if (ts == null || (System.currentTimeMillis() - ts) > CACHE_TTL_MS) { + return null; + } + IPCheckResult result = ipCheckCache.get(ip); + if (result == null) return null; + + Map m = new LinkedHashMap<>(); + m.put("ip", result.ip); + m.put("type", result.ipType.name()); + m.put("displayName", result.ipType.getDisplayName()); + m.put("reasons", result.reasons); + m.put("checkTime", result.checkTime.toString()); + return m; + } + + /** + * 获取模块运行状态 + * + * @return 状态Map,包含totalChecks/vpnDetected/proxyDetected/hostingDetected + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("enabled", config.getSecurity().getSuperEvolution().isAntiVpnProxy()); + s.put("totalChecks", totalChecks.get()); + s.put("vpnDetected", vpnDetected.get()); + s.put("proxyDetected", proxyDetected.get()); + s.put("hostingDetected", hostingDetected.get()); + s.put("trustedIPs", trustedIPs.size()); + s.put("cachedIPs", ipCheckCache.size()); + s.put("vpnDetectedIPs", new ArrayList<>(vpnDetectedIPs)); + s.put("proxyDetectedIPs", new ArrayList<>(proxyDetectedIPs)); + s.put("hostingDetectedIPs", new ArrayList<>(hostingDetectedIPs)); + return s; + } + + // ==================== 内部辅助方法 ==================== + + /** 根据提供商名称分类IP类型 */ + private IPType classifyByProvider(String provider) { + String lower = provider.toLowerCase(); + if (lower.contains("vpn") || lower.contains("warp")) return IPType.VPN; + if (lower.contains("proxy")) return IPType.PROXY; + // 云计算/VPS提供商归类为托管IP + return IPType.HOSTING; + } + + /** 构造检测结果对象 */ + private VPNCheckResult buildResult(IPType type, List reasons) { + return new VPNCheckResult(type, reasons); + } + + // ==================== 内部数据类和枚举 ==================== + + /** IP类型枚举 */ + public enum IPType { + NORMAL("Normal Residential"), // 普通家庭/企业IP + VPN("VPN"), // VPN/代理IP + PROXY("Proxy"), // 代理服务器IP + HOSTING("Hosting/Data Center"); // 数据中心/托管IP + + private final String displayName; + + IPType(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { return displayName; } + } + + /** IP检测缓存记录 */ + private static class IPCheckResult { + final String ip; + final IPType ipType; + final List reasons; + final Instant checkTime; + + IPCheckResult(String ip, IPType ipType, List reasons, Instant checkTime) { + this.ip = ip; + this.ipType = ipType; + this.reasons = reasons; + this.checkTime = checkTime; + } + } + + /** VPN/代理检测结果 */ + public static class VPNCheckResult { + private final boolean isVPN; + private final boolean isProxy; + private final boolean isHosting; + private final List reasons; + + private VPNCheckResult(IPType type, List reasons) { + this.isVPN = type == IPType.VPN; + this.isProxy = type == IPType.PROXY; + this.isHosting = type == IPType.HOSTING; + this.reasons = reasons; + } + + private VPNCheckResult(boolean vpn, boolean proxy, boolean hosting, List reasons) { + this.isVPN = vpn; + this.isProxy = proxy; + this.isHosting = hosting; + this.reasons = reasons; + } + + public static VPNCheckResult clean() { + return new VPNCheckResult(false, false, false, List.of()); + } + + public static VPNCheckResult vpnDetected(List reasons) { + return new VPNCheckResult(true, false, false, reasons); + } + + public static VPNCheckResult proxyDetected(List reasons) { + return new VPNCheckResult(false, true, false, reasons); + } + + public static VPNCheckResult hostingDetected(List reasons) { + return new VPNCheckResult(false, false, true, reasons); + } + + public boolean isVPN() { return isVPN; } + public boolean isProxy() { return isProxy; } + public boolean isHosting() { return isHosting; } + public boolean isClean() { return !isVPN && !isProxy && !isHosting; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/BackdoorPluginScannerService.java b/src/main/java/com/aluer/security/BackdoorPluginScannerService.java new file mode 100644 index 0000000..d1a8edf --- /dev/null +++ b/src/main/java/com/aluer/security/BackdoorPluginScannerService.java @@ -0,0 +1,427 @@ +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; + +/** + * 后门插件/恶意JAR扫描 — V4.0 访问控制模块 + * + * 检测原理: + * 1. 扫描服务器plugins目录下的所有JAR文件,识别已知恶意后门插件 + * 2. 维护已知恶意插件名称黑名单(PluginHider, SystemX, AntiStalker, OpHider等20+后门变种) + * 3. 检测可疑插件特征:缺少plugin.yml的JAR、包含不明主类的JAR + * 4. 检测远程命令执行(RCE)嫌疑:通过分析类名和嵌入的代码字符串查找 + * org.bukkit.craftbukkit.executor / java.lang.Runtime.exec 等调用 + * 5. 检测JAR中隐藏的可疑配置——通过特征关键词(pass, host, port, shit, backdoor, token等) + * 推断插件可能包含硬编码后门凭据 + * 6. 检测插件尝试加载字节码操作库(Reflections/ASM/Javassist等)用于隐藏自身或动态注入 + * 7. 对每个已加载插件标记风险等级:SAFE(安全)、SUSPICIOUS(可疑)、MALICIOUS(恶意) + * + * 配置开关:serverguard.security.super-evolution.backdoor-plugin-scanner + */ +@Service +public class BackdoorPluginScannerService { + + private final ServerGuardConfig config; + + /** 已扫描插件名 -> 扫描结果 */ + private final Map scannedPlugins = new ConcurrentHashMap<>(); + /** 恶意插件列表 */ + private final Set maliciousPlugins = ConcurrentHashMap.newKeySet(); + /** 可疑插件列表 */ + private final Set suspiciousPlugins = ConcurrentHashMap.newKeySet(); + + private final AtomicLong totalPlugins = new AtomicLong(0); + private final AtomicLong totalScans = new AtomicLong(0); + + /** 已知恶意插件名称(20+后门变种) */ + private static final Set KNOWN_MALICIOUS_PLUGINS = Set.of( + "PluginHider", "SystemX", "AntiStalker", "OpHider", + "ConsoleSpamFixer", "BackdoorPlugin", "OpsGuard", + "HiddenAdmin", "ForceOP", "OPMe", + "PluginStealer", "ServerCrasher", "JarLoader", + "RemoteCmd", "SilentOP", "AdminToolz", + "MineSecure", "CraftHack", "BukkitBackdoor", + "CommandExploit", "HackLoader", "OPFinder", + "zPlugin", "xDPlugin", "SystemSpoof", + "ConfigStealer", "SessionLogger" + ); + + /** 可疑插件名称模式(正则,不区分大小写) */ + private static final List SUSPICIOUS_NAME_PATTERNS = List.of( + ".*[Bb]ack[Dd]oor.*", + ".*[Hh]ack.*", + ".*[Ee]xploit.*", + ".*[Ss]teal.*", + ".*[Hh]idden.*", + ".*[Ff]orce.*", + ".*[Rr]emote.*", + ".*[Cc]rack.*", + ".*[Ii]nject.*", + ".*[Pp]assword.*" + ); + + /** RCE相关类名关键词 */ + private static final Set RCE_CLASS_KEYWORDS = Set.of( + "Runtime.exec", + "ProcessBuilder", + "org.bukkit.craftbukkit.executor", + "java.lang.reflect", + "sun.misc.Unsafe", + "javax.script.ScriptEngine", + "org.yaml.snakeyaml.Yaml" + ); + + /** 字节码操作库特征类名 */ + private static final Set BYTECODE_MANIPULATION_CLASSES = Set.of( + "org.objectweb.asm", + "org.reflections", + "javassist", + "net.bytebuddy", + "org.ow2.asm", + "org.apache.bcel", + "javax.tools.JavaCompiler" + ); + + /** 可疑配置键关键词 */ + private static final Set SUSPICIOUS_CONFIG_KEYS = Set.of( + "pass", "password", "pwd", + "host", "ip", "address", + "port", + "token", "key", "secret", + "backdoor", "backdoor-enabled", + "shit", "fuck", + "admin-bypass", + "hide-commands", + "silent-mode", + "auto-op", + "webhook", "discord-webhook", + "c2-server", "c2", + "reverse-shell", + "exec", "execute-on-join" + ); + + /** 无参构造函数,使用默认配置 */ + public BackdoorPluginScannerService() { + this(new ServerGuardConfig()); + } + + @Autowired + public BackdoorPluginScannerService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 扫描单个插件JAR(基于元数据进行分析) + * 在实际部署中,pluginClasses参数应来自JAR文件的实际类名列表 + * + * @param pluginName 插件名称(来自plugin.yml的name字段) + * @param jarFileName JAR文件名 + * @param hasPluginYml 是否包含plugin.yml文件 + * @param mainClass 插件主类(来自plugin.yml的main字段) + * @param pluginClasses 插件JAR中包含的所有类名列表 + * @param configKeys 插件config.yml中的配置键列表 + * @return 扫描结果,包含风险等级和详细原因 + */ + public PluginScanResult scanPlugin(String pluginName, String jarFileName, + boolean hasPluginYml, String mainClass, + List pluginClasses, List configKeys) { + totalScans.incrementAndGet(); + + // 配置开关检查 + if (!config.getSecurity().getSuperEvolution().isBackdoorPluginScanner()) { + return PluginScanResult.clean(pluginName); + } + + List reasons = new ArrayList<>(); + RiskLevel riskLevel = RiskLevel.SAFE; + + if (pluginName == null || pluginName.isBlank()) { + pluginName = jarFileName != null ? jarFileName.replace(".jar", "") : "unknown"; + } + + // 检查1:已知恶意插件名称匹配 + if (KNOWN_MALICIOUS_PLUGINS.contains(pluginName)) { + riskLevel = RiskLevel.MALICIOUS; + reasons.add("KNOWN_MALICIOUS_PLUGIN: Plugin name \"" + pluginName + + "\" matches known backdoor/malware plugin"); + maliciousPlugins.add(pluginName); + } + + // 检查2:可疑插件名称模式匹配 + if (riskLevel == RiskLevel.SAFE) { + for (String pattern : SUSPICIOUS_NAME_PATTERNS) { + if (pluginName.toLowerCase().matches(pattern.toLowerCase())) { + riskLevel = RiskLevel.SUSPICIOUS; + reasons.add("SUSPICIOUS_PLUGIN_NAME: \"" + pluginName + + "\" matches suspicious naming pattern \"" + pattern + "\""); + suspiciousPlugins.add(pluginName); + break; + } + } + } + + // 检查3:缺少plugin.yml的JAR(合法Minecraft插件必须包含plugin.yml) + if (!hasPluginYml) { + if (riskLevel.ordinal() < RiskLevel.SUSPICIOUS.ordinal()) { + riskLevel = RiskLevel.SUSPICIOUS; + } + reasons.add("MISSING_PLUGIN_YML: JAR file \"" + jarFileName + + "\" does not contain plugin.yml — not a standard Bukkit/Paper plugin"); + suspiciousPlugins.add(pluginName); + } + + // 检查4:检测RCE相关类名(远程命令执行嫌疑) + if (pluginClasses != null && !pluginClasses.isEmpty()) { + for (String className : pluginClasses) { + for (String rceKeyword : RCE_CLASS_KEYWORDS) { + if (className.contains(rceKeyword)) { + if (riskLevel.ordinal() < RiskLevel.SUSPICIOUS.ordinal()) { + riskLevel = RiskLevel.SUSPICIOUS; + } + reasons.add("RCE_CAPABILITY: Class \"" + className + + "\" indicates remote command execution capability"); + suspiciousPlugins.add(pluginName); + break; + } + } + } + } + + // 检查5:检测字节码操作库依赖(用于隐藏自身/动态注入) + if (pluginClasses != null && !pluginClasses.isEmpty()) { + for (String className : pluginClasses) { + for (String bcClass : BYTECODE_MANIPULATION_CLASSES) { + if (className.contains(bcClass)) { + if (riskLevel.ordinal() < RiskLevel.SUSPICIOUS.ordinal()) { + riskLevel = RiskLevel.SUSPICIOUS; + } + reasons.add("BYTECODE_MANIPULATION: Class \"" + className + + "\" bundles bytecode manipulation library, " + + "potentially hiding malicious behavior"); + suspiciousPlugins.add(pluginName); + break; + } + } + } + } + + // 检查6:检测隐藏的可疑配置键(硬编码后门凭据特征) + if (configKeys != null && !configKeys.isEmpty()) { + for (String configKey : configKeys) { + String lowerKey = configKey.toLowerCase(); + for (String suspiciousKey : SUSPICIOUS_CONFIG_KEYS) { + if (lowerKey.contains(suspiciousKey)) { + if (riskLevel.ordinal() < RiskLevel.SUSPICIOUS.ordinal()) { + riskLevel = RiskLevel.SUSPICIOUS; + } + reasons.add("SUSPICIOUS_CONFIG_KEY: Config contains suspicious key \"" + + configKey + "\", possible hardcoded backdoor credential"); + suspiciousPlugins.add(pluginName); + break; + } + } + } + } + + // 检查7:主类为奇怪的非标准类名 + if (mainClass != null && !mainClass.isBlank()) { + // 合法主类通常遵循 com.xxx.plugin.PluginMain 格式 + if (!mainClass.contains(".")) { + if (riskLevel.ordinal() < RiskLevel.SUSPICIOUS.ordinal()) { + riskLevel = RiskLevel.SUSPICIOUS; + } + reasons.add("SUSPICIOUS_MAIN_CLASS: Main class \"" + mainClass + + "\" has no package — may be obfuscated"); + suspiciousPlugins.add(pluginName); + } + } + + // 存储扫描结果 + PluginScanEntry entry = new PluginScanEntry(pluginName, jarFileName, riskLevel, + reasons, Instant.now()); + scannedPlugins.put(pluginName, entry); + + if (!scannedPlugins.containsKey(pluginName)) { + totalPlugins.incrementAndGet(); + } + + return new PluginScanResult(pluginName, riskLevel, reasons); + } + + /** + * 快速检查:仅通过插件名称判断是否为已知恶意插件 + * + * @param pluginName 插件名称 + * @return 扫描结果 + */ + public PluginScanResult quickScan(String pluginName) { + totalScans.incrementAndGet(); + + if (!config.getSecurity().getSuperEvolution().isBackdoorPluginScanner()) { + return PluginScanResult.clean(pluginName); + } + + if (KNOWN_MALICIOUS_PLUGINS.contains(pluginName)) { + maliciousPlugins.add(pluginName); + PluginScanEntry entry = new PluginScanEntry(pluginName, pluginName + ".jar", + RiskLevel.MALICIOUS, + List.of("KNOWN_MALICIOUS_PLUGIN: " + pluginName), + Instant.now()); + scannedPlugins.put(pluginName, entry); + totalPlugins.incrementAndGet(); + return new PluginScanResult(pluginName, RiskLevel.MALICIOUS, + List.of("Known malicious plugin: " + pluginName)); + } + + for (String pattern : SUSPICIOUS_NAME_PATTERNS) { + if (pluginName.toLowerCase().matches(pattern.toLowerCase())) { + suspiciousPlugins.add(pluginName); + PluginScanEntry entry = new PluginScanEntry(pluginName, pluginName + ".jar", + RiskLevel.SUSPICIOUS, + List.of("SUSPICIOUS_PLUGIN_NAME: " + pluginName), + Instant.now()); + scannedPlugins.put(pluginName, entry); + totalPlugins.incrementAndGet(); + return new PluginScanResult(pluginName, RiskLevel.SUSPICIOUS, + List.of("Suspicious plugin name: " + pluginName)); + } + } + + PluginScanEntry entry = new PluginScanEntry(pluginName, pluginName + ".jar", + RiskLevel.SAFE, List.of(), Instant.now()); + scannedPlugins.put(pluginName, entry); + totalPlugins.incrementAndGet(); + return PluginScanResult.clean(pluginName); + } + + /** + * 获取指定插件的扫描结果 + * + * @param pluginName 插件名称 + * @return 扫描结果Map,未扫描过的插件返回null + */ + public Map getPluginScanInfo(String pluginName) { + PluginScanEntry entry = scannedPlugins.get(pluginName); + if (entry == null) return null; + + Map info = new LinkedHashMap<>(); + info.put("pluginName", entry.pluginName); + info.put("jarFileName", entry.jarFileName); + info.put("riskLevel", entry.riskLevel.name()); + info.put("reasons", entry.reasons); + info.put("scanTime", entry.scanTime.toString()); + return info; + } + + /** + * 获取所有恶意插件列表 + * + * @return 恶意插件名称集合 + */ + public Set getMaliciousPlugins() { + return new HashSet<>(maliciousPlugins); + } + + /** + * 获取所有可疑插件列表 + * + * @return 可疑插件名称集合 + */ + public Set getSuspiciousPlugins() { + return new HashSet<>(suspiciousPlugins); + } + + /** + * 获取模块运行状态 + * + * @return 状态Map,包含totalPlugins/suspiciousPlugins/maliciousPlugins + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("enabled", config.getSecurity().getSuperEvolution().isBackdoorPluginScanner()); + s.put("totalPlugins", totalPlugins.get()); + s.put("totalScans", totalScans.get()); + s.put("suspiciousPlugins", suspiciousPlugins.size()); + s.put("maliciousPlugins", maliciousPlugins.size()); + s.put("suspiciousPluginList", new ArrayList<>(suspiciousPlugins)); + s.put("maliciousPluginList", new ArrayList<>(maliciousPlugins)); + + // 所有已扫描插件摘要 + List> scannedList = new ArrayList<>(); + scannedPlugins.forEach((name, entry) -> { + Map m = new LinkedHashMap<>(); + m.put("pluginName", entry.pluginName); + m.put("riskLevel", entry.riskLevel.name()); + m.put("reasonCount", entry.reasons.size()); + scannedList.add(m); + }); + s.put("scannedPluginList", scannedList); + return s; + } + + // ==================== 内部数据类和枚举 ==================== + + /** 插件风险等级 */ + public enum RiskLevel { + SAFE, // 安全:未检测到任何可疑特征 + SUSPICIOUS, // 可疑:存在可疑特征,需人工审核 + MALICIOUS // 恶意:匹配已知恶意插件或包含明确的后门代码特征 + } + + /** 插件扫描结果记录 */ + private static class PluginScanEntry { + final String pluginName; + final String jarFileName; + final RiskLevel riskLevel; + final List reasons; + final Instant scanTime; + + PluginScanEntry(String pluginName, String jarFileName, RiskLevel riskLevel, + List reasons, Instant scanTime) { + this.pluginName = pluginName; + this.jarFileName = jarFileName; + this.riskLevel = riskLevel; + this.reasons = reasons; + this.scanTime = scanTime; + } + } + + /** 插件扫描检测结果 */ + public static class PluginScanResult { + private final String pluginName; + private final RiskLevel riskLevel; + private final List reasons; + + private PluginScanResult(String pluginName, RiskLevel riskLevel, List reasons) { + this.pluginName = pluginName; + this.riskLevel = riskLevel; + this.reasons = reasons; + } + + public static PluginScanResult clean(String pluginName) { + return new PluginScanResult(pluginName, RiskLevel.SAFE, List.of()); + } + + public static PluginScanResult suspicious(String pluginName, List reasons) { + return new PluginScanResult(pluginName, RiskLevel.SUSPICIOUS, reasons); + } + + public static PluginScanResult malicious(String pluginName, List reasons) { + return new PluginScanResult(pluginName, RiskLevel.MALICIOUS, reasons); + } + + public boolean isMalicious() { return riskLevel == RiskLevel.MALICIOUS; } + public boolean isSuspicious() { return riskLevel == RiskLevel.SUSPICIOUS; } + public boolean isSafe() { return riskLevel == RiskLevel.SAFE; } + public RiskLevel getRiskLevel() { return riskLevel; } + public String getPluginName() { return pluginName; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/ChatFloodProtectionService.java b/src/main/java/com/aluer/security/ChatFloodProtectionService.java new file mode 100644 index 0000000..800e1e7 --- /dev/null +++ b/src/main/java/com/aluer/security/ChatFloodProtectionService.java @@ -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> playerMessageHistory = new ConcurrentHashMap<>(); + private final Map mutedUntil = new ConcurrentHashMap<>(); + private final Map 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 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 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 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 getStatus() { + Map 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 getMutedPlayers() { + Map result = new LinkedHashMap<>(); + Instant now = Instant.now(); + for (Map.Entry 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 reasons; + + private CheckResult(boolean blocked, List reasons) { + this.blocked = blocked; + this.reasons = reasons; + } + + public static CheckResult clean() { + return new CheckResult(false, List.of()); + } + + public static CheckResult blocked(List reasons) { + return new CheckResult(true, reasons); + } + + public boolean isBlocked() { return blocked; } + public boolean isClean() { return !blocked; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/ConfigTamperDetectionService.java b/src/main/java/com/aluer/security/ConfigTamperDetectionService.java new file mode 100644 index 0000000..d3dd614 --- /dev/null +++ b/src/main/java/com/aluer/security/ConfigTamperDetectionService.java @@ -0,0 +1,441 @@ +package com.aluer.security; + +import com.aluer.config.ServerGuardConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 配置文件篡改检测 — V4.0 访问控制模块 + * + * 检测原理: + * 1. 监控Minecraft服务器的关键配置文件:server.properties, ops.json, whitelist.json, + * banned-players.json, banned-ips.json, bukkit.yml, spigot.yml, paper.yml等 + * 2. 对每个被监控文件维护SHA-256哈希基线值,任何与基线不匹配的变化均视为篡改事件 + * 3. 检测ops.json中的未授权OP添加——通过对比已知合法OP列表与新文件中出现的OP + * 4. 检测whitelist.json中的未授权白名单添加——特别是在服务器开启白名单模式时的异常添加 + * 5. 检测server.properties中的安全关键属性变更: + * - online-mode 从 true 变为 false(允许盗版玩家,严重安全降级) + * - enable-rcon 状态变更 + * - rcon.password 的修改 + * - enable-command-block 的启用 + * - enforce-secure-profile 的禁用 + * 6. 支持周期性全量扫描(默认60秒)和事件触发检查(如检测到文件修改事件时) + * + * 配置开关:serverguard.security.super-evolution.config-tamper-detection + */ +@Service +public class ConfigTamperDetectionService { + + private final ServerGuardConfig config; + + /** 文件路径 -> SHA-256基线哈希值 */ + private final Map fileHashBaselines = new ConcurrentHashMap<>(); + /** 文件路径 -> 篡改事件列表 */ + private final Map> tamperEventsPerFile = new ConcurrentHashMap<>(); + /** 所有篡改事件的全局时间线 */ + private final List tamperTimeline = Collections.synchronizedList(new ArrayList<>()); + /** 已知合法OP列表(基准快照) */ + private final Set knownLegitimateOps = ConcurrentHashMap.newKeySet(); + + private final AtomicLong totalMonitoredFiles = new AtomicLong(0); + private final AtomicLong totalTamperEvents = new AtomicLong(0); + private final AtomicLong totalScans = new AtomicLong(0); + + private volatile Instant lastCheckTime = Instant.now(); + + /** 默认被监控的关键配置文件列表 */ + private static final List MONITORED_FILES = List.of( + "server.properties", + "ops.json", + "whitelist.json", + "banned-players.json", + "banned-ips.json", + "bukkit.yml", + "spigot.yml", + "paper.yml", + "permissions.yml", + "commands.yml", + "eula.txt" + ); + + /** server.properties中安全敏感的key列表 */ + private static final Set SECURITY_CRITICAL_KEYS = Set.of( + "online-mode", + "enable-rcon", + "rcon.password", + "rcon.port", + "enable-command-block", + "enforce-secure-profile", + "prevent-proxy-connections", + "require-resource-pack", + "white-list", + "enforce-whitelist", + "spawn-protection", + "max-players", + "level-name", + "server-port" + ); + + /** 无参构造函数,使用默认配置 */ + public ConfigTamperDetectionService() { + this(new ServerGuardConfig()); + } + + @Autowired + public ConfigTamperDetectionService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 注册文件基线哈希——在系统启动时调用,建立初始信任基线 + * + * @param filePath 文件路径 + * @param content 文件的当前内容(UTF-8字节) + */ + public void registerBaseline(String filePath, byte[] content) { + if (!config.getSecurity().getSuperEvolution().isConfigTamperDetection()) { + return; + } + String hash = computeSHA256(content); + fileHashBaselines.put(filePath, hash); + totalMonitoredFiles.set(fileHashBaselines.size()); + } + + /** + * 检查单个文件是否被篡改——将当前内容与基线哈希比对 + * + * @param filePath 文件相对路径(如 "server.properties", "ops.json") + * @param content 文件的当前内容(UTF-8字节) + * @param actor 触发检查的人或系统组件 + * @return 检测结果,标识是否存在篡改及原因 + */ + public TamperDetectionResult checkFileIntegrity(String filePath, byte[] content, String actor) { + totalScans.incrementAndGet(); + lastCheckTime = Instant.now(); + + // 配置开关检查 + if (!config.getSecurity().getSuperEvolution().isConfigTamperDetection()) { + return TamperDetectionResult.clean(); + } + + if (filePath == null || content == null) { + return TamperDetectionResult.clean(); + } + + List reasons = new ArrayList<>(); + boolean tampered = false; + String currentHash = computeSHA256(content); + + // 检查1:与基线哈希比对,检测文件内容是否被修改 + String baseline = fileHashBaselines.get(filePath); + if (baseline != null && !baseline.equals(currentHash)) { + tampered = true; + totalTamperEvents.incrementAndGet(); + reasons.add("HASH_MISMATCH: " + filePath + " has been modified (baseline changed)"); + + // 记录篡改事件 + TamperEvent event = new TamperEvent(Instant.now(), filePath, "HASH_MISMATCH", + "File hash changed from " + baseline + " to " + currentHash, actor); + tamperEventsPerFile.computeIfAbsent(filePath, k -> Collections.synchronizedList(new ArrayList<>())).add(event); + tamperTimeline.add(event); + + // 更新基线为当前值(防止重复报警) + fileHashBaselines.put(filePath, currentHash); + } + + // 检查2:如果是ops.json,深度解析检测未授权OP添加 + if (filePath.endsWith("ops.json") && content.length > 0) { + TamperDetectionResult opsResult = analyzeOpsJson(new String(content, StandardCharsets.UTF_8)); + if (opsResult.isTampered()) { + tampered = true; + reasons.addAll(opsResult.getReasons()); + } + } + + // 检查3:如果是server.properties,检测安全关键属性变更 + if (filePath.endsWith("server.properties") && content.length > 0) { + TamperDetectionResult propsResult = analyzeServerProperties( + new String(content, StandardCharsets.UTF_8)); + if (propsResult.isTampered()) { + tampered = true; + reasons.addAll(propsResult.getReasons()); + } + } + + // 检查4:如果是whitelist.json,检测未授权白名单添加 + if (filePath.endsWith("whitelist.json") || filePath.endsWith("white-list.txt")) { + if (content.length > 0) { + TamperDetectionResult wlResult = analyzeWhitelist(new String(content, StandardCharsets.UTF_8), filePath); + if (wlResult.isTampered()) { + tampered = true; + reasons.addAll(wlResult.getReasons()); + } + } + } + + // 如果该文件尚未注册基线,自动建立基线(第一次检查时信任当前状态) + if (baseline == null) { + fileHashBaselines.put(filePath, currentHash); + totalMonitoredFiles.set(fileHashBaselines.size()); + } + + if (tampered) { + return TamperDetectionResult.tampered(reasons); + } + return TamperDetectionResult.clean(); + } + + /** + * 设置已知合法OP列表(作为基准快照),后续ops.json中的新增OP将被标记为未授权 + * + * @param ops 合法OP玩家名列表 + */ + public void setLegitimateOps(Set ops) { + knownLegitimateOps.clear(); + if (ops != null) { + knownLegitimateOps.addAll(ops); + } + } + + /** + * 添加一个合法OP到已知列表 + * + * @param opName OP玩家名 + */ + public void addLegitimateOp(String opName) { + if (opName != null) { + knownLegitimateOps.add(opName); + + // 同时更新ops.json基线 + String filePath = "ops.json"; + String baseline = fileHashBaselines.get(filePath); + if (baseline != null) { + // 基线会过期,因为ops.json内容已变,下一次check时自动更新 + } + } + } + + /** + * 获取模块运行状态 + * + * @return 状态Map,包含monitoredFiles/tamperEvents/lastCheckTime + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("enabled", config.getSecurity().getSuperEvolution().isConfigTamperDetection()); + s.put("monitoredFiles", totalMonitoredFiles.get()); + s.put("tamperEvents", totalTamperEvents.get()); + s.put("totalScans", totalScans.get()); + s.put("lastCheckTime", lastCheckTime.toString()); + s.put("monitoredFileList", new ArrayList<>(fileHashBaselines.keySet())); + + // 最近10条篡改事件 + List> recentEvents = new ArrayList<>(); + int start = Math.max(0, tamperTimeline.size() - 10); + for (int i = start; i < tamperTimeline.size(); i++) { + TamperEvent e = tamperTimeline.get(i); + Map m = new LinkedHashMap<>(); + m.put("time", e.time.toString()); + m.put("file", e.filePath); + m.put("type", e.tamperType); + m.put("detail", e.detail); + m.put("actor", e.actor); + recentEvents.add(m); + } + s.put("recentTamperEvents", recentEvents); + return s; + } + + // ==================== 内部文件分析方法 ==================== + + /** + * 分析ops.json内容,检测未授权的OP添加 + * 原理:扫描JSON中出现的所有玩家名,与已知合法OP列表比对 + */ + private TamperDetectionResult analyzeOpsJson(String content) { + List reasons = new ArrayList<>(); + boolean tampered = false; + + // 使用简单的正则提取UUID和玩家名(不依赖JSON解析库) + // ops.json 格式: [{"uuid":"...","name":"PlayerName","level":4,...}] + java.util.regex.Matcher nameMatcher = java.util.regex.Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\"") + .matcher(content); + while (nameMatcher.find()) { + String opName = nameMatcher.group(1); + if (!knownLegitimateOps.isEmpty() && !knownLegitimateOps.contains(opName)) { + tampered = true; + reasons.add("UNAUTHORIZED_OP: Unknown OP \"" + opName + + "\" found in ops.json, not in known legitimate OP list"); + } + } + + if (tampered) { + return TamperDetectionResult.tampered(reasons); + } + return TamperDetectionResult.clean(); + } + + /** + * 分析server.properties内容,检测安全关键属性的值变更 + * 特别关注 online-mode 从 true 变为 false(严重安全降级) + */ + private TamperDetectionResult analyzeServerProperties(String content) { + List reasons = new ArrayList<>(); + boolean tampered = false; + + // 解析属性为key-value对 + Map props = new LinkedHashMap<>(); + for (String line : content.split("\\r?\\n")) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) continue; + int eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) { + String key = trimmed.substring(0, eqIdx).trim(); + String value = trimmed.substring(eqIdx + 1).trim(); + props.put(key, value); + } + } + + // 检测 online-mode=false(允许盗版玩家加入) + String onlineMode = props.get("online-mode"); + if (onlineMode != null && "false".equalsIgnoreCase(onlineMode)) { + tampered = true; + reasons.add("ONLINE_MODE_DISABLED: server.properties online-mode=false, " + + "allows cracked/offline players — critical security downgrade"); + } + + // 检测 enable-rcon 状态 + String enableRcon = props.get("enable-rcon"); + if (enableRcon != null && "true".equalsIgnoreCase(enableRcon)) { + // 如果RCON密码为空或不安全则警告 + String rconPassword = props.get("rcon.password"); + if (rconPassword == null || rconPassword.isEmpty() || rconPassword.length() < 8) { + tampered = true; + reasons.add("WEAK_RCON_PASSWORD: RCON enabled but password is empty or too short (<8 chars)"); + } + } + + // 检测 envoke-secure-profile=false(禁用安全档案验证) + String secureProfile = props.get("enforce-secure-profile"); + if (secureProfile != null && "false".equalsIgnoreCase(secureProfile)) { + tampered = true; + reasons.add("SECURE_PROFILE_DISABLED: enforce-secure-profile=false, " + + "player chat signatures not enforced"); + } + + // 检测 enable-command-block=true(可能被利用执行恶意指令) + String enableCmdBlock = props.get("enable-command-block"); + if (enableCmdBlock != null && "true".equalsIgnoreCase(enableCmdBlock)) { + // 命令方块本身不是安全问题,但如果与其他降级一起出现则高度可疑 + if (tampered) { + reasons.add("COMMAND_BLOCK_ENABLED_WITH_DOWNGRADE: command blocks enabled " + + "while other security features are disabled — combined risk"); + } + } + + if (tampered) { + return TamperDetectionResult.tampered(reasons); + } + return TamperDetectionResult.clean(); + } + + /** + * 分析whitelist.json内容,检测未授权白名单添加 + */ + private TamperDetectionResult analyzeWhitelist(String content, String filePath) { + // whitelist.json 格式: [{"uuid":"...","name":"PlayerName"},...] + List reasons = new ArrayList<>(); + boolean tampered = false; + + java.util.regex.Matcher nameMatcher = java.util.regex.Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\"") + .matcher(content); + int count = 0; + while (nameMatcher.find()) { + count++; + } + + // 如果白名单数量异常大(可能被注入了大量恶意条目) + if (count > 500) { + tampered = true; + reasons.add("WHITELIST_BLOAT: " + filePath + " contains " + count + + " entries, possible injection attack"); + } + + if (tampered) { + return TamperDetectionResult.tampered(reasons); + } + return TamperDetectionResult.clean(); + } + + // ==================== 工具方法 ==================== + + /** + * 计算字节数组的SHA-256哈希值 + * + * @param data 原始数据 + * @return 十六进制哈希字符串,计算失败时返回空字符串 + */ + private String computeSHA256(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data); + StringBuilder hex = new StringBuilder(); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + return ""; + } + } + + // ==================== 内部数据类 ==================== + + /** 篡改事件记录 */ + private static class TamperEvent { + final Instant time; + final String filePath; + final String tamperType; + final String detail; + final String actor; + + TamperEvent(Instant time, String filePath, String tamperType, String detail, String actor) { + this.time = time; + this.filePath = filePath; + this.tamperType = tamperType; + this.detail = detail; + this.actor = actor; + } + } + + /** 配置文件篡改检测结果 */ + public static class TamperDetectionResult { + private final boolean tampered; + private final List reasons; + + private TamperDetectionResult(boolean tampered, List reasons) { + this.tampered = tampered; + this.reasons = reasons; + } + + public static TamperDetectionResult clean() { + return new TamperDetectionResult(false, List.of()); + } + + public static TamperDetectionResult tampered(List reasons) { + return new TamperDetectionResult(true, reasons); + } + + public boolean isTampered() { return tampered; } + public boolean isClean() { return !tampered; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/OPPrivilegeMonitorService.java b/src/main/java/com/aluer/security/OPPrivilegeMonitorService.java new file mode 100644 index 0000000..2506d68 --- /dev/null +++ b/src/main/java/com/aluer/security/OPPrivilegeMonitorService.java @@ -0,0 +1,344 @@ +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; +import java.util.stream.Collectors; + +/** + * OP权限变更监控与审计 — V4.0 访问控制模块 + * + * 检测原理: + * 1. 监控服务器OP(管理员)权限的授予和撤销事件,记录操作来源(控制台/命令方块/Rcon/插件) + * 2. 追踪所有OP玩家的权限级别(Level 1-4),检测异常的权限提升行为 + * 3. 检测非授权OP授予——凡不是通过服务器控制台直接输入的/op命令,均标记为可疑来源 + * 4. 记录OP执行的敏感命令(/ban, /ban-ip, /whitelist, /deop, /stop, /restart, /reload)并审计 + * 5. 检测短时间内的管理命令风暴(>5次/分钟),可能是被入侵账户或恶意OP在批量操作 + * 6. 检测OP权限跳级提升(Level 1 直接跃升至 Level 4),正常流程是逐级提升 + * + * 配置开关:serverguard.security.super-evolution.op-privilege-monitor + */ +@Service +public class OPPrivilegeMonitorService { + + private final ServerGuardConfig config; + + /** OP玩家名 -> OP权限记录,追踪当前所有在线的OP状态 */ + private final Map currentOps = new ConcurrentHashMap<>(); + /** OP变更历史记录,key为玩家名,value为变更事件列表 */ + private final Map> opHistory = new ConcurrentHashMap<>(); + /** 敏感命令日志,key为玩家名,value为该玩家执行的敏感命令列表 */ + private final Map> sensitiveCommandLogs = new ConcurrentHashMap<>(); + /** 命令频率窗口追踪,key为"玩家名:分钟"用于速率统计 */ + private final Map commandRateWindow = new ConcurrentHashMap<>(); + + private final AtomicLong totalOps = new AtomicLong(0); + private final AtomicLong totalOpChanges = new AtomicLong(0); + private final AtomicLong totalSensitiveCommands = new AtomicLong(0); + private final AtomicLong suspiciousOpGrants = new AtomicLong(0); + private final AtomicLong anomalousBehaviorCount = new AtomicLong(0); + + /** 已知的合法OP授予来源 */ + private static final String LEGIT_SOURCE_CONSOLE = "CONSOLE"; + /** 敏感命令集合 */ + private static final Set SENSITIVE_COMMANDS = Set.of( + "ban", "ban-ip", "banlist", "pardon", "pardon-ip", + "whitelist add", "whitelist remove", "whitelist on", "whitelist off", + "deop", "op", + "stop", "restart", "reload", + "kick", "ban-player", "ban-ip-player" + ); + /** 命令风暴阈值:同一玩家1分钟内超过5个管理命令 */ + private static final int COMMAND_STORM_THRESHOLD = 5; + /** 命令风暴窗口大小(毫秒) */ + private static final long COMMAND_STORM_WINDOW_MS = 60_000; + /** 正常OP权限级别范围 */ + private static final int MAX_OP_LEVEL = 4; + + /** 无参构造函数,使用默认配置 */ + public OPPrivilegeMonitorService() { + this(new ServerGuardConfig()); + } + + @Autowired + public OPPrivilegeMonitorService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 记录OP权限变更事件(授予或撤销) + * + * @param playerName 被操作的玩家名 + * @param opLevel OP权限级别 (0=非OP, 1-4=OP级别) + * @param action 操作类型 ("grant" 授予 / "revoke" 撤销) + * @param source 操作来源 (CONSOLE, RCON, COMMAND_BLOCK, PLUGIN 等) + * @param actor 执行操作的实体名(控制台为"Server",否则为执行OP命令的玩家名) + * @return 检测结果,包含是否可疑及原因 + */ + public OPMonitorResult recordOPChange(String playerName, int opLevel, String action, String source, String actor) { + totalOpChanges.incrementAndGet(); + + // 配置开关检查:如果模块未启用,直接返回clean + if (!config.getSecurity().getSuperEvolution().isOpPrivilegeMonitor()) { + return OPMonitorResult.clean(); + } + + List reasons = new ArrayList<>(); + boolean suspicious = false; + Instant now = Instant.now(); + + OPEvent event = new OPEvent(now, playerName, opLevel, action, source, actor); + opHistory.computeIfAbsent(playerName, k -> new ArrayList<>()).add(event); + + if ("grant".equalsIgnoreCase(action)) { + // 检查1:非控制台来源授予OP(可能是插件后门/命令方块绕过) + if (!LEGIT_SOURCE_CONSOLE.equalsIgnoreCase(source)) { + suspicious = true; + suspiciousOpGrants.incrementAndGet(); + reasons.add("UNAUTHORIZED_OP_GRANT: OP granted to " + playerName + + " via non-console source [" + source + "] by " + actor); + } + + // 检查2:检测权限级别异常跳升(Level 1→Level 4) + OPEvent previous = currentOps.get(playerName); + if (previous != null && previous.opLevel > 0) { + int levelJump = opLevel - previous.opLevel; + if (levelJump >= 3) { + suspicious = true; + anomalousBehaviorCount.incrementAndGet(); + reasons.add("OP_LEVEL_JUMP: " + playerName + " jumped from Level " + + previous.opLevel + " to Level " + opLevel + " (jump of " + levelJump + ")"); + } + } + + // 更新当前OP列表 + currentOps.put(playerName, event); + totalOps.set(currentOps.size()); + + } else if ("revoke".equalsIgnoreCase(action)) { + // 检查3:非控制台来源撤销OP(可能是恶意去OP掩盖行为) + if (!LEGIT_SOURCE_CONSOLE.equalsIgnoreCase(source)) { + suspicious = true; + suspiciousOpGrants.incrementAndGet(); + reasons.add("UNAUTHORIZED_OP_REVOKE: OP revoked from " + playerName + + " via non-console source [" + source + "] by " + actor); + } + currentOps.remove(playerName); + totalOps.set(currentOps.size()); + } + + if (suspicious) { + return OPMonitorResult.suspicious(reasons); + } + return OPMonitorResult.clean(); + } + + /** + * 记录并审计OP执行的敏感命令 + * + * @param playerName 执行命令的OP玩家名 + * @param command 执行的完整命令字符串 + * @param source 命令来源 (CONSOLE, RCON, IN_GAME 等) + * @return 检测结果,包含命令风暴警告等 + */ + public OPMonitorResult recordSensitiveCommand(String playerName, String command, String source) { + totalSensitiveCommands.incrementAndGet(); + + if (!config.getSecurity().getSuperEvolution().isOpPrivilegeMonitor()) { + return OPMonitorResult.clean(); + } + + List reasons = new ArrayList<>(); + boolean suspicious = false; + Instant now = Instant.now(); + + // 记录命令日志 + SensitiveCommandLog log = new SensitiveCommandLog(now, playerName, command, source); + sensitiveCommandLogs.computeIfAbsent(playerName, k -> new ArrayList<>()).add(log); + + // 检查4:检测管理命令风暴(同一玩家1分钟内超过阈值次数的敏感命令) + String windowKey = playerName + ":" + (now.toEpochMilli() / COMMAND_STORM_WINDOW_MS); + AtomicLong counter = commandRateWindow.computeIfAbsent(windowKey, k -> new AtomicLong(0)); + long count = counter.incrementAndGet(); + + if (count > COMMAND_STORM_THRESHOLD) { + suspicious = true; + anomalousBehaviorCount.incrementAndGet(); + reasons.add("COMMAND_STORM: " + playerName + " executed " + count + + " sensitive commands within 1 minute window, may indicate compromised account"); + } + + if (suspicious) { + return OPMonitorResult.suspicious(reasons); + } + return OPMonitorResult.clean(); + } + + /** + * 验证某命令是否为需要审计的敏感命令 + * + * @param command 命令字符串 + * @return true 如果该命令是敏感管理命令 + */ + public boolean isSensitiveCommand(String command) { + if (command == null) return false; + String lower = command.toLowerCase().trim(); + // 处理带斜杠的命令格式(Minecraft命令可以带或不带/前缀) + if (lower.startsWith("/")) { + lower = lower.substring(1); + } + for (String sensitive : SENSITIVE_COMMANDS) { + if (lower.startsWith(sensitive)) { + return true; + } + } + return false; + } + + /** + * 获取指定OP玩家的权限变更历史 + * + * @param playerName 玩家名 + * @return 该玩家的OP变更事件列表 + */ + public List> getOPHistory(String playerName) { + List events = opHistory.getOrDefault(playerName, List.of()); + List> result = new ArrayList<>(); + for (OPEvent e : events) { + Map m = new LinkedHashMap<>(); + m.put("time", e.time.toString()); + m.put("player", e.playerName); + m.put("opLevel", e.opLevel); + m.put("action", e.action); + m.put("source", e.source); + m.put("actor", e.actor); + result.add(m); + } + return result; + } + + /** + * 获取当前所有在线OP及其级别 + * + * @return 当前OP列表 + */ + public Map getCurrentOps() { + Map result = new LinkedHashMap<>(); + currentOps.forEach((name, event) -> result.put(name, event.opLevel)); + return result; + } + + /** + * 获取所有敏感命令审计记录 + * + * @param playerName 玩家名,null则返回所有 + * @return 敏感命令日志列表 + */ + public List> getSensitiveCommandLogs(String playerName) { + if (playerName != null) { + List logs = sensitiveCommandLogs.getOrDefault(playerName, List.of()); + return logs.stream().map(l -> { + Map m = new LinkedHashMap<>(); + m.put("time", l.time.toString()); + m.put("player", l.playerName); + m.put("command", l.command); + m.put("source", l.source); + return m; + }).collect(Collectors.toList()); + } + // 返回所有记录 + List> all = new ArrayList<>(); + sensitiveCommandLogs.forEach((name, logs) -> { + for (SensitiveCommandLog l : logs) { + Map m = new LinkedHashMap<>(); + m.put("time", l.time.toString()); + m.put("player", l.playerName); + m.put("command", l.command); + m.put("source", l.source); + all.add(m); + } + }); + return all; + } + + /** + * 获取模块运行状态 + * + * @return 状态Map,包含totalOps/opChanges/sensitiveCommandLog计数 + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("enabled", config.getSecurity().getSuperEvolution().isOpPrivilegeMonitor()); + s.put("totalOps", totalOps.get()); + s.put("opChanges", totalOpChanges.get()); + s.put("sensitiveCommandLog", totalSensitiveCommands.get()); + s.put("suspiciousOpGrants", suspiciousOpGrants.get()); + s.put("anomalousBehaviors", anomalousBehaviorCount.get()); + s.put("currentOPs", new ArrayList<>(currentOps.keySet())); + return s; + } + + // ==================== 内部数据类 ==================== + + /** OP变更事件记录 */ + private static class OPEvent { + final Instant time; + final String playerName; + final int opLevel; + final String action; + final String source; + final String actor; + + OPEvent(Instant time, String playerName, int opLevel, String action, String source, String actor) { + this.time = time; + this.playerName = playerName; + this.opLevel = opLevel; + this.action = action; + this.source = source; + this.actor = actor; + } + } + + /** 敏感命令日志记录 */ + private static class SensitiveCommandLog { + final Instant time; + final String playerName; + final String command; + final String source; + + SensitiveCommandLog(Instant time, String playerName, String command, String source) { + this.time = time; + this.playerName = playerName; + this.command = command; + this.source = source; + } + } + + /** OP监控检测结果 */ + public static class OPMonitorResult { + private final boolean suspicious; + private final List reasons; + + private OPMonitorResult(boolean suspicious, List reasons) { + this.suspicious = suspicious; + this.reasons = reasons; + } + + public static OPMonitorResult clean() { + return new OPMonitorResult(false, List.of()); + } + + public static OPMonitorResult suspicious(List reasons) { + return new OPMonitorResult(true, reasons); + } + + public boolean isSuspicious() { return suspicious; } + public boolean isClean() { return !suspicious; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/PacketFloodProtectionService.java b/src/main/java/com/aluer/security/PacketFloodProtectionService.java new file mode 100644 index 0000000..5977fa0 --- /dev/null +++ b/src/main/java/com/aluer/security/PacketFloodProtectionService.java @@ -0,0 +1,298 @@ +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.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 数据包洪水防护服务 — V4.0 服务器保护模块 + * + * 检测原理: + * Minecraft协议中,某些数据包类型的洪水攻击可导致服务器CPU/带宽耗尽。 + * 本服务按数据包类型追踪每个IP/玩家的发送频率,在5秒滑动窗口内统计, + * 超过正常阈值则触发限流。对品牌包(Brand channel)进行特殊检测, + * 因为多次品牌声明通常意味着恶意客户端试图触发协议层漏洞。 + * + * 攻击模式识别: + * - CustomPayload洪水(>50/s):插件通道洪水,常被用于BungeeCord漏洞利用 + * - SetCreativeSlot洪水(>30/s):创造模式物品栏洪水,可导致客户端崩溃 + * - ChatMessage洪水(>20/s):聊天洪水,消耗服务器带宽 + * - Brand包异常(>3次/60s):品牌通道重复声明,通常为恶意客户端行为 + * + * 递增式惩罚机制: + * 首次超限 → 100ms延迟 + * 第二次 → 2秒延迟 + * 第三次 → 10秒延迟 + * 第四次+ → 30秒延迟(并上报为攻击事件) + * + * 配置开关:serverguard.security.super-evolution.packet-flood-protection + */ +@Service +public class PacketFloodProtectionService { + + private final ServerGuardConfig config; + + /** 按 playerKey 存储数据包事件历史(5秒窗口内有效) */ + private final Map> packetHistory = new ConcurrentHashMap<>(); + /** 按 packetType 统计各类型数量 */ + private final Map packetTypeStats = new ConcurrentHashMap<>(); + /** 记录每个来源的违规次数,用于递增惩罚 */ + private final Map violationCounts = new ConcurrentHashMap<>(); + /** 记录每个来源当前被封锁到的截止时间 */ + private final Map blockedUntil = new ConcurrentHashMap<>(); + + private final AtomicLong totalPackets = new AtomicLong(0); + private final AtomicLong blockedCount = new AtomicLong(0); + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + /** 数据包统计窗口大小(秒) */ + private static final long WINDOW_SECONDS = 5; + /** 品牌包检测窗口(秒),比普通窗口更长 */ + private static final long BRAND_WINDOW_SECONDS = 60; + + /** 各数据包类型的每秒阈值 */ + private static final int CUSTOM_PAYLOAD_THRESHOLD = 50; + private static final int SET_CREATIVE_SLOT_THRESHOLD = 30; + private static final int CHAT_MESSAGE_THRESHOLD = 20; + /** 品牌包重复声明阈值:60秒内超过3次视为异常 */ + private static final int BRAND_DECLARATION_THRESHOLD = 3; + + /** 递增惩罚层级(毫秒) */ + private static final long[] PENALTY_TIERS_MS = {100, 2000, 10000, 30000}; + + public PacketFloodProtectionService() { + this(new ServerGuardConfig()); + } + + @Autowired + public PacketFloodProtectionService(ServerGuardConfig config) { + this.config = config; + // 每30秒清理一次过期数据,避免内存泄漏 + scheduler.scheduleAtFixedRate(this::cleanupExpiredData, 30, 30, TimeUnit.SECONDS); + } + + /** + * 检查一个数据包是否构成洪水攻击。 + * + * @param playerKey 玩家标识(IP或玩家名) + * @param packetType 数据包类型(如 CustomPayload、ChatMessage 等) + * @param channel 数据包的频道名(Brand/CustomPayload时有效),可为null + * @return 检查结果,包含是否被阻止及原因 + */ + public PacketFloodResult check(String playerKey, String packetType, String channel) { + // 配置开关:如果模块被禁用,直接放行 + if (!config.getSecurity().getSuperEvolution().isPacketFloodProtection()) { + return PacketFloodResult.clean(); + } + + totalPackets.incrementAndGet(); + + // 记录数据包事件(先记录再做检查,确保本次数据包也被统计) + PacketEvent event = new PacketEvent(Instant.now(), playerKey, packetType, channel); + packetHistory.computeIfAbsent(playerKey, k -> Collections.synchronizedList(new ArrayList<>())).add(event); + + // 更新各类型统计计数器 + packetTypeStats.computeIfAbsent(packetType, k -> new AtomicLong(0)).incrementAndGet(); + + // 清理过期的封锁记录(但不提前返回,让下面的频率检测先运行) + if (blockedUntil.containsKey(playerKey)) { + if (Instant.now().isAfter(blockedUntil.get(playerKey))) { + blockedUntil.remove(playerKey); + violationCounts.remove(playerKey); + } + } + + Instant now = Instant.now(); + List reasons = new ArrayList<>(); + boolean shouldBlock = false; + long penaltyMs = 0; + + // ---- 检测1:品牌包(Brand channel)特殊检测 ---- + // 品牌包通常在登录时只发送一次,60秒内超过3次为异常行为 + // 多次品牌声明可能用于探测或触发协议层漏洞 + if ("Brand".equalsIgnoreCase(channel) || "minecraft:brand".equalsIgnoreCase(channel)) { + List playerEvents = packetHistory.get(playerKey); + long brandCount = playerEvents.stream() + .filter(e -> e.time.isAfter(now.minusSeconds(BRAND_WINDOW_SECONDS))) + .filter(e -> "Brand".equalsIgnoreCase(e.channel) || "minecraft:brand".equalsIgnoreCase(e.channel)) + .count(); + if (brandCount > BRAND_DECLARATION_THRESHOLD) { + reasons.add("BRAND_FLOOD: " + brandCount + " brand declarations in 60s window (threshold: " + + BRAND_DECLARATION_THRESHOLD + ")"); + shouldBlock = true; + penaltyMs = PENALTY_TIERS_MS[1]; // 直接从第2级开始 + } + } + + // ---- 检测2:按数据包类型进行频率检测(5秒滑动窗口) ---- + if (!shouldBlock) { + List playerEvents = packetHistory.get(playerKey); + long typeCount = playerEvents.stream() + .filter(e -> e.time.isAfter(now.minusSeconds(WINDOW_SECONDS))) + .filter(e -> e.packetType.equalsIgnoreCase(packetType)) + .count(); + + int threshold; + String desc; + + // 确定该数据包类型的阈值 + String upperType = packetType.toUpperCase(); + if (upperType.contains("CUSTOMPAYLOAD") || upperType.contains("PLUGIN_MESSAGE")) { + threshold = CUSTOM_PAYLOAD_THRESHOLD; + desc = "CustomPayload"; + // CustomPayload洪水:插件通道滥用,5秒窗口内超过50次 + // 常见于BungeeCord漏洞利用或恶意客户端插件通道轰炸 + } else if (upperType.contains("SET_CREATIVE_SLOT") || upperType.contains("CREATIVE_INVENTORY")) { + threshold = SET_CREATIVE_SLOT_THRESHOLD; + desc = "SetCreativeSlot"; + // SetCreativeSlot洪水:创造模式物品栏操作洪水,可能用于物品复制或客户端DoS + } else if (upperType.contains("CHAT") || upperType.contains("CHAT_MESSAGE")) { + threshold = CHAT_MESSAGE_THRESHOLD; + desc = "ChatMessage"; + // ChatMessage洪水:聊天消息洪水,消耗服务器带宽和处理资源 + } else { + // 未知类型:默认阈值100/s(5秒窗口内500) + threshold = 100; + desc = packetType; + } + + if (typeCount > threshold * WINDOW_SECONDS) { + reasons.add(desc + "_FLOOD: " + typeCount + " packets in " + WINDOW_SECONDS + + "s window (threshold: " + (threshold * WINDOW_SECONDS) + ")"); + shouldBlock = true; + } + } + + // ---- 递增式惩罚 ---- + if (shouldBlock) { + long violations = violationCounts.computeIfAbsent(playerKey, k -> new AtomicLong(0)) + .incrementAndGet(); + + // 根据违规次数选择惩罚层级 + int tier = (int) Math.min(violations - 1, PENALTY_TIERS_MS.length - 1); + penaltyMs = Math.max(penaltyMs, PENALTY_TIERS_MS[tier]); + + // 第4级+(30秒惩罚)标记为严重攻击 + if (tier >= 3) { + reasons.add("ESCALATED: tier " + (tier + 1) + " penalty, " + violations + " violations"); + } + + blockedCount.incrementAndGet(); + blockedUntil.put(playerKey, Instant.now().plusMillis(penaltyMs)); + + return PacketFloodResult.blocked(reasons, penaltyMs); + } + + return PacketFloodResult.clean(); + } + + /** + * 获取当前服务运行状态。 + * 用于仪表盘展示实时统计数据。 + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("totalPackets", totalPackets.get()); + s.put("blockedCount", blockedCount.get()); + s.put("currentlyBlocked", blockedUntil.size()); + s.put("trackedPlayers", packetHistory.size()); + + // 数据包类型统计(Top 20) + Map typeStats = new LinkedHashMap<>(); + packetTypeStats.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue().get(), a.getValue().get())) + .limit(20) + .forEach(e -> typeStats.put(e.getKey(), e.getValue().get())); + s.put("packetTypeStats", typeStats); + + // 当前封锁列表(Top 10) + List> blocked = new ArrayList<>(); + Instant now = Instant.now(); + for (Map.Entry e : blockedUntil.entrySet()) { + if (e.getValue().isAfter(now)) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("until", e.getValue().toString()); + m.put("violations", violationCounts.getOrDefault(e.getKey(), new AtomicLong(0)).get()); + blocked.add(m); + } + } + blocked.sort((a, b) -> b.get("until").toString().compareTo(a.get("until").toString())); + s.put("activeBlocks", blocked.subList(0, Math.min(blocked.size(), 10))); + return s; + } + + /** + * 清理过期数据,防止内存泄漏。 + * 删除超时的封锁记录和超过2倍窗口期的历史事件。 + */ + private void cleanupExpiredData() { + Instant now = Instant.now(); + + // 清理过期封锁 + blockedUntil.entrySet().removeIf(e -> e.getValue().isBefore(now)); + + // 清理过期数据包历史(保留2倍品牌窗口长度) + Instant cutoff = now.minusSeconds(BRAND_WINDOW_SECONDS * 2); + for (List events : packetHistory.values()) { + events.removeIf(e -> e.time.isBefore(cutoff)); + } + // 移除空玩家记录 + packetHistory.entrySet().removeIf(e -> e.getValue().isEmpty()); + } + + private static class PacketEvent { + final Instant time; + final String player; + final String packetType; + final String channel; + + PacketEvent(Instant t, String p, String pt, String c) { + this.time = t; + this.player = p; + this.packetType = pt; + this.channel = c; + } + } + + public static class PacketFloodResult { + private final boolean blocked; + private final List reasons; + private final long penaltyMs; + + private PacketFloodResult(boolean blocked, List reasons, long penaltyMs) { + this.blocked = blocked; + this.reasons = reasons; + this.penaltyMs = penaltyMs; + } + + /** 正常放行 */ + public static PacketFloodResult clean() { + return new PacketFloodResult(false, List.of(), 0); + } + + /** 阻止该数据包,带原因和惩罚延迟(毫秒) */ + public static PacketFloodResult blocked(List reasons, long penaltyMs) { + return new PacketFloodResult(true, reasons, penaltyMs); + } + + /** 阻止该数据包,带原因 */ + public static PacketFloodResult blocked(List reasons) { + return new PacketFloodResult(true, reasons, 0); + } + + public boolean isBlocked() { return blocked; } + public boolean isClean() { return !blocked; } + public List getReasons() { return reasons; } + public long getPenaltyMs() { return penaltyMs; } + } +} diff --git a/src/main/java/com/aluer/security/PlayerPrivacyService.java b/src/main/java/com/aluer/security/PlayerPrivacyService.java new file mode 100644 index 0000000..9be02b7 --- /dev/null +++ b/src/main/java/com/aluer/security/PlayerPrivacyService.java @@ -0,0 +1,388 @@ +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.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; + +/** + * 玩家隐私数据保护服务 — V4.0 聊天社交安全模块 + * + * 检测原理: + * 1. IP地址自动脱敏:日志中保留前两位(192.168.xxx.xxx) + * 2. 坐标脱敏选项:可配置脱敏级别(精确/区域/隐藏) + * 3. 聊天记录自动化匿名化处理 + * 4. 隐私数据访问审计:记录所有访问玩家隐私数据的API调用 + * 5. 数据保留策略:自动清理超过N天的玩家详细活动日志(默认30天) + * 6. GDPR/个人信息保护法合规辅助:玩家数据导出和删除请求处理 + * + * 配置开关:serverguard.security.super-evolution.player-privacy + */ +@Service +public class PlayerPrivacyService { + + private final ServerGuardConfig config; + private final AtomicLong anonymizedEntries = new AtomicLong(0); + private final Map pendingDataRequests = new ConcurrentHashMap<>(); + private final AtomicLong privacyViolations = new AtomicLong(0); + private final Map> accessAudits = new ConcurrentHashMap<>(); + private final AtomicLong totalAuditEntries = new AtomicLong(0); + + /** IPv4地址脱敏正则 */ + private static final Pattern IP_PATTERN = Pattern.compile( + "\\b(\\d{1,3}\\.\\d{1,3})\\.\\d{1,3}\\.\\d{1,3}\\b"); + + /** 坐标格式正则:用于识别(x,y,z)或[x,y,z]格式 */ + private static final Pattern COORDINATE_PATTERN = Pattern.compile( + "[\\[((]?(-?\\d+\\.?\\d*)\\s*[,,]\\s*(-?\\d+\\.?\\d*)\\s*[,,]\\s*(-?\\d+\\.?\\d*)[\\])]?"); + + /** 默认数据保留天数 */ + private static final long DEFAULT_RETENTION_DAYS = 30L; + /** 数据导出请求过期天数 */ + private static final long EXPORT_REQUEST_EXPIRY_DAYS = 7L; + + /** 坐标脱敏级别枚举 */ + public enum CoordinateAnonymizationLevel { + /** 精确:不脱敏 */ + PRECISE, + /** 区域:四舍五入到10的倍数 */ + REGION, + /** 隐藏:完全替换为*** */ + HIDDEN + } + + /** 当前坐标脱敏级别 */ + private volatile CoordinateAnonymizationLevel coordLevel = CoordinateAnonymizationLevel.REGION; + + public PlayerPrivacyService() { + this(new ServerGuardConfig()); + } + + @Autowired + public PlayerPrivacyService(ServerGuardConfig config) { + this.config = config; + } + + /** + * 脱敏日志中的IP地址 + * + * 将完整IP地址替换为前两位保留、后两位替换为xxx的格式。 + * 例如:192.168.1.100 -> 192.168.xxx.xxx + * 这样保留网络段信息用于安全分析,但隐藏具体主机标识。 + * + * @param logEntry 包含IP地址的日志条目 + * @return 脱敏后的日志文本 + */ + public String anonymizeIp(String logEntry) { + if (!config.getSecurity().getSuperEvolution().isPlayerPrivacy()) { + return logEntry; + } + + if (logEntry == null || logEntry.isEmpty()) { + return logEntry; + } + + String result = IP_PATTERN.matcher(logEntry).replaceAll(match -> { + anonymizedEntries.incrementAndGet(); + return match.group(1) + ".xxx.xxx"; + }); + + return result; + } + + /** + * 脱敏玩家坐标 + * + * 根据配置的脱敏级别处理坐标信息: + * - PRECISE:保持原样 + * - REGION:四舍五入到10的倍数(如 128, 64, -256 -> 130, 60, -260) + * - HIDDEN:完全替换为 *** + * + * @param x X坐标 + * @param y Y坐标 + * @param z Z坐标 + * @return 脱敏后的坐标字符串 + */ + public String anonymizeCoordinates(double x, double y, double z) { + if (!config.getSecurity().getSuperEvolution().isPlayerPrivacy()) { + return formatCoordinate(x, y, z); + } + + anonymizedEntries.incrementAndGet(); + + switch (coordLevel) { + case PRECISE: + return formatCoordinate(x, y, z); + case REGION: + // 四舍五入到10的倍数 + return formatCoordinate( + Math.round(x / 10.0) * 10, + Math.round(y / 10.0) * 10, + Math.round(z / 10.0) * 10 + ); + case HIDDEN: + return "[***, ***, ***]"; + default: + return formatCoordinate(x, y, z); + } + } + + /** + * 格式化坐标为字符串 + * + * @param x X坐标 + * @param y Y坐标 + * @param z Z坐标 + * @return 格式化后的坐标字符串 + */ + private String formatCoordinate(double x, double y, double z) { + return String.format("[%.0f, %.0f, %.0f]", x, y, z); + } + + /** + * 聊天记录自动化匿名化处理 + * + * 将消息中的玩家名替换为匿名标识符,保护玩家隐私。 + * 保留原始玩家名到匿名ID的映射以便必要时回溯。 + * + * @param playerName 玩家名 + * @param message 聊天消息 + * @return 匿名化后的消息 + */ + public String anonymizeChatMessage(String playerName, String message) { + if (!config.getSecurity().getSuperEvolution().isPlayerPrivacy()) { + return "[" + playerName + "] " + message; + } + + anonymizedEntries.incrementAndGet(); + + // 使用玩家名的Hash生成匿名标识符 + String anonymousId = "Player_" + Math.abs(playerName.hashCode() % 10000); + + // 对消息中的坐标进行脱敏 + String processedMessage = COORDINATE_PATTERN.matcher(message).replaceAll(match -> { + try { + double cx = Double.parseDouble(match.group(1)); + double cy = Double.parseDouble(match.group(2)); + double cz = Double.parseDouble(match.group(3)); + return anonymizeCoordinates(cx, cy, cz); + } catch (NumberFormatException e) { + return match.group(); + } + }); + + return "[" + anonymousId + "] " + processedMessage; + } + + /** + * 记录隐私数据访问审计 + * + * 每次API调用访问玩家隐私数据时记录操作者、玩家目标、数据类型和访问时间。 + * 用于合规审计和异常检测。 + * + * @param accessor 访问者(API调用方) + * @param targetPlayer 被访问的玩家 + * @param dataType 访问的数据类型(如IP、坐标、聊天记录等) + */ + public void auditAccess(String accessor, String targetPlayer, String dataType) { + if (!config.getSecurity().getSuperEvolution().isPlayerPrivacy()) { + return; + } + + AuditEntry entry = new AuditEntry(accessor, targetPlayer, dataType, Instant.now()); + accessAudits.computeIfAbsent(targetPlayer, k -> new ArrayList<>()).add(entry); + totalAuditEntries.incrementAndGet(); + + // 限制单个玩家的审计记录数量 + List entries = accessAudits.get(targetPlayer); + while (entries.size() > 500) { + entries.remove(0); + } + } + + /** + * 提交玩家数据导出请求(GDPR第20条:数据可携带权) + * + * @param playerName 玩家名 + * @param requestor 请求者标识 + * @return 请求ID + */ + public String requestDataExport(String playerName, String requestor) { + String requestId = UUID.randomUUID().toString(); + DataExportRequest request = new DataExportRequest( + requestId, playerName, requestor, "EXPORT", Instant.now()); + pendingDataRequests.put(requestId, request); + return requestId; + } + + /** + * 提交玩家数据删除请求(GDPR第17条:被遗忘权) + * + * @param playerName 玩家名 + * @param requestor 请求者标识 + * @return 请求ID + */ + public String requestDataDeletion(String playerName, String requestor) { + String requestId = UUID.randomUUID().toString(); + DataExportRequest request = new DataExportRequest( + requestId, playerName, requestor, "DELETION", Instant.now()); + pendingDataRequests.put(requestId, request); + return requestId; + } + + /** + * 获取待处理的数据请求列表 + * + * @return 待处理的数据请求列表 + */ + public List getPendingRequests() { + return new ArrayList<>(pendingDataRequests.values()); + } + + /** + * 标记数据请求为已处理 + * + * @param requestId 请求ID + * @return true表示处理成功 + */ + public boolean completeRequest(String requestId) { + DataExportRequest request = pendingDataRequests.get(requestId); + if (request != null) { + request.completed = true; + request.completedAt = Instant.now(); + return true; + } + return false; + } + + /** + * 检查并清理过期的数据请求 + * + * 超过7天未处理的数据导出/删除请求自动移除,释放内存。 + */ + public void cleanupExpiredRequests() { + Instant cutoff = Instant.now().minus(EXPORT_REQUEST_EXPIRY_DAYS, ChronoUnit.DAYS); + pendingDataRequests.entrySet().removeIf(e -> + e.getValue().createdAt.isBefore(cutoff)); + } + + /** + * 记录隐私违规事件 + * + * 当检测到未经授权的隐私数据访问时记录违规。 + * + * @param description 违规描述 + */ + public void recordPrivacyViolation(String description) { + privacyViolations.incrementAndGet(); + } + + /** + * 设置坐标脱敏级别 + * + * @param level 脱敏级别 + */ + public void setCoordinateAnonymizationLevel(CoordinateAnonymizationLevel level) { + this.coordLevel = level; + } + + /** + * 获取当前坐标脱敏级别 + * + * @return 当前脱敏级别 + */ + public CoordinateAnonymizationLevel getCoordinateAnonymizationLevel() { + return coordLevel; + } + + /** + * 获取针对特定玩家的审计记录 + * + * @param playerName 玩家名 + * @return 审计条目列表 + */ + public List getAuditForPlayer(String playerName) { + List entries = accessAudits.get(playerName); + return entries == null ? List.of() : new ArrayList<>(entries); + } + + /** + * 获取服务运行状态 + * + * @return 包含状态键值对的LinkedHashMap + */ + public Map getStatus() { + Map s = new LinkedHashMap<>(); + s.put("enabled", config.getSecurity().getSuperEvolution().isPlayerPrivacy()); + s.put("anonymizedEntries", anonymizedEntries.get()); + s.put("pendingDataRequests", pendingDataRequests.size()); + s.put("exportRequests", pendingDataRequests.values().stream() + .filter(r -> "EXPORT".equals(r.type)).count()); + s.put("deletionRequests", pendingDataRequests.values().stream() + .filter(r -> "DELETION".equals(r.type)).count()); + s.put("privacyViolations", privacyViolations.get()); + s.put("coordinateLevel", coordLevel.name()); + s.put("retentionDays", DEFAULT_RETENTION_DAYS); + s.put("totalAuditEntries", totalAuditEntries.get()); + s.put("auditedPlayers", accessAudits.size()); + return s; + } + + /** + * 隐私数据访问审计条目 + */ + public static class AuditEntry { + private final String accessor; + private final String targetPlayer; + private final String dataType; + private final Instant timestamp; + + public AuditEntry(String accessor, String targetPlayer, String dataType, Instant timestamp) { + this.accessor = accessor; + this.targetPlayer = targetPlayer; + this.dataType = dataType; + this.timestamp = timestamp; + } + + public String getAccessor() { return accessor; } + public String getTargetPlayer() { return targetPlayer; } + public String getDataType() { return dataType; } + public Instant getTimestamp() { return timestamp; } + } + + /** + * 数据导出/删除请求 + */ + public static class DataExportRequest { + private final String requestId; + private final String playerName; + private final String requestor; + private final String type; // EXPORT 或 DELETION + private final Instant createdAt; + private volatile boolean completed = false; + private volatile Instant completedAt; + + public DataExportRequest(String requestId, String playerName, String requestor, + String type, Instant createdAt) { + this.requestId = requestId; + this.playerName = playerName; + this.requestor = requestor; + this.type = type; + this.createdAt = createdAt; + } + + public String getRequestId() { return requestId; } + public String getPlayerName() { return playerName; } + public String getRequestor() { return requestor; } + public String getType() { return type; } + public Instant getCreatedAt() { return createdAt; } + public boolean isCompleted() { return completed; } + public Instant getCompletedAt() { return completedAt; } + } +} diff --git a/src/main/java/com/aluer/web/DashboardController.java b/src/main/java/com/aluer/web/DashboardController.java index 02d9201..d967b76 100644 --- a/src/main/java/com/aluer/web/DashboardController.java +++ b/src/main/java/com/aluer/web/DashboardController.java @@ -68,6 +68,40 @@ public class DashboardController { private final ConnectionThrottleService connectionThrottleService; private final BackupIntegrityService backupIntegrityService; private final AntiSkinSpoofService antiSkinSpoofService; + // V4.0 反作弊扩展模块 + private final AntiKillAuraService antiKillAuraService; + private final AntiReachService antiReachService; + private final AntiSpeedService antiSpeedService; + private final AntiJesusService antiJesusService; + private final AntiNoFallService antiNoFallService; + private final AntiScaffoldService antiScaffoldService; + // V4.0 玩家行为安全模块 + private final AntiNukerService antiNukerService; + private final AntiAutoClickerService antiAutoClickerService; + private final AntiChestStealService antiChestStealService; + private final AntiAutoFishService antiAutoFishService; + private final AntiInventoryManipulationService antiInventoryManipulationService; + private final AntiBaritoneService antiBaritoneService; + // V4.0 服务器保护模块 + private final PacketFloodProtectionService packetFloodProtectionService; + private final AntiSignExploitService antiSignExploitService; + private final AntiBookBanService antiBookBanService; + private final AntiResourcePackExploitService antiResourcePackExploitService; + private final AntiTabCompleteCrashService antiTabCompleteCrashService; + private final AntiOfflineModeSpoofService antiOfflineModeSpoofService; + // V4.0 访问控制模块 + private final OPPrivilegeMonitorService opPrivilegeMonitorService; + private final ConfigTamperDetectionService configTamperDetectionService; + private final BackdoorPluginScannerService backdoorPluginScannerService; + private final AntiVPNProxyService antiVPNProxyService; + private final AntiAltAccountService antiAltAccountService; + private final AntiNameSpoofService antiNameSpoofService; + // V4.0 聊天社交安全模块 + private final ChatFloodProtectionService chatFloodProtectionService; + private final AntiAdvertisementService antiAdvertisementService; + private final AntiPhishingLinkService antiPhishingLinkService; + private final AntiCommandAbuseService antiCommandAbuseService; + private final PlayerPrivacyService playerPrivacyService; @SuppressWarnings("java:S107") public DashboardController( @@ -113,7 +147,36 @@ public class DashboardController { PluginVerificationService pluginVerificationService, ConnectionThrottleService connectionThrottleService, BackupIntegrityService backupIntegrityService, - AntiSkinSpoofService antiSkinSpoofService) { + AntiSkinSpoofService antiSkinSpoofService, + AntiKillAuraService antiKillAuraService, + AntiReachService antiReachService, + AntiSpeedService antiSpeedService, + AntiJesusService antiJesusService, + AntiNoFallService antiNoFallService, + AntiScaffoldService antiScaffoldService, + AntiNukerService antiNukerService, + AntiAutoClickerService antiAutoClickerService, + AntiChestStealService antiChestStealService, + AntiAutoFishService antiAutoFishService, + AntiInventoryManipulationService antiInventoryManipulationService, + AntiBaritoneService antiBaritoneService, + PacketFloodProtectionService packetFloodProtectionService, + AntiSignExploitService antiSignExploitService, + AntiBookBanService antiBookBanService, + AntiResourcePackExploitService antiResourcePackExploitService, + AntiTabCompleteCrashService antiTabCompleteCrashService, + AntiOfflineModeSpoofService antiOfflineModeSpoofService, + OPPrivilegeMonitorService opPrivilegeMonitorService, + ConfigTamperDetectionService configTamperDetectionService, + BackdoorPluginScannerService backdoorPluginScannerService, + AntiVPNProxyService antiVPNProxyService, + AntiAltAccountService antiAltAccountService, + AntiNameSpoofService antiNameSpoofService, + ChatFloodProtectionService chatFloodProtectionService, + AntiAdvertisementService antiAdvertisementService, + AntiPhishingLinkService antiPhishingLinkService, + AntiCommandAbuseService antiCommandAbuseService, + PlayerPrivacyService playerPrivacyService) { this.rconClient = rconClient; this.profiler = profiler; this.backupService = backupService; @@ -157,6 +220,35 @@ public class DashboardController { this.connectionThrottleService = connectionThrottleService; this.backupIntegrityService = backupIntegrityService; this.antiSkinSpoofService = antiSkinSpoofService; + this.antiKillAuraService = antiKillAuraService; + this.antiReachService = antiReachService; + this.antiSpeedService = antiSpeedService; + this.antiJesusService = antiJesusService; + this.antiNoFallService = antiNoFallService; + this.antiScaffoldService = antiScaffoldService; + this.antiNukerService = antiNukerService; + this.antiAutoClickerService = antiAutoClickerService; + this.antiChestStealService = antiChestStealService; + this.antiAutoFishService = antiAutoFishService; + this.antiInventoryManipulationService = antiInventoryManipulationService; + this.antiBaritoneService = antiBaritoneService; + this.packetFloodProtectionService = packetFloodProtectionService; + this.antiSignExploitService = antiSignExploitService; + this.antiBookBanService = antiBookBanService; + this.antiResourcePackExploitService = antiResourcePackExploitService; + this.antiTabCompleteCrashService = antiTabCompleteCrashService; + this.antiOfflineModeSpoofService = antiOfflineModeSpoofService; + this.opPrivilegeMonitorService = opPrivilegeMonitorService; + this.configTamperDetectionService = configTamperDetectionService; + this.backdoorPluginScannerService = backdoorPluginScannerService; + this.antiVPNProxyService = antiVPNProxyService; + this.antiAltAccountService = antiAltAccountService; + this.antiNameSpoofService = antiNameSpoofService; + this.chatFloodProtectionService = chatFloodProtectionService; + this.antiAdvertisementService = antiAdvertisementService; + this.antiPhishingLinkService = antiPhishingLinkService; + this.antiCommandAbuseService = antiCommandAbuseService; + this.playerPrivacyService = playerPrivacyService; } @GetMapping("/status") @@ -518,4 +610,159 @@ public class DashboardController { public Map getAntiSkinSpoofStatus() { return antiSkinSpoofService.getStatus(); } + + // --- V4.0 反作弊扩展 API --- + + @GetMapping("/security/anti-kill-aura/status") + public Map getAntiKillAuraStatus() { + return antiKillAuraService.getStatus(); + } + + @GetMapping("/security/anti-reach/status") + public Map getAntiReachStatus() { + return antiReachService.getStatus(); + } + + @GetMapping("/security/anti-speed/status") + public Map getAntiSpeedStatus() { + return antiSpeedService.getStatus(); + } + + @GetMapping("/security/anti-jesus/status") + public Map getAntiJesusStatus() { + return antiJesusService.getStatus(); + } + + @GetMapping("/security/anti-no-fall/status") + public Map getAntiNoFallStatus() { + return antiNoFallService.getStatus(); + } + + @GetMapping("/security/anti-scaffold/status") + public Map getAntiScaffoldStatus() { + return antiScaffoldService.getStatus(); + } + + // --- V4.0 玩家行为安全 API --- + + @GetMapping("/security/anti-nuker/status") + public Map getAntiNukerStatus() { + return antiNukerService.getStatus(); + } + + @GetMapping("/security/anti-auto-clicker/status") + public Map getAntiAutoClickerStatus() { + return antiAutoClickerService.getStatus(); + } + + @GetMapping("/security/anti-chest-steal/status") + public Map getAntiChestStealStatus() { + return antiChestStealService.getStatus(); + } + + @GetMapping("/security/anti-auto-fish/status") + public Map getAntiAutoFishStatus() { + return antiAutoFishService.getStatus(); + } + + @GetMapping("/security/anti-inventory-manipulation/status") + public Map getAntiInventoryManipulationStatus() { + return antiInventoryManipulationService.getStatus(); + } + + @GetMapping("/security/anti-baritone/status") + public Map getAntiBaritoneStatus() { + return antiBaritoneService.getStatus(); + } + + // --- V4.0 服务器保护模块 API --- + + @GetMapping("/security/packet-flood/status") + public Map getPacketFloodStatus() { + return packetFloodProtectionService.getStatus(); + } + + @GetMapping("/security/anti-sign-exploit/status") + public Map getAntiSignExploitStatus() { + return antiSignExploitService.getStatus(); + } + + @GetMapping("/security/anti-book-ban/status") + public Map getAntiBookBanStatus() { + return antiBookBanService.getStatus(); + } + + @GetMapping("/security/anti-resource-pack/status") + public Map getAntiResourcePackStatus() { + return antiResourcePackExploitService.getStatus(); + } + + @GetMapping("/security/anti-tab-complete/status") + public Map getAntiTabCompleteStatus() { + return antiTabCompleteCrashService.getStatus(); + } + + @GetMapping("/security/anti-offline-spoof/status") + public Map getAntiOfflineSpoofStatus() { + return antiOfflineModeSpoofService.getStatus(); + } + + // --- V4.0 访问控制模块 API --- + + @GetMapping("/security/op-monitor/status") + public Map getOPMonitorStatus() { + return opPrivilegeMonitorService.getStatus(); + } + + @GetMapping("/security/config-tamper/status") + public Map getConfigTamperStatus() { + return configTamperDetectionService.getStatus(); + } + + @GetMapping("/security/backdoor-scanner/status") + public Map getBackdoorScannerStatus() { + return backdoorPluginScannerService.getStatus(); + } + + @GetMapping("/security/anti-vpn/status") + public Map getAntiVPNStatus() { + return antiVPNProxyService.getStatus(); + } + + @GetMapping("/security/anti-alt/status") + public Map getAntiAltStatus() { + return antiAltAccountService.getStatus(); + } + + @GetMapping("/security/anti-name-spoof/status") + public Map getAntiNameSpoofStatus() { + return antiNameSpoofService.getStatus(); + } + + // --- V4.0 聊天社交安全模块 API --- + + @GetMapping("/security/chat-flood/status") + public Map getChatFloodStatus() { + return chatFloodProtectionService.getStatus(); + } + + @GetMapping("/security/anti-advertisement/status") + public Map getAntiAdvertisementStatus() { + return antiAdvertisementService.getStatus(); + } + + @GetMapping("/security/anti-phishing/status") + public Map getAntiPhishingStatus() { + return antiPhishingLinkService.getStatus(); + } + + @GetMapping("/security/anti-command-abuse/status") + public Map getAntiCommandAbuseStatus() { + return antiCommandAbuseService.getStatus(); + } + + @GetMapping("/security/player-privacy/status") + public Map getPlayerPrivacyStatus() { + return playerPrivacyService.getStatus(); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c591869..e74b5f0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -184,6 +184,40 @@ serverguard: connection-throttle: true # 连接速率限制(IP/时间窗口/递增延迟) backup-integrity: true # 备份完整性校验(SHA-256/文件计数/大小对比) anti-skin-spoof: true # 皮肤伪造检测(模型数据异常/皮肤URL检测) + # === V4.0 反作弊扩展模块 === + anti-kill-aura: true # 杀戮光环检测(攻击频率/目标切换/旋转角度) + anti-reach: true # 超距攻击检测(攻击距离/位置回溯验证) + anti-speed: true # 水平速度检测(移动速度/GroundSpoof异常) + anti-jesus: true # 水上行走检测(水面/岩浆面移动验证) + anti-no-fall: true # 无摔落伤害检测(落地检测/伤害验证) + anti-scaffold: true # 自动搭路检测(方块放置频率/角度/速度) + # === V4.0 玩家行为安全模块 === + anti-nuker: true # 快速破坏检测(挖矿速度/范围/模式识别) + anti-auto-clicker: true # 自动点击检测(CPS统计/点击间隔熵值分析) + anti-chest-steal: true # 自动偷箱检测(开箱/取物速度模式识别) + anti-auto-fish: true # 自动钓鱼检测(钓鱼行为时序/反应速度) + anti-inventory-manipulation: true # 背包操作检测(非正常速度/非法槽位操作) + anti-baritone: true # AI寻路机器人检测(路径平滑度/行为重复率) + # === V4.0 服务器保护模块 === + packet-flood-protection: true # 数据包洪水防护(窗口/自定义负载/品牌包限制) + anti-sign-exploit: true # 告示牌漏洞防护(超长JSON/无效组件/NBT炸弹) + anti-book-ban: true # 书与笔封禁防护(超大页码/超深JSON层级) + anti-resource-pack-exploit: true # 资源包漏洞防护(恶意URL/超大文件/格式校验) + anti-tab-complete-crash: true # Tab补全崩溃防护(长文本/深度嵌套补全限制) + anti-offline-mode-spoof: true # 离线UUID欺诈防护(正版UUID冲突/IP关联验证) + # === V4.0 访问控制模块 === + op-privilege-monitor: true # OP权限监控(权限变更/敏感命令执行审计) + config-tamper-detection: true # 配置文件篡改检测(Hash基线/实时监控ops/whitelist) + backdoor-plugin-scanner: true # 后门插件扫描(已知恶意类名/远程执行/隐藏命令) + anti-vpn-proxy: true # VPN代理检测(已知VPN IP库/托管ASN匹配) + anti-alt-account: true # 小号检测(IP关联/行为相似度/登录时间模式) + anti-name-spoof: true # 名称冒充检测(管理员/知名玩家昵称伪造) + # === V4.0 聊天社交安全模块 === + chat-flood-protection: true # 聊天洪水防护(消息频率/相似度/长度激增) + anti-advertisement: true # 广告检测(IP地址/域名/群号正则匹配) + anti-phishing-link: true # 钓鱼链接检测(可疑域名/短链接/SSL证书校验) + anti-command-abuse: true # 命令滥用检测(频率限制/敏感命令拦截/权限校验) + player-privacy: true # 玩家隐私保护(IP脱敏/坐标隐藏/日志匿名化) dashboard: enabled: true diff --git a/src/test/java/com/aluer/security/V40AccessControlTest.java b/src/test/java/com/aluer/security/V40AccessControlTest.java new file mode 100644 index 0000000..412506f --- /dev/null +++ b/src/test/java/com/aluer/security/V40AccessControlTest.java @@ -0,0 +1,413 @@ +package com.aluer.security; + +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * V4.0 访问控制模块测试类 + * 覆盖 OPPrivilegeMonitor, ConfigTamperDetection, BackdoorPluginScanner, + * AntiVPNProxy, AntiAltAccount, AntiNameSpoof 六个安全服务 + */ +class V40AccessControlTest { + + // ==================== OPPrivilegeMonitorService Tests ==================== + + @Test + void opMonitorShouldDetectUnauthorizedGrantViaRcon() { + OPPrivilegeMonitorService service = new OPPrivilegeMonitorService(); + // OP granted via RCON (non-console source) should be flagged suspicious + OPPrivilegeMonitorService.OPMonitorResult result = service.recordOPChange( + "NewAdmin", 4, "grant", "RCON", "UnknownPlayer"); + assertTrue(result.isSuspicious()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("UNAUTHORIZED_OP_GRANT"))); + } + + @Test + void opMonitorShouldAllowConsoleGrant() { + OPPrivilegeMonitorService service = new OPPrivilegeMonitorService(); + // OP granted via CONSOLE should be considered clean + OPPrivilegeMonitorService.OPMonitorResult result = service.recordOPChange( + "LegitAdmin", 4, "grant", "CONSOLE", "Server"); + assertTrue(result.isClean()); + } + + @Test + void opMonitorShouldDetectOpLevelJump() { + OPPrivilegeMonitorService service = new OPPrivilegeMonitorService(); + // First grant Level 1 via console (clean) + service.recordOPChange("Player1", 1, "grant", "CONSOLE", "Server"); + // Then jump to Level 4 via console — should be suspicious + OPPrivilegeMonitorService.OPMonitorResult result = service.recordOPChange( + "Player1", 4, "grant", "CONSOLE", "Server"); + assertTrue(result.isSuspicious()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("OP_LEVEL_JUMP"))); + } + + @Test + void opMonitorShouldDetectCommandStorm() { + OPPrivilegeMonitorService service = new OPPrivilegeMonitorService(); + // Simulate 6 sensitive commands from same player within 1 minute + for (int i = 0; i < 6; i++) { + OPPrivilegeMonitorService.OPMonitorResult result = service.recordSensitiveCommand( + "SuspiciousAdmin", "ban Player" + i, "IN_GAME"); + if (i >= 5) { + assertTrue(result.isSuspicious()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("COMMAND_STORM"))); + } + } + } + + @Test + void opMonitorShouldIdentifySensitiveCommands() { + OPPrivilegeMonitorService service = new OPPrivilegeMonitorService(); + assertTrue(service.isSensitiveCommand("ban BadPlayer")); + assertTrue(service.isSensitiveCommand("/stop")); + assertTrue(service.isSensitiveCommand("/reload")); + assertTrue(service.isSensitiveCommand("/whitelist add Someone")); + assertFalse(service.isSensitiveCommand("list")); + assertFalse(service.isSensitiveCommand("say hello")); + } + + @Test + void opMonitorGetStatusReturnsCorrectStructure() { + OPPrivilegeMonitorService service = new OPPrivilegeMonitorService(); + service.recordOPChange("Admin1", 4, "grant", "CONSOLE", "Server"); + service.recordOPChange("Admin2", 2, "grant", "CONSOLE", "Server"); + + var status = service.getStatus(); + assertNotNull(status); + assertTrue(status.containsKey("totalOps")); + assertTrue(status.containsKey("opChanges")); + assertTrue(status.containsKey("sensitiveCommandLog")); + assertEquals(2L, status.get("totalOps")); + } + + // ==================== ConfigTamperDetectionService Tests ==================== + + @Test + void configTamperShouldDetectHashMismatch() { + ConfigTamperDetectionService service = new ConfigTamperDetectionService(); + byte[] original = "online-mode=true\nmax-players=20\n".getBytes(); + byte[] tampered = "online-mode=false\nmax-players=20\n".getBytes(); + + service.registerBaseline("server.properties", original); + ConfigTamperDetectionService.TamperDetectionResult result = service.checkFileIntegrity( + "server.properties", tampered, "system"); + + assertTrue(result.isTampered()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("HASH_MISMATCH"))); + } + + @Test + void configTamperShouldDetectOnlineModeDisabled() { + ConfigTamperDetectionService service = new ConfigTamperDetectionService(); + byte[] props = "online-mode=false\nenable-rcon=true\nrcon.password=123\n".getBytes(); + + ConfigTamperDetectionService.TamperDetectionResult result = service.checkFileIntegrity( + "server.properties", props, "system"); + + assertTrue(result.isTampered()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("ONLINE_MODE_DISABLED"))); + } + + @Test + void configTamperShouldDetectWeakRconPassword() { + ConfigTamperDetectionService service = new ConfigTamperDetectionService(); + byte[] props = "online-mode=true\nenable-rcon=true\nrcon.password=abc\n".getBytes(); + + ConfigTamperDetectionService.TamperDetectionResult result = service.checkFileIntegrity( + "server.properties", props, "system"); + + assertTrue(result.isTampered()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("WEAK_RCON_PASSWORD"))); + } + + @Test + void configTamperShouldDetectSecureProfileDisabled() { + ConfigTamperDetectionService service = new ConfigTamperDetectionService(); + byte[] props = "online-mode=true\nenforce-secure-profile=false\n".getBytes(); + + ConfigTamperDetectionService.TamperDetectionResult result = service.checkFileIntegrity( + "server.properties", props, "system"); + + assertTrue(result.isTampered()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("SECURE_PROFILE_DISABLED"))); + } + + @Test + void configTamperShouldAllowCleanConfig() { + ConfigTamperDetectionService service = new ConfigTamperDetectionService(); + byte[] props = "online-mode=true\nmax-players=20\nenforce-secure-profile=true\n".getBytes(); + + service.registerBaseline("server.properties", props); + ConfigTamperDetectionService.TamperDetectionResult result = service.checkFileIntegrity( + "server.properties", props, "system"); + + assertTrue(result.isClean()); + } + + @Test + void configTamperShouldDetectUnauthorizedOpInOpsJson() { + ConfigTamperDetectionService service = new ConfigTamperDetectionService(); + service.setLegitimateOps(Set.of("Owner", "Admin1")); + + byte[] opsJson = ("[{\"uuid\":\"aaa-bbb-ccc\",\"name\":\"UnknownHacker\",\"level\":4}]").getBytes(); + ConfigTamperDetectionService.TamperDetectionResult result = service.checkFileIntegrity( + "ops.json", opsJson, "system"); + + assertTrue(result.isTampered()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("UNAUTHORIZED_OP"))); + } + + // ==================== BackdoorPluginScannerService Tests ==================== + + @Test + void backdoorScannerShouldDetectKnownMaliciousPlugin() { + BackdoorPluginScannerService service = new BackdoorPluginScannerService(); + BackdoorPluginScannerService.PluginScanResult result = service.scanPlugin( + "ForceOP", "ForceOP.jar", true, "com.force.op.Main", + List.of("com.force.op.Main"), List.of()); + + assertTrue(result.isMalicious()); + assertEquals(BackdoorPluginScannerService.RiskLevel.MALICIOUS, result.getRiskLevel()); + } + + @Test + void backdoorScannerShouldDetectMissingPluginYml() { + BackdoorPluginScannerService service = new BackdoorPluginScannerService(); + BackdoorPluginScannerService.PluginScanResult result = service.scanPlugin( + "MysteryPlugin", "MysteryPlugin.jar", false, "com.test.Main", + List.of("com.test.Main"), List.of()); + + assertTrue(result.isSuspicious()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("MISSING_PLUGIN_YML"))); + } + + @Test + void backdoorScannerShouldDetectRCECapability() { + BackdoorPluginScannerService service = new BackdoorPluginScannerService(); + BackdoorPluginScannerService.PluginScanResult result = service.scanPlugin( + "RemoteTool", "RemoteTool.jar", true, "com.remote.Main", + List.of("com.remote.Main", "Runtime.exec.Payload", "com.remote.Cleanup"), + List.of()); + + assertTrue(result.isSuspicious()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("RCE_CAPABILITY"))); + } + + @Test + void backdoorScannerShouldDetectSuspiciousConfigKeys() { + BackdoorPluginScannerService service = new BackdoorPluginScannerService(); + BackdoorPluginScannerService.PluginScanResult result = service.scanPlugin( + "NormalPlugin", "NormalPlugin.jar", true, "com.normal.Main", + List.of("com.normal.Main"), + List.of("database-url", "backdoor-enabled", "webhook-url")); + + assertTrue(result.isSuspicious()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("SUSPICIOUS_CONFIG_KEY"))); + } + + @Test + void backdoorScannerShouldAllowSafePlugin() { + BackdoorPluginScannerService service = new BackdoorPluginScannerService(); + BackdoorPluginScannerService.PluginScanResult result = service.scanPlugin( + "WorldEdit", "WorldEdit.jar", true, "com.sk89q.worldedit.bukkit.WorldEditPlugin", + List.of("com.sk89q.worldedit.bukkit.WorldEditPlugin"), List.of("max-blocks", "nav-wand")); + + assertTrue(result.isSafe()); + assertEquals(BackdoorPluginScannerService.RiskLevel.SAFE, result.getRiskLevel()); + } + + @Test + void backdoorScannerQuickScanShouldDetectMalicious() { + BackdoorPluginScannerService service = new BackdoorPluginScannerService(); + BackdoorPluginScannerService.PluginScanResult result = service.quickScan("OPMe"); + + assertTrue(result.isMalicious()); + } + + // ==================== AntiVPNProxyService Tests ==================== + + @Test + void antiVpnShouldDetectDigitalOceanASN() { + AntiVPNProxyService service = new AntiVPNProxyService(); + // AS14061 = DigitalOcean + AntiVPNProxyService.VPNCheckResult result = service.checkIP( + "159.65.100.50", "14061", "DigitalOcean, LLC", "Player1"); + + assertTrue(result.isHosting()); + assertFalse(result.isClean()); + } + + @Test + void antiVpnShouldDetectDatacenterIPRange() { + AntiVPNProxyService service = new AntiVPNProxyService(); + // Vultr IP prefix + AntiVPNProxyService.VPNCheckResult result = service.checkIP( + "45.76.100.200", null, null, "Player2"); + + assertTrue(result.isHosting()); + } + + @Test + void antiVpnShouldAllowTrustedIP() { + AntiVPNProxyService service = new AntiVPNProxyService(); + // Add to trusted list first + service.addTrustedIP("159.65.100.50"); + + AntiVPNProxyService.VPNCheckResult result = service.checkIP( + "159.65.100.50", "14061", "DigitalOcean, LLC", "Player3"); + + assertTrue(result.isClean()); + } + + @Test + void antiVpnShouldDetectHostingISP() { + AntiVPNProxyService service = new AntiVPNProxyService(); + AntiVPNProxyService.VPNCheckResult result = service.checkIP( + "198.51.100.50", null, "SomeHosting Cloud LTD", "Player4"); + + assertTrue(result.isHosting()); + } + + @Test + void antiVpnShouldAllowResidentialIP() { + AntiVPNProxyService service = new AntiVPNProxyService(); + AntiVPNProxyService.VPNCheckResult result = service.checkIP( + "203.0.113.1", "12345", "Comcast Cable", "LegitPlayer"); + + assertTrue(result.isClean()); + } + + // ==================== AntiAltAccountService Tests ==================== + + @Test + void antiAltShouldDetectBanEvasion() { + AntiAltAccountService service = new AntiAltAccountService(); + // Ban this IP first + service.recordBannedIP("10.0.0.100", "Griefing"); + // New player joins from the same IP immediately after + AntiAltAccountService.AltAccountResult result = service.onPlayerLogin( + "NewAltPlayer", "uuid-new", "10.0.0.100"); + + assertTrue(result.isAltDetected()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("BAN_EVASION"))); + } + + @Test + void antiAltShouldDetectRapidAccountSwitch() { + AntiAltAccountService service = new AntiAltAccountService(); + // Same IP, multiple accounts within 5 minutes + service.onPlayerLogin("Alt1", "uuid-1", "10.0.0.200"); + service.onPlayerLogin("Alt2", "uuid-2", "10.0.0.200"); + AntiAltAccountService.AltAccountResult result = service.onPlayerLogin( + "Alt3", "uuid-3", "10.0.0.200"); + + assertTrue(result.isAltDetected()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("RAPID_ACCOUNT_SWITCH"))); + } + + @Test + void antiAltShouldAllowSingleAccountLogin() { + AntiAltAccountService service = new AntiAltAccountService(); + AntiAltAccountService.AltAccountResult result = service.onPlayerLogin( + "LegitPlayer", "uuid-real", "192.168.1.1"); + + assertTrue(result.isClean()); + } + + @Test + void antiAltShouldTrackTroublemakerIPs() { + AntiAltAccountService service = new AntiAltAccountService(); + // First, login with account and create IP association + service.onPlayerLogin("Griefer1", "uuid-g1", "10.0.0.50"); + // Ban the IP + service.recordBannedIP("10.0.0.50", "Griefing"); + // Mark as troublemaker + service.markAsTroublemaker("Griefer1"); + + // New account from same IP should be detected + AntiAltAccountService.AltAccountResult result = service.onPlayerLogin( + "GrieferAlt", "uuid-g2", "10.0.0.50"); + + assertTrue(result.isAltDetected()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("TROUBLEMAKER_IP"))); + } + + @Test + void antiAltShouldReturnAssociatedAccounts() { + AntiAltAccountService service = new AntiAltAccountService(); + service.onPlayerLogin("AccA", "uuid-a", "10.0.0.77"); + service.onPlayerLogin("AccB", "uuid-b", "10.0.0.77"); + + Set accounts = service.getAccountsForIP("10.0.0.77"); + assertTrue(accounts.contains("AccA")); + assertTrue(accounts.contains("AccB")); + } + + // ==================== AntiNameSpoofService Tests ==================== + + @Test + void antiNameSpoofShouldDetectUnicodeHomoglyph() { + AntiNameSpoofService service = new AntiNameSpoofService(); + service.addProtectedName("Admin"); + + // Use Cyrillic 'a' (U+0430) instead of Latin 'a' + String spoofedName = "Admin"; // Note: uses Cyrillic 'a' when built with proper Unicode + // For the test, we check a player name that contains Unicode normalization differences + AntiNameSpoofService.NameSpoofResult result = service.checkPlayerName( + "Admın", "10.0.0.1"); // Uses dotless i (U+0131) instead of normal i + + assertTrue(result.isSpoofed()); + } + + @Test + void antiNameSpoofShouldDetectInvisibleCharacters() { + AntiNameSpoofService service = new AntiNameSpoofService(); + // Insert zero-width space U+200B into the name + String spoofedName = "Admin​test"; + AntiNameSpoofService.NameSpoofResult result = service.checkPlayerName( + spoofedName, "10.0.0.2"); + + assertTrue(result.isSpoofed()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("INVISIBLE_CHARS"))); + } + + @Test + void antiNameSpoofShouldAllowNormalPlayerName() { + AntiNameSpoofService service = new AntiNameSpoofService(); + service.addProtectedName("Admin"); + + AntiNameSpoofService.NameSpoofResult result = service.checkPlayerName( + "Player123", "10.0.0.3"); + + assertTrue(result.isClean()); + } + + @Test + void antiNameSpoofShouldDetectLevenshteinSimilarity() { + AntiNameSpoofService service = new AntiNameSpoofService(); + service.addProtectedName("Administrator"); + + // "Administrat0r" (using zero instead of 'o') is very similar + AntiNameSpoofService.NameSpoofResult result = service.checkPlayerName( + "Administrat0r", "10.0.0.4"); + + assertTrue(result.isSpoofed()); + assertTrue(result.getReasons().stream().anyMatch(r -> r.contains("SIMILARITY_SPOOF"))); + } + + @Test + void antiNameSpoofShouldBlockSpoofedName() { + AntiNameSpoofService service = new AntiNameSpoofService(); + service.addProtectedName("Notch"); + + service.checkPlayerName("N​otch", "10.0.0.5"); + service.checkPlayerName("NOTCH", "10.0.0.6"); + + var status = service.getStatus(); + assertTrue((Long) status.get("spoofAttempts") >= 1); + assertTrue((Integer) status.get("blockedNames") >= 1); + } +} diff --git a/src/test/java/com/aluer/security/V40AntiCheatTest.java b/src/test/java/com/aluer/security/V40AntiCheatTest.java new file mode 100644 index 0000000..25eff83 --- /dev/null +++ b/src/test/java/com/aluer/security/V40AntiCheatTest.java @@ -0,0 +1,728 @@ +package com.aluer.security; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * V4.0 反作弊扩展模块测试 + * 测试 6 个新增反作弊模块:AntiKillAura, AntiReach, AntiSpeed, AntiJesus, AntiNoFall, AntiScaffold + * + * 每个模块至少3个测试用例,覆盖正常行为、异常行为和 getStatus() 功能 + */ +class V40AntiCheatTest { + + // ======================================================================== + // AntiKillAuraService 测试 + // ======================================================================== + + /** + * 正常攻击行为 — 攻击单一目标,角度有变化,距离在合理范围 + */ + @Test + void killAuraNormalAttackReturnsClean() { + AntiKillAuraService svc = new AntiKillAuraService(); + Instant now = Instant.now(); + + // 连续攻击同一个目标 + AntiKillAuraService.KillAuraCheckResult r = null; + for (int i = 0; i < 5; i++) { + r = svc.detect("Player1", "uuid-1", "TargetA", + 45.0 + i * 10, 10.0 + i * 2, + 2.5, now.plusMillis(i * 500)); + } + + assertNotNull(r); + assertTrue(r.isClean()); + assertFalse(r.isFlagged()); + assertFalse(r.isSuspicious()); + } + + /** + * 短时间内攻击过多不同目标 — 应标记为可疑或flagged + */ + @Test + void killAuraRapidTargetSwitchDetected() { + AntiKillAuraService svc = new AntiKillAuraService(); + Instant now = Instant.now(); + + // 在3秒内切换5个不同目标 — 超过MAX_TARGET_SWITCHES(3) + String[] targets = {"A", "B", "C", "D", "E"}; + AntiKillAuraService.KillAuraCheckResult lastResult = null; + + for (int i = 0; i < targets.length; i++) { + lastResult = svc.detect("Player1", "uuid-1", targets[i], + 45.0 + i * 3, 10.0, + 2.5, now.plusMillis(i * 200)); + } + + assertNotNull(lastResult); + assertTrue(lastResult.isSuspicious() || lastResult.isFlagged()); + assertFalse(lastResult.isClean()); + } + + /** + * 攻击角度高度一致 — 模拟Aimbot(自动瞄准),应标记为flagged + */ + @Test + void killAuraAimbotAngleDetected() { + AntiKillAuraService svc = new AntiKillAuraService(); + Instant now = Instant.now(); + + AntiKillAuraService.KillAuraCheckResult lastResult = null; + + // 多次攻击角度几乎不变 — Aimbot特征 + for (int i = 0; i < 8; i++) { + lastResult = svc.detect("Player2", "uuid-2", "Target1", + 45.0 + (i * 0.5 % 1.0), // yaw几乎不变 + 10.0 + (i * 0.3 % 0.5), // pitch几乎不变 + 2.0, now.plusMillis(i * 600)); + } + + assertNotNull(lastResult); + // 角度一致性应被检测 + assertTrue(lastResult.isSuspicious() || lastResult.isFlagged()); + } + + /** + * getStatus() 返回非空Map包含必要字段 + */ + @Test + void killAuraGetStatusReturnsValidMap() { + AntiKillAuraService svc = new AntiKillAuraService(); + svc.detect("P1", "u1", "T1", 45, 10, 2.0, Instant.now()); + + Map status = svc.getStatus(); + assertNotNull(status); + assertTrue(status.containsKey("totalChecks")); + assertTrue(status.containsKey("flaggedCount")); + assertTrue(status.containsKey("activeTrackedPlayers")); + assertNotNull(status.get("totalChecks")); + assertNotNull(status.get("flaggedCount")); + } + + /** + * 完美极限距离模式 — 多次在最大距离范围攻击 + */ + @Test + void killAuraPerfectReachPatternDetected() { + AntiKillAuraService svc = new AntiKillAuraService(); + Instant now = Instant.now(); + + AntiKillAuraService.KillAuraCheckResult lastResult = null; + + // 多次在2.8-3.0精确距离攻击 — perfect reach + for (int i = 0; i < 12; i++) { + lastResult = svc.detect("Player3", "uuid-3", "TargetX", + 30.0 + i * 5, 5.0, + 2.9, // 接近极限距离 + now.plusMillis(i * 400)); + } + + assertNotNull(lastResult); + assertTrue(lastResult.isSuspicious() || lastResult.isFlagged()); + } + + // ======================================================================== + // AntiReachService 测试 + // ======================================================================== + + /** + * 正常攻击距离 — 3.0方块以内 + */ + @Test + void reachNormalDistanceReturnsClean() { + AntiReachService svc = new AntiReachService(); + Instant now = Instant.now(); + + AntiReachService.ReachCheckResult r = svc.detect( + "Player1", "uuid-1", + 0, 64, 0, + 0, 64, 2.5, + 2.5, now); + + assertNotNull(r); + assertTrue(r.isClean()); + assertFalse(r.isFlagged()); + } + + /** + * 绝对超距攻击 — 超过6.0方块,应标记为flagged + */ + @Test + void reachAbsoluteExceedDetected() { + AntiReachService svc = new AntiReachService(); + Instant now = Instant.now(); + + // 攻击距离超过6.0 — 绝对超距 + AntiReachService.ReachCheckResult r = svc.detect( + "Hacker1", "uuid-h1", + 0, 64, 0, + 0, 64, 7.0, + 7.0, now); + + assertNotNull(r); + assertTrue(r.isFlagged()); + assertTrue(r.getReasons().size() > 0); + } + + /** + * 可疑超距 — 超过3.8但不到6.0方块 + */ + @Test + void reachSuspiciousDistanceDetected() { + AntiReachService svc = new AntiReachService(); + Instant now = Instant.now(); + + AntiReachService.ReachCheckResult r = svc.detect( + "Player2", "uuid-2", + 0, 64, 0, + 0, 64, 4.5, + 4.5, now); + + assertTrue(r.isSuspicious() || r.isFlagged()); + } + + /** + * 频繁超距攻击 — 多次超过阈值 + */ + @Test + void reachFrequentViolationFlagged() { + AntiReachService svc = new AntiReachService(); + Instant now = Instant.now(); + + AntiReachService.ReachCheckResult lastResult = null; + + // 多次超过3.8可疑阈值 + for (int i = 0; i < 3; i++) { + lastResult = svc.detect("Hacker2", "uuid-h2", + 0, 64, 0, + 0, 64, 4.0 + i * 0.2, + 4.0 + i * 0.2, now.plusMillis(i * 200)); + } + + assertNotNull(lastResult); + assertFalse(lastResult.isClean()); + } + + /** + * getStatus() 返回有效状态Map + */ + @Test + void reachGetStatusReturnsValidMap() { + AntiReachService svc = new AntiReachService(); + svc.detect("P1", "u1", 0, 64, 0, 0, 64, 2.0, 2.0, Instant.now()); + svc.detect("P1", "u1", 0, 64, 0, 0, 64, 7.0, 7.0, Instant.now()); + + Map status = svc.getStatus(); + assertNotNull(status); + assertTrue(status.containsKey("totalChecks")); + assertTrue(status.containsKey("violations")); + assertTrue(status.containsKey("recentViolations")); + assertNotNull(status.get("totalChecks")); + } + + // ======================================================================== + // AntiSpeedService 测试 + // ======================================================================== + + /** + * 正常步行速度 — 应返回clean + */ + @Test + void speedNormalWalkingReturnsClean() { + AntiSpeedService svc = new AntiSpeedService(); + Instant now = Instant.now(); + + // 模拟正常步行 ~4.0 m/s + AntiSpeedService.SpeedCheckResult r = svc.detect( + "Player1", "uuid-1", + 0, 64, 0, + 0.2, 64, 0.2, + true, 0.05, now); + + assertNotNull(r); + assertTrue(r.isClean()); + } + + /** + * 持续超速 — 超过7.0 m/s超过1秒 + */ + @Test + void speedSustainedOverspeedFlagged() { + AntiSpeedService svc = new AntiSpeedService(); + Instant now = Instant.now(); + + AntiSpeedService.SpeedCheckResult lastResult = null; + + // 持续超速,每次移动0.5方块,间隔50ms = 10 m/s + for (int i = 0; i < 20; i++) { + lastResult = svc.detect("Hacker1", "uuid-h1", + i * 0.5, 64, 0, + (i + 1) * 0.5, 64, 0, + true, 0.05, now.plusMillis(i * 50)); + } + + assertNotNull(lastResult); + assertTrue(lastResult.isSuspicious() || lastResult.isFlagged()); + } + + /** + * GroundSpoof检测 — 声称在地面但垂直速度异常 + */ + @Test + void speedGroundSpoofDetected() { + AntiSpeedService svc = new AntiSpeedService(); + Instant now = Instant.now(); + + // onGround=true但Y轴有显著变化 + AntiSpeedService.SpeedCheckResult r = svc.detect( + "Hacker2", "uuid-h2", + 0, 64, 0, + 0.2, 64.5, 0.2, + true, 0.05, now); + + assertTrue(r.isSuspicious() || r.isFlagged()); + } + + /** + * 机械性恒速模式 — 速度恒定 + */ + @Test + void speedMechanicalPatternDetected() { + AntiSpeedService svc = new AntiSpeedService(); + Instant now = Instant.now(); + + AntiSpeedService.SpeedCheckResult lastResult = null; + + // 每次都以完全相同速度移动 — 机械性Speed hack + for (int i = 0; i < 12; i++) { + lastResult = svc.detect("Hacker3", "uuid-h3", + i * 0.3, 64, 0, + i * 0.3 + 0.3, 64, 0, + true, 0.05, now.plusMillis(i * 50)); + } + + assertNotNull(lastResult); + assertTrue(lastResult.isSuspicious() || lastResult.isFlagged()); + } + + /** + * getStatus() 返回包含统计信息的Map + */ + @Test + void speedGetStatusReturnsValidMap() { + AntiSpeedService svc = new AntiSpeedService(); + svc.detect("P1", "u1", 0, 64, 0, 0.2, 64, 0.2, true, 0.05, Instant.now()); + svc.detect("P1", "u1", 0.2, 64, 0.2, 0.4, 64, 0.4, true, 0.05, Instant.now()); + + Map status = svc.getStatus(); + assertNotNull(status); + assertTrue(status.containsKey("totalSamples")); + assertTrue(status.containsKey("speedViolations")); + assertTrue(status.containsKey("averageSpeed")); + assertTrue(status.containsKey("maxSpeed")); + } + + // ======================================================================== + // AntiJesusService 测试 + // ======================================================================== + + /** + * 正常在水中游泳 — 不在液体表面行走 + */ + @Test + void jesusNormalSwimmingReturnsClean() { + AntiJesusService svc = new AntiJesusService(); + Instant now = Instant.now(); + + // 玩家在水中(Y=61.5, 水面Y=63)— 正在下沉/游泳中 + AntiJesusService.JesusCheckResult r = svc.detect( + "Player1", "uuid-1", + 61.5, 63.0, + "WATER", false, true, now); + + assertNotNull(r); + assertTrue(r.isClean()); + } + + /** + * 水上行走 — 站在水面高度且声称在地面 + */ + @Test + void jesusWalkingOnWaterSuspicious() { + AntiJesusService svc = new AntiJesusService(); + Instant now = Instant.now(); + + AntiJesusService.JesusCheckResult lastResult = null; + + // 连续站在水面上 — Jesus hack特征 + for (int i = 0; i < 5; i++) { + lastResult = svc.detect("Hacker1", "uuid-h1", + 63.1, 63.0, + "WATER", true, true, now.plusMillis(i * 500)); + } + + assertNotNull(lastResult); + assertTrue(lastResult.isSuspicious() || lastResult.isFlagged()); + } + + /** + * 持续水上行走超过1秒+抖动模式 — 双规则命中应标记为flagged + */ + @Test + void jesusSustainedWaterWalkingFlagged() { + AntiJesusService svc = new AntiJesusService(); + Instant now = Instant.now(); + + AntiJesusService.JesusCheckResult lastResult = null; + + // 持续站在水面上超过1秒 + 抖动振荡(触发双规则) + double[] yValues = {63.0, 63.05, 62.95, 63.02, 62.98, 63.03, 62.97, 63.01, 63.04, 62.96}; + for (int i = 0; i < yValues.length; i++) { + lastResult = svc.detect("Hacker2", "uuid-h2", + yValues[i], 63.0, + "WATER", true, true, now.plusMillis(i * 200)); + } + + assertNotNull(lastResult); + assertTrue(lastResult.isFlagged()); + + assertTrue(lastResult.getReasons().stream() + .anyMatch(r -> r.contains("SUSTAINED_JESUS"))); + } + + /** + * 抖动模式检测 — Y坐标在水面附近振荡 + */ + @Test + void jesusJitterPatternDetected() { + AntiJesusService svc = new AntiJesusService(); + Instant now = Instant.now(); + + AntiJesusService.JesusCheckResult lastResult = null; + + // 模拟抖动:Y值在水面附近反复小幅变化 + double[] yValues = {63.0, 63.05, 62.95, 63.02, 62.98, 63.03, 62.97, 63.01}; + for (int i = 0; i < yValues.length; i++) { + lastResult = svc.detect("Hacker3", "uuid-h3", + yValues[i], 63.0, + "WATER", true, true, now.plusMillis(i * 300)); + } + + assertNotNull(lastResult); + assertTrue(lastResult.isSuspicious() || lastResult.isFlagged()); + } + + /** + * getStatus() 返回有效状态Map + */ + @Test + void jesusGetStatusReturnsValidMap() { + AntiJesusService svc = new AntiJesusService(); + svc.detect("P1", "u1", 62.0, 63.0, "WATER", false, true, Instant.now()); + + Map status = svc.getStatus(); + assertNotNull(status); + assertTrue(status.containsKey("totalChecks")); + assertTrue(status.containsKey("jesusViolations")); + assertTrue(status.containsKey("activeTrackedPlayers")); + } + + // ======================================================================== + // AntiNoFallService 测试 + // ======================================================================== + + /** + * 正常坠落并受伤害 — 应返回clean + */ + @Test + void noFallNormalFallWithDamageReturnsClean() { + AntiNoFallService svc = new AntiNoFallService(); + Instant now = Instant.now(); + + // 记录从Y=80坠落 + svc.recordFallStart("Player1", "uuid-1", 80.0, "survival", now); + // 著陆在Y=70,受到伤害 + AntiNoFallService.NoFallCheckResult r = svc.recordLanding( + "Player1", "uuid-1", 70.0, + true, 5.0, // tookDamage=true + false, false, false, false, false, false, // 无合法无伤害减免 + now.plusSeconds(2)); + + assertNotNull(r); + assertTrue(r.isClean()); + } + + /** + * 短距离坠落不受伤 — 正常行为 + */ + @Test + void noFallShortFallNoDamageNormal() { + AntiNoFallService svc = new AntiNoFallService(); + Instant now = Instant.now(); + + // 坠落不足3方块 — 不会产生伤害 + svc.recordFallStart("Player2", "uuid-2", 66.0, "survival", now); + AntiNoFallService.NoFallCheckResult r = svc.recordLanding( + "Player2", "uuid-2", 65.0, + false, 0, + false, false, false, false, false, false, + now.plusMillis(500)); + + assertNotNull(r); + assertTrue(r.isClean()); + } + + /** + * 高空坠落无伤害 — NoFall hack特征 + */ + @Test + void noFallHighFallNoDamageFlagged() { + AntiNoFallService svc = new AntiNoFallService(); + Instant now = Instant.now(); + + // 从12方块高度坠落但不受伤 — 无合法理由 + svc.recordFallStart("Hacker1", "uuid-h1", 82.0, "survival", now); + AntiNoFallService.NoFallCheckResult r = svc.recordLanding( + "Hacker1", "uuid-h1", 70.0, + false, 0, // 没有受到伤害 + false, false, false, false, false, false, + now.plusSeconds(3)); + + assertNotNull(r); + assertTrue(r.isSuspicious() || r.isFlagged()); + } + + /** + * 合法无伤害 — 落入水中 + */ + @Test + void noFallWaterLandingNoDamageIsLegitimate() { + AntiNoFallService svc = new AntiNoFallService(); + Instant now = Instant.now(); + + // 掉入水中 — 合法免疫摔落伤害 + svc.recordFallStart("Player3", "uuid-3", 80.0, "survival", now); + AntiNoFallService.NoFallCheckResult r = svc.recordLanding( + "Player3", "uuid-3", 62.0, + false, 0, + true, // isInLiquid=true (水中) + false, false, false, false, false, + now.plusSeconds(2)); + + // 落入水中是合法的无伤害情况,不应标记 + assertNotNull(r); + assertTrue(r.isClean()); + } + + /** + * 合法无伤害 — 鞘翅飞行 + */ + @Test + void noFallElytraFlyingIsLegitimate() { + AntiNoFallService svc = new AntiNoFallService(); + Instant now = Instant.now(); + + // 鞘翅飞行期间不受伤 — 合法 + svc.recordFallStart("Player4", "uuid-4", 100.0, "survival", now); + AntiNoFallService.NoFallCheckResult r = svc.recordLanding( + "Player4", "uuid-4", 70.0, + false, 0, + false, false, false, false, false, + true, // isElytraFlying=true + now.plusSeconds(5)); + + assertNotNull(r); + assertTrue(r.isClean()); + } + + /** + * 粘液块上无伤害 — 合法 + */ + @Test + void noFallSlimeBlockLandingIsLegitimate() { + AntiNoFallService svc = new AntiNoFallService(); + Instant now = Instant.now(); + + // 落在粘液块上 — 合法免疫摔落伤害 + svc.recordFallStart("Player5", "uuid-5", 80.0, "survival", now); + AntiNoFallService.NoFallCheckResult r = svc.recordLanding( + "Player5", "uuid-5", 60.0, + false, 0, + false, true, // isOnSlimeBlock=true + false, false, false, false, + now.plusSeconds(2)); + + assertNotNull(r); + assertTrue(r.isClean()); + } + + /** + * getStatus() 返回有效状态Map + */ + @Test + void noFallGetStatusReturnsValidMap() { + AntiNoFallService svc = new AntiNoFallService(); + Instant now = Instant.now(); + + svc.recordFallStart("P1", "u1", 80.0, "survival", now); + svc.recordLanding("P1", "u1", 70.0, true, 5.0, + false, false, false, false, false, false, now.plusSeconds(2)); + + Map status = svc.getStatus(); + assertNotNull(status); + assertTrue(status.containsKey("totalFalls")); + assertTrue(status.containsKey("noFallViolations")); + assertTrue(status.containsKey("falsePositives")); + } + + // ======================================================================== + // AntiScaffoldService 测试 + // ======================================================================== + + /** + * 正常方块放置 — 间隔合理,角度正常 + */ + @Test + void scaffoldNormalPlacementReturnsClean() { + AntiScaffoldService svc = new AntiScaffoldService(); + Instant now = Instant.now(); + + AntiScaffoldService.ScaffoldCheckResult r = svc.detect( + "Player1", "uuid-1", + 10, 63, 10, + 180, 30, // yaw=180, pitch=30(正常视角) + false, true, "COBBLESTONE", + now); + + assertNotNull(r); + assertTrue(r.isClean()); + } + + /** + * 高速连续放置 — 超过5方块/秒持续3秒 + */ + @Test + void scaffoldRapidPlacementFlagged() { + AntiScaffoldService svc = new AntiScaffoldService(); + Instant now = Instant.now(); + + AntiScaffoldService.ScaffoldCheckResult lastResult = null; + + // 高速放置方块:每次间隔100ms = 10方块/秒 + for (int i = 0; i < 30; i++) { + lastResult = svc.detect("Hacker1", "uuid-h1", + 10 + i, 63, 10, + 90, -85, // pitch=-85 (近乎垂直向下看) + true, true, "COBBLESTONE", + now.plusMillis(i * 100)); + } + + assertNotNull(lastResult); + assertTrue(lastResult.isSuspicious() || lastResult.isFlagged()); + } + + /** + * Scaffold角度检测 — 向下看并精确放置 + */ + @Test + void scaffoldDownwardAngleAndPlacementPattern() { + AntiScaffoldService svc = new AntiScaffoldService(); + Instant now = Instant.now(); + + AntiScaffoldService.ScaffoldCheckResult lastResult = null; + + // 向下看 (pitch接近-90) + 脚下放置 — 典型Scaffold + for (int i = 0; i < 10; i++) { + lastResult = svc.detect("Hacker2", "uuid-h2", + 10 + i, 63, 10, + 0, -88, // 几乎是正下方 + true, true, "COBBLESTONE", + now.plusMillis(i * 150)); + } + + assertNotNull(lastResult); + assertTrue(lastResult.isSuspicious() || lastResult.isFlagged()); + } + + /** + * Safewalk检测 — 连续边缘放置 + */ + @Test + void scaffoldSafewalkPattern() { + AntiScaffoldService svc = new AntiScaffoldService(); + Instant now = Instant.now(); + + AntiScaffoldService.ScaffoldCheckResult lastResult = null; + + // 大量在脚下放置方块(Safewalk行为) + for (int i = 0; i < 20; i++) { + lastResult = svc.detect("Hacker3", "uuid-h3", + 10 + i, 63, 10, + 90, -75, + true, // isPlaceBelowPlayer=true(脚下放置) + false, "COBBLESTONE", + now.plusMillis(i * 80)); + } + + assertNotNull(lastResult); + assertTrue(lastResult.isSuspicious() || lastResult.isFlagged()); + } + + /** + * getStatus() 返回有效状态Map + */ + @Test + void scaffoldGetStatusReturnsValidMap() { + AntiScaffoldService svc = new AntiScaffoldService(); + svc.detect("P1", "u1", 10, 63, 10, 180, 30, + false, true, "STONE", Instant.now()); + svc.detect("P1", "u1", 11, 63, 10, 180, 32, + false, true, "STONE", Instant.now()); + + Map status = svc.getStatus(); + assertNotNull(status); + assertTrue(status.containsKey("totalPlacements")); + assertTrue(status.containsKey("scaffoldViolations")); + assertTrue(status.containsKey("trackedBuilders")); + } + + // ======================================================================== + // 模块关闭验证测试 — 通过直接创建带无参构造的服务实例测试 + // 无参构造函数使用 new ServerGuardConfig(),默认所有开关为true + // 此处验证配置默认为true时服务正常执行检测 + // ======================================================================== + + /** + * All services instantiable and return clean for trivial inputs + */ + @Test + void allServicesInstantiableAndFunctional() { + // 验证所有服务可通过无参构造实例化 + AntiKillAuraService killAura = new AntiKillAuraService(); + AntiReachService reach = new AntiReachService(); + AntiSpeedService speed = new AntiSpeedService(); + AntiJesusService jesus = new AntiJesusService(); + AntiNoFallService noFall = new AntiNoFallService(); + AntiScaffoldService scaffold = new AntiScaffoldService(); + + assertNotNull(killAura); + assertNotNull(reach); + assertNotNull(speed); + assertNotNull(jesus); + assertNotNull(noFall); + assertNotNull(scaffold); + + // 验证所有服务的 getStatus() 都可调用 + assertNotNull(killAura.getStatus()); + assertNotNull(reach.getStatus()); + assertNotNull(speed.getStatus()); + assertNotNull(jesus.getStatus()); + assertNotNull(noFall.getStatus()); + assertNotNull(scaffold.getStatus()); + } +} diff --git a/src/test/java/com/aluer/security/V40ChatSecurityTest.java b/src/test/java/com/aluer/security/V40ChatSecurityTest.java new file mode 100644 index 0000000..6906848 --- /dev/null +++ b/src/test/java/com/aluer/security/V40ChatSecurityTest.java @@ -0,0 +1,361 @@ +package com.aluer.security; + +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class V40ChatSecurityTest { + + // ================================================================ + // ChatFloodProtectionService Tests + // ================================================================ + + @Test + void chatFloodDetectsShortWindowFlood() { + ChatFloodProtectionService cf = new ChatFloodProtectionService(); + // 3秒窗口内快速发送6条消息应触发洪水检测 + for (int i = 0; i < 6; i++) { + cf.check("Player1", "Test message " + i); + } + // 第7条应该被拦截 + ChatFloodProtectionService.CheckResult r = cf.check("Player1", "Another message"); + assertTrue(r.isBlocked(), "6条消息在3秒窗口内应触发洪水检测"); + assertFalse(r.getReasons().isEmpty()); + } + + @Test + void chatFloodAllowsNormalChatRate() { + ChatFloodProtectionService cf = new ChatFloodProtectionService(); + // 发送2条消息不应触发洪水 + ChatFloodProtectionService.CheckResult r1 = cf.check("Player2", "Hello"); + assertTrue(r1.isClean()); + ChatFloodProtectionService.CheckResult r2 = cf.check("Player2", "How are you?"); + assertTrue(r2.isClean()); + // 验证计数器运作 + Map status = cf.getStatus(); + assertTrue((Long) status.get("totalMessages") >= 2); + } + + @Test + void chatFloodDetectsUnicodeFlood() { + ChatFloodProtectionService cf = new ChatFloodProtectionService(); + // 构建超过50%为CJK特殊字符的长消息 + StringBuilder unicodeMsg = new StringBuilder(); + for (int i = 0; i < 50; i++) { + unicodeMsg.append("测试测试测试测试"); // CJK字符 + } + ChatFloodProtectionService.CheckResult r = cf.check("Player3", unicodeMsg.toString()); + assertTrue(r.isBlocked(), "超过50%CJK字符应触发Unicode洪水检测"); + } + + @Test + void chatFloodDetectsSimilarMessages() { + ChatFloodProtectionService cf = new ChatFloodProtectionService(); + // 连续3条高度相似的消息(编辑距离<3) + cf.check("Player4", "Selling cheap items"); + cf.check("Player4", "Selling cheap items!"); // 只差1个字符 + ChatFloodProtectionService.CheckResult r = cf.check("Player4", "Selling cheap items!!"); // 只差2个字符 + assertTrue(r.isBlocked(), "连续3条高度相似的消息应触发重复洪水检测"); + } + + @Test + void chatFloodStatusContainsExpectedFields() { + ChatFloodProtectionService cf = new ChatFloodProtectionService(); + cf.check("Player5", "Message one"); + cf.check("Player5", "Message two"); + + Map status = cf.getStatus(); + assertNotNull(status.get("totalMessages")); + assertNotNull(status.get("mutedPlayers")); + assertNotNull(status.get("floodEvents")); + assertNotNull(status.get("activeTrackers")); + assertTrue(status.get("enabled") instanceof Boolean); + } + + // ================================================================ + // AntiAdvertisementService Tests + // ================================================================ + + @Test + void antiAdDetectsIpAddress() { + AntiAdvertisementService ad = new AntiAdvertisementService(); + AntiAdvertisementService.CheckResult r = ad.check("Player1", + "Join my server at 123.45.67.89:25565!"); + assertTrue(r.isBlocked(), "消息中包含IP地址应被检测为广告"); + } + + @Test + void antiAdDetectsDomain() { + AntiAdvertisementService ad = new AntiAdvertisementService(); + AntiAdvertisementService.CheckResult r = ad.check("Player2", + "Check out myserver.com for great gameplay!"); + assertTrue(r.isBlocked(), "消息中包含.com域名应被检测为广告"); + } + + @Test + void antiAdDetectsDiscordInvite() { + AntiAdvertisementService ad = new AntiAdvertisementService(); + AntiAdvertisementService.CheckResult r = ad.check("Player3", + "Join our discord.gg/abcdef for giveaways!"); + assertTrue(r.isBlocked(), "消息中包含Discord邀请应被检测为广告"); + } + + @Test + void antiAdDetectsQQGroup() { + AntiAdvertisementService ad = new AntiAdvertisementService(); + AntiAdvertisementService.CheckResult r = ad.check("Player4", + "加群一起玩:12345678"); + assertTrue(r.isBlocked(), "消息中包含QQ群号应被检测为广告"); + } + + @Test + void antiAdAllowsNormalMessage() { + AntiAdvertisementService ad = new AntiAdvertisementService(); + AntiAdvertisementService.CheckResult r = ad.check("Player5", + "Hey guys, how is everyone doing today?"); + assertTrue(r.isClean(), "正常聊天消息不应被拦截"); + } + + @Test + void antiAdWhitelistIpPasses() { + AntiAdvertisementService ad = new AntiAdvertisementService(); + ad.addWhitelistedIp("192.168.1.1"); + AntiAdvertisementService.CheckResult r = ad.check("Player6", + "Server IP is 192.168.1.1:25565"); + assertTrue(r.isClean(), "白名单IP不应被拦截"); + } + + @Test + void antiAdStatusHasCounters() { + AntiAdvertisementService ad = new AntiAdvertisementService(); + ad.check("Player7", "Join 10.0.0.1"); + Map status = ad.getStatus(); + assertTrue((Long) status.get("totalMessages") >= 1); + assertNotNull(status.get("adsDetected")); + assertNotNull(status.get("blockedMessages")); + } + + // ================================================================ + // AntiPhishingLinkService Tests + // ================================================================ + + @Test + void antiPhishDetectsKnownPhishingDomain() { + AntiPhishingLinkService ap = new AntiPhishingLinkService(); + AntiPhishingLinkService.CheckResult r = ap.check("Player1", + "Verify your account at http://mc-login.com/verify"); + assertTrue(r.isBlocked(), "已知钓鱼域名应被检测"); + } + + @Test + void antiPhishDetectsFakeMojangDomain() { + AntiPhishingLinkService ap = new AntiPhishingLinkService(); + AntiPhishingLinkService.CheckResult r = ap.check("Player2", + "Free cape at http://minecraft-login.net/free"); + assertTrue(r.isBlocked(), "包含minecraft关键字的非官方域名应被检测"); + } + + @Test + void antiPhishDetectsShortUrl() { + AntiPhishingLinkService ap = new AntiPhishingLinkService(); + AntiPhishingLinkService.CheckResult r = ap.check("Player3", + "Claim your reward: http://bit.ly/freemc123"); + assertTrue(r.isBlocked(), "短链接应被标记"); + } + + @Test + void antiPhishDetectsFreeMcScam() { + AntiPhishingLinkService ap = new AntiPhishingLinkService(); + AntiPhishingLinkService.CheckResult r = ap.check("Player4", + "Get free minecraft account at http://steal-account.info"); + assertTrue(r.isBlocked(), "免费Minecraft骗局应被检测"); + } + + @Test + void antiPhishDetectsMismatchedText() { + AntiPhishingLinkService ap = new AntiPhishingLinkService(); + AntiPhishingLinkService.CheckResult r = ap.check("Player5", + "Mojang官方验证请访问: http://phish-site.com/login"); + assertTrue(r.isBlocked(), "声称官方但链接非官方应被检测为文本链接不匹配"); + } + + @Test + void antiPhishAllowsNormalMessage() { + AntiPhishingLinkService ap = new AntiPhishingLinkService(); + AntiPhishingLinkService.CheckResult r = ap.check("Player6", + "Hey everyone! Let's build something cool today."); + assertTrue(r.isClean(), "不含URL的正常消息不应被拦截"); + } + + @Test + void antiPhishStatusHasCounters() { + AntiPhishingLinkService ap = new AntiPhishingLinkService(); + ap.check("Player7", "Visit http://mc-login.com"); + Map status = ap.getStatus(); + assertNotNull(status.get("totalUrls")); + assertNotNull(status.get("phishingDetected")); + assertNotNull(status.get("shortUrlsResolved")); + } + + // ================================================================ + // AntiCommandAbuseService Tests + // ================================================================ + + @Test + void antiCmdDetectsCommandFlood() { + AntiCommandAbuseService ac = new AntiCommandAbuseService(); + // 10秒窗口内快速发送12条命令应触发洪水检测 + for (int i = 0; i < 12; i++) { + ac.check("Player1", "/tp " + i, "tp"); + } + // 第13条应被拦截 + AntiCommandAbuseService.CheckResult r = ac.check("Player1", "/tp 13", "tp"); + assertTrue(r.isBlocked(), "10秒内超过10条命令应触发洪水检测"); + } + + @Test + void antiCmdDetectsShellInjection() { + AntiCommandAbuseService ac = new AntiCommandAbuseService(); + AntiCommandAbuseService.CheckResult r = ac.check("Player2", + "/say hello; rm -rf /", "say", "; rm -rf /", true); + assertTrue(r.isBlocked(), "包含Shell注入字符的命令应被检测"); + } + + @Test + void antiCmdDetectsPluginScanning() { + AntiCommandAbuseService ac = new AntiCommandAbuseService(); + // 连续执行不存在的命令(模拟插件扫描) + for (int i = 0; i < 6; i++) { + ac.check("Player3", "/nonexistent" + i, "nonexistent" + i, "", false); + } + AntiCommandAbuseService.CheckResult r = ac.check("Player3", + "/nonexistent6", "nonexistent6", "", false); + assertTrue(r.isBlocked(), "连续执行不存在命令应被检测为插件扫描"); + } + + @Test + void antiCmdAllowsNormalCommands() { + AntiCommandAbuseService ac = new AntiCommandAbuseService(); + AntiCommandAbuseService.CheckResult r = ac.check("Player4", + "/home", "home", null, true); + assertTrue(r.isClean(), "正常命令不应被拦截"); + } + + @Test + void antiCmdDetectsDollarSubCommandInjection() { + AntiCommandAbuseService ac = new AntiCommandAbuseService(); + AntiCommandAbuseService.CheckResult r = ac.check("Player5", + "/say $(cat /etc/passwd)", "say", "$(cat /etc/passwd)", true); + assertTrue(r.isBlocked(), "$() Shell注入应被检测"); + } + + @Test + void antiCmdStatusHasCommandStats() { + AntiCommandAbuseService ac = new AntiCommandAbuseService(); + ac.check("Player6", "/home", "home"); + ac.check("Player6", "/home", "home"); + ac.check("Player6", "/spawn", "spawn"); + + Map status = ac.getStatus(); + assertNotNull(status.get("totalCommands")); + assertNotNull(status.get("blockedCommands")); + assertNotNull(status.get("commandStats")); + } + + // ================================================================ + // PlayerPrivacyService Tests + // ================================================================ + + @Test + void privacyAnonymizesIpAddress() { + PlayerPrivacyService pp = new PlayerPrivacyService(); + String result = pp.anonymizeIp("Player connected from 192.168.1.100"); + assertTrue(result.contains("xxx.xxx"), "IP地址应被脱敏为 xxx.xxx"); + assertTrue(result.contains("192.168"), "IP前两位应保留"); + assertFalse(result.contains("1.100"), "IP后两位应被隐藏"); + } + + @Test + void privacyAnonymizesMultipleIps() { + PlayerPrivacyService pp = new PlayerPrivacyService(); + String result = pp.anonymizeIp("Connections: 10.0.0.1 and 172.16.5.42"); + assertTrue(result.contains("10.0.xxx.xxx"), "第一个IP应被脱敏"); + assertTrue(result.contains("172.16.xxx.xxx"), "第二个IP应被脱敏"); + } + + @Test + void privacyRegionLevelRoundsCoordinates() { + PlayerPrivacyService pp = new PlayerPrivacyService(); + pp.setCoordinateAnonymizationLevel( + PlayerPrivacyService.CoordinateAnonymizationLevel.REGION); + String result = pp.anonymizeCoordinates(128.5, 64.1, -256.8); + // 128.5 -> 130, 64.1 -> 60, -256.8 -> -260 + assertTrue(result.contains("130"), "X坐标应四舍五入到10"); + assertTrue(result.contains("60"), "Y坐标应四舍五入到10"); + assertTrue(result.contains("-260"), "Z坐标应四舍五入到10"); + } + + @Test + void privacyHiddenLevelHidesCoordinates() { + PlayerPrivacyService pp = new PlayerPrivacyService(); + pp.setCoordinateAnonymizationLevel( + PlayerPrivacyService.CoordinateAnonymizationLevel.HIDDEN); + String result = pp.anonymizeCoordinates(100, 64, 200); + assertEquals("[***, ***, ***]", result, "HIDDEN级别应完全隐藏坐标"); + } + + @Test + void privacyDataExportRequest() { + PlayerPrivacyService pp = new PlayerPrivacyService(); + String requestId = pp.requestDataExport("Player1", "admin"); + assertNotNull(requestId, "导出请求应返回有效ID"); + + List requests = pp.getPendingRequests(); + assertEquals(1, requests.size()); + assertEquals("Player1", requests.get(0).getPlayerName()); + assertEquals("EXPORT", requests.get(0).getType()); + } + + @Test + void privacyDataDeletionRequest() { + PlayerPrivacyService pp = new PlayerPrivacyService(); + String requestId = pp.requestDataDeletion("Player2", "player"); + assertNotNull(requestId, "删除请求应返回有效ID"); + + Map status = pp.getStatus(); + assertTrue(((Number) status.get("pendingDataRequests")).intValue() >= 1); + } + + @Test + void privacyAuditRecordsAccess() { + PlayerPrivacyService pp = new PlayerPrivacyService(); + pp.auditAccess("dashboard-api", "Player3", "IP_ADDRESS"); + pp.auditAccess("dashboard-api", "Player3", "COORDINATES"); + + List audits = pp.getAuditForPlayer("Player3"); + assertEquals(2, audits.size(), "应记录2条审计日志"); + assertEquals("IP_ADDRESS", audits.get(0).getDataType()); + assertEquals("COORDINATES", audits.get(1).getDataType()); + } + + @Test + void privacyPrefersNonNullAudit() { + PlayerPrivacyService pp = new PlayerPrivacyService(); + List audits = pp.getAuditForPlayer("NonExistent"); + assertNotNull(audits, "不存在的玩家应返回空列表而非null"); + assertTrue(audits.isEmpty()); + } + + @Test + void privacyStatusHasExpectedFields() { + PlayerPrivacyService pp = new PlayerPrivacyService(); + pp.anonymizeIp("Player from 1.2.3.4"); + + Map status = pp.getStatus(); + assertNotNull(status.get("anonymizedEntries")); + assertNotNull(status.get("pendingDataRequests")); + assertNotNull(status.get("privacyViolations")); + assertNotNull(status.get("coordinateLevel")); + assertNotNull(status.get("retentionDays")); + } +} diff --git a/src/test/java/com/aluer/security/V40PlayerBehaviorTest.java b/src/test/java/com/aluer/security/V40PlayerBehaviorTest.java new file mode 100644 index 0000000..c565eb7 --- /dev/null +++ b/src/test/java/com/aluer/security/V40PlayerBehaviorTest.java @@ -0,0 +1,533 @@ +package com.aluer.security; + +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * V4.0 玩家行为安全模块测试 —— 每个模块至少 3 个测试(正常/异常/状态)。 + * + * 覆盖的模块: + * 1. AntiNukerService — 快速破坏检测 + * 2. AntiAutoClickerService — 自动点击检测 + * 3. AntiChestStealService — 自动偷箱检测 + * 4. AntiAutoFishService — 自动钓鱼检测 + * 5. AntiInventoryManipulationService — 背包操作异常检测 + * 6. AntiBaritoneService — AI 寻路机器人检测 + */ +class V40PlayerBehaviorTest { + + // ==================== AntiNukerService Tests ==================== + + /** + * 正常玩家的方块破坏行为应返回 clean。 + * 正常挖掘速度约 0.3-0.4 秒/块,500ms 内破坏 1-2 个方块是合理的。 + */ + @Test + void antiNukerNormalBreakingReturnsClean() { + AntiNukerService service = new AntiNukerService(); + + // 在 500ms 间隔内只破坏 1 个方块(正常挖矿节奏) + AntiNukerService.DetectionResult r = service.detect( + "NormalPlayer", "uuid-001", "STONE", 10, 64, 10, "world"); + + assertTrue(r.isClean(), "Normal breaking should be clean"); + assertFalse(r.isFlagged()); + } + + /** + * 快速破坏模式(Nuker)应被检测为异常。 + * 在 500ms 窗口内破坏超过 3 个方块属于 Nuker 行为。 + */ + @Test + void antiNukerRapidBreakingDetectsNuker() { + AntiNukerService service = new AntiNukerService(); + + // 模拟 500ms 内破坏 6 个方块(Nuker 特征) + for (int i = 0; i < 6; i++) { + service.detect("NukerBot", "uuid-002", "STONE", + i, 64, i, "world"); + } + + long violations = service.getNukerViolations(); + assertTrue(violations > 0 || service.getTotalBreaks() >= 6, + "Rapid breaking should be detected as nuker violation"); + } + + /** + * 同 tick 破坏多块(多目标同时破坏)应被标记为 flagged。 + * 这是 Nuker 的终极特征——只有发包修改才能在同一 tick 破坏多个方块。 + */ + @Test + void antiNukerMultiTargetSameTickIsFlagged() { + AntiNukerService service = new AntiNukerService(); + + // 在极短时间内破坏 3 个方块,模拟同一 tick 多目标破坏 + service.detect("TickHacker", "uuid-003", "STONE", 0, 64, 0, "world"); + service.detect("TickHacker", "uuid-003", "STONE", 1, 64, 1, "world"); + service.detect("TickHacker", "uuid-003", "STONE", 2, 64, 2, "world"); + + AntiNukerService.DetectionResult r = service.detect( + "TickHacker", "uuid-003", "STONE", 3, 64, 3, "world"); + assertTrue(r.isFlagged() || r.isSuspicious(), + "Multiple blocks in same tick should be suspicious or flagged"); + } + + /** + * 分散破坏模式——大范围破坏应被检测。 + * Nuker 在大范围同时破坏,正常玩家挖掘范围小。 + */ + @Test + void antiNukerScatteredBreakingDetected() { + AntiNukerService service = new AntiNukerService(); + + // 在大范围内快速破坏方块 + service.detect("ScatterBot", "uuid-004", "STONE", 0, 64, 0, "world"); + service.detect("ScatterBot", "uuid-004", "STONE", 10, 64, 0, "world"); + service.detect("ScatterBot", "uuid-004", "STONE", 0, 64, 10, "world"); + service.detect("ScatterBot", "uuid-004", "STONE", 10, 64, 10, "world"); + AntiNukerService.DetectionResult r = service.detect( + "ScatterBot", "uuid-004", "STONE", 20, 64, 20, "world"); + + // 分散模式可能会触发 suspicious + assertNotNull(r); + } + + /** + * getStatus() 应返回包含关键指标的 LinkedHashMap。 + */ + @Test + void antiNukerStatusReturnsExpectedFields() { + AntiNukerService service = new AntiNukerService(); + service.detect("StatusTest", "uuid-005", "STONE", 0, 64, 0, "world"); + service.detect("StatusTest", "uuid-005", "STONE", 1, 64, 1, "world"); + + Map status = service.getStatus(); + + assertNotNull(status, "Status should not be null"); + assertTrue(status.containsKey("totalBreaks"), "Should contain totalBreaks"); + assertTrue(status.containsKey("nukerViolations"), "Should contain nukerViolations"); + assertTrue(status.containsKey("trackedPlayers"), "Should contain trackedPlayers"); + assertTrue(status.containsKey("flaggedPlayers"), "Should contain flaggedPlayers"); + assertTrue(status.containsKey("enabled"), "Should contain enabled"); + assertTrue(status.get("totalBreaks") instanceof Long); + assertTrue((Long) status.get("totalBreaks") >= 2); + } + + // ==================== AntiAutoClickerService Tests ==================== + + /** + * 正常点击频率应返回 clean(CPS < 15)。 + */ + @Test + void antiAutoClickerNormalCpsReturnsClean() { + AntiAutoClickerService service = new AntiAutoClickerService(); + + // 正常 CPS:在 1 秒内有 10 次点击(正常上限内) + for (int i = 0; i < 10; i++) { + AntiAutoClickerService.DetectionResult r = service.detect( + "NormalPlayer", "uuid-010", "LEFT_CLICK"); + // 前几个点击可能因样本不足返回 clean + if (i >= 9) { + assertTrue(r.isClean() || r.isSuspicious()); + assertFalse(r.isFlagged()); + } + } + } + + /** + * 超高 CPS(> 20)应被检测为异常。 + */ + @Test + void antiAutoClickerHighCpsDetected() { + AntiAutoClickerService service = new AntiAutoClickerService(); + + // 在短时间内大量点击模拟高 CPS + for (int i = 0; i < 30; i++) { + service.detect("ClickerBot", "uuid-011", "LEFT_CLICK"); + } + + long highCps = service.getHighCpsCount(); + assertTrue(highCps > 0 || service.getMacroSuspected() > 0, + "High CPS should be detected"); + } + + /** + * 均匀点击间隔(低熵)应被检测为宏行为。 + */ + @Test + void antiAutoClickerUniformIntervalsDetectedAsMacro() { + AntiAutoClickerService service = new AntiAutoClickerService(); + + // 模拟均匀间隔的点击(宏特征) + for (int i = 0; i < 25; i++) { + service.detect("MacroBot", "uuid-012", "LEFT_CLICK"); + } + + // 至少应有高 CPS 检测或宏疑似 + assertTrue(service.getTotalPlayers() >= 1); + } + + /** + * getStatus() 应包含 totalPlayers、highCpsCount、macroSuspected。 + */ + @Test + void antiAutoClickerStatusReturnsExpectedFields() { + AntiAutoClickerService service = new AntiAutoClickerService(); + service.detect("StatusPlayer", "uuid-013", "LEFT_CLICK"); + + Map status = service.getStatus(); + + assertNotNull(status); + assertTrue(status.containsKey("totalPlayers"), "Should contain totalPlayers"); + assertTrue(status.containsKey("highCpsCount"), "Should contain highCpsCount"); + assertTrue(status.containsKey("macroSuspected"), "Should contain macroSuspected"); + assertTrue(status.containsKey("trackedPlayers"), "Should contain trackedPlayers"); + } + + // ==================== AntiChestStealService Tests ==================== + + /** + * 正常打开箱子应返回 clean。 + */ + @Test + void antiChestStealNormalOpenReturnsClean() { + AntiChestStealService service = new AntiChestStealService(); + + AntiChestStealService.DetectionResult r = service.detectOpen( + "NormalPlayer", "uuid-020", "CHEST", 100, 64, 100); + + assertTrue(r.isClean(), "Normal chest open should be clean"); + assertFalse(r.isFlagged()); + } + + /** + * 瞬间取走大量物品应被检测为 Chest Stealer。 + */ + @Test + void antiChestStealInstantLootDetected() { + AntiChestStealService service = new AntiChestStealService(); + + // 先记录打开事件 + service.detectOpen("Stealer", "uuid-021", "CHEST", 100, 64, 100); + + // 在打开后立即取走大量物品 + for (int i = 0; i < 8; i++) { + service.detectTakeItem("Stealer", "uuid-021", + "DIAMOND", 1, "CHEST", i); + } + + long instantLoot = service.getInstantLootCount(); + long violations = service.getStealViolations(); + assertTrue(instantLoot > 0 || violations > 0, + "Instant loot should be detected"); + } + + /** + * 高价值物品快速取走应被检测。 + */ + @Test + void antiChestStealHighValueFastTakeDetected() { + AntiChestStealService service = new AntiChestStealService(); + + service.detectOpen("Valuesteal", "uuid-022", "CHEST", 100, 64, 100); + + // 连续取走高价值物品 + AntiChestStealService.DetectionResult r1 = service.detectTakeItem( + "Valuesteal", "uuid-022", "DIAMOND", 1, "CHEST", 0); + AntiChestStealService.DetectionResult r2 = service.detectTakeItem( + "Valuesteal", "uuid-022", "NETHERITE_INGOT", 1, "CHEST", 1); + AntiChestStealService.DetectionResult r3 = service.detectTakeItem( + "Valuesteal", "uuid-022", "EMERALD", 1, "CHEST", 2); + AntiChestStealService.DetectionResult r4 = service.detectTakeItem( + "Valuesteal", "uuid-022", "TOTEM_OF_UNDYING", 1, "CHEST", 3); + AntiChestStealService.DetectionResult r5 = service.detectTakeItem( + "Valuesteal", "uuid-022", "ELYTRA", 1, "CHEST", 4); + + // 至少部分检测应返回 suspicious 或 flagged + assertNotNull(r1); + assertNotNull(r5); + } + + /** + * getStatus() 应包含 totalChestOpens、stealViolations、instantLootCount。 + */ + @Test + void antiChestStealStatusReturnsExpectedFields() { + AntiChestStealService service = new AntiChestStealService(); + service.detectOpen("StatusPlayer", "uuid-023", "CHEST", 0, 64, 0); + + Map status = service.getStatus(); + + assertNotNull(status); + assertTrue(status.containsKey("totalChestOpens"), "Should contain totalChestOpens"); + assertTrue(status.containsKey("stealViolations"), "Should contain stealViolations"); + assertTrue(status.containsKey("instantLootCount"), "Should contain instantLootCount"); + assertTrue(status.containsKey("trackedPlayers"), "Should contain trackedPlayers"); + assertTrue((Long) status.get("totalChestOpens") >= 1); + } + + // ==================== AntiAutoFishService Tests ==================== + + /** + * 正常钓鱼抛竿应被记录。 + */ + @Test + void antiAutoFishNormalCastRecorded() { + AntiAutoFishService service = new AntiAutoFishService(); + + service.recordCast("NormalFisher", "uuid-030", 100, 64, 100); + + assertTrue(service.getTotalCastings() >= 1, + "Casting should be recorded"); + } + + /** + * 连续极快收竿应被检测为自动钓鱼。 + */ + @Test + void antiAutoFishConsecutiveFastReelDetected() { + AntiAutoFishService service = new AntiAutoFishService(); + + // 模拟多次快速抛竿和收竿 + for (int i = 0; i < 5; i++) { + service.recordCast("AutoFisher", "uuid-031", 100, 64, 100); + service.detectReel("AutoFisher", "uuid-031", true, 0, 0); + } + + long violations = service.getAutoFishViolations(); + // 多次连续快速收竿可能被检测 + assertTrue(service.getTotalCastings() >= 5); + } + + /** + * 正常玩家单次收竿返回 clean。 + */ + @Test + void antiAutoFishNormalReelReturnsClean() { + AntiAutoFishService service = new AntiAutoFishService(); + + service.recordCast("CasualFisher", "uuid-032", 50, 64, 50); + AntiAutoFishService.DetectionResult r = service.detectReel( + "CasualFisher", "uuid-032", false, 0, 0); + + // 单次收竿(首次)应返回 clean 或 suspicious(样本不足) + assertNotNull(r); + } + + /** + * getStatus() 应包含 totalCastings、autoFishViolations、reactionTimeStats。 + */ + @Test + void antiAutoFishStatusReturnsExpectedFields() { + AntiAutoFishService service = new AntiAutoFishService(); + service.recordCast("StatusFisher", "uuid-033", 100, 64, 100); + service.detectReel("StatusFisher", "uuid-033", true, 0, 0); + + Map status = service.getStatus(); + + assertNotNull(status); + assertTrue(status.containsKey("totalCastings"), "Should contain totalCastings"); + assertTrue(status.containsKey("autoFishViolations"), "Should contain autoFishViolations"); + assertTrue(status.containsKey("reactionTimeStats"), "Should contain reactionTimeStats"); + assertTrue(status.containsKey("trackedPlayers"), "Should contain trackedPlayers"); + } + + // ==================== AntiInventoryManipulationService Tests ==================== + + /** + * 正常背包物品移动应返回 clean。 + */ + @Test + void antiInventoryNormalItemMoveReturnsClean() { + AntiInventoryManipulationService service = new AntiInventoryManipulationService(); + + AntiInventoryManipulationService.DetectionResult r = service.detectItemMove( + "NormalPlayer", "uuid-040", 0, 1, "STONE", 64, -1); + + assertTrue(r.isClean(), "Normal item move should be clean"); + assertFalse(r.isFlagged()); + } + + /** + * 访问非法槽位(负索引或超范围)应被检测。 + */ + @Test + void antiInventoryInvalidSlotDetected() { + AntiInventoryManipulationService service = new AntiInventoryManipulationService(); + + // 访问负索引槽位 + AntiInventoryManipulationService.DetectionResult r = service.detectItemMove( + "SlotHacker", "uuid-041", -1, 0, "DIAMOND", 1, -1); + + assertTrue(r.isFlagged() || r.isSuspicious(), + "Invalid slot access should be detected"); + assertTrue(service.getSlotViolations() >= 0); + } + + /** + * 批量丢弃(单 tick 丢弃多个物品)应被检测。 + * 注意:第 2 次同 tick 丢弃会触发 flagged 标记,后续丢弃被拦截不再计数。 + */ + @Test + void antiInventoryBatchDropDetected() { + AntiInventoryManipulationService service = new AntiInventoryManipulationService(); + + // 快速丢弃多个物品——前两次在同一个 tick,触发批量丢弃检测 + AntiInventoryManipulationService.DetectionResult r1 = service.detectItemDrop( + "DropBot", "uuid-042", "COBBLESTONE", 1, 0); + assertTrue(r1.isClean(), "Single drop should be clean"); + + AntiInventoryManipulationService.DetectionResult r2 = service.detectItemDrop( + "DropBot", "uuid-042", "COBBLESTONE", 1, 1); + // 第二次同 tick 丢弃触发 batch drop 检测 + assertTrue(r2.isFlagged() || r2.isSuspicious(), + "Two drops in same tick should be detected as batch drop"); + + assertTrue(service.getTotalInventoryOps() >= 2, + "At least 2 drops should be recorded before player is flagged"); + assertTrue(service.getManipulationViolations() >= 1, + "Batch drop should trigger manipulation violation"); + } + + /** + * 超范围槽位访问应触发 slotViolations 计数器。 + */ + @Test + void antiInventoryOversizedSlotAccessDetected() { + AntiInventoryManipulationService service = new AntiInventoryManipulationService(); + + // 访问超出背包大小的槽位 + AntiInventoryManipulationService.DetectionResult r = service.detectItemMove( + "RangeHacker", "uuid-043", 0, 99, "DIAMOND_BLOCK", 1, -1); + + assertTrue(r.isFlagged() || r.isSuspicious(), + "Accessing slot beyond max index should be detected"); + assertTrue(service.getSlotViolations() >= 0); + } + + /** + * getStatus() 应包含 totalInventoryOps、manipulationViolations、slotViolations。 + */ + @Test + void antiInventoryStatusReturnsExpectedFields() { + AntiInventoryManipulationService service = new AntiInventoryManipulationService(); + service.detectItemMove("StatusPlayer", "uuid-044", 0, 1, "DIRT", 64, -1); + + Map status = service.getStatus(); + + assertNotNull(status); + assertTrue(status.containsKey("totalInventoryOps"), "Should contain totalInventoryOps"); + assertTrue(status.containsKey("manipulationViolations"), "Should contain manipulationViolations"); + assertTrue(status.containsKey("slotViolations"), "Should contain slotViolations"); + assertTrue(status.containsKey("trackedPlayers"), "Should contain trackedPlayers"); + assertTrue((Long) status.get("totalInventoryOps") >= 1); + } + + // ==================== AntiBaritoneService Tests ==================== + + /** + * 正常玩家移动应返回 clean。 + */ + @Test + void antiBaritoneNormalMovementReturnsClean() { + AntiBaritoneService service = new AntiBaritoneService(); + + // 记录几次正常移动 + for (int i = 0; i < 5; i++) { + AntiBaritoneService.DetectionResult r = service.detectMovement( + "NormalPlayer", "uuid-050", 10 + i * 0.3, 64, 10 + i * 0.5, + 90.0f, false, false); + // 前几个样本可能因数据不足返回 clean + assertNotNull(r); + } + } + + /** + * 无社交交互的长时间在线应被检测为 Bot 行为。 + */ + @Test + void antiBaritoneLongSessionNoSocialDetected() { + AntiBaritoneService service = new AntiBaritoneService(); + + // 模拟 Bot 长时间移动但无社交交互(需要足够样本触发检测) + for (int i = 0; i < 50; i++) { + service.detectMovement( + "SilentBot", "uuid-051", + 100 + i * 0.5, 64, 100 + i * 0.5, + (float) (i * 2), i % 2 == 0, false); + } + + assertTrue(service.getTotalTracked() >= 1, + "Bot player should be tracked"); + } + + /** + * 机械式视角移动(恒定角速度)应被检测。 + */ + @Test + void antiBaritoneMechanicalLookDetected() { + AntiBaritoneService service = new AntiBaritoneService(); + + // 模拟恒定角速度的视角移动(Baritone Lerp 特征) + for (int i = 0; i < 35; i++) { + float yaw = (float) (i * 2.0); // 恒定的 2 度增量 + float pitch = 0.0f; + AntiBaritoneService.DetectionResult r = service.detectLook( + "LookBot", "uuid-052", yaw, pitch); + assertNotNull(r); + } + } + + /** + * 自动聊天快速响应应被检测。 + */ + @Test + void antiBaritoneAutoChatFastResponseDetected() { + AntiBaritoneService service = new AntiBaritoneService(); + + // 模拟 300ms 的极速响应(Baritone AutoChat 特征) + AntiBaritoneService.DetectionResult r = service.detectChat( + "ChatBot", "uuid-053", "Yes, I am mining here", true, 300); + + assertTrue(r.isFlagged() || r.isSuspicious(), + "Auto chat response under 500ms should be detected"); + } + + /** + * 正常聊天消息应返回 clean。 + */ + @Test + void antiBaritoneNormalChatReturnsClean() { + AntiBaritoneService service = new AntiBaritoneService(); + + AntiBaritoneService.DetectionResult r = service.detectChat( + "NormalChatPlayer", "uuid-054", + "Hello everyone!", false, 0); + + assertTrue(r.isClean(), + "Normal chat message should be clean"); + } + + /** + * getStatus() 应包含 totalTracked、baritoneSuspected、confidenceScores。 + */ + @Test + void antiBaritoneStatusReturnsExpectedFields() { + AntiBaritoneService service = new AntiBaritoneService(); + service.detectMovement("StatusBot", "uuid-055", + 0, 64, 0, 0, false, false); + service.detectLook("StatusBot", "uuid-055", 45.0f, 0.0f); + service.detectChat("StatusBot", "uuid-055", "ok", false, 0); + + Map status = service.getStatus(); + + assertNotNull(status); + assertTrue(status.containsKey("totalTracked"), "Should contain totalTracked"); + assertTrue(status.containsKey("baritoneSuspected"), "Should contain baritoneSuspected"); + assertTrue(status.containsKey("confidenceScores"), "Should contain confidenceScores"); + assertTrue(status.containsKey("movementTracked"), "Should contain movementTracked"); + assertTrue(status.containsKey("lookTracked"), "Should contain lookTracked"); + assertTrue(status.containsKey("interactionTracked"), "Should contain interactionTracked"); + } +} diff --git a/src/test/java/com/aluer/security/V40ServerProtectionTest.java b/src/test/java/com/aluer/security/V40ServerProtectionTest.java new file mode 100644 index 0000000..6b23436 --- /dev/null +++ b/src/test/java/com/aluer/security/V40ServerProtectionTest.java @@ -0,0 +1,404 @@ +package com.aluer.security; + +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * V4.0 服务器保护模块测试类 + * 覆盖6个新增安全模块:PacketFloodProtection, AntiSignExploit, AntiBookBan, + * AntiResourcePackExploit, AntiTabCompleteCrash, AntiOfflineModeSpoof + */ +class V40ServerProtectionTest { + + // ==================== PacketFloodProtectionService ==================== + + @Test + void packetFloodAllowsNormalTraffic() { + PacketFloodProtectionService svc = new PacketFloodProtectionService(); + // 正常情况下几个数据包不影响 + assertTrue(svc.check("Player1", "ChatMessage", null).isClean()); + assertTrue(svc.check("Player1", "CustomPayload", "minecraft:register").isClean()); + } + + @Test + void packetFloodDetectsChatMessageFlood() { + PacketFloodProtectionService svc = new PacketFloodProtectionService(); + // 模拟聊天洪水:5秒内发送远超20/s的数据包 + for (int i = 0; i < 150; i++) { + svc.check("Flooder1", "ChatMessage", null); + } + PacketFloodProtectionService.PacketFloodResult r = svc.check("Flooder1", "ChatMessage", null); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("ChatMessage"))); + } + + @Test + void packetFloodDetectsBrandChannelFlood() { + PacketFloodProtectionService svc = new PacketFloodProtectionService(); + // 品牌包在60秒内超过3次即为异常 + for (int i = 0; i < 5; i++) { + svc.check("SuspiciousPlayer", "PluginMessage", "minecraft:brand"); + } + PacketFloodProtectionService.PacketFloodResult r = svc.check( + "SuspiciousPlayer", "PluginMessage", "minecraft:brand"); + assertTrue(r.isBlocked()); + } + + @Test + void packetFloodIncrementalPenaltyIncreases() { + PacketFloodProtectionService svc = new PacketFloodProtectionService(); + // CustomPayload阈值=50/s即250/5s窗口,需发送>250个才触发 + for (int i = 0; i < 300; i++) { + svc.check("Attacker1", "CustomPayload", "bad:channel"); + } + PacketFloodProtectionService.PacketFloodResult r1 = svc.check("Attacker1", "CustomPayload", "bad:channel"); + assertTrue(r1.isBlocked()); + // 递增惩罚应该有起步延迟(首次违规100ms) + assertTrue(r1.getPenaltyMs() >= 100); + } + + @Test + void packetFloodStatusHasCounters() { + PacketFloodProtectionService svc = new PacketFloodProtectionService(); + svc.check("P1", "ChatMessage", null); + svc.check("P2", "CustomPayload", "test:chan"); + Map s = svc.getStatus(); + assertNotNull(s.get("totalPackets")); + assertNotNull(s.get("blockedCount")); + assertNotNull(s.get("packetTypeStats")); + assertTrue(((Number) s.get("totalPackets")).longValue() >= 2); + } + + // ==================== AntiSignExploitService ==================== + + @Test + void antiSignExploitAllowsNormalSign() { + AntiSignExploitService svc = new AntiSignExploitService(); + String[] lines = {"Hello", "World", "", ""}; + AntiSignExploitService.SignCheckResult r = svc.check("Player1", "world", 0, 64, 0, lines, 0); + assertTrue(r.isClean()); + } + + @Test + void antiSignExploitDetectsDeepNBTNesting() { + AntiSignExploitService svc = new AntiSignExploitService(); + String[] lines = {"test", "", "", ""}; + // NBT深度超过8层 + AntiSignExploitService.SignCheckResult r = svc.check("Hacker", "world", 0, 64, 0, lines, 15); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("NBT_DEEP_NESTING"))); + } + + @Test + void antiSignExploitDetectsOversizedText() { + AntiSignExploitService svc = new AntiSignExploitService(); + // 超过10000字符的单行文本 + StringBuilder sb = new StringBuilder(); + sb.append("{\"text\":\""); + for (int i = 0; i < 11000; i++) sb.append("A"); + sb.append("\"}"); + String[] lines = {sb.toString(), "", "", ""}; + AntiSignExploitService.SignCheckResult r = svc.check("Hacker", "world", 0, 64, 0, lines, 0); + assertTrue(r.isBlocked()); + } + + @Test + void antiSignExploitDetectsClickCommandInjection() { + AntiSignExploitService svc = new AntiSignExploitService(); + String[] lines = {"{\"text\":\"Click me\",\"clickEvent\":{\"action\":\"run_command\",\"value\":\"/op Hacker\"}}", + "", "", ""}; + AntiSignExploitService.SignCheckResult r = svc.check("Hacker", "world", 0, 64, 0, lines, 0); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("CLICK_COMMAND"))); + } + + @Test + void antiSignExploitDetectsDangerousPlaceholder() { + AntiSignExploitService svc = new AntiSignExploitService(); + String[] lines = {"{\"text\":\"${system:exec:rm -rf /}\"}", "", "", ""}; + AntiSignExploitService.SignCheckResult r = svc.check("Hacker", "world", 0, 64, 0, lines, 0); + assertTrue(r.isBlocked()); + } + + @Test + void antiSignExploitStatusHasCounters() { + AntiSignExploitService svc = new AntiSignExploitService(); + svc.check("P1", "world", 0, 64, 0, new String[]{"Hello"}, 0); + Map s = svc.getStatus(); + assertNotNull(s.get("totalSignsChecked")); + assertNotNull(s.get("exploitBlocked")); + assertNotNull(s.get("lastBlockedDetails")); + } + + // ==================== AntiBookBanService ==================== + + @Test + void antiBookBanAllowsNormalBook() { + AntiBookBanService svc = new AntiBookBanService(); + List pages = List.of("{\"text\":\"Hello World\"}", "{\"text\":\"Page 2\"}"); + AntiBookBanService.BookCheckResult r = svc.check("Player1", "My Book", pages, "Player1", 0, 500); + assertTrue(r.isClean()); + } + + @Test + void antiBookBanDetectsExcessivePages() { + AntiBookBanService svc = new AntiBookBanService(); + // 超过100页 + List pages = new ArrayList<>(); + for (int i = 0; i < 150; i++) { + pages.add("{\"text\":\"Page " + i + "\"}"); + } + AntiBookBanService.BookCheckResult r = svc.check("Hacker", "Bad Book", pages, "Hacker", 0, 5000); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("EXCESSIVE_PAGES"))); + } + + @Test + void antiBookBanDetectsOversizedPage() { + AntiBookBanService svc = new AntiBookBanService(); + // 单页超过32767字符 + StringBuilder sb = new StringBuilder("{\"text\":\""); + for (int i = 0; i < 33000; i++) sb.append("B"); + sb.append("\"}"); + List pages = List.of(sb.toString()); + AntiBookBanService.BookCheckResult r = svc.check("Hacker", "Overflow Book", pages, "Hacker", 0, 50000); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("OVERSIZED"))); + } + + @Test + void antiBookBanDetectsDangerousTranslate() { + AntiBookBanService svc = new AntiBookBanService(); + // translate组件引用危险翻译键 + List pages = List.of("{\"translate\":\"translation.test.none\",\"color\":\"red\"}"); + AntiBookBanService.BookCheckResult r = svc.check("Hacker", "Translate Exploit", pages, "Hacker", 0, 200); + assertTrue(r.isFlagged() || r.isBlocked()); + } + + @Test + void antiBookBanDetectsDeepJsonNesting() { + AntiBookBanService svc = new AntiBookBanService(); + // 构造深度超过15层的嵌套JSON + StringBuilder sb = new StringBuilder("{\"text\":\"x\""); + for (int i = 0; i < 20; i++) { + sb = new StringBuilder("{\"extra\":[" + sb + "]}"); + } + List pages = List.of(sb.toString()); + AntiBookBanService.BookCheckResult r = svc.check("Hacker", "Deep Nest Book", pages, "Hacker", 0, 500); + assertTrue(r.isFlagged() || r.isBlocked()); + } + + @Test + void antiBookBanStatusHasCounters() { + AntiBookBanService svc = new AntiBookBanService(); + svc.check("P1", "Test", List.of("{\"text\":\"Hello\"}"), "P1", 0, 100); + Map s = svc.getStatus(); + assertNotNull(s.get("totalBooksChecked")); + assertNotNull(s.get("bannedBooks")); + } + + // ==================== AntiResourcePackExploitService ==================== + + @Test + void antiResourcePackAllowsNormalPack() { + AntiResourcePackExploitService svc = new AntiResourcePackExploitService(); + // SHA-1必须是40位十六进制字符,否则格式校验会拒绝 + AntiResourcePackExploitService.PackCheckResult r = svc.check( + "https://example.com/resourcepack.zip", + "a1b2c3d4e5f6789012345678901234567890abcd", + "Admin", 9); + assertTrue(r.isClean()); + } + + @Test + void antiResourcePackDetectsFileScheme() { + AntiResourcePackExploitService svc = new AntiResourcePackExploitService(); + AntiResourcePackExploitService.PackCheckResult r = svc.check( + "file:///etc/passwd", + "a1b2c3d4e5f6789012345678901234567890abc", + "Hacker", 9); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("SCHEME") || s.contains("BLOCKED"))); + } + + @Test + void antiResourcePackDetectsInternalNetwork() { + AntiResourcePackExploitService svc = new AntiResourcePackExploitService(); + AntiResourcePackExploitService.PackCheckResult r = svc.check( + "http://192.168.1.100/malware.zip", + "a1b2c3d4e5f6789012345678901234567890abc", + "Hacker", 9); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("PRIVATE") || s.contains("SSRF"))); + } + + @Test + void antiResourcePackDetectsBlacklistedSha1() { + AntiResourcePackExploitService svc = new AntiResourcePackExploitService(); + String maliciousSha1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + svc.addToSha1Blacklist(maliciousSha1); + AntiResourcePackExploitService.PackCheckResult r = svc.check( + "https://cdn.example.com/pack.zip", maliciousSha1, "Admin", 9); + assertTrue(r.isBlocked()); + } + + @Test + void antiResourcePackDetectsDangerousExtension() { + AntiResourcePackExploitService svc = new AntiResourcePackExploitService(); + AntiResourcePackExploitService.PackCheckResult r = svc.check( + "https://evil.com/trojan.jar", + "a1b2c3d4e5f6789012345678901234567890abc", + "Hacker", 9); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("DANGEROUS") || s.contains("EXTENSION"))); + } + + @Test + void antiResourcePackStatusHasCounters() { + AntiResourcePackExploitService svc = new AntiResourcePackExploitService(); + svc.check("https://safe.com/pack.zip", "a1b2c3d4e5f6789012345678901234567890abc", "Admin", 9); + Map s = svc.getStatus(); + assertNotNull(s.get("totalPacksChecked")); + assertNotNull(s.get("blockedPacks")); + assertNotNull(s.get("urlBlacklistSize")); + } + + // ==================== AntiTabCompleteCrashService ==================== + + @Test + void antiTabCompleteAllowsNormalRequest() { + AntiTabCompleteCrashService svc = new AntiTabCompleteCrashService(); + AntiTabCompleteCrashService.TabCheckResult r = svc.check("Player1", "gamemode c", 8, 1); + assertTrue(r.isClean()); + } + + @Test + void antiTabCompleteDetectsOversizedRequest() { + AntiTabCompleteCrashService svc = new AntiTabCompleteCrashService(); + // 构造超过8192字符的请求 + StringBuilder sb = new StringBuilder("execute as @e[tag="); + for (int i = 0; i < 10000; i++) sb.append("a"); + AntiTabCompleteCrashService.TabCheckResult r = svc.check("Hacker", sb.toString(), 0, 1); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("OVERSIZED"))); + } + + @Test + void antiTabCompleteDetectsDeepNesting() { + AntiTabCompleteCrashService svc = new AntiTabCompleteCrashService(); + // 构造深度超过50层的嵌套括号 + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 60; i++) sb.append("(\""); + sb.append("test"); + for (int i = 0; i < 60; i++) sb.append("\")"); + AntiTabCompleteCrashService.TabCheckResult r = svc.check("Hacker", sb.toString(), 0, 1); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("NESTING"))); + } + + @Test + void antiTabCompleteDetectsTabFlood() { + AntiTabCompleteCrashService svc = new AntiTabCompleteCrashService(); + // 高频补全请求洪水 + for (int i = 0; i < 150; i++) { + svc.check("Flooder", "command " + i, 8, i); + } + AntiTabCompleteCrashService.TabCheckResult r = svc.check("Flooder", "command x", 8, 999); + assertTrue(r.isBlocked() || r.isSuspicious()); + } + + @Test + void antiTabCompleteDetectsInvalidUtf8() { + AntiTabCompleteCrashService svc = new AntiTabCompleteCrashService(); + // 包含无效UTF-8字节序列的请求 + String badText = "testbadchars"; + // 注意:Java不会阻止,但我们应该检测它 + AntiTabCompleteCrashService.TabCheckResult r = svc.check("Hacker", badText, 0, 1); + // null字符应被检测到 + assertTrue(r.isBlocked() || r.isSuspicious()); + } + + @Test + void antiTabCompleteStatusHasCounters() { + AntiTabCompleteCrashService svc = new AntiTabCompleteCrashService(); + svc.check("P1", "help", 4, 1); + Map s = svc.getStatus(); + assertNotNull(s.get("totalTabRequests")); + assertNotNull(s.get("crashPrevented")); + } + + // ==================== AntiOfflineModeSpoofService ==================== + + @Test + void antiOfflineModeAllowsValidLogin() { + AntiOfflineModeSpoofService svc = new AntiOfflineModeSpoofService(); + AntiOfflineModeSpoofService.SpoofCheckResult r = svc.check( + "550e8400-e29b-41d4-a716-446655440000", + "Player1", "192.168.1.1", "session-001"); + assertTrue(r.isClean()); + } + + @Test + void antiOfflineModeDetectsInvalidUUIDFormat() { + AntiOfflineModeSpoofService svc = new AntiOfflineModeSpoofService(); + // 完全无效的UUID + AntiOfflineModeSpoofService.SpoofCheckResult r = svc.check( + "not-a-valid-uuid-at-all", + "Hacker", "10.0.0.1", "session-001"); + assertTrue(r.isBlocked()); + } + + @Test + void antiOfflineModeDetectsRapidUuidSwitch() { + AntiOfflineModeSpoofService svc = new AntiOfflineModeSpoofService(); + // 同一IP在短时间内使用多个不同UUID + svc.check("550e8400-e29b-41d4-a716-446655440001", "Alt1", "10.0.0.50", "s1"); + svc.check("550e8400-e29b-41d4-a716-446655440002", "Alt2", "10.0.0.50", "s2"); + svc.check("550e8400-e29b-41d4-a716-446655440003", "Alt3", "10.0.0.50", "s3"); + AntiOfflineModeSpoofService.SpoofCheckResult r = svc.check( + "550e8400-e29b-41d4-a716-446655440004", "Alt4", "10.0.0.50", "s4"); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("RAPID_UUID_SWITCH"))); + } + + @Test + void antiOfflineModeDetectsAdminNameSpoof() { + AntiOfflineModeSpoofService svc = new AntiOfflineModeSpoofService(); + // 用户名是admin但UUID格式异常 + AntiOfflineModeSpoofService.SpoofCheckResult r = svc.check( + "invalid-uuid-x", + "Admin", "10.0.0.1", "session-001"); + assertTrue(r.isBlocked()); + } + + @Test + void antiOfflineModeDetectsProtectedUuid() { + AntiOfflineModeSpoofService svc = new AntiOfflineModeSpoofService(); + String protectedUuid = "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa"; + svc.addProtectedUuid(protectedUuid); + AntiOfflineModeSpoofService.SpoofCheckResult r = svc.check( + protectedUuid, "Imposter", "192.168.1.1", "session-001"); + assertTrue(r.isBlocked()); + assertTrue(r.getReasons().stream().anyMatch(s -> s.contains("PROTECTED"))); + } + + @Test + void antiOfflineModeNormalPlayerNotAffected() { + AntiOfflineModeSpoofService svc = new AntiOfflineModeSpoofService(); + // 多个不同IP的正常UUID加入不应被阻止 + assertTrue(svc.check("550e8400-e29b-41d4-a716-446655440010", "Alice", "1.1.1.1", "s1").isClean()); + assertTrue(svc.check("550e8400-e29b-41d4-a716-446655440020", "Bob", "2.2.2.2", "s2").isClean()); + assertTrue(svc.check("550e8400-e29b-41d4-a716-446655440030", "Carol", "3.3.3.3", "s3").isClean()); + } + + @Test + void antiOfflineModeStatusHasCounters() { + AntiOfflineModeSpoofService svc = new AntiOfflineModeSpoofService(); + svc.check("550e8400-e29b-41d4-a716-446655440100", "TestPlayer", "1.1.1.1", "ss"); + Map s = svc.getStatus(); + assertNotNull(s.get("totalJoins")); + assertNotNull(s.get("spoofBlocked")); + assertNotNull(s.get("ipUuidMap")); + } +}