V4.0 安全扩展:29个新模块 + 60项API端点 + 164项新测试全部通过
新增模块(5组29个): 反作弊扩展(6个): - AntiKillAuraService: 杀戮光环检测(攻击频率/目标切换/旋转角度) - AntiReachService: 超距攻击检测(攻击距离/位置回溯验证) - AntiSpeedService: 水平速度检测(移动速度/GroundSpoof异常) - AntiJesusService: 水上行走检测(水面/岩浆面移动验证) - AntiNoFallService: 无摔落伤害检测(落地检测/伤害验证) - AntiScaffoldService: 自动搭路检测(方块放置频率/角度/速度) 玩家行为安全(6个): - AntiNukerService: 快速破坏检测(挖矿速度/范围/模式识别) - AntiAutoClickerService: 自动点击检测(CPS统计/点击间隔熵值分析) - AntiChestStealService: 自动偷箱检测(开箱/取物速度模式识别) - AntiAutoFishService: 自动钓鱼检测(钓鱼行为时序/反应速度) - AntiInventoryManipulationService: 背包操作检测(非法丢弃/槽位异常) - AntiBaritoneService: AI寻路机器人检测(路径平滑度/行为重复率) 服务器保护(6个): - PacketFloodProtectionService: 数据包洪水防护(窗口/递增惩罚机制) - AntiSignExploitService: 告示牌漏洞防护(超长JSON/NBT/命令注入) - AntiBookBanService: 书与笔漏洞防护(超大页码/翻译键滥用/命令注入) - AntiResourcePackExploitService: 资源包漏洞防护(内网SSRF/SHA-1黑名单) - AntiTabCompleteCrashService: Tab补全崩溃防护(深度嵌套/洪水/无效UTF-8) - AntiOfflineModeSpoofService: 离线UUID欺诈防护(格式校验/IP-UUID关联) 访问控制(6个): - OPPrivilegeMonitorService: OP权限监控(权限变更/敏感命令审计) - ConfigTamperDetectionService: 配置文件篡改检测(SHA-256基线监控) - BackdoorPluginScannerService: 后门插件扫描(恶意类名/远程执行检测) - AntiVPNProxyService: VPN代理检测(托管ASN/IP范围匹配) - AntiAltAccountService: 小号检测(IP关联/行为相似度/封禁逃逸) - AntiNameSpoofService: 名称冒充检测(Unicode同形字/空白字符/编辑距离) 聊天社交安全(5个): - ChatFloodProtectionService: 聊天洪水防护(双窗口频率/相似度/递增静音) - AntiAdvertisementService: 广告检测(IP/域名/QQ群/Discord邀请正则) - AntiPhishingLinkService: 钓鱼链接检测(短链接/可疑域名/显示文本不匹配) - AntiCommandAbuseService: 命令滥用检测(洪水/插件探测/Shell注入) - PlayerPrivacyService: 玩家隐私保护(IP脱敏/GDPR合规/数据保留策略) 配置与API: - ServerGuardConfig新增29个boolean开关(默认true),全部独立可控 - application.yml新增29行YAML配置(含中文注释说明功能) - DashboardController整合29个新服务,新增60个REST API端点(总计108个端点) - 所有模块使用双构造函数(无参+@Autowired)、静态结果类工厂方法 测试:新增5个测试类164项测试,全部通过(全项目278项/17测试类/159源文件) 修复: - AntiBookBanService: helper方法改为返回int得分贡献,修复translate/score/clickEvent检测不生效 - AntiResourcePackExploitService: 修复Set.of()中.vbs重复导致ExceptionInInitializer - AntiTabCompleteCrashService: 深度嵌套得分提高至75(触发blocked阈值) - AntiOfflineModeSpoofService: 快速UUID切换得分提高至75(触发blocked阈值) - PacketFloodProtectionService: 重构检测流程,移除提前返回导致的检测屏蔽
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<String, Long> playerBlockCount = new ConcurrentHashMap<>();
|
||||
|
||||
/** 自身服务器白名单IP/域名 */
|
||||
private final Set<String> whitelistIps = ConcurrentHashMap.newKeySet();
|
||||
private final Set<String> 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<String> 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<String> getWhitelistIps() {
|
||||
return new HashSet<>(whitelistIps);
|
||||
}
|
||||
|
||||
public Set<String> getWhitelistDomains() {
|
||||
return new HashSet<>(whitelistDomains);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取玩家被拦截次数
|
||||
*
|
||||
* @param player 玩家名
|
||||
* @return 被拦截次数
|
||||
*/
|
||||
public long getPlayerBlockCount(String player) {
|
||||
return playerBlockCount.getOrDefault(player, 0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务运行状态
|
||||
*
|
||||
* @return 包含状态键值对的LinkedHashMap
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> 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<String> reasons;
|
||||
|
||||
private CheckResult(boolean blocked, List<String> reasons) {
|
||||
this.blocked = blocked;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
public static CheckResult clean() {
|
||||
return new CheckResult(false, List.of());
|
||||
}
|
||||
|
||||
public static CheckResult blocked(List<String> reasons) {
|
||||
return new CheckResult(true, reasons);
|
||||
}
|
||||
|
||||
public boolean isBlocked() { return blocked; }
|
||||
public boolean isClean() { return !blocked; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<AccountLogin>> ipToAccounts = new ConcurrentHashMap<>();
|
||||
/** 账号名 -> 该账号登录过的IP列表 */
|
||||
private final Map<String, List<String>> accountToIPs = new ConcurrentHashMap<>();
|
||||
/** 被封禁的IP -> 封禁时间 */
|
||||
private final Map<String, Instant> bannedIPs = new ConcurrentHashMap<>();
|
||||
/** 已知捣乱者IP -> 关联的全部账号 */
|
||||
private final Map<String, Set<String>> troublemakerIPToAccounts = new ConcurrentHashMap<>();
|
||||
/** 已识别的小号组(alt group ID -> 账号列表) */
|
||||
private final Map<String, Set<String>> 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<String> 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<AccountLogin> sameIPLogins = ipToAccounts.get(ip);
|
||||
if (sameIPLogins != null) {
|
||||
List<AccountLogin> recentLogins = sameIPLogins.stream()
|
||||
.filter(l -> ChronoUnit.MILLIS.between(l.loginTime, now) < RAPID_SWITCH_WINDOW_MS)
|
||||
.toList();
|
||||
Set<String> 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<String> 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<String> 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<String> 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<AccountLogin> logins = ipToAccounts.get(ip);
|
||||
if (logins != null && !logins.isEmpty()) {
|
||||
Set<String> 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<String> ips = accountToIPs.get(playerName);
|
||||
if (ips != null) {
|
||||
for (String ip : ips) {
|
||||
Set<String> accounts = troublemakerIPToAccounts.computeIfAbsent(
|
||||
ip, k -> ConcurrentHashMap.newKeySet());
|
||||
// 将该IP关联的所有账号加入
|
||||
List<AccountLogin> logins = ipToAccounts.get(ip);
|
||||
if (logins != null) {
|
||||
for (AccountLogin l : logins) {
|
||||
accounts.add(l.playerName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某IP关联的所有账号
|
||||
*
|
||||
* @param ip IP地址
|
||||
* @return 该IP关联的账号集合
|
||||
*/
|
||||
public Set<String> getAccountsForIP(String ip) {
|
||||
List<AccountLogin> logins = ipToAccounts.get(ip);
|
||||
if (logins == null) return Set.of();
|
||||
|
||||
Instant cutoff = Instant.now().minusMillis(IP_ASSOCIATION_WINDOW_MS);
|
||||
Set<String> 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<String> getIPsForAccount(String playerName) {
|
||||
return new ArrayList<>(accountToIPs.getOrDefault(playerName, List.of()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
*
|
||||
* @return 状态Map,包含totalAccounts/altGroups/banEvasionAttempts
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> 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<Map<String, Object>> groupDetails = new ArrayList<>();
|
||||
altGroups.forEach((groupId, accounts) -> {
|
||||
Map<String, Object> 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<AccountLogin> 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<String> reasons;
|
||||
|
||||
AltAccountResult(boolean altDetected, List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<ClickEvent>> playerClickRecords = new ConcurrentHashMap<>();
|
||||
private final Map<String, Instant> 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<ClickEvent> 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<String> 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<ClickEvent> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<Map<String, Object>> flagged = new ArrayList<>();
|
||||
for (Map.Entry<String, Instant> e : flaggedPlayers.entrySet()) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("player", e.getKey());
|
||||
m.put("flaggedUntil", e.getValue().toString());
|
||||
List<ClickEvent> 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<ClickEvent> 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<ClickEvent> records) {
|
||||
if (records.size() < 3) return 0;
|
||||
|
||||
// 提取最近 20 个点击的间隔(毫秒)
|
||||
List<Long> 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<Long, Integer> 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<ClickEvent> records) {
|
||||
if (records.size() < 4) return 1.0;
|
||||
|
||||
List<Long> 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<ClickEvent> 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<String> reasons;
|
||||
|
||||
private DetectionResult(boolean flagged, boolean suspicious, int score, List<String> 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<String> reasons) {
|
||||
return new DetectionResult(false, true, score, reasons);
|
||||
}
|
||||
|
||||
/** 已标记:检测到高置信度自动点击行为 */
|
||||
public static DetectionResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<FishingEvent>> playerFishingEvents = new ConcurrentHashMap<>();
|
||||
private final Map<String, FishSessionStats> playerSessionStats = new ConcurrentHashMap<>();
|
||||
private final Map<String, Instant> 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<FishingEvent> 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<FishingEvent> events = playerFishingEvents.computeIfAbsent(playerName,
|
||||
k -> Collections.synchronizedList(new ArrayList<>()));
|
||||
|
||||
// 找到最近一次 CAST 事件
|
||||
Optional<FishingEvent> 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<String> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<String, Object> reactionTimeStats = new LinkedHashMap<>();
|
||||
List<Long> allReactionTimes = new ArrayList<>();
|
||||
for (List<FishingEvent> 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<Map<String, Object>> flagged = new ArrayList<>();
|
||||
for (Map.Entry<String, Instant> e : flaggedPlayers.entrySet()) {
|
||||
Map<String, Object> 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<FishingEvent> events) {
|
||||
List<FishingEvent> 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<FishingEvent> events) {
|
||||
List<FishingEvent> 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<FishingEvent> 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<Long> 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<String> reasons;
|
||||
|
||||
private DetectionResult(boolean flagged, boolean suspicious, int score, List<String> 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<String> reasons) {
|
||||
return new DetectionResult(false, true, score, reasons);
|
||||
}
|
||||
|
||||
/** 已标记:检测到高置信度自动钓鱼行为 */
|
||||
public static DetectionResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, PlayerProfile> playerProfiles = new ConcurrentHashMap<>();
|
||||
private final Map<String, List<MovementSample>> movementRecords = new ConcurrentHashMap<>();
|
||||
private final Map<String, List<LookSample>> lookRecords = new ConcurrentHashMap<>();
|
||||
private final Map<String, List<InteractRecord>> interactionRecords = new ConcurrentHashMap<>();
|
||||
private final Map<String, Instant> 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<MovementSample> 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<String> 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<InteractRecord> 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<LookSample> 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<String> 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<InteractRecord> 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<String> 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<InteractRecord> 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<String> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<String, Object> confidenceScores = new LinkedHashMap<>();
|
||||
// 计算每个可疑玩家的综合置信度
|
||||
for (Map.Entry<String, PlayerProfile> 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<MovementSample> 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<String, Object> 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<Map<String, Object>> flagged = new ArrayList<>();
|
||||
for (Map.Entry<String, Instant> e : flaggedPlayers.entrySet()) {
|
||||
Map<String, Object> 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<MovementSample> samples) {
|
||||
if (samples.size() < 5) return -1;
|
||||
|
||||
List<Double> 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<MovementSample> samples) {
|
||||
if (samples.size() < 4) return -1;
|
||||
|
||||
List<Double> 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<LookSample> samples) {
|
||||
if (samples.size() < 3) return -1;
|
||||
|
||||
List<Double> 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<LookSample> samples) {
|
||||
if (samples.size() < 5) return 0;
|
||||
|
||||
List<Double> 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<MovementSample> 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<InteractRecord> interactions) {
|
||||
if (interactions.size() < 4) return 1.0;
|
||||
|
||||
List<Long> 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<InteractRecord> interactions,
|
||||
String newMessage) {
|
||||
if (newMessage == null || newMessage.isEmpty()) return 0;
|
||||
|
||||
List<String> 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<String> newWords = new HashSet<>(Arrays.asList(
|
||||
newMessage.toLowerCase().split("\\s+")));
|
||||
double totalSimilarity = 0;
|
||||
for (String msg : recentMessages) {
|
||||
Set<String> msgWords = new HashSet<>(Arrays.asList(
|
||||
msg.toLowerCase().split("\\s+")));
|
||||
Set<String> intersection = new HashSet<>(newWords);
|
||||
intersection.retainAll(msgWords);
|
||||
Set<String> 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<MovementSample> samples = e.getValue();
|
||||
samples.removeIf(s -> s.time.isBefore(cutoff));
|
||||
return samples.isEmpty();
|
||||
});
|
||||
lookRecords.entrySet().removeIf(e -> {
|
||||
List<LookSample> samples = e.getValue();
|
||||
samples.removeIf(s -> s.time.isBefore(cutoff));
|
||||
return samples.isEmpty();
|
||||
});
|
||||
interactionRecords.entrySet().removeIf(e -> {
|
||||
List<InteractRecord> 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<String> reasons;
|
||||
|
||||
private DetectionResult(boolean flagged, boolean suspicious, int score, List<String> 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<String> reasons) {
|
||||
return new DetectionResult(false, true, score, reasons);
|
||||
}
|
||||
|
||||
/** 已标记:检测到高置信度 Baritone Bot 行为 */
|
||||
public static DetectionResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<BookEvent>> bookHistory = new ConcurrentHashMap<>();
|
||||
private final Map<String, AtomicLong> playerViolations = new ConcurrentHashMap<>();
|
||||
private final AtomicLong totalBooksChecked = new AtomicLong(0);
|
||||
private final AtomicLong bannedBooks = new AtomicLong(0);
|
||||
|
||||
/** 已知的恶意翻译键(可导致客户端崩溃) */
|
||||
private static final Set<String> 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<String> pages,
|
||||
String author, int generation, long totalBytes) {
|
||||
// 配置开关检查
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiBookBan()) {
|
||||
return BookCheckResult.clean();
|
||||
}
|
||||
|
||||
totalBooksChecked.incrementAndGet();
|
||||
List<String> 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<String> 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<String> 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<String> 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<String, Object> getStatus() {
|
||||
Map<String, Object> s = new LinkedHashMap<>();
|
||||
s.put("totalBooksChecked", totalBooksChecked.get());
|
||||
s.put("bannedBooks", bannedBooks.get());
|
||||
s.put("trackedPlayers", bookHistory.size());
|
||||
|
||||
List<Map<String, Object>> recent = new ArrayList<>();
|
||||
for (Map.Entry<String, List<BookEvent>> e : bookHistory.entrySet()) {
|
||||
List<BookEvent> events = e.getValue();
|
||||
if (!events.isEmpty()) {
|
||||
BookEvent last = events.get(events.size() - 1);
|
||||
Map<String, Object> 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<Map<String, Object>> violators = new ArrayList<>();
|
||||
for (Map.Entry<String, AtomicLong> e : playerViolations.entrySet()) {
|
||||
Map<String, Object> 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<BookEvent> 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<String> reasons;
|
||||
|
||||
private BookCheckResult(boolean blocked, boolean flagged, int score, List<String> 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<String> reasons, int score) {
|
||||
return new BookCheckResult(true, false, score, reasons);
|
||||
}
|
||||
|
||||
public static BookCheckResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<ChestInteraction>> playerInteractions = new ConcurrentHashMap<>();
|
||||
private final Map<String, Instant> 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<String> 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<ChestInteraction> 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<String> 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<ChestInteraction> 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<String> 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<ChestInteraction> 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<ChestInteraction> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<Map<String, Object>> flagged = new ArrayList<>();
|
||||
for (Map.Entry<String, Instant> e : flaggedPlayers.entrySet()) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("player", e.getKey());
|
||||
m.put("flaggedUntil", e.getValue().toString());
|
||||
List<ChestInteraction> 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<ChestInteraction> 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<String> reasons;
|
||||
|
||||
private DetectionResult(boolean flagged, boolean suspicious, int score, List<String> 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<String> reasons) {
|
||||
return new DetectionResult(false, true, score, reasons);
|
||||
}
|
||||
|
||||
/** 已标记:检测到高置信度 Chest Stealer 行为 */
|
||||
public static DetectionResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, Deque<CommandEvent>> playerCommandHistory = new ConcurrentHashMap<>();
|
||||
private final Map<String, Integer> unknownCommandCounts = new ConcurrentHashMap<>();
|
||||
private final AtomicLong totalCommands = new AtomicLong(0);
|
||||
private final AtomicLong blockedCommands = new AtomicLong(0);
|
||||
private final Map<String, Long> 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<String> 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<CommandEvent> 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<String> 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<CommandEvent> getPlayerHistory(String player) {
|
||||
Deque<CommandEvent> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<String, Object> topCommands = new LinkedHashMap<>();
|
||||
commandStats.entrySet().stream()
|
||||
.sorted(Map.Entry.<String, Long>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<String> reasons;
|
||||
|
||||
private CheckResult(boolean blocked, List<String> reasons) {
|
||||
this.blocked = blocked;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
public static CheckResult clean() {
|
||||
return new CheckResult(false, List.of());
|
||||
}
|
||||
|
||||
public static CheckResult blocked(List<String> reasons) {
|
||||
return new CheckResult(true, reasons);
|
||||
}
|
||||
|
||||
public boolean isBlocked() { return blocked; }
|
||||
public boolean isClean() { return !blocked; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<InventoryOp>> playerInvOps = new ConcurrentHashMap<>();
|
||||
private final Map<String, Map<Integer, InventorySlotState>> playerSlotStates = new ConcurrentHashMap<>();
|
||||
private final Map<String, Instant> 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<InventoryOp> 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<String> 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<Integer, InventorySlotState> 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<InventoryOp> 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<String> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<Map<String, Object>> flagged = new ArrayList<>();
|
||||
for (Map.Entry<String, Instant> e : flaggedPlayers.entrySet()) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("player", e.getKey());
|
||||
m.put("flaggedUntil", e.getValue().toString());
|
||||
List<InventoryOp> 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<InventoryOp> 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<String> reasons;
|
||||
|
||||
private DetectionResult(boolean flagged, boolean suspicious, int score, List<String> 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<String> reasons) {
|
||||
return new DetectionResult(false, true, score, reasons);
|
||||
}
|
||||
|
||||
/** 已标记:检测到高置信度背包操作异常 */
|
||||
public static DetectionResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<LiquidRecord>> playerLiquidHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家在液体上方的连续时间(playerName -> 首次检测到水上行走的时间戳)
|
||||
*/
|
||||
private final Map<String, Instant> playerJesusStart = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近的Y坐标(用于抖动检测)
|
||||
*/
|
||||
private final Map<String, List<Double>> 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<LiquidRecord> history = playerLiquidHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
List<Double> 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<String> 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<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("jesusViolations", jesusViolations.get());
|
||||
status.put("activeTrackedPlayers", playerLiquidHistory.size());
|
||||
|
||||
// 列出当前疑似Jesus的玩家
|
||||
List<String> 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<String> reasons;
|
||||
|
||||
private JesusCheckResult(boolean flagged, boolean suspicious, List<String> 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<String> reasons) {
|
||||
return new JesusCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认在液体表面非法行走(Jesus hack) */
|
||||
public static JesusCheckResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<AttackRecord>> playerAttackHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的连续攻击角度记录(playerName -> 最近5次攻击角度列表)
|
||||
* 用于检测自动瞄准的角度一致性
|
||||
*/
|
||||
private final Map<String, List<Double>> playerAngleHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的攻击距离记录(playerName -> 距离列表)
|
||||
* 用于检测精确的极限距离攻击模式
|
||||
*/
|
||||
private final Map<String, List<Double>> 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<String> reasons = new ArrayList<>();
|
||||
|
||||
// 1. 攻击目标切换频率检测
|
||||
List<AttackRecord> 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<String> 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<Double> 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<Double> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<String> reasons;
|
||||
|
||||
private KillAuraCheckResult(boolean flagged, boolean suspicious, List<String> 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<String> reasons) {
|
||||
return new KillAuraCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 多项检测规则命中,高度可能使用KillAura */
|
||||
public static KillAuraCheckResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String> protectedNames = ConcurrentHashMap.newKeySet();
|
||||
/** 自定义虚假目标(特殊名单) */
|
||||
private final Set<String> customSpoofTargets = ConcurrentHashMap.newKeySet();
|
||||
/** 已检测到的冒充尝试 */
|
||||
private final Map<String, List<SpoofAttempt>> spoofHistory = new ConcurrentHashMap<>();
|
||||
/** 已被阻止的冒充名称集合 */
|
||||
private final Set<String> 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<Character, List<Character>> 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<Character> 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<String> 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<String> 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<String> names) {
|
||||
if (names != null) {
|
||||
protectedNames.addAll(names);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从受保护名单中移除名称
|
||||
*
|
||||
* @param name 名称
|
||||
*/
|
||||
public void removeProtectedName(String name) {
|
||||
if (name != null) {
|
||||
protectedNames.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有受保护的名称
|
||||
*
|
||||
* @return 受保护名称集合
|
||||
*/
|
||||
public Set<String> getProtectedNames() {
|
||||
return new HashSet<>(protectedNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
*
|
||||
* @return 状态Map,包含totalNameChecks/spoofAttempts/blockedNames
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> 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<Map<String, Object>> recent = new ArrayList<>();
|
||||
int total = 0;
|
||||
for (List<SpoofAttempt> attempts : spoofHistory.values()) {
|
||||
for (SpoofAttempt a : attempts) {
|
||||
if (total < 10) {
|
||||
Map<String, Object> 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<String> reasons;
|
||||
|
||||
private NameSpoofResult(boolean spoofed, List<String> reasons) {
|
||||
this.spoofed = spoofed;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
public static NameSpoofResult clean() {
|
||||
return new NameSpoofResult(false, List.of());
|
||||
}
|
||||
|
||||
public static NameSpoofResult spoofed(List<String> reasons) {
|
||||
return new NameSpoofResult(true, reasons);
|
||||
}
|
||||
|
||||
public boolean isSpoofed() { return spoofed; }
|
||||
public boolean isClean() { return !spoofed; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<FallRecord>> playerFallHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家当前是否处于坠落状态及其起始信息(playerName -> 当前坠落上下文)
|
||||
*/
|
||||
private final Map<String, ActiveFall> activeFalls = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪疑似NoFall的事件(playerName -> NoFall事件列表)
|
||||
*/
|
||||
private final Map<String, List<Map<String, Object>>> 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<String> 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<FallRecord> 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<Map<String, Object>> events = noFallEvents.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
Map<String, Object> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<Map<String, Object>> suspiciousPlayers = new ArrayList<>();
|
||||
for (Map.Entry<String, List<FallRecord>> entry : playerFallHistory.entrySet()) {
|
||||
long noDamageCount = entry.getValue().stream()
|
||||
.filter(r -> !r.tookDamage && !r.legitimateNoDamage).count();
|
||||
if (noDamageCount > 0) {
|
||||
Map<String, Object> 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<String> reasons;
|
||||
|
||||
private NoFallCheckResult(boolean flagged, boolean suspicious, List<String> 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<String> reasons) {
|
||||
return new NoFallCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认NoFall hack,从危险高度坠落未受伤害且无合法理由 */
|
||||
public static NoFallCheckResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<BreakRecord>> playerBreakRecords = new ConcurrentHashMap<>();
|
||||
private final Map<String, List<PositionRecord>> playerPositionRecords = new ConcurrentHashMap<>();
|
||||
private final Map<String, Instant> 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<BreakRecord> records = playerBreakRecords.computeIfAbsent(playerName,
|
||||
k -> Collections.synchronizedList(new ArrayList<>()));
|
||||
records.add(record);
|
||||
totalBreaks.incrementAndGet();
|
||||
|
||||
// 记录坐标用于分散模式分析
|
||||
PositionRecord posRecord = new PositionRecord(now, x, y, z, world);
|
||||
List<PositionRecord> posRecords = playerPositionRecords.computeIfAbsent(playerName,
|
||||
k -> Collections.synchronizedList(new ArrayList<>()));
|
||||
posRecords.add(posRecord);
|
||||
|
||||
int score = 0;
|
||||
List<String> 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<PositionRecord> 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<Long> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<Map<String, Object>> flagged = new ArrayList<>();
|
||||
for (Map.Entry<String, Instant> e : flaggedPlayers.entrySet()) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("player", e.getKey());
|
||||
m.put("flaggedUntil", e.getValue().toString());
|
||||
List<BreakRecord> 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<BreakRecord> records = e.getValue();
|
||||
records.removeIf(r -> r.time.isBefore(cutoff));
|
||||
return records.isEmpty();
|
||||
});
|
||||
playerPositionRecords.entrySet().removeIf(e -> {
|
||||
List<PositionRecord> 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<String> reasons;
|
||||
|
||||
private DetectionResult(boolean flagged, boolean suspicious, int score, List<String> 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<String> reasons) {
|
||||
return new DetectionResult(false, true, score, reasons);
|
||||
}
|
||||
|
||||
/** 已标记:检测到高置信度 Nuker 行为 */
|
||||
public static DetectionResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, Map<String, Instant>> ipUuidMap = new ConcurrentHashMap<>();
|
||||
/** UUID -> 最近使用信息 */
|
||||
private final Map<String, UuidRecord> uuidRecords = new ConcurrentHashMap<>();
|
||||
/** IP被封锁记录 */
|
||||
private final Map<String, Instant> blockedIps = new ConcurrentHashMap<>();
|
||||
/** UUID被封锁记录 */
|
||||
private final Map<String, Instant> 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<String> PROTECTED_UUIDS = new HashSet<>(Arrays.asList(
|
||||
// Mojang/Microsoft员工(示例UUID,非真实值)
|
||||
"aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa",
|
||||
"bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb"
|
||||
));
|
||||
|
||||
/** 常见管理员用户名模式(大小写不敏感) */
|
||||
private static final Set<String> 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<String> 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<String, Instant> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<Map<String, Object>> ipMappings = new ArrayList<>();
|
||||
for (Map.Entry<String, Map<String, Instant>> e : ipUuidMap.entrySet()) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("ip", e.getKey());
|
||||
m.put("uuidCount", e.getValue().size());
|
||||
|
||||
// 最近活跃的UUID(每个IP取最多5个)
|
||||
List<Map<String, String>> recentUuids = new ArrayList<>();
|
||||
e.getValue().entrySet().stream()
|
||||
.sorted((a, b) -> b.getValue().compareTo(a.getValue()))
|
||||
.limit(5)
|
||||
.forEach(ue -> {
|
||||
Map<String, String> 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<String, Instant> 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<String> reasons;
|
||||
|
||||
private SpoofCheckResult(boolean blocked, boolean flagged, int score, List<String> 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<String> reasons) {
|
||||
return new SpoofCheckResult(true, false, 100, reasons);
|
||||
}
|
||||
|
||||
public static SpoofCheckResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, Long> playerPhishingCount = new ConcurrentHashMap<>();
|
||||
|
||||
/** URL提取正则 */
|
||||
private static final Pattern URL_PATTERN = Pattern.compile(
|
||||
"https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+|" +
|
||||
"www\\.[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
/** 短链接服务域名列表 */
|
||||
private static final Set<String> 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<String> 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<String> 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<String> reasons = new ArrayList<>();
|
||||
String lowerMessage = message.toLowerCase();
|
||||
|
||||
// 提取所有URL
|
||||
Matcher urlMatcher = URL_PATTERN.matcher(message);
|
||||
List<String> 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<String> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<String> reasons;
|
||||
|
||||
private CheckResult(boolean blocked, List<String> reasons) {
|
||||
this.blocked = blocked;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
public static CheckResult clean() {
|
||||
return new CheckResult(false, List.of());
|
||||
}
|
||||
|
||||
public static CheckResult blocked(List<String> reasons) {
|
||||
return new CheckResult(true, reasons);
|
||||
}
|
||||
|
||||
public boolean isBlocked() { return blocked; }
|
||||
public boolean isClean() { return !blocked; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<DistanceRecord>> playerDistanceHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的违规攻击记录(playerName -> 违规详情列表),保留用于getStatus()展示
|
||||
*/
|
||||
private final Map<String, List<Map<String, Object>>> 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<DistanceRecord> 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<String> 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<String> 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<Map<String, Object>> violations = playerViolations.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
Map<String, Object> 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<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("violations", violations.get());
|
||||
status.put("activeTrackedPlayers", playerDistanceHistory.size());
|
||||
|
||||
// 汇总最近违规记录
|
||||
List<Map<String, Object>> recentViolations = new ArrayList<>();
|
||||
for (Map.Entry<String, List<Map<String, Object>>> entry : playerViolations.entrySet()) {
|
||||
if (!entry.getValue().isEmpty()) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("player", entry.getKey());
|
||||
Map<String, Object> 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<String> reasons;
|
||||
|
||||
private ReachCheckResult(boolean flagged, boolean suspicious, List<String> 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<String> reasons) {
|
||||
return new ReachCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认超距攻击,距离显著超出合法范围 */
|
||||
public static ReachCheckResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<PackEvent>> packHistory = new ConcurrentHashMap<>();
|
||||
private final Map<String, PackUrlInfo> urlCache = new ConcurrentHashMap<>();
|
||||
/** SHA-1哈希黑名单(已知恶意资源包) */
|
||||
private final Set<String> sha1Blacklist = ConcurrentHashMap.newKeySet();
|
||||
private final Set<String> 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<String> 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<String> 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<String> 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<String> 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<String, Object> getStatus() {
|
||||
Map<String, Object> 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<Map<String, Object>> recent = new ArrayList<>();
|
||||
for (Map.Entry<String, List<PackEvent>> e : packHistory.entrySet()) {
|
||||
List<PackEvent> events = e.getValue();
|
||||
if (!events.isEmpty()) {
|
||||
PackEvent last = events.get(events.size() - 1);
|
||||
Map<String, Object> 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<PackEvent> 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<String> reasons;
|
||||
|
||||
PackUrlInfo(Instant t, List<String> r) {
|
||||
this.timestamp = t;
|
||||
this.reasons = r;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PackCheckResult {
|
||||
private final boolean blocked;
|
||||
private final List<String> reasons;
|
||||
|
||||
private PackCheckResult(boolean blocked, List<String> reasons) {
|
||||
this.blocked = blocked;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
public static PackCheckResult clean() {
|
||||
return new PackCheckResult(false, List.of());
|
||||
}
|
||||
|
||||
public static PackCheckResult blocked(List<String> reasons) {
|
||||
return new PackCheckResult(true, reasons);
|
||||
}
|
||||
|
||||
public boolean isBlocked() { return blocked; }
|
||||
public boolean isClean() { return !blocked; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<PlacementRecord>> playerPlacementHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的持续高速放置计时(playerName -> 高速放置开始时间)
|
||||
*/
|
||||
private final Map<String, Instant> playerRapidPlacingSince = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的放置角度记录(playerName -> 角度列表)
|
||||
*/
|
||||
private final Map<String, List<Double>> playerPitchHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的方块边缘安全状态(playerName -> 是否处于安全行走区域)
|
||||
*/
|
||||
private final Map<String, SafewalkData> 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<PlacementRecord> history = playerPlacementHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
List<Double> 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<String> 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<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalPlacements", totalPlacements.get());
|
||||
status.put("scaffoldViolations", scaffoldViolations.get());
|
||||
status.put("trackedBuilders", playerPlacementHistory.size());
|
||||
|
||||
// 列出当前活跃的建造玩家
|
||||
List<Map<String, Object>> activeBuilders = new ArrayList<>();
|
||||
for (Map.Entry<String, List<PlacementRecord>> entry : playerPlacementHistory.entrySet()) {
|
||||
if (!entry.getValue().isEmpty()) {
|
||||
Map<String, Object> 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<String> reasons;
|
||||
|
||||
private ScaffoldCheckResult(boolean flagged, boolean suspicious, List<String> 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<String> reasons) {
|
||||
return new ScaffoldCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认Scaffold hack(高速搭路+固定视角+脚下放置模式) */
|
||||
public static ScaffoldCheckResult flagged(List<String> 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<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<SignEvent>> signHistory = new ConcurrentHashMap<>();
|
||||
private final Map<String, AtomicLong> playerViolations = new ConcurrentHashMap<>();
|
||||
private final AtomicLong totalSignsChecked = new AtomicLong(0);
|
||||
private final AtomicLong exploitBlocked = new AtomicLong(0);
|
||||
|
||||
/** 最近被阻止的详情,用于仪表盘展示 */
|
||||
private final List<Map<String, Object>> 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<String> 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<String> 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 == ' | ||||