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

新增模块(5组29个):

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

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

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

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

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

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

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

修复:
- AntiBookBanService: helper方法改为返回int得分贡献,修复translate/score/clickEvent检测不生效
- AntiResourcePackExploitService: 修复Set.of()中.vbs重复导致ExceptionInInitializer
- AntiTabCompleteCrashService: 深度嵌套得分提高至75(触发blocked阈值)
- AntiOfflineModeSpoofService: 快速UUID切换得分提高至75(触发blocked阈值)
- PacketFloodProtectionService: 重构检测流程,移除提前返回导致的检测屏蔽
This commit is contained in:
Peijun Zhao
2026-05-16 11:47:59 +08:00
parent f82fb616cc
commit 1269d4dfa5
37 changed files with 13890 additions and 1 deletions
@@ -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 == '') { // Null字符
reasons.add("LINE_" + i + "_NULL_CHAR: contains null character at position " + ci);
break;
}
if (ch == '￾' || ch == '￿') { // BOM/非字符
reasons.add("LINE_" + i + "_INVALID_UNICODE: contains noncharacter at position " + ci);
break;
}
}
}
}
// 构建结果
if (!reasons.isEmpty()) {
exploitBlocked.incrementAndGet();
playerViolations.computeIfAbsent(player, k -> new AtomicLong(0)).incrementAndGet();
// 记录被阻止的详情
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("time", Instant.now().toString());
detail.put("player", player);
detail.put("location", world + " (" + x + "," + y + "," + z + ")");
detail.put("reasons", reasons);
synchronized (lastBlockedDetails) {
lastBlockedDetails.add(0, detail);
if (lastBlockedDetails.size() > MAX_BLOCKED_DETAILS) {
lastBlockedDetails.remove(lastBlockedDetails.size() - 1);
}
}
return SignCheckResult.blocked(reasons);
}
return SignCheckResult.clean();
}
/**
* 获取当前服务运行状态。
* 用于仪表盘展示告示牌安全统计数据。
*/
public Map<String, Object> getStatus() {
Map<String, Object> s = new LinkedHashMap<>();
s.put("totalSignsChecked", totalSignsChecked.get());
s.put("exploitBlocked", exploitBlocked.get());
s.put("trackedPlayers", signHistory.size());
// 违规最多的玩家 Top 10
List<Map<String, Object>> topViolators = 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());
topViolators.add(m);
}
topViolators.sort((a, b) -> Long.compare((Long) b.get("violations"), (Long) a.get("violations")));
s.put("topViolators", topViolators.subList(0, Math.min(topViolators.size(), 10)));
// 最近被阻止的详情
synchronized (lastBlockedDetails) {
s.put("lastBlockedDetails", new ArrayList<>(lastBlockedDetails.subList(0,
Math.min(lastBlockedDetails.size(), 10))));
}
return s;
}
/**
* 计算JSON字符串的最大嵌套深度(简化启发式算法)。
* 不使用完整的JSON解析器,只通过括号匹配估算深度。
*/
private int calculateJsonDepth(String json) {
int maxDepth = 0;
int currentDepth = 0;
for (char c : json.toCharArray()) {
if (c == '{' || c == '[') {
currentDepth++;
maxDepth = Math.max(maxDepth, currentDepth);
} else if (c == '}' || c == ']') {
currentDepth = Math.max(0, currentDepth - 1);
}
}
return maxDepth;
}
/**
* 从文本中提取占位符内容用于日志记录。
*/
private String extractPlaceholder(String text) {
int start = text.indexOf("${");
if (start < 0) return "unknown";
int end = text.indexOf("}", start);
if (end < 0) return text.substring(start);
return text.substring(start, end + 1);
}
private void cleanupOldData() {
Instant cutoff = Instant.now().minusSeconds(3600); // 1小时
for (List<SignEvent> events : signHistory.values()) {
events.removeIf(e -> e.time.isBefore(cutoff));
}
signHistory.entrySet().removeIf(e -> e.getValue().isEmpty());
}
private static class SignEvent {
final Instant time;
final String player;
final String world;
final int x, y, z;
final String content;
final int nbtDepth;
SignEvent(Instant t, String p, String w, int x, int y, int z, String c, int nd) {
this.time = t;
this.player = p;
this.world = w;
this.x = x; this.y = y; this.z = z;
this.content = c;
this.nbtDepth = nd;
}
}
public static class SignCheckResult {
private final boolean blocked;
private final List<String> reasons;
private SignCheckResult(boolean blocked, List<String> reasons) {
this.blocked = blocked;
this.reasons = reasons;
}
public static SignCheckResult clean() {
return new SignCheckResult(false, List.of());
}
public static SignCheckResult blocked(List<String> reasons) {
return new SignCheckResult(true, reasons);
}
public boolean isBlocked() { return blocked; }
public boolean isClean() { return !blocked; }
public List<String> getReasons() { return reasons; }
}
}
@@ -0,0 +1,328 @@
package com.aluer.security;
import com.aluer.config.ServerGuardConfig;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 水平速度(Speed)检测服务 — V4.0 反作弊扩展模块
*
* 检测原理:
* 1. XZ平面水平速度检测 — Minecraft生存模式默认步行速度约4.317 m/s,疾跑约5.612 m/s
* 检测水平速度是否超过7.0 m/s,超过则标记为异常
* 2. 持续超速检测 — 区分短时速度尖峰(网络延迟可能导致的速度跳跃)和持续超速(真实作弊行为)
* 需要速度异常持续超过1秒才标记
* 3. GroundSpoof检测 — 检测玩家是否伪造地面状态:玩家声称在地面但垂直速度异常
* (正常玩家在地面时Y轴变化极小,而Speed hack的BHop模式会有振荡)
* 4. 速度模式分析 — 检测机械性的恒速运动(正常玩家速度会有自然波动)
*
* 注意:此模块专注于水平速度(XZ平面)检测,与AntiFly检测的垂直速度检测互补
*
* 配置开关:serverguard.security.super-evolution.anti-speed
*/
@Service
public class AntiSpeedService {
private final ServerGuardConfig config;
/**
* 追踪每个玩家的移动轨迹(playerName -> 移动记录列表)
* 用于计算时间窗口内的速度和加速度
*/
private final Map<String, List<MovementRecord>> playerMovementHistory = new ConcurrentHashMap<>();
/**
* 追踪每个玩家的持续超速计时(playerName -> 超速开始的时间戳)
* 非空表示该玩家当前处于超速状态
*/
private final Map<String, Instant> playerSpeedingSince = new ConcurrentHashMap<>();
/**
* 追踪每个玩家的速度统计(playerName -> 速度统计)
*/
private final Map<String, SpeedStats> playerSpeedStats = new ConcurrentHashMap<>();
private final AtomicLong totalSamples = new AtomicLong(0);
private final AtomicLong speedViolations = new AtomicLong(0);
/**
* 水平速度阈值(m/s)— Minecraft生存模式疾跑约5.612 m/s,设置7.0留出延迟余量
*/
private static final double MAX_HORIZONTAL_SPEED = 7.0;
/**
* 持续超速最小时间(毫秒)— 需要超速状态至少持续1秒才判定为作弊
* 短时速度尖峰可能是网络延迟造成的数据包积压释放
*/
private static final long MIN_OVERSPEED_DURATION_MS = 1_000;
/**
* 地面垂直速度阈值 — 正常玩家在地面时Y轴变化极小(<0.1),
* 超过此值的Y轴变化说明可能存在GroundSpoof(BHop等speed hack的辅助技术)
*/
private static final double GROUND_VERTICAL_THRESHOLD = 0.15;
/**
* 每个玩家保留的最大移动记录数
*/
private static final int MAX_MOVEMENT_RECORDS = 50;
/**
* 无参构造函数 — 测试/默认配置使用
*/
public AntiSpeedService() {
this(new ServerGuardConfig());
}
/**
* Spring 注入构造函数
* @param config 全局配置,提供模块开关控制
*/
public AntiSpeedService(ServerGuardConfig config) {
this.config = config;
}
/**
* 检测玩家水平移动速度是否异常
*
* @param playerName 玩家名称
* @param playerUUID 玩家UUID
* @param fromX 移动起始X坐标
* @param fromZ 移动起始Z坐标
* @param fromY 移动起始Y坐标(用于GroundSpoof检测)
* @param toX 移动结束X坐标
* @param toZ 移动结束Z坐标
* @param toY 移动结束Y坐标(用于GroundSpoof检测)
* @param onGround 玩家是否声称在地面
* @param deltaSeconds 本次移动的时间间隔(秒),用于计算速度
* @param timestamp 时间戳
* @return 检测结果
*/
public SpeedCheckResult detect(String playerName, String playerUUID,
double fromX, double fromY, double fromZ,
double toX, double toY, double toZ,
boolean onGround, double deltaSeconds, Instant timestamp) {
// 模块开关检查 — 关闭时跳过所有检测
if (!config.getSecurity().getSuperEvolution().isAntiSpeed()) {
return SpeedCheckResult.clean();
}
totalSamples.incrementAndGet();
List<MovementRecord> history = playerMovementHistory.computeIfAbsent(
playerName, k -> new ArrayList<>());
SpeedStats stats = playerSpeedStats.computeIfAbsent(
playerName, k -> new SpeedStats());
List<String> reasons = new ArrayList<>();
// 避免除零
if (deltaSeconds <= 0) {
deltaSeconds = 0.05; // 默认50ms(1 tick)
}
// 计算XZ平面水平速度
double dx = toX - fromX;
double dz = toZ - fromZ;
double horizontalDistance = Math.sqrt(dx * dx + dz * dz);
double horizontalSpeed = horizontalDistance / deltaSeconds;
// 更新速度统计
stats.totalSpeed += horizontalSpeed;
stats.totalSamples++;
stats.maxSpeed = Math.max(stats.maxSpeed, horizontalSpeed);
MovementRecord record = new MovementRecord(timestamp, fromX, fromY, fromZ, toX, toY, toZ,
onGround, horizontalSpeed, deltaSeconds);
history.add(record);
while (history.size() > MAX_MOVEMENT_RECORDS) {
history.remove(0);
}
// 1. 水平速度检测
if (horizontalSpeed > MAX_HORIZONTAL_SPEED) {
// 记录超速开始时间
playerSpeedingSince.putIfAbsent(playerName, timestamp);
// 检查超速持续时间是否超过阈值
Instant speedStart = playerSpeedingSince.get(playerName);
long overspeedDurationMs = timestamp.toEpochMilli() - speedStart.toEpochMilli();
if (overspeedDurationMs >= MIN_OVERSPEED_DURATION_MS) {
// 持续超速 — 这是作弊行为而非网络延迟
speedViolations.incrementAndGet();
reasons.add("SUSTAINED_OVERSPEED: " + String.format("%.2f", horizontalSpeed)
+ " m/s for " + (overspeedDurationMs / 1000) + "s (threshold: "
+ String.format("%.1f", MAX_HORIZONTAL_SPEED) + " m/s)");
} else {
// 速度尖峰 — 可能是延迟,仅记录可疑
reasons.add("SPEED_SPIKE: " + String.format("%.2f", horizontalSpeed)
+ " m/s (duration: " + overspeedDurationMs + "ms, threshold: " + MIN_OVERSPEED_DURATION_MS + "ms)");
}
} else {
// 速度恢复正常,清除超速计时器
playerSpeedingSince.remove(playerName);
}
// 2. GroundSpoof检测 — 玩家声称在地面但Y轴速度异常
if (onGround) {
double dy = toY - fromY;
double verticalSpeed = Math.abs(dy) / deltaSeconds;
if (verticalSpeed > GROUND_VERTICAL_THRESHOLD) {
reasons.add("GROUND_SPOOF: claims on ground but vertical speed is "
+ String.format("%.2f", verticalSpeed) + " m/s (max ground: "
+ String.format("%.2f", GROUND_VERTICAL_THRESHOLD) + " m/s)");
}
}
// 3. 速度模式分析 — 检测机械性恒速运动
if (history.size() >= 10) {
double speedSum = 0;
double speedSumSq = 0;
int count = Math.min(history.size(), 10);
List<MovementRecord> recentRecords = history.subList(history.size() - count, history.size());
for (MovementRecord r : recentRecords) {
speedSum += r.horizontalSpeed;
speedSumSq += r.horizontalSpeed * r.horizontalSpeed;
}
double meanSpeed = speedSum / count;
double variance = (speedSumSq / count) - (meanSpeed * meanSpeed);
double stdDev = Math.sqrt(Math.max(0, variance));
// 速度非常恒定(标准差极小)且速度较高 — 典型的机械性Speed hack
if (stdDev < 0.1 && meanSpeed > 5.0) {
reasons.add("MECHANICAL_SPEED: constant speed " + String.format("%.2f", meanSpeed)
+ " m/s with stdDev " + String.format("%.3f", stdDev));
}
}
if (reasons.size() >= 2) {
return SpeedCheckResult.flagged(reasons);
} else if (reasons.size() == 1) {
return SpeedCheckResult.suspicious(reasons);
}
return SpeedCheckResult.clean();
}
/**
* 玩家离线时清理追踪数据
* @param playerName 离线玩家名称
*/
public void clearPlayer(String playerName) {
playerMovementHistory.remove(playerName);
playerSpeedingSince.remove(playerName);
playerSpeedStats.remove(playerName);
}
/**
* 获取模块运行状态
* @return 包含计数器、平均速度、最高速度的Map
*/
public Map<String, Object> getStatus() {
Map<String, Object> status = new LinkedHashMap<>();
status.put("totalSamples", totalSamples.get());
status.put("speedViolations", speedViolations.get());
status.put("activeTrackedPlayers", playerMovementHistory.size());
// 计算全局平均速度和最高速度
double globalAvgSpeed = 0;
double globalMaxSpeed = 0;
int totalSamples = 0;
for (SpeedStats stats : playerSpeedStats.values()) {
totalSamples += stats.totalSamples;
globalMaxSpeed = Math.max(globalMaxSpeed, stats.maxSpeed);
}
if (totalSamples > 0) {
double totalSpeedSum = 0;
for (SpeedStats stats : playerSpeedStats.values()) {
totalSpeedSum += stats.totalSpeed;
}
globalAvgSpeed = totalSpeedSum / totalSamples;
}
status.put("averageSpeed", String.format("%.2f", globalAvgSpeed));
status.put("maxSpeed", String.format("%.2f", globalMaxSpeed));
// 列出当前超速的玩家
List<String> overspeedingPlayers = new ArrayList<>(playerSpeedingSince.keySet());
status.put("overspeedingPlayers", overspeedingPlayers);
return status;
}
/**
* 内部移动记录 — 记录单次移动的空间和时间信息
*/
private static class MovementRecord {
final Instant timestamp;
final double fromX, fromY, fromZ;
final double toX, toY, toZ;
final boolean onGround;
final double horizontalSpeed;
final double deltaSeconds;
MovementRecord(Instant timestamp, double fromX, double fromY, double fromZ,
double toX, double toY, double toZ, boolean onGround,
double horizontalSpeed, double deltaSeconds) {
this.timestamp = timestamp;
this.fromX = fromX; this.fromY = fromY; this.fromZ = fromZ;
this.toX = toX; this.toY = toY; this.toZ = toZ;
this.onGround = onGround;
this.horizontalSpeed = horizontalSpeed;
this.deltaSeconds = deltaSeconds;
}
}
/**
* 速度统计内部类 — 追踪单个玩家的累加速度数据
*/
private static class SpeedStats {
double totalSpeed = 0;
int totalSamples = 0;
double maxSpeed = 0;
}
/**
* 速度检测结果 — 不可变结果类
*/
public static class SpeedCheckResult {
private final boolean flagged;
private final boolean suspicious;
private final List<String> reasons;
private SpeedCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
this.flagged = flagged;
this.suspicious = suspicious;
this.reasons = reasons;
}
/** 无异常 — 正常移动速度 */
public static SpeedCheckResult clean() {
return new SpeedCheckResult(false, false, List.of());
}
/** 可疑 — 速度略超阈值但可能是延迟导致 */
public static SpeedCheckResult suspicious(List<String> reasons) {
return new SpeedCheckResult(false, true, reasons);
}
/** 已标记 — 确认超速移动(持续超速或GroundSpoof) */
public static SpeedCheckResult flagged(List<String> reasons) {
return new SpeedCheckResult(true, false, reasons);
}
public boolean isFlagged() { return flagged; }
public boolean isSuspicious() { return suspicious; }
public boolean isClean() { return !flagged && !suspicious; }
public List<String> getReasons() { return reasons; }
}
}
@@ -0,0 +1,402 @@
package com.aluer.security;
import com.aluer.config.ServerGuardConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* Tab补全崩溃防护服务 — V4.0 服务器保护模块
*
* 检测原理:
* Minecraft的Tab补全系统(Command Suggestions)在解析补全请求时存在多个已知漏洞。
* 特殊构造的补全请求——如超长字符串、深度嵌套引号/括号、无效UTF-8序列——
* 可能导致服务器端的Brigadier命令解析器陷入死循环或触发OOM,最终导致服务器崩溃。
* 此外,高频补全请求也是一种洪水攻击变体,可消耗大量服务器CPU资源。
*
* 已知漏洞示例:
* - MC-156660: 超长Tab补全请求导致OOM
* - MC-164312: 深度嵌套引号导致解析器递归溢出
* - MC-191726: 无效UTF-8序列导致解析器异常
* - Tab补全洪水: 高频请求导致服务器CPU飙升
*
* 检测维度:
* - 请求长度检测(>8192字符为极端异常,正常<256字符)
* - 深度嵌套检测(引号/括号嵌套>50层为递归溢出风险)
* - 频率检测(>20次/秒为洪水攻击)
* - 无效UTF-8序列检测
* - 特殊字符滥用检测
*
* 配置开关:serverguard.security.super-evolution.anti-tab-complete-crash
*/
@Service
public class AntiTabCompleteCrashService {
private final ServerGuardConfig config;
private final Map<String, List<TabEvent>> tabHistory = new ConcurrentHashMap<>();
private final Map<String, Instant> rateLimited = new ConcurrentHashMap<>();
private final Map<String, AtomicLong> violationCounts = new ConcurrentHashMap<>();
private final AtomicLong totalTabRequests = new AtomicLong(0);
private final AtomicLong crashPrevented = new AtomicLong(0);
private final AtomicLong rateLimitedRequests = new AtomicLong(0);
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
/** Tab补全请求最大允许长度 */
private static final int MAX_REQUEST_LENGTH = 8192;
/** 正常Tab补全请求建议长度上限 */
private static final int NORMAL_REQUEST_LENGTH = 256;
/** 最大允许的引号/括号嵌套深度 */
private static final int MAX_NEST_DEPTH = 50;
/** 每玩家每秒最大允许的Tab补全请求数 */
private static final int MAX_TAB_REQUESTS_PER_SEC = 20;
/** 频率检测窗口(秒) */
private static final int FREQUENCY_WINDOW_SECONDS = 5;
/** 限流封禁时间(秒) */
private static final long RATE_LIMIT_BLOCK_SECONDS = 60;
public AntiTabCompleteCrashService() {
this(new ServerGuardConfig());
}
@Autowired
public AntiTabCompleteCrashService(ServerGuardConfig config) {
this.config = config;
scheduler.scheduleAtFixedRate(this::cleanupOldData, 30, 60, TimeUnit.SECONDS);
}
/**
* 检查Tab补全请求是否安全。
*
* @param player 发起补全请求的玩家
* @param commandText 正在补全的命令文本
* @param cursorPosition 光标位置
* @param requestId 请求的事务ID
* @return 检查结果
*/
public TabCheckResult check(String player, String commandText, int cursorPosition, int requestId) {
// 配置开关检查
if (!config.getSecurity().getSuperEvolution().isAntiTabCompleteCrash()) {
return TabCheckResult.clean();
}
totalTabRequests.incrementAndGet();
List<String> reasons = new ArrayList<>();
int score = 0;
// 记录事件
TabEvent event = new TabEvent(Instant.now(), player, commandText, cursorPosition, requestId);
tabHistory.computeIfAbsent(player, k -> Collections.synchronizedList(new ArrayList<>())).add(event);
// ---- 检测1:频率限制 ----
// 正常玩家Tab补全频率不会超过每秒数次
// >20次/秒明确是自动化洪水攻击
Instant now = Instant.now();
List<TabEvent> playerEvents = tabHistory.get(player);
long recentCount = playerEvents.stream()
.filter(e -> e.time.isAfter(now.minusSeconds(FREQUENCY_WINDOW_SECONDS)))
.count();
long requestsPerSec = recentCount / FREQUENCY_WINDOW_SECONDS;
if (requestsPerSec > MAX_TAB_REQUESTS_PER_SEC) {
score += 50;
reasons.add("TAB_FLOOD: " + requestsPerSec + " requests/sec (threshold: " + MAX_TAB_REQUESTS_PER_SEC + ")");
rateLimitedRequests.incrementAndGet();
// 限流该玩家
rateLimited.put(player, Instant.now().plusSeconds(RATE_LIMIT_BLOCK_SECONDS));
}
// ---- 检测2:请求长度 ----
// 正常Tab补全请求通常<256字符(命令行长度限制)
// 超过8192字符明确是恶意请求,利用Brigadier解析器的O(N^2)行为
if (commandText != null) {
int textLen = commandText.length();
if (textLen > MAX_REQUEST_LENGTH) {
score += 70;
reasons.add("OVERSIZED_REQUEST: " + textLen + " chars (max: " + MAX_REQUEST_LENGTH + ")");
} else if (textLen > NORMAL_REQUEST_LENGTH * 4) { // >1024
score += 30;
reasons.add("SUSPICIOUS_LENGTH: " + textLen + " chars (normal < " + NORMAL_REQUEST_LENGTH + ")");
} else if (textLen > NORMAL_REQUEST_LENGTH * 2) { // >512
score += 10;
reasons.add("ABNORMAL_LENGTH: " + textLen + " chars");
}
// ---- 检测3:深度嵌套引号/括号 ----
// 深度嵌套的引号或括号会触发Brigadier解析器的递归机制
// 嵌套层数>50时递归调用栈可能溢出
int nestedDepth = calculateNestedDepth(commandText);
if (nestedDepth > MAX_NEST_DEPTH) {
score += 75; // 深度嵌套为严重威胁,直接触发blocked(>=70即阻止)
reasons.add("DEEP_NESTING: nested depth=" + nestedDepth + " (max: " + MAX_NEST_DEPTH + ")");
} else if (nestedDepth > 20) {
score += 20;
reasons.add("MODERATE_NESTING: nested depth=" + nestedDepth);
}
// ---- 检测4:无效UTF-8序列 ----
// 无效的UTF-8字节序列在解析时可能导致NIO异常
// 这是MC-191726等相关漏洞的攻击向量
byte[] bytes = commandText.getBytes(StandardCharsets.UTF_8);
// 检查是否包含无效的UTF-8字节序列
int invalidSeqCount = countInvalidUtf8Sequences(bytes);
if (invalidSeqCount > 0) {
score += 40;
reasons.add("INVALID_UTF8: " + invalidSeqCount + " invalid byte sequences detected");
}
// ---- 检测5:Null字符检测 ----
// Java字符串中的null字符(\0)可能导致字符串截断或缓冲区异常
if (commandText.indexOf('\0') >= 0) {
score += 50;
reasons.add("NULL_CHAR: contains null character (\\\\0)");
}
// ---- 检测6:重复字符模式(可能导致解析器性能退化) ----
// 大量重复括号或引号可能导致O(N^2)复杂度的解析路径
int repeatedCharScore = checkRepeatedPatterns(commandText);
if (repeatedCharScore > 0) {
score += repeatedCharScore;
reasons.add("REPEATED_PATTERNS: excessive repeated special characters");
}
// ---- 检测7:Unicode方向控制字符 ----
// 双向文本控制字符(如RLO/LRO)可能导致显示混乱或被用于混淆
for (char c : commandText.toCharArray()) {
if (c == '‪' || c == '‫' || c == '‬'
|| c == '‭' || c == '‮'
|| c == '⁦' || c == '⁧' || c == '⁨' || c == '⁩') {
score += 15;
reasons.add("UNICODE_BIDI: contains Unicode bidirectional control characters");
break;
}
}
}
// 如果该玩家被限流,额外增加评分
if (rateLimited.containsKey(player)) {
if (Instant.now().isAfter(rateLimited.get(player))) {
rateLimited.remove(player);
} else {
score += 30;
reasons.add("RATE_LIMITED: player is currently rate-limited");
}
}
// 判定结果
if (score >= 70) {
crashPrevented.incrementAndGet();
violationCounts.computeIfAbsent(player, k -> new AtomicLong(0)).incrementAndGet();
return TabCheckResult.blocked(reasons, score);
} else if (score >= 30) {
return TabCheckResult.suspicious(reasons, score);
}
return TabCheckResult.clean();
}
/**
* 计算命令文本中引号和括号的嵌套深度。
* 模拟Brigadier解析器的基本行为模式,检测可能导致递归溢出的深度嵌套。
*/
private int calculateNestedDepth(String text) {
int maxDepth = 0;
int currentDepth = 0;
for (char c : text.toCharArray()) {
if (c == '(' || c == '[' || c == '{' || c == '"') {
currentDepth++;
maxDepth = Math.max(maxDepth, currentDepth);
} else if (c == ')' || c == ']' || c == '}' || c == '"') {
currentDepth = Math.max(0, currentDepth - 1);
}
}
return maxDepth;
}
/**
* 检测UTF-8字节序列中的无效序列数量。
* 对前4096字节进行快速采样检测。
*/
private int countInvalidUtf8Sequences(byte[] bytes) {
int invalid = 0;
int maxCheck = Math.min(bytes.length, 4096);
for (int i = 0; i < maxCheck; i++) {
byte b = bytes[i];
if ((b & 0x80) == 0) continue; // ASCII
// UTF-8多字节序列检测
if ((b & 0xE0) == 0xC0) {
// 2字节序列
if (i + 1 >= bytes.length || (bytes[i + 1] & 0xC0) != 0x80) {
invalid++;
continue;
}
i++;
} else if ((b & 0xF0) == 0xE0) {
// 3字节序列
if (i + 2 >= bytes.length
|| (bytes[i + 1] & 0xC0) != 0x80
|| (bytes[i + 2] & 0xC0) != 0x80) {
invalid++;
continue;
}
// 检查是否过高代理(0xED A0-0xED BF)
if ((b & 0xFF) == 0xED && (bytes[i + 1] & 0xA0) == 0xA0) {
invalid++;
}
i += 2;
} else if ((b & 0xF8) == 0xF0) {
// 4字节序列
if (i + 3 >= bytes.length
|| (bytes[i + 1] & 0xC0) != 0x80
|| (bytes[i + 2] & 0xC0) != 0x80
|| (bytes[i + 3] & 0xC0) != 0x80) {
invalid++;
continue;
}
// 检查是否超过Unicode范围
int codepoint = ((bytes[i] & 0x07) << 18)
| ((bytes[i + 1] & 0x3F) << 12)
| ((bytes[i + 2] & 0x3F) << 6)
| (bytes[i + 3] & 0x3F);
if (codepoint > 0x10FFFF) {
invalid++;
}
i += 3;
} else {
// 无效的起始字节
invalid++;
}
}
return invalid;
}
/**
* 检测重复字符模式,可能导致解析器性能退化。
*/
private int checkRepeatedPatterns(String text) {
int score = 0;
// 检测连续超过100个相同括号/引号
int consecutiveQuotes = 0;
int maxConsecutiveQuotes = 0;
int consecutiveBrackets = 0;
int maxConsecutiveBrackets = 0;
for (char c : text.toCharArray()) {
if (c == '"') {
consecutiveQuotes++;
maxConsecutiveQuotes = Math.max(maxConsecutiveQuotes, consecutiveQuotes);
consecutiveBrackets = 0;
} else if (c == '(' || c == ')') {
consecutiveBrackets++;
maxConsecutiveBrackets = Math.max(maxConsecutiveBrackets, consecutiveBrackets);
consecutiveQuotes = 0;
} else {
consecutiveQuotes = 0;
consecutiveBrackets = 0;
}
}
if (maxConsecutiveQuotes > 100) score += 25;
if (maxConsecutiveBrackets > 100) score += 25;
return score;
}
public Map<String, Object> getStatus() {
Map<String, Object> s = new LinkedHashMap<>();
s.put("totalTabRequests", totalTabRequests.get());
s.put("crashPrevented", crashPrevented.get());
s.put("rateLimitedRequests", rateLimitedRequests.get());
s.put("currentlyRateLimited", rateLimited.size());
s.put("trackedPlayers", tabHistory.size());
// 请求频率最高的玩家
Instant now = Instant.now();
List<Map<String, Object>> freqPlayers = new ArrayList<>();
for (Map.Entry<String, List<TabEvent>> e : tabHistory.entrySet()) {
long count = e.getValue().stream()
.filter(ev -> ev.time.isAfter(now.minusSeconds(10)))
.count();
if (count > 0) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("player", e.getKey());
m.put("requests10s", count);
m.put("rate", String.format("%.1f/s", count / 10.0));
m.put("violations", violationCounts.getOrDefault(e.getKey(), new AtomicLong(0)).get());
freqPlayers.add(m);
}
}
freqPlayers.sort((a, b) -> Long.compare((Long) b.get("requests10s"), (Long) a.get("requests10s")));
s.put("topRequesters", freqPlayers.subList(0, Math.min(freqPlayers.size(), 10)));
return s;
}
private void cleanupOldData() {
Instant cutoff = Instant.now().minusSeconds(300); // 5分钟
for (List<TabEvent> events : tabHistory.values()) {
events.removeIf(e -> e.time.isBefore(cutoff));
}
tabHistory.entrySet().removeIf(e -> e.getValue().isEmpty());
rateLimited.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now()));
}
private static class TabEvent {
final Instant time;
final String player;
final String commandText;
final int cursorPosition;
final int requestId;
TabEvent(Instant t, String p, String ct, int cp, int rid) {
this.time = t;
this.player = p;
this.commandText = ct;
this.cursorPosition = cp;
this.requestId = rid;
}
}
public static class TabCheckResult {
private final boolean blocked;
private final boolean suspicious;
private final int score;
private final List<String> reasons;
private TabCheckResult(boolean blocked, boolean suspicious, int score, List<String> reasons) {
this.blocked = blocked;
this.suspicious = suspicious;
this.score = score;
this.reasons = reasons;
}
public static TabCheckResult clean() {
return new TabCheckResult(false, false, 0, List.of());
}
public static TabCheckResult blocked(List<String> reasons, int score) {
return new TabCheckResult(true, false, score, reasons);
}
public static TabCheckResult suspicious(List<String> reasons, int score) {
return new TabCheckResult(false, true, score, reasons);
}
public boolean isBlocked() { return blocked; }
public boolean isSuspicious() { return suspicious; }
public boolean isClean() { return !blocked && !suspicious; }
public int getScore() { return score; }
public List<String> getReasons() { return reasons; }
}
}
@@ -0,0 +1,412 @@
package com.aluer.security;
import com.aluer.config.ServerGuardConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* VPN/代理/托管IP检测 — V4.0 访问控制模块
*
* 检测原理:
* 1. 检测玩家连接IP是否来自已知VPN服务商、代理服务器或数据中心(托管IP)
* 2. 维护已知VPN/代理/托管服务商ASN黑名单:
* - AS14061 (DigitalOcean) — 常见VPS用作代理
* - AS16276 (OVH) — 法国数据中心/VPS
* - AS24940 (Hetzner) — 德国数据中心/VPS
* - AS20473 (Vultr) — 美国VPS
* - AS16509, AS14618 (AWS) — 云计算IP
* - AS8075 (Microsoft Azure) — 云计算IP
* - AS396982 (Google Cloud) — 云计算IP
* - AS13335 (Cloudflare WARP) — VPN/代理
* - AS62567, AS53667 — 匿名代理/VPN提供商
* 3. 检测数据中心IP段:维护常见云服务商IP段对应关系
* 4. 检测Tor出口节点(Tor出口节点列表特征)
* 5. 检测住宅代理特征(通过IP反向DNS、ISP名称等特征判断)
* 6. 区分合法与恶意VPN使用——支持配置信任的VPN IP白名单,允许管理员指定放行IP
*
* 配置开关:serverguard.security.super-evolution.anti-vpn-proxy
*/
@Service
public class AntiVPNProxyService {
private final ServerGuardConfig config;
/** IP地址 -> 历史检测结果缓存 */
private final Map<String, IPCheckResult> ipCheckCache = new ConcurrentHashMap<>();
/** IP地址 -> 缓存时间戳(毫秒) */
private final Map<String, Long> ipCheckTimestamps = new ConcurrentHashMap<>();
/** 信任的IP白名单(即使VPN也不拦截) */
private final Set<String> trustedIPs = ConcurrentHashMap.newKeySet();
/** 已检测VPN的IP列表 */
private final Set<String> vpnDetectedIPs = ConcurrentHashMap.newKeySet();
/** 已检测代理的IP列表 */
private final Set<String> proxyDetectedIPs = ConcurrentHashMap.newKeySet();
/** 已检测托管IP的列表 */
private final Set<String> hostingDetectedIPs = ConcurrentHashMap.newKeySet();
private final AtomicLong totalChecks = new AtomicLong(0);
private final AtomicLong vpnDetected = new AtomicLong(0);
private final AtomicLong proxyDetected = new AtomicLong(0);
private final AtomicLong hostingDetected = new AtomicLong(0);
/** IP检测缓存有效期(毫秒,默认1小时) */
private static final long CACHE_TTL_MS = 3600000;
/** 已知VPN/代理/托管服务商ASN及其名称 */
private static final Map<String, String> KNOWN_VPN_ASNS = Map.ofEntries(
Map.entry("14061", "DigitalOcean"),
Map.entry("16276", "OVH"),
Map.entry("24940", "Hetzner"),
Map.entry("20473", "Vultr"),
Map.entry("16509", "AWS"),
Map.entry("14618", "AWS"),
Map.entry("8075", "Microsoft Azure"),
Map.entry("396982", "Google Cloud"),
Map.entry("13335", "Cloudflare WARP"),
Map.entry("62567", "Anonymous VPN Provider"),
Map.entry("53667", "Anonymous VPN/Proxy Provider"),
Map.entry("40676", "Psychz Networks"),
Map.entry("36352", "ColoCrossing"),
Map.entry("8100", "QuadraNet"),
Map.entry("46606", "Unified Layer"),
Map.entry("202425", "M247"),
Map.entry("206092", "M247")
);
/** 已知数据中心/IP段前缀(CIDR /16和/24级别) */
private static final List<String> DATACENTER_IP_PREFIXES = List.of(
"45.32.", // Vultr
"45.63.", // Vultr
"45.76.", // Vultr
"45.77.", // Vultr
"104.238.", // Vultr
"139.180.", // Vultr
"149.28.", // Vultr
"159.65.", // DigitalOcean
"159.89.", // DigitalOcean
"167.99.", // DigitalOcean
"142.93.", // DigitalOcean
"165.227.", // DigitalOcean
"198.211.", // DigitalOcean
"51.89.", // OVH
"54.38.", // OVH
"94.23.", // OVH
"178.33.", // OVH
"188.165.", // OVH
"213.32.", // OVH
"95.216.", // Hetzner
"88.198.", // Hetzner
"136.243.", // Hetzner
"78.46.", // Hetzner
"116.202.", // Hetzner
"50.16.", // AWS
"52.0.", // AWS
"54.80.", // AWS
"34.192.", // AWS
"35.160.", // AWS
"40.112.", // Azure
"20.0.", // Azure
"34.64.", // Google Cloud
"35.192." // Google Cloud
);
/** 无参构造函数,使用默认配置 */
public AntiVPNProxyService() {
this(new ServerGuardConfig());
}
@Autowired
public AntiVPNProxyService(ServerGuardConfig config) {
this.config = config;
}
/**
* 检测玩家IP是否属于VPN/代理/托管数据中心IP
* 实际生产环境会结合外部IP信誉API,此处使用本地规则引擎
*
* @param ip 玩家IP地址
* @param asn 该IP所属的ASN编号(可从GeoIP数据库获取,可为null)
* @param ispName ISP名称(可为null)
* @param playerName 玩家名(用于日志记录)
* @return 检测结果,包含IP类型和原因
*/
public VPNCheckResult checkIP(String ip, String asn, String ispName, String playerName) {
totalChecks.incrementAndGet();
// 配置开关检查
if (!config.getSecurity().getSuperEvolution().isAntiVpnProxy()) {
return VPNCheckResult.clean();
}
if (ip == null || ip.isBlank()) {
return VPNCheckResult.clean();
}
// 信任白名单检查——允许管理员配置信任的VPN IP放行
if (trustedIPs.contains(ip)) {
return VPNCheckResult.clean();
}
// 检查缓存(避免重复查询外部API)
Long cachedTime = ipCheckTimestamps.get(ip);
if (cachedTime != null && (System.currentTimeMillis() - cachedTime) < CACHE_TTL_MS) {
IPCheckResult cached = ipCheckCache.get(ip);
if (cached != null) {
if (cached.ipType != IPType.NORMAL) {
return buildResult(cached.ipType, cached.reasons);
}
return VPNCheckResult.clean();
}
}
List<String> reasons = new ArrayList<>();
IPType resultType = IPType.NORMAL;
// 检查1:ASN匹配检测(已知VPN/代理/托管服务商)
if (asn != null && !asn.isBlank()) {
String provider = KNOWN_VPN_ASNS.get(asn.trim());
if (provider != null) {
resultType = classifyByProvider(provider);
reasons.add("ASN_MATCH: IP " + ip + " belongs to AS" + asn + " (" + provider + ")"
+ " — known " + resultType.getDisplayName() + " provider");
}
}
// 检查2:数据中心IP段前缀匹配
if (resultType == IPType.NORMAL) {
for (String prefix : DATACENTER_IP_PREFIXES) {
if (ip.startsWith(prefix)) {
resultType = IPType.HOSTING;
reasons.add("DATACENTER_RANGE: IP " + ip + " falls within known "
+ "datacenter IP range (prefix: " + prefix + ")");
break;
}
}
}
// 检查3:ISP名称特征检测(Hosting/Cloud/Data Center等关键词)
if (resultType == IPType.NORMAL && ispName != null && !ispName.isBlank()) {
String lowerIsp = ispName.toLowerCase();
if (lowerIsp.contains("hosting") || lowerIsp.contains("vps")
|| lowerIsp.contains("cloud") || lowerIsp.contains("data center")
|| lowerIsp.contains("server") || lowerIsp.contains("dedicated")) {
// 排除合法的家庭ISP
if (!lowerIsp.contains("telkom") && !lowerIsp.contains("comcast")
&& !lowerIsp.contains("verizon") && !lowerIsp.contains("att")
&& !lowerIsp.contains("bt ") && !lowerIsp.contains("spectrum")) {
resultType = IPType.HOSTING;
reasons.add("HOSTING_ISP: ISP \"" + ispName
+ "\" indicates hosting/datacenter provider");
}
}
if (lowerIsp.contains("vpn") || lowerIsp.contains("proxy")
|| lowerIsp.contains("tunnel")) {
resultType = IPType.VPN;
reasons.add("VPN_ISP: ISP \"" + ispName + "\" identifies as VPN service");
}
}
// 记录检测结果
IPCheckResult checkResult = new IPCheckResult(ip, resultType, reasons, Instant.now());
ipCheckCache.put(ip, checkResult);
ipCheckTimestamps.put(ip, System.currentTimeMillis());
// 更新分类计数
switch (resultType) {
case VPN:
vpnDetectedIPs.add(ip);
vpnDetected.incrementAndGet();
break;
case PROXY:
proxyDetectedIPs.add(ip);
proxyDetected.incrementAndGet();
break;
case HOSTING:
hostingDetectedIPs.add(ip);
hostingDetected.incrementAndGet();
break;
default:
break;
}
if (resultType != IPType.NORMAL) {
return buildResult(resultType, reasons);
}
return VPNCheckResult.clean();
}
/**
* 添加信任的IP地址到白名单
*
* @param ip IP地址
*/
public void addTrustedIP(String ip) {
if (ip != null && !ip.isBlank()) {
trustedIPs.add(ip);
// 清理该IP之前的缓存结果
ipCheckCache.remove(ip);
ipCheckTimestamps.remove(ip);
}
}
/**
* 从信任白名单中移除IP
*
* @param ip IP地址
*/
public void removeTrustedIP(String ip) {
if (ip != null) {
trustedIPs.remove(ip);
}
}
/**
* 获取所有信任的IP白名单
*
* @return 信任IP集合
*/
public Set<String> getTrustedIPs() {
return new HashSet<>(trustedIPs);
}
/**
* 获取缓存中某IP的历史检测结果
*
* @param ip IP地址
* @return 检测结果Map,缓存过期或不存在则返回null
*/
public Map<String, Object> getCachedIPResult(String ip) {
Long ts = ipCheckTimestamps.get(ip);
if (ts == null || (System.currentTimeMillis() - ts) > CACHE_TTL_MS) {
return null;
}
IPCheckResult result = ipCheckCache.get(ip);
if (result == null) return null;
Map<String, Object> m = new LinkedHashMap<>();
m.put("ip", result.ip);
m.put("type", result.ipType.name());
m.put("displayName", result.ipType.getDisplayName());
m.put("reasons", result.reasons);
m.put("checkTime", result.checkTime.toString());
return m;
}
/**
* 获取模块运行状态
*
* @return 状态Map,包含totalChecks/vpnDetected/proxyDetected/hostingDetected
*/
public Map<String, Object> getStatus() {
Map<String, Object> s = new LinkedHashMap<>();
s.put("enabled", config.getSecurity().getSuperEvolution().isAntiVpnProxy());
s.put("totalChecks", totalChecks.get());
s.put("vpnDetected", vpnDetected.get());
s.put("proxyDetected", proxyDetected.get());
s.put("hostingDetected", hostingDetected.get());
s.put("trustedIPs", trustedIPs.size());
s.put("cachedIPs", ipCheckCache.size());
s.put("vpnDetectedIPs", new ArrayList<>(vpnDetectedIPs));
s.put("proxyDetectedIPs", new ArrayList<>(proxyDetectedIPs));
s.put("hostingDetectedIPs", new ArrayList<>(hostingDetectedIPs));
return s;
}
// ==================== 内部辅助方法 ====================
/** 根据提供商名称分类IP类型 */
private IPType classifyByProvider(String provider) {
String lower = provider.toLowerCase();
if (lower.contains("vpn") || lower.contains("warp")) return IPType.VPN;
if (lower.contains("proxy")) return IPType.PROXY;
// 云计算/VPS提供商归类为托管IP
return IPType.HOSTING;
}
/** 构造检测结果对象 */
private VPNCheckResult buildResult(IPType type, List<String> reasons) {
return new VPNCheckResult(type, reasons);
}
// ==================== 内部数据类和枚举 ====================
/** IP类型枚举 */
public enum IPType {
NORMAL("Normal Residential"), // 普通家庭/企业IP
VPN("VPN"), // VPN/代理IP
PROXY("Proxy"), // 代理服务器IP
HOSTING("Hosting/Data Center"); // 数据中心/托管IP
private final String displayName;
IPType(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() { return displayName; }
}
/** IP检测缓存记录 */
private static class IPCheckResult {
final String ip;
final IPType ipType;
final List<String> reasons;
final Instant checkTime;
IPCheckResult(String ip, IPType ipType, List<String> reasons, Instant checkTime) {
this.ip = ip;
this.ipType = ipType;
this.reasons = reasons;
this.checkTime = checkTime;
}
}
/** VPN/代理检测结果 */
public static class VPNCheckResult {
private final boolean isVPN;
private final boolean isProxy;
private final boolean isHosting;
private final List<String> reasons;
private VPNCheckResult(IPType type, List<String> reasons) {
this.isVPN = type == IPType.VPN;
this.isProxy = type == IPType.PROXY;
this.isHosting = type == IPType.HOSTING;
this.reasons = reasons;
}
private VPNCheckResult(boolean vpn, boolean proxy, boolean hosting, List<String> reasons) {
this.isVPN = vpn;
this.isProxy = proxy;
this.isHosting = hosting;
this.reasons = reasons;
}
public static VPNCheckResult clean() {
return new VPNCheckResult(false, false, false, List.of());
}
public static VPNCheckResult vpnDetected(List<String> reasons) {
return new VPNCheckResult(true, false, false, reasons);
}
public static VPNCheckResult proxyDetected(List<String> reasons) {
return new VPNCheckResult(false, true, false, reasons);
}
public static VPNCheckResult hostingDetected(List<String> reasons) {
return new VPNCheckResult(false, false, true, reasons);
}
public boolean isVPN() { return isVPN; }
public boolean isProxy() { return isProxy; }
public boolean isHosting() { return isHosting; }
public boolean isClean() { return !isVPN && !isProxy && !isHosting; }
public List<String> getReasons() { return reasons; }
}
}
@@ -0,0 +1,427 @@
package com.aluer.security;
import com.aluer.config.ServerGuardConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 后门插件/恶意JAR扫描 — V4.0 访问控制模块
*
* 检测原理:
* 1. 扫描服务器plugins目录下的所有JAR文件,识别已知恶意后门插件
* 2. 维护已知恶意插件名称黑名单(PluginHider, SystemX, AntiStalker, OpHider等20+后门变种)
* 3. 检测可疑插件特征:缺少plugin.yml的JAR、包含不明主类的JAR
* 4. 检测远程命令执行(RCE)嫌疑:通过分析类名和嵌入的代码字符串查找
* org.bukkit.craftbukkit.executor / java.lang.Runtime.exec 等调用
* 5. 检测JAR中隐藏的可疑配置——通过特征关键词(pass, host, port, shit, backdoor, token等)
* 推断插件可能包含硬编码后门凭据
* 6. 检测插件尝试加载字节码操作库(Reflections/ASM/Javassist等)用于隐藏自身或动态注入
* 7. 对每个已加载插件标记风险等级:SAFE(安全)、SUSPICIOUS(可疑)、MALICIOUS(恶意)
*
* 配置开关:serverguard.security.super-evolution.backdoor-plugin-scanner
*/
@Service
public class BackdoorPluginScannerService {
private final ServerGuardConfig config;
/** 已扫描插件名 -> 扫描结果 */
private final Map<String, PluginScanEntry> scannedPlugins = new ConcurrentHashMap<>();
/** 恶意插件列表 */
private final Set<String> maliciousPlugins = ConcurrentHashMap.newKeySet();
/** 可疑插件列表 */
private final Set<String> suspiciousPlugins = ConcurrentHashMap.newKeySet();
private final AtomicLong totalPlugins = new AtomicLong(0);
private final AtomicLong totalScans = new AtomicLong(0);
/** 已知恶意插件名称(20+后门变种) */
private static final Set<String> KNOWN_MALICIOUS_PLUGINS = Set.of(
"PluginHider", "SystemX", "AntiStalker", "OpHider",
"ConsoleSpamFixer", "BackdoorPlugin", "OpsGuard",
"HiddenAdmin", "ForceOP", "OPMe",
"PluginStealer", "ServerCrasher", "JarLoader",
"RemoteCmd", "SilentOP", "AdminToolz",
"MineSecure", "CraftHack", "BukkitBackdoor",
"CommandExploit", "HackLoader", "OPFinder",
"zPlugin", "xDPlugin", "SystemSpoof",
"ConfigStealer", "SessionLogger"
);
/** 可疑插件名称模式(正则,不区分大小写) */
private static final List<String> SUSPICIOUS_NAME_PATTERNS = List.of(
".*[Bb]ack[Dd]oor.*",
".*[Hh]ack.*",
".*[Ee]xploit.*",
".*[Ss]teal.*",
".*[Hh]idden.*",
".*[Ff]orce.*",
".*[Rr]emote.*",
".*[Cc]rack.*",
".*[Ii]nject.*",
".*[Pp]assword.*"
);
/** RCE相关类名关键词 */
private static final Set<String> RCE_CLASS_KEYWORDS = Set.of(
"Runtime.exec",
"ProcessBuilder",
"org.bukkit.craftbukkit.executor",
"java.lang.reflect",
"sun.misc.Unsafe",
"javax.script.ScriptEngine",
"org.yaml.snakeyaml.Yaml"
);
/** 字节码操作库特征类名 */
private static final Set<String> BYTECODE_MANIPULATION_CLASSES = Set.of(
"org.objectweb.asm",
"org.reflections",
"javassist",
"net.bytebuddy",
"org.ow2.asm",
"org.apache.bcel",
"javax.tools.JavaCompiler"
);
/** 可疑配置键关键词 */
private static final Set<String> SUSPICIOUS_CONFIG_KEYS = Set.of(
"pass", "password", "pwd",
"host", "ip", "address",
"port",
"token", "key", "secret",
"backdoor", "backdoor-enabled",
"shit", "fuck",
"admin-bypass",
"hide-commands",
"silent-mode",
"auto-op",
"webhook", "discord-webhook",
"c2-server", "c2",
"reverse-shell",
"exec", "execute-on-join"
);
/** 无参构造函数,使用默认配置 */
public BackdoorPluginScannerService() {
this(new ServerGuardConfig());
}
@Autowired
public BackdoorPluginScannerService(ServerGuardConfig config) {
this.config = config;
}
/**
* 扫描单个插件JAR(基于元数据进行分析)
* 在实际部署中,pluginClasses参数应来自JAR文件的实际类名列表
*
* @param pluginName 插件名称(来自plugin.yml的name字段)
* @param jarFileName JAR文件名
* @param hasPluginYml 是否包含plugin.yml文件
* @param mainClass 插件主类(来自plugin.yml的main字段)
* @param pluginClasses 插件JAR中包含的所有类名列表
* @param configKeys 插件config.yml中的配置键列表
* @return 扫描结果,包含风险等级和详细原因
*/
public PluginScanResult scanPlugin(String pluginName, String jarFileName,
boolean hasPluginYml, String mainClass,
List<String> pluginClasses, List<String> configKeys) {
totalScans.incrementAndGet();
// 配置开关检查
if (!config.getSecurity().getSuperEvolution().isBackdoorPluginScanner()) {
return PluginScanResult.clean(pluginName);
}
List<String> reasons = new ArrayList<>();
RiskLevel riskLevel = RiskLevel.SAFE;
if (pluginName == null || pluginName.isBlank()) {
pluginName = jarFileName != null ? jarFileName.replace(".jar", "") : "unknown";
}
// 检查1:已知恶意插件名称匹配
if (KNOWN_MALICIOUS_PLUGINS.contains(pluginName)) {
riskLevel = RiskLevel.MALICIOUS;
reasons.add("KNOWN_MALICIOUS_PLUGIN: Plugin name \"" + pluginName
+ "\" matches known backdoor/malware plugin");
maliciousPlugins.add(pluginName);
}
// 检查2:可疑插件名称模式匹配
if (riskLevel == RiskLevel.SAFE) {
for (String pattern : SUSPICIOUS_NAME_PATTERNS) {
if (pluginName.toLowerCase().matches(pattern.toLowerCase())) {
riskLevel = RiskLevel.SUSPICIOUS;
reasons.add("SUSPICIOUS_PLUGIN_NAME: \"" + pluginName
+ "\" matches suspicious naming pattern \"" + pattern + "\"");
suspiciousPlugins.add(pluginName);
break;
}
}
}
// 检查3:缺少plugin.yml的JAR(合法Minecraft插件必须包含plugin.yml)
if (!hasPluginYml) {
if (riskLevel.ordinal() < RiskLevel.SUSPICIOUS.ordinal()) {
riskLevel = RiskLevel.SUSPICIOUS;
}
reasons.add("MISSING_PLUGIN_YML: JAR file \"" + jarFileName
+ "\" does not contain plugin.yml — not a standard Bukkit/Paper plugin");
suspiciousPlugins.add(pluginName);
}
// 检查4:检测RCE相关类名(远程命令执行嫌疑)
if (pluginClasses != null && !pluginClasses.isEmpty()) {
for (String className : pluginClasses) {
for (String rceKeyword : RCE_CLASS_KEYWORDS) {
if (className.contains(rceKeyword)) {
if (riskLevel.ordinal() < RiskLevel.SUSPICIOUS.ordinal()) {
riskLevel = RiskLevel.SUSPICIOUS;
}
reasons.add("RCE_CAPABILITY: Class \"" + className
+ "\" indicates remote command execution capability");
suspiciousPlugins.add(pluginName);
break;
}
}
}
}
// 检查5:检测字节码操作库依赖(用于隐藏自身/动态注入)
if (pluginClasses != null && !pluginClasses.isEmpty()) {
for (String className : pluginClasses) {
for (String bcClass : BYTECODE_MANIPULATION_CLASSES) {
if (className.contains(bcClass)) {
if (riskLevel.ordinal() < RiskLevel.SUSPICIOUS.ordinal()) {
riskLevel = RiskLevel.SUSPICIOUS;
}
reasons.add("BYTECODE_MANIPULATION: Class \"" + className
+ "\" bundles bytecode manipulation library, "
+ "potentially hiding malicious behavior");
suspiciousPlugins.add(pluginName);
break;
}
}
}
}
// 检查6:检测隐藏的可疑配置键(硬编码后门凭据特征)
if (configKeys != null && !configKeys.isEmpty()) {
for (String configKey : configKeys) {
String lowerKey = configKey.toLowerCase();
for (String suspiciousKey : SUSPICIOUS_CONFIG_KEYS) {
if (lowerKey.contains(suspiciousKey)) {
if (riskLevel.ordinal() < RiskLevel.SUSPICIOUS.ordinal()) {
riskLevel = RiskLevel.SUSPICIOUS;
}
reasons.add("SUSPICIOUS_CONFIG_KEY: Config contains suspicious key \""
+ configKey + "\", possible hardcoded backdoor credential");
suspiciousPlugins.add(pluginName);
break;
}
}
}
}
// 检查7:主类为奇怪的非标准类名
if (mainClass != null && !mainClass.isBlank()) {
// 合法主类通常遵循 com.xxx.plugin.PluginMain 格式
if (!mainClass.contains(".")) {
if (riskLevel.ordinal() < RiskLevel.SUSPICIOUS.ordinal()) {
riskLevel = RiskLevel.SUSPICIOUS;
}
reasons.add("SUSPICIOUS_MAIN_CLASS: Main class \"" + mainClass
+ "\" has no package — may be obfuscated");
suspiciousPlugins.add(pluginName);
}
}
// 存储扫描结果
PluginScanEntry entry = new PluginScanEntry(pluginName, jarFileName, riskLevel,
reasons, Instant.now());
scannedPlugins.put(pluginName, entry);
if (!scannedPlugins.containsKey(pluginName)) {
totalPlugins.incrementAndGet();
}
return new PluginScanResult(pluginName, riskLevel, reasons);
}
/**
* 快速检查:仅通过插件名称判断是否为已知恶意插件
*
* @param pluginName 插件名称
* @return 扫描结果
*/
public PluginScanResult quickScan(String pluginName) {
totalScans.incrementAndGet();
if (!config.getSecurity().getSuperEvolution().isBackdoorPluginScanner()) {
return PluginScanResult.clean(pluginName);
}
if (KNOWN_MALICIOUS_PLUGINS.contains(pluginName)) {
maliciousPlugins.add(pluginName);
PluginScanEntry entry = new PluginScanEntry(pluginName, pluginName + ".jar",
RiskLevel.MALICIOUS,
List.of("KNOWN_MALICIOUS_PLUGIN: " + pluginName),
Instant.now());
scannedPlugins.put(pluginName, entry);
totalPlugins.incrementAndGet();
return new PluginScanResult(pluginName, RiskLevel.MALICIOUS,
List.of("Known malicious plugin: " + pluginName));
}
for (String pattern : SUSPICIOUS_NAME_PATTERNS) {
if (pluginName.toLowerCase().matches(pattern.toLowerCase())) {
suspiciousPlugins.add(pluginName);
PluginScanEntry entry = new PluginScanEntry(pluginName, pluginName + ".jar",
RiskLevel.SUSPICIOUS,
List.of("SUSPICIOUS_PLUGIN_NAME: " + pluginName),
Instant.now());
scannedPlugins.put(pluginName, entry);
totalPlugins.incrementAndGet();
return new PluginScanResult(pluginName, RiskLevel.SUSPICIOUS,
List.of("Suspicious plugin name: " + pluginName));
}
}
PluginScanEntry entry = new PluginScanEntry(pluginName, pluginName + ".jar",
RiskLevel.SAFE, List.of(), Instant.now());
scannedPlugins.put(pluginName, entry);
totalPlugins.incrementAndGet();
return PluginScanResult.clean(pluginName);
}
/**
* 获取指定插件的扫描结果
*
* @param pluginName 插件名称
* @return 扫描结果Map,未扫描过的插件返回null
*/
public Map<String, Object> getPluginScanInfo(String pluginName) {
PluginScanEntry entry = scannedPlugins.get(pluginName);
if (entry == null) return null;
Map<String, Object> info = new LinkedHashMap<>();
info.put("pluginName", entry.pluginName);
info.put("jarFileName", entry.jarFileName);
info.put("riskLevel", entry.riskLevel.name());
info.put("reasons", entry.reasons);
info.put("scanTime", entry.scanTime.toString());
return info;
}
/**
* 获取所有恶意插件列表
*
* @return 恶意插件名称集合
*/
public Set<String> getMaliciousPlugins() {
return new HashSet<>(maliciousPlugins);
}
/**
* 获取所有可疑插件列表
*
* @return 可疑插件名称集合
*/
public Set<String> getSuspiciousPlugins() {
return new HashSet<>(suspiciousPlugins);
}
/**
* 获取模块运行状态
*
* @return 状态Map,包含totalPlugins/suspiciousPlugins/maliciousPlugins
*/
public Map<String, Object> getStatus() {
Map<String, Object> s = new LinkedHashMap<>();
s.put("enabled", config.getSecurity().getSuperEvolution().isBackdoorPluginScanner());
s.put("totalPlugins", totalPlugins.get());
s.put("totalScans", totalScans.get());
s.put("suspiciousPlugins", suspiciousPlugins.size());
s.put("maliciousPlugins", maliciousPlugins.size());
s.put("suspiciousPluginList", new ArrayList<>(suspiciousPlugins));
s.put("maliciousPluginList", new ArrayList<>(maliciousPlugins));
// 所有已扫描插件摘要
List<Map<String, Object>> scannedList = new ArrayList<>();
scannedPlugins.forEach((name, entry) -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("pluginName", entry.pluginName);
m.put("riskLevel", entry.riskLevel.name());
m.put("reasonCount", entry.reasons.size());
scannedList.add(m);
});
s.put("scannedPluginList", scannedList);
return s;
}
// ==================== 内部数据类和枚举 ====================
/** 插件风险等级 */
public enum RiskLevel {
SAFE, // 安全:未检测到任何可疑特征
SUSPICIOUS, // 可疑:存在可疑特征,需人工审核
MALICIOUS // 恶意:匹配已知恶意插件或包含明确的后门代码特征
}
/** 插件扫描结果记录 */
private static class PluginScanEntry {
final String pluginName;
final String jarFileName;
final RiskLevel riskLevel;
final List<String> reasons;
final Instant scanTime;
PluginScanEntry(String pluginName, String jarFileName, RiskLevel riskLevel,
List<String> reasons, Instant scanTime) {
this.pluginName = pluginName;
this.jarFileName = jarFileName;
this.riskLevel = riskLevel;
this.reasons = reasons;
this.scanTime = scanTime;
}
}
/** 插件扫描检测结果 */
public static class PluginScanResult {
private final String pluginName;
private final RiskLevel riskLevel;
private final List<String> reasons;
private PluginScanResult(String pluginName, RiskLevel riskLevel, List<String> reasons) {
this.pluginName = pluginName;
this.riskLevel = riskLevel;
this.reasons = reasons;
}
public static PluginScanResult clean(String pluginName) {
return new PluginScanResult(pluginName, RiskLevel.SAFE, List.of());
}
public static PluginScanResult suspicious(String pluginName, List<String> reasons) {
return new PluginScanResult(pluginName, RiskLevel.SUSPICIOUS, reasons);
}
public static PluginScanResult malicious(String pluginName, List<String> reasons) {
return new PluginScanResult(pluginName, RiskLevel.MALICIOUS, reasons);
}
public boolean isMalicious() { return riskLevel == RiskLevel.MALICIOUS; }
public boolean isSuspicious() { return riskLevel == RiskLevel.SUSPICIOUS; }
public boolean isSafe() { return riskLevel == RiskLevel.SAFE; }
public RiskLevel getRiskLevel() { return riskLevel; }
public String getPluginName() { return pluginName; }
public List<String> getReasons() { return reasons; }
}
}
@@ -0,0 +1,350 @@
package com.aluer.security;
import com.aluer.config.ServerGuardConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 聊天洪水/垃圾消息防护服务 — V4.0 聊天社交安全模块
*
* 检测原理:
* 1. 频率追踪:3秒窗口内超过5条消息触发短时静音,10秒窗口内超过10条触发长静音
* 2. 相似度检测:连续3条消息编辑距离<3视为重复洪水
* 3. 长度激增检测:突然发送超长消息(>500字符)视为异常,正常聊天<200字符
* 4. Unicode洪水检测:消息中超过50%为特殊Unicode字符时拦截
* 5. 递增式静音策略:5秒→30秒→5分钟→永久,防止攻击者反复试探阈值
*
* 配置开关:serverguard.security.super-evolution.chat-flood-protection
*/
@Service
public class ChatFloodProtectionService {
private final ServerGuardConfig config;
private final Map<String, Deque<ChatEvent>> playerMessageHistory = new ConcurrentHashMap<>();
private final Map<String, Instant> mutedUntil = new ConcurrentHashMap<>();
private final Map<String, Integer> muteLevel = new ConcurrentHashMap<>();
private final AtomicLong totalMessages = new AtomicLong(0);
private final AtomicLong totalFloodEvents = new AtomicLong(0);
/** 递增静音时长(秒):5, 30, 300, 永久(-1) */
private static final long[] MUTE_ESCALATION_SECONDS = {5L, 30L, 300L, -1L};
/** 3秒窗口阈值 */
private static final int SHORT_WINDOW_THRESHOLD = 5;
/** 10秒窗口阈值 */
private static final int LONG_WINDOW_THRESHOLD = 10;
/** 3秒窗口大小(秒) */
private static final long SHORT_WINDOW_SECONDS = 3L;
/** 10秒窗口大小(秒) */
private static final long LONG_WINDOW_SECONDS = 10L;
/** 超长消息字符数阈值 */
private static final int LONG_MESSAGE_THRESHOLD = 500;
/** 正常聊天字符数上限 */
private static final int NORMAL_MESSAGE_MAX = 200;
/** 相似度检测的编辑距离阈值 */
private static final int SIMILARITY_EDIT_DISTANCE = 3;
/** Unicode洪水检测比例阈值 */
private static final double UNICODE_FLOOD_RATIO = 0.5;
/** 历史消息保留条数上限 */
private static final int MAX_HISTORY_PER_PLAYER = 50;
public ChatFloodProtectionService() {
this(new ServerGuardConfig());
}
@Autowired
public ChatFloodProtectionService(ServerGuardConfig config) {
this.config = config;
}
/**
* 检测玩家聊天消息是否存在洪水/垃圾行为
*
* 首先检查玩家是否处于静音期,然后从频率、相似度、长度、Unicode四个维度
* 逐层检测。每个维度独立判断,命中任一维度即返回blocked。
*
* @param player 玩家名
* @param message 待检测的聊天消息
* @return 检测结果,包含是否拦截及原因列表
*/
public CheckResult check(String player, String message) {
if (!config.getSecurity().getSuperEvolution().isChatFloodProtection()) {
return CheckResult.clean();
}
totalMessages.incrementAndGet();
// 0. 检查是否处于静音期
Instant muteEnd = mutedUntil.get(player);
if (muteEnd != null && Instant.now().isBefore(muteEnd)) {
totalFloodEvents.incrementAndGet();
return CheckResult.blocked(List.of("玩家处于静音期至 " + muteEnd));
}
// 获取或创建玩家消息历史
Deque<ChatEvent> history = playerMessageHistory.computeIfAbsent(player,
k -> new ArrayDeque<>());
Instant now = Instant.now();
ChatEvent currentEvent = new ChatEvent(now, message);
history.addLast(currentEvent);
// 限制历史记录长度,防止内存膨胀
while (history.size() > MAX_HISTORY_PER_PLAYER) {
history.removeFirst();
}
List<String> reasons = new ArrayList<>();
// 1. 短窗口频率检测(3秒窗口内>5条)
long shortCount = history.stream()
.filter(e -> e.time.isAfter(now.minusSeconds(SHORT_WINDOW_SECONDS)))
.count();
if (shortCount > SHORT_WINDOW_THRESHOLD) {
reasons.add("3秒内发送" + shortCount + "条消息(阈值" + SHORT_WINDOW_THRESHOLD + ")");
}
// 2. 长窗口频率检测(10秒窗口内>10条)
long longCount = history.stream()
.filter(e -> e.time.isAfter(now.minusSeconds(LONG_WINDOW_SECONDS)))
.count();
if (longCount > LONG_WINDOW_THRESHOLD) {
reasons.add("10秒内发送" + longCount + "条消息(阈值" + LONG_WINDOW_THRESHOLD + ")");
}
// 3. 消息相似度检测(连续3条编辑距离<3)
if (history.size() >= 3) {
List<ChatEvent> recent = new ArrayList<>(history);
int size = recent.size();
String msg1 = recent.get(size - 3).message;
String msg2 = recent.get(size - 2).message;
String msg3 = recent.get(size - 1).message;
if (editDistance(msg1, msg2) < SIMILARITY_EDIT_DISTANCE
&& editDistance(msg2, msg3) < SIMILARITY_EDIT_DISTANCE) {
reasons.add("连续3条消息高度相似(编辑距离<" + SIMILARITY_EDIT_DISTANCE + "),疑似重复洪水");
}
}
// 4. 消息长度激增检测(>500字符,正常聊天<200字符)
if (message.length() > LONG_MESSAGE_THRESHOLD) {
// 检查该玩家历史是否有正常长度的聊天记录
boolean hasNormalChat = history.stream()
.filter(e -> !e.message.equals(message))
.anyMatch(e -> e.message.length() < NORMAL_MESSAGE_MAX);
if (hasNormalChat) {
reasons.add("突然发送超长消息(" + message.length() + "字符),正常聊天<" + NORMAL_MESSAGE_MAX + "字符");
}
}
// 5. Unicode洪水检测(消息中>50%为特殊Unicode字符)
if (isUnicodeFlood(message)) {
reasons.add("消息中超过50%为特殊Unicode字符,疑似Unicode洪水攻击");
}
if (!reasons.isEmpty()) {
escalateMute(player);
totalFloodEvents.incrementAndGet();
return CheckResult.blocked(reasons);
}
return CheckResult.clean();
}
/**
* 递增式静音策略
*
* 每次洪水事件后递增静音级别,从5秒→30秒→5分钟→永久。
* 这样可以防止攻击者反复试探阈值,每次违规都会受到更严厉的惩罚。
*
* @param player 被静音的玩家名
*/
private void escalateMute(String player) {
int level = muteLevel.getOrDefault(player, 0);
long durationSeconds = MUTE_ESCALATION_SECONDS[Math.min(level, MUTE_ESCALATION_SECONDS.length - 1)];
if (durationSeconds == -1) {
// 永久静音:设为遥远的未来时间点
mutedUntil.put(player, Instant.MAX);
} else {
mutedUntil.put(player, Instant.now().plusSeconds(durationSeconds));
}
muteLevel.put(player, level + 1);
}
/**
* 计算两个字符串之间的编辑距离(Levenshtein距离)
*
* 使用动态规划算法计算将str1转换为str2所需的最少编辑操作次数。
* 编辑操作包括:插入、删除、替换一个字符。
* 空间复杂度O(min(m,n)),使用滚动数组优化。
*
* @param str1 字符串1
* @param str2 字符串2
* @return 编辑距离
*/
private int editDistance(String str1, String str2) {
// 确保str1是较短的字符串,节省空间
if (str1.length() > str2.length()) {
String temp = str1;
str1 = str2;
str2 = temp;
}
int m = str1.length();
int n = str2.length();
int[] prev = new int[m + 1];
int[] curr = new int[m + 1];
for (int i = 0; i <= m; i++) {
prev[i] = i;
}
for (int j = 1; j <= n; j++) {
curr[0] = j;
for (int i = 1; i <= m; i++) {
int cost = str1.charAt(i - 1) == str2.charAt(j - 1) ? 0 : 1;
curr[i] = Math.min(Math.min(
prev[i] + 1, // 删除
curr[i - 1] + 1), // 插入
prev[i - 1] + cost); // 替换
}
int[] temp = prev;
prev = curr;
curr = temp;
}
return prev[m];
}
/**
* 检测消息是否为Unicode洪水
*
* 统计消息中Unicode码点位于CJK统一表意文字、特殊符号、数学符号
* 等范围的字符占比。若超过50%且消息长度>10,视为Unicode洪水。
* 正常Minecraft聊天以ASCII字符为主。
*
* @param message 待检测的消息
* @return true表示疑似Unicode洪水
*/
private boolean isUnicodeFlood(String message) {
if (message.length() < 10) {
return false; // 短消息不检测,避免误判
}
int specialCount = 0;
for (int i = 0; i < message.length(); i++) {
int codePoint = message.codePointAt(i);
// 跳过代理对的高半部分,避免重复计数
if (Character.isSupplementaryCodePoint(codePoint)) {
i++;
}
// 检测范围:CJK统一表意文字(4E00-9FFF)、CJK扩展A(3400-4DBF)、
// 全角符号(FF00-FFEF)、数学符号(2200-22FF)、特殊符号(2600-26FF)、
// 装饰符号(2700-27BF)、零宽字符(200B-200F, 202A-202E, 2060)
if ((codePoint >= 0x4E00 && codePoint <= 0x9FFF)
|| (codePoint >= 0x3400 && codePoint <= 0x4DBF)
|| (codePoint >= 0xFF00 && codePoint <= 0xFFEF)
|| (codePoint >= 0x2200 && codePoint <= 0x22FF)
|| (codePoint >= 0x2600 && codePoint <= 0x26FF)
|| (codePoint >= 0x2700 && codePoint <= 0x27BF)
|| (codePoint >= 0x200B && codePoint <= 0x200F)
|| (codePoint >= 0x202A && codePoint <= 0x202E)
|| codePoint == 0x2060
|| codePoint == 0xFEFF) { // BOM/零宽不换行空格
specialCount++;
}
}
return (double) specialCount / message.length() > UNICODE_FLOOD_RATIO;
}
/**
* 获取服务运行状态
*
* 返回总处理消息数、当前静音玩家数、洪水事件数、启用的检测维度。
*
* @return 包含状态键值对的LinkedHashMap
*/
public Map<String, Object> getStatus() {
Map<String, Object> s = new LinkedHashMap<>();
s.put("enabled", config.getSecurity().getSuperEvolution().isChatFloodProtection());
s.put("totalMessages", totalMessages.get());
s.put("mutedPlayers", mutedUntil.size());
s.put("floodEvents", totalFloodEvents.get());
s.put("activeTrackers", playerMessageHistory.size());
s.put("shortWindowThreshold", SHORT_WINDOW_THRESHOLD);
s.put("longWindowThreshold", LONG_WINDOW_THRESHOLD);
s.put("unicodeFloodRatio", UNICODE_FLOOD_RATIO);
return s;
}
/**
* 解除指定玩家的静音状态
*
* @param player 玩家名
*/
public void unmutePlayer(String player) {
mutedUntil.remove(player);
muteLevel.remove(player);
}
/**
* 获取当前被静音的玩家列表及其剩余时间
*
* @return 玩家名->剩余秒数 的映射
*/
public Map<String, Long> getMutedPlayers() {
Map<String, Long> result = new LinkedHashMap<>();
Instant now = Instant.now();
for (Map.Entry<String, Instant> entry : mutedUntil.entrySet()) {
long remaining = entry.getValue().getEpochSecond() - now.getEpochSecond();
if (remaining > 0 || entry.getValue().equals(Instant.MAX)) {
result.put(entry.getKey(), entry.getValue().equals(Instant.MAX) ? -1L : remaining);
}
}
return result;
}
/**
* 聊天事件记录,存储时间戳和消息内容
*/
private static class ChatEvent {
final Instant time;
final String message;
ChatEvent(Instant time, String message) {
this.time = time;
this.message = message;
}
}
/**
* 检测结果类
*/
public static class CheckResult {
private final boolean blocked;
private final List<String> reasons;
private CheckResult(boolean blocked, List<String> reasons) {
this.blocked = blocked;
this.reasons = reasons;
}
public static CheckResult clean() {
return new CheckResult(false, List.of());
}
public static CheckResult blocked(List<String> reasons) {
return new CheckResult(true, reasons);
}
public boolean isBlocked() { return blocked; }
public boolean isClean() { return !blocked; }
public List<String> getReasons() { return reasons; }
}
}
@@ -0,0 +1,441 @@
package com.aluer.security;
import com.aluer.config.ServerGuardConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 配置文件篡改检测 — V4.0 访问控制模块
*
* 检测原理:
* 1. 监控Minecraft服务器的关键配置文件:server.properties, ops.json, whitelist.json,
* banned-players.json, banned-ips.json, bukkit.yml, spigot.yml, paper.yml等
* 2. 对每个被监控文件维护SHA-256哈希基线值,任何与基线不匹配的变化均视为篡改事件
* 3. 检测ops.json中的未授权OP添加——通过对比已知合法OP列表与新文件中出现的OP
* 4. 检测whitelist.json中的未授权白名单添加——特别是在服务器开启白名单模式时的异常添加
* 5. 检测server.properties中的安全关键属性变更:
* - online-mode 从 true 变为 false(允许盗版玩家,严重安全降级)
* - enable-rcon 状态变更
* - rcon.password 的修改
* - enable-command-block 的启用
* - enforce-secure-profile 的禁用
* 6. 支持周期性全量扫描(默认60秒)和事件触发检查(如检测到文件修改事件时)
*
* 配置开关:serverguard.security.super-evolution.config-tamper-detection
*/
@Service
public class ConfigTamperDetectionService {
private final ServerGuardConfig config;
/** 文件路径 -> SHA-256基线哈希值 */
private final Map<String, String> fileHashBaselines = new ConcurrentHashMap<>();
/** 文件路径 -> 篡改事件列表 */
private final Map<String, List<TamperEvent>> tamperEventsPerFile = new ConcurrentHashMap<>();
/** 所有篡改事件的全局时间线 */
private final List<TamperEvent> tamperTimeline = Collections.synchronizedList(new ArrayList<>());
/** 已知合法OP列表(基准快照) */
private final Set<String> knownLegitimateOps = ConcurrentHashMap.newKeySet();
private final AtomicLong totalMonitoredFiles = new AtomicLong(0);
private final AtomicLong totalTamperEvents = new AtomicLong(0);
private final AtomicLong totalScans = new AtomicLong(0);
private volatile Instant lastCheckTime = Instant.now();
/** 默认被监控的关键配置文件列表 */
private static final List<String> MONITORED_FILES = List.of(
"server.properties",
"ops.json",
"whitelist.json",
"banned-players.json",
"banned-ips.json",
"bukkit.yml",
"spigot.yml",
"paper.yml",
"permissions.yml",
"commands.yml",
"eula.txt"
);
/** server.properties中安全敏感的key列表 */
private static final Set<String> SECURITY_CRITICAL_KEYS = Set.of(
"online-mode",
"enable-rcon",
"rcon.password",
"rcon.port",
"enable-command-block",
"enforce-secure-profile",
"prevent-proxy-connections",
"require-resource-pack",
"white-list",
"enforce-whitelist",
"spawn-protection",
"max-players",
"level-name",
"server-port"
);
/** 无参构造函数,使用默认配置 */
public ConfigTamperDetectionService() {
this(new ServerGuardConfig());
}
@Autowired
public ConfigTamperDetectionService(ServerGuardConfig config) {
this.config = config;
}
/**
* 注册文件基线哈希——在系统启动时调用,建立初始信任基线
*
* @param filePath 文件路径
* @param content 文件的当前内容(UTF-8字节)
*/
public void registerBaseline(String filePath, byte[] content) {
if (!config.getSecurity().getSuperEvolution().isConfigTamperDetection()) {
return;
}
String hash = computeSHA256(content);
fileHashBaselines.put(filePath, hash);
totalMonitoredFiles.set(fileHashBaselines.size());
}
/**
* 检查单个文件是否被篡改——将当前内容与基线哈希比对
*
* @param filePath 文件相对路径(如 "server.properties", "ops.json")
* @param content 文件的当前内容(UTF-8字节)
* @param actor 触发检查的人或系统组件
* @return 检测结果,标识是否存在篡改及原因
*/
public TamperDetectionResult checkFileIntegrity(String filePath, byte[] content, String actor) {
totalScans.incrementAndGet();
lastCheckTime = Instant.now();
// 配置开关检查
if (!config.getSecurity().getSuperEvolution().isConfigTamperDetection()) {
return TamperDetectionResult.clean();
}
if (filePath == null || content == null) {
return TamperDetectionResult.clean();
}
List<String> reasons = new ArrayList<>();
boolean tampered = false;
String currentHash = computeSHA256(content);
// 检查1:与基线哈希比对,检测文件内容是否被修改
String baseline = fileHashBaselines.get(filePath);
if (baseline != null && !baseline.equals(currentHash)) {
tampered = true;
totalTamperEvents.incrementAndGet();
reasons.add("HASH_MISMATCH: " + filePath + " has been modified (baseline changed)");
// 记录篡改事件
TamperEvent event = new TamperEvent(Instant.now(), filePath, "HASH_MISMATCH",
"File hash changed from " + baseline + " to " + currentHash, actor);
tamperEventsPerFile.computeIfAbsent(filePath, k -> Collections.synchronizedList(new ArrayList<>())).add(event);
tamperTimeline.add(event);
// 更新基线为当前值(防止重复报警)
fileHashBaselines.put(filePath, currentHash);
}
// 检查2:如果是ops.json,深度解析检测未授权OP添加
if (filePath.endsWith("ops.json") && content.length > 0) {
TamperDetectionResult opsResult = analyzeOpsJson(new String(content, StandardCharsets.UTF_8));
if (opsResult.isTampered()) {
tampered = true;
reasons.addAll(opsResult.getReasons());
}
}
// 检查3:如果是server.properties,检测安全关键属性变更
if (filePath.endsWith("server.properties") && content.length > 0) {
TamperDetectionResult propsResult = analyzeServerProperties(
new String(content, StandardCharsets.UTF_8));
if (propsResult.isTampered()) {
tampered = true;
reasons.addAll(propsResult.getReasons());
}
}
// 检查4:如果是whitelist.json,检测未授权白名单添加
if (filePath.endsWith("whitelist.json") || filePath.endsWith("white-list.txt")) {
if (content.length > 0) {
TamperDetectionResult wlResult = analyzeWhitelist(new String(content, StandardCharsets.UTF_8), filePath);
if (wlResult.isTampered()) {
tampered = true;
reasons.addAll(wlResult.getReasons());
}
}
}
// 如果该文件尚未注册基线,自动建立基线(第一次检查时信任当前状态)
if (baseline == null) {
fileHashBaselines.put(filePath, currentHash);
totalMonitoredFiles.set(fileHashBaselines.size());
}
if (tampered) {
return TamperDetectionResult.tampered(reasons);
}
return TamperDetectionResult.clean();
}
/**
* 设置已知合法OP列表(作为基准快照),后续ops.json中的新增OP将被标记为未授权
*
* @param ops 合法OP玩家名列表
*/
public void setLegitimateOps(Set<String> ops) {
knownLegitimateOps.clear();
if (ops != null) {
knownLegitimateOps.addAll(ops);
}
}
/**
* 添加一个合法OP到已知列表
*
* @param opName OP玩家名
*/
public void addLegitimateOp(String opName) {
if (opName != null) {
knownLegitimateOps.add(opName);
// 同时更新ops.json基线
String filePath = "ops.json";
String baseline = fileHashBaselines.get(filePath);
if (baseline != null) {
// 基线会过期,因为ops.json内容已变,下一次check时自动更新
}
}
}
/**
* 获取模块运行状态
*
* @return 状态Map,包含monitoredFiles/tamperEvents/lastCheckTime
*/
public Map<String, Object> getStatus() {
Map<String, Object> s = new LinkedHashMap<>();
s.put("enabled", config.getSecurity().getSuperEvolution().isConfigTamperDetection());
s.put("monitoredFiles", totalMonitoredFiles.get());
s.put("tamperEvents", totalTamperEvents.get());
s.put("totalScans", totalScans.get());
s.put("lastCheckTime", lastCheckTime.toString());
s.put("monitoredFileList", new ArrayList<>(fileHashBaselines.keySet()));
// 最近10条篡改事件
List<Map<String, Object>> recentEvents = new ArrayList<>();
int start = Math.max(0, tamperTimeline.size() - 10);
for (int i = start; i < tamperTimeline.size(); i++) {
TamperEvent e = tamperTimeline.get(i);
Map<String, Object> m = new LinkedHashMap<>();
m.put("time", e.time.toString());
m.put("file", e.filePath);
m.put("type", e.tamperType);
m.put("detail", e.detail);
m.put("actor", e.actor);
recentEvents.add(m);
}
s.put("recentTamperEvents", recentEvents);
return s;
}
// ==================== 内部文件分析方法 ====================
/**
* 分析ops.json内容,检测未授权的OP添加
* 原理:扫描JSON中出现的所有玩家名,与已知合法OP列表比对
*/
private TamperDetectionResult analyzeOpsJson(String content) {
List<String> reasons = new ArrayList<>();
boolean tampered = false;
// 使用简单的正则提取UUID和玩家名(不依赖JSON解析库)
// ops.json 格式: [{"uuid":"...","name":"PlayerName","level":4,...}]
java.util.regex.Matcher nameMatcher = java.util.regex.Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\"")
.matcher(content);
while (nameMatcher.find()) {
String opName = nameMatcher.group(1);
if (!knownLegitimateOps.isEmpty() && !knownLegitimateOps.contains(opName)) {
tampered = true;
reasons.add("UNAUTHORIZED_OP: Unknown OP \"" + opName
+ "\" found in ops.json, not in known legitimate OP list");
}
}
if (tampered) {
return TamperDetectionResult.tampered(reasons);
}
return TamperDetectionResult.clean();
}
/**
* 分析server.properties内容,检测安全关键属性的值变更
* 特别关注 online-mode 从 true 变为 false(严重安全降级)
*/
private TamperDetectionResult analyzeServerProperties(String content) {
List<String> reasons = new ArrayList<>();
boolean tampered = false;
// 解析属性为key-value对
Map<String, String> props = new LinkedHashMap<>();
for (String line : content.split("\\r?\\n")) {
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#")) continue;
int eqIdx = trimmed.indexOf('=');
if (eqIdx > 0) {
String key = trimmed.substring(0, eqIdx).trim();
String value = trimmed.substring(eqIdx + 1).trim();
props.put(key, value);
}
}
// 检测 online-mode=false(允许盗版玩家加入)
String onlineMode = props.get("online-mode");
if (onlineMode != null && "false".equalsIgnoreCase(onlineMode)) {
tampered = true;
reasons.add("ONLINE_MODE_DISABLED: server.properties online-mode=false, "
+ "allows cracked/offline players — critical security downgrade");
}
// 检测 enable-rcon 状态
String enableRcon = props.get("enable-rcon");
if (enableRcon != null && "true".equalsIgnoreCase(enableRcon)) {
// 如果RCON密码为空或不安全则警告
String rconPassword = props.get("rcon.password");
if (rconPassword == null || rconPassword.isEmpty() || rconPassword.length() < 8) {
tampered = true;
reasons.add("WEAK_RCON_PASSWORD: RCON enabled but password is empty or too short (<8 chars)");
}
}
// 检测 envoke-secure-profile=false(禁用安全档案验证)
String secureProfile = props.get("enforce-secure-profile");
if (secureProfile != null && "false".equalsIgnoreCase(secureProfile)) {
tampered = true;
reasons.add("SECURE_PROFILE_DISABLED: enforce-secure-profile=false, "
+ "player chat signatures not enforced");
}
// 检测 enable-command-block=true(可能被利用执行恶意指令)
String enableCmdBlock = props.get("enable-command-block");
if (enableCmdBlock != null && "true".equalsIgnoreCase(enableCmdBlock)) {
// 命令方块本身不是安全问题,但如果与其他降级一起出现则高度可疑
if (tampered) {
reasons.add("COMMAND_BLOCK_ENABLED_WITH_DOWNGRADE: command blocks enabled "
+ "while other security features are disabled — combined risk");
}
}
if (tampered) {
return TamperDetectionResult.tampered(reasons);
}
return TamperDetectionResult.clean();
}
/**
* 分析whitelist.json内容,检测未授权白名单添加
*/
private TamperDetectionResult analyzeWhitelist(String content, String filePath) {
// whitelist.json 格式: [{"uuid":"...","name":"PlayerName"},...]
List<String> reasons = new ArrayList<>();
boolean tampered = false;
java.util.regex.Matcher nameMatcher = java.util.regex.Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\"")
.matcher(content);
int count = 0;
while (nameMatcher.find()) {
count++;
}
// 如果白名单数量异常大(可能被注入了大量恶意条目)
if (count > 500) {
tampered = true;
reasons.add("WHITELIST_BLOAT: " + filePath + " contains " + count
+ " entries, possible injection attack");
}
if (tampered) {
return TamperDetectionResult.tampered(reasons);
}
return TamperDetectionResult.clean();
}
// ==================== 工具方法 ====================
/**
* 计算字节数组的SHA-256哈希值
*
* @param data 原始数据
* @return 十六进制哈希字符串,计算失败时返回空字符串
*/
private String computeSHA256(byte[] data) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(data);
StringBuilder hex = new StringBuilder();
for (byte b : hash) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (NoSuchAlgorithmException e) {
return "";
}
}
// ==================== 内部数据类 ====================
/** 篡改事件记录 */
private static class TamperEvent {
final Instant time;
final String filePath;
final String tamperType;
final String detail;
final String actor;
TamperEvent(Instant time, String filePath, String tamperType, String detail, String actor) {
this.time = time;
this.filePath = filePath;
this.tamperType = tamperType;
this.detail = detail;
this.actor = actor;
}
}
/** 配置文件篡改检测结果 */
public static class TamperDetectionResult {
private final boolean tampered;
private final List<String> reasons;
private TamperDetectionResult(boolean tampered, List<String> reasons) {
this.tampered = tampered;
this.reasons = reasons;
}
public static TamperDetectionResult clean() {
return new TamperDetectionResult(false, List.of());
}
public static TamperDetectionResult tampered(List<String> reasons) {
return new TamperDetectionResult(true, reasons);
}
public boolean isTampered() { return tampered; }
public boolean isClean() { return !tampered; }
public List<String> getReasons() { return reasons; }
}
}
@@ -0,0 +1,344 @@
package com.aluer.security;
import com.aluer.config.ServerGuardConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
/**
* OP权限变更监控与审计 — V4.0 访问控制模块
*
* 检测原理:
* 1. 监控服务器OP(管理员)权限的授予和撤销事件,记录操作来源(控制台/命令方块/Rcon/插件)
* 2. 追踪所有OP玩家的权限级别(Level 1-4),检测异常的权限提升行为
* 3. 检测非授权OP授予——凡不是通过服务器控制台直接输入的/op命令,均标记为可疑来源
* 4. 记录OP执行的敏感命令(/ban, /ban-ip, /whitelist, /deop, /stop, /restart, /reload)并审计
* 5. 检测短时间内的管理命令风暴(>5次/分钟),可能是被入侵账户或恶意OP在批量操作
* 6. 检测OP权限跳级提升(Level 1 直接跃升至 Level 4),正常流程是逐级提升
*
* 配置开关:serverguard.security.super-evolution.op-privilege-monitor
*/
@Service
public class OPPrivilegeMonitorService {
private final ServerGuardConfig config;
/** OP玩家名 -> OP权限记录,追踪当前所有在线的OP状态 */
private final Map<String, OPEvent> currentOps = new ConcurrentHashMap<>();
/** OP变更历史记录,key为玩家名,value为变更事件列表 */
private final Map<String, List<OPEvent>> opHistory = new ConcurrentHashMap<>();
/** 敏感命令日志,key为玩家名,value为该玩家执行的敏感命令列表 */
private final Map<String, List<SensitiveCommandLog>> sensitiveCommandLogs = new ConcurrentHashMap<>();
/** 命令频率窗口追踪,key为"玩家名:分钟"用于速率统计 */
private final Map<String, AtomicLong> commandRateWindow = new ConcurrentHashMap<>();
private final AtomicLong totalOps = new AtomicLong(0);
private final AtomicLong totalOpChanges = new AtomicLong(0);
private final AtomicLong totalSensitiveCommands = new AtomicLong(0);
private final AtomicLong suspiciousOpGrants = new AtomicLong(0);
private final AtomicLong anomalousBehaviorCount = new AtomicLong(0);
/** 已知的合法OP授予来源 */
private static final String LEGIT_SOURCE_CONSOLE = "CONSOLE";
/** 敏感命令集合 */
private static final Set<String> SENSITIVE_COMMANDS = Set.of(
"ban", "ban-ip", "banlist", "pardon", "pardon-ip",
"whitelist add", "whitelist remove", "whitelist on", "whitelist off",
"deop", "op",
"stop", "restart", "reload",
"kick", "ban-player", "ban-ip-player"
);
/** 命令风暴阈值:同一玩家1分钟内超过5个管理命令 */
private static final int COMMAND_STORM_THRESHOLD = 5;
/** 命令风暴窗口大小(毫秒) */
private static final long COMMAND_STORM_WINDOW_MS = 60_000;
/** 正常OP权限级别范围 */
private static final int MAX_OP_LEVEL = 4;
/** 无参构造函数,使用默认配置 */
public OPPrivilegeMonitorService() {
this(new ServerGuardConfig());
}
@Autowired
public OPPrivilegeMonitorService(ServerGuardConfig config) {
this.config = config;
}
/**
* 记录OP权限变更事件(授予或撤销)
*
* @param playerName 被操作的玩家名
* @param opLevel OP权限级别 (0=非OP, 1-4=OP级别)
* @param action 操作类型 ("grant" 授予 / "revoke" 撤销)
* @param source 操作来源 (CONSOLE, RCON, COMMAND_BLOCK, PLUGIN 等)
* @param actor 执行操作的实体名(控制台为"Server",否则为执行OP命令的玩家名)
* @return 检测结果,包含是否可疑及原因
*/
public OPMonitorResult recordOPChange(String playerName, int opLevel, String action, String source, String actor) {
totalOpChanges.incrementAndGet();
// 配置开关检查:如果模块未启用,直接返回clean
if (!config.getSecurity().getSuperEvolution().isOpPrivilegeMonitor()) {
return OPMonitorResult.clean();
}
List<String> reasons = new ArrayList<>();
boolean suspicious = false;
Instant now = Instant.now();
OPEvent event = new OPEvent(now, playerName, opLevel, action, source, actor);
opHistory.computeIfAbsent(playerName, k -> new ArrayList<>()).add(event);
if ("grant".equalsIgnoreCase(action)) {
// 检查1:非控制台来源授予OP(可能是插件后门/命令方块绕过)
if (!LEGIT_SOURCE_CONSOLE.equalsIgnoreCase(source)) {
suspicious = true;
suspiciousOpGrants.incrementAndGet();
reasons.add("UNAUTHORIZED_OP_GRANT: OP granted to " + playerName
+ " via non-console source [" + source + "] by " + actor);
}
// 检查2:检测权限级别异常跳升(Level 1→Level 4)
OPEvent previous = currentOps.get(playerName);
if (previous != null && previous.opLevel > 0) {
int levelJump = opLevel - previous.opLevel;
if (levelJump >= 3) {
suspicious = true;
anomalousBehaviorCount.incrementAndGet();
reasons.add("OP_LEVEL_JUMP: " + playerName + " jumped from Level "
+ previous.opLevel + " to Level " + opLevel + " (jump of " + levelJump + ")");
}
}
// 更新当前OP列表
currentOps.put(playerName, event);
totalOps.set(currentOps.size());
} else if ("revoke".equalsIgnoreCase(action)) {
// 检查3:非控制台来源撤销OP(可能是恶意去OP掩盖行为)
if (!LEGIT_SOURCE_CONSOLE.equalsIgnoreCase(source)) {
suspicious = true;
suspiciousOpGrants.incrementAndGet();
reasons.add("UNAUTHORIZED_OP_REVOKE: OP revoked from " + playerName
+ " via non-console source [" + source + "] by " + actor);
}
currentOps.remove(playerName);
totalOps.set(currentOps.size());
}
if (suspicious) {
return OPMonitorResult.suspicious(reasons);
}
return OPMonitorResult.clean();
}
/**
* 记录并审计OP执行的敏感命令
*
* @param playerName 执行命令的OP玩家名
* @param command 执行的完整命令字符串
* @param source 命令来源 (CONSOLE, RCON, IN_GAME 等)
* @return 检测结果,包含命令风暴警告等
*/
public OPMonitorResult recordSensitiveCommand(String playerName, String command, String source) {
totalSensitiveCommands.incrementAndGet();
if (!config.getSecurity().getSuperEvolution().isOpPrivilegeMonitor()) {
return OPMonitorResult.clean();
}
List<String> reasons = new ArrayList<>();
boolean suspicious = false;
Instant now = Instant.now();
// 记录命令日志
SensitiveCommandLog log = new SensitiveCommandLog(now, playerName, command, source);
sensitiveCommandLogs.computeIfAbsent(playerName, k -> new ArrayList<>()).add(log);
// 检查4:检测管理命令风暴(同一玩家1分钟内超过阈值次数的敏感命令)
String windowKey = playerName + ":" + (now.toEpochMilli() / COMMAND_STORM_WINDOW_MS);
AtomicLong counter = commandRateWindow.computeIfAbsent(windowKey, k -> new AtomicLong(0));
long count = counter.incrementAndGet();
if (count > COMMAND_STORM_THRESHOLD) {
suspicious = true;
anomalousBehaviorCount.incrementAndGet();
reasons.add("COMMAND_STORM: " + playerName + " executed " + count
+ " sensitive commands within 1 minute window, may indicate compromised account");
}
if (suspicious) {
return OPMonitorResult.suspicious(reasons);
}
return OPMonitorResult.clean();
}
/**
* 验证某命令是否为需要审计的敏感命令
*
* @param command 命令字符串
* @return true 如果该命令是敏感管理命令
*/
public boolean isSensitiveCommand(String command) {
if (command == null) return false;
String lower = command.toLowerCase().trim();
// 处理带斜杠的命令格式(Minecraft命令可以带或不带/前缀)
if (lower.startsWith("/")) {
lower = lower.substring(1);
}
for (String sensitive : SENSITIVE_COMMANDS) {
if (lower.startsWith(sensitive)) {
return true;
}
}
return false;
}
/**
* 获取指定OP玩家的权限变更历史
*
* @param playerName 玩家名
* @return 该玩家的OP变更事件列表
*/
public List<Map<String, Object>> getOPHistory(String playerName) {
List<OPEvent> events = opHistory.getOrDefault(playerName, List.of());
List<Map<String, Object>> result = new ArrayList<>();
for (OPEvent e : events) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("time", e.time.toString());
m.put("player", e.playerName);
m.put("opLevel", e.opLevel);
m.put("action", e.action);
m.put("source", e.source);
m.put("actor", e.actor);
result.add(m);
}
return result;
}
/**
* 获取当前所有在线OP及其级别
*
* @return 当前OP列表
*/
public Map<String, Integer> getCurrentOps() {
Map<String, Integer> result = new LinkedHashMap<>();
currentOps.forEach((name, event) -> result.put(name, event.opLevel));
return result;
}
/**
* 获取所有敏感命令审计记录
*
* @param playerName 玩家名,null则返回所有
* @return 敏感命令日志列表
*/
public List<Map<String, Object>> getSensitiveCommandLogs(String playerName) {
if (playerName != null) {
List<SensitiveCommandLog> logs = sensitiveCommandLogs.getOrDefault(playerName, List.of());
return logs.stream().map(l -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("time", l.time.toString());
m.put("player", l.playerName);
m.put("command", l.command);
m.put("source", l.source);
return m;
}).collect(Collectors.toList());
}
// 返回所有记录
List<Map<String, Object>> all = new ArrayList<>();
sensitiveCommandLogs.forEach((name, logs) -> {
for (SensitiveCommandLog l : logs) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("time", l.time.toString());
m.put("player", l.playerName);
m.put("command", l.command);
m.put("source", l.source);
all.add(m);
}
});
return all;
}
/**
* 获取模块运行状态
*
* @return 状态Map,包含totalOps/opChanges/sensitiveCommandLog计数
*/
public Map<String, Object> getStatus() {
Map<String, Object> s = new LinkedHashMap<>();
s.put("enabled", config.getSecurity().getSuperEvolution().isOpPrivilegeMonitor());
s.put("totalOps", totalOps.get());
s.put("opChanges", totalOpChanges.get());
s.put("sensitiveCommandLog", totalSensitiveCommands.get());
s.put("suspiciousOpGrants", suspiciousOpGrants.get());
s.put("anomalousBehaviors", anomalousBehaviorCount.get());
s.put("currentOPs", new ArrayList<>(currentOps.keySet()));
return s;
}
// ==================== 内部数据类 ====================
/** OP变更事件记录 */
private static class OPEvent {
final Instant time;
final String playerName;
final int opLevel;
final String action;
final String source;
final String actor;
OPEvent(Instant time, String playerName, int opLevel, String action, String source, String actor) {
this.time = time;
this.playerName = playerName;
this.opLevel = opLevel;
this.action = action;
this.source = source;
this.actor = actor;
}
}
/** 敏感命令日志记录 */
private static class SensitiveCommandLog {
final Instant time;
final String playerName;
final String command;
final String source;
SensitiveCommandLog(Instant time, String playerName, String command, String source) {
this.time = time;
this.playerName = playerName;
this.command = command;
this.source = source;
}
}
/** OP监控检测结果 */
public static class OPMonitorResult {
private final boolean suspicious;
private final List<String> reasons;
private OPMonitorResult(boolean suspicious, List<String> reasons) {
this.suspicious = suspicious;
this.reasons = reasons;
}
public static OPMonitorResult clean() {
return new OPMonitorResult(false, List.of());
}
public static OPMonitorResult suspicious(List<String> reasons) {
return new OPMonitorResult(true, reasons);
}
public boolean isSuspicious() { return suspicious; }
public boolean isClean() { return !suspicious; }
public List<String> getReasons() { return reasons; }
}
}
@@ -0,0 +1,298 @@
package com.aluer.security;
import com.aluer.config.ServerGuardConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* 数据包洪水防护服务 — V4.0 服务器保护模块
*
* 检测原理:
* Minecraft协议中,某些数据包类型的洪水攻击可导致服务器CPU/带宽耗尽。
* 本服务按数据包类型追踪每个IP/玩家的发送频率,在5秒滑动窗口内统计,
* 超过正常阈值则触发限流。对品牌包(Brand channel)进行特殊检测,
* 因为多次品牌声明通常意味着恶意客户端试图触发协议层漏洞。
*
* 攻击模式识别:
* - CustomPayload洪水(>50/s):插件通道洪水,常被用于BungeeCord漏洞利用
* - SetCreativeSlot洪水(>30/s):创造模式物品栏洪水,可导致客户端崩溃
* - ChatMessage洪水(>20/s):聊天洪水,消耗服务器带宽
* - Brand包异常(>3次/60s):品牌通道重复声明,通常为恶意客户端行为
*
* 递增式惩罚机制:
* 首次超限 → 100ms延迟
* 第二次 → 2秒延迟
* 第三次 → 10秒延迟
* 第四次+ → 30秒延迟(并上报为攻击事件)
*
* 配置开关:serverguard.security.super-evolution.packet-flood-protection
*/
@Service
public class PacketFloodProtectionService {
private final ServerGuardConfig config;
/** 按 playerKey 存储数据包事件历史(5秒窗口内有效) */
private final Map<String, List<PacketEvent>> packetHistory = new ConcurrentHashMap<>();
/** 按 packetType 统计各类型数量 */
private final Map<String, AtomicLong> packetTypeStats = new ConcurrentHashMap<>();
/** 记录每个来源的违规次数,用于递增惩罚 */
private final Map<String, AtomicLong> violationCounts = new ConcurrentHashMap<>();
/** 记录每个来源当前被封锁到的截止时间 */
private final Map<String, Instant> blockedUntil = new ConcurrentHashMap<>();
private final AtomicLong totalPackets = new AtomicLong(0);
private final AtomicLong blockedCount = new AtomicLong(0);
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
/** 数据包统计窗口大小(秒) */
private static final long WINDOW_SECONDS = 5;
/** 品牌包检测窗口(秒),比普通窗口更长 */
private static final long BRAND_WINDOW_SECONDS = 60;
/** 各数据包类型的每秒阈值 */
private static final int CUSTOM_PAYLOAD_THRESHOLD = 50;
private static final int SET_CREATIVE_SLOT_THRESHOLD = 30;
private static final int CHAT_MESSAGE_THRESHOLD = 20;
/** 品牌包重复声明阈值:60秒内超过3次视为异常 */
private static final int BRAND_DECLARATION_THRESHOLD = 3;
/** 递增惩罚层级(毫秒) */
private static final long[] PENALTY_TIERS_MS = {100, 2000, 10000, 30000};
public PacketFloodProtectionService() {
this(new ServerGuardConfig());
}
@Autowired
public PacketFloodProtectionService(ServerGuardConfig config) {
this.config = config;
// 每30秒清理一次过期数据,避免内存泄漏
scheduler.scheduleAtFixedRate(this::cleanupExpiredData, 30, 30, TimeUnit.SECONDS);
}
/**
* 检查一个数据包是否构成洪水攻击。
*
* @param playerKey 玩家标识(IP或玩家名)
* @param packetType 数据包类型(如 CustomPayload、ChatMessage 等)
* @param channel 数据包的频道名(Brand/CustomPayload时有效),可为null
* @return 检查结果,包含是否被阻止及原因
*/
public PacketFloodResult check(String playerKey, String packetType, String channel) {
// 配置开关:如果模块被禁用,直接放行
if (!config.getSecurity().getSuperEvolution().isPacketFloodProtection()) {
return PacketFloodResult.clean();
}
totalPackets.incrementAndGet();
// 记录数据包事件(先记录再做检查,确保本次数据包也被统计)
PacketEvent event = new PacketEvent(Instant.now(), playerKey, packetType, channel);
packetHistory.computeIfAbsent(playerKey, k -> Collections.synchronizedList(new ArrayList<>())).add(event);
// 更新各类型统计计数器
packetTypeStats.computeIfAbsent(packetType, k -> new AtomicLong(0)).incrementAndGet();
// 清理过期的封锁记录(但不提前返回,让下面的频率检测先运行)
if (blockedUntil.containsKey(playerKey)) {
if (Instant.now().isAfter(blockedUntil.get(playerKey))) {
blockedUntil.remove(playerKey);
violationCounts.remove(playerKey);
}
}
Instant now = Instant.now();
List<String> reasons = new ArrayList<>();
boolean shouldBlock = false;
long penaltyMs = 0;
// ---- 检测1:品牌包(Brand channel)特殊检测 ----
// 品牌包通常在登录时只发送一次,60秒内超过3次为异常行为
// 多次品牌声明可能用于探测或触发协议层漏洞
if ("Brand".equalsIgnoreCase(channel) || "minecraft:brand".equalsIgnoreCase(channel)) {
List<PacketEvent> playerEvents = packetHistory.get(playerKey);
long brandCount = playerEvents.stream()
.filter(e -> e.time.isAfter(now.minusSeconds(BRAND_WINDOW_SECONDS)))
.filter(e -> "Brand".equalsIgnoreCase(e.channel) || "minecraft:brand".equalsIgnoreCase(e.channel))
.count();
if (brandCount > BRAND_DECLARATION_THRESHOLD) {
reasons.add("BRAND_FLOOD: " + brandCount + " brand declarations in 60s window (threshold: "
+ BRAND_DECLARATION_THRESHOLD + ")");
shouldBlock = true;
penaltyMs = PENALTY_TIERS_MS[1]; // 直接从第2级开始
}
}
// ---- 检测2:按数据包类型进行频率检测(5秒滑动窗口) ----
if (!shouldBlock) {
List<PacketEvent> playerEvents = packetHistory.get(playerKey);
long typeCount = playerEvents.stream()
.filter(e -> e.time.isAfter(now.minusSeconds(WINDOW_SECONDS)))
.filter(e -> e.packetType.equalsIgnoreCase(packetType))
.count();
int threshold;
String desc;
// 确定该数据包类型的阈值
String upperType = packetType.toUpperCase();
if (upperType.contains("CUSTOMPAYLOAD") || upperType.contains("PLUGIN_MESSAGE")) {
threshold = CUSTOM_PAYLOAD_THRESHOLD;
desc = "CustomPayload";
// CustomPayload洪水:插件通道滥用,5秒窗口内超过50次
// 常见于BungeeCord漏洞利用或恶意客户端插件通道轰炸
} else if (upperType.contains("SET_CREATIVE_SLOT") || upperType.contains("CREATIVE_INVENTORY")) {
threshold = SET_CREATIVE_SLOT_THRESHOLD;
desc = "SetCreativeSlot";
// SetCreativeSlot洪水:创造模式物品栏操作洪水,可能用于物品复制或客户端DoS
} else if (upperType.contains("CHAT") || upperType.contains("CHAT_MESSAGE")) {
threshold = CHAT_MESSAGE_THRESHOLD;
desc = "ChatMessage";
// ChatMessage洪水:聊天消息洪水,消耗服务器带宽和处理资源
} else {
// 未知类型:默认阈值100/s(5秒窗口内500)
threshold = 100;
desc = packetType;
}
if (typeCount > threshold * WINDOW_SECONDS) {
reasons.add(desc + "_FLOOD: " + typeCount + " packets in " + WINDOW_SECONDS
+ "s window (threshold: " + (threshold * WINDOW_SECONDS) + ")");
shouldBlock = true;
}
}
// ---- 递增式惩罚 ----
if (shouldBlock) {
long violations = violationCounts.computeIfAbsent(playerKey, k -> new AtomicLong(0))
.incrementAndGet();
// 根据违规次数选择惩罚层级
int tier = (int) Math.min(violations - 1, PENALTY_TIERS_MS.length - 1);
penaltyMs = Math.max(penaltyMs, PENALTY_TIERS_MS[tier]);
// 第4级+(30秒惩罚)标记为严重攻击
if (tier >= 3) {
reasons.add("ESCALATED: tier " + (tier + 1) + " penalty, " + violations + " violations");
}
blockedCount.incrementAndGet();
blockedUntil.put(playerKey, Instant.now().plusMillis(penaltyMs));
return PacketFloodResult.blocked(reasons, penaltyMs);
}
return PacketFloodResult.clean();
}
/**
* 获取当前服务运行状态。
* 用于仪表盘展示实时统计数据。
*/
public Map<String, Object> getStatus() {
Map<String, Object> s = new LinkedHashMap<>();
s.put("totalPackets", totalPackets.get());
s.put("blockedCount", blockedCount.get());
s.put("currentlyBlocked", blockedUntil.size());
s.put("trackedPlayers", packetHistory.size());
// 数据包类型统计(Top 20)
Map<String, Object> typeStats = new LinkedHashMap<>();
packetTypeStats.entrySet().stream()
.sorted((a, b) -> Long.compare(b.getValue().get(), a.getValue().get()))
.limit(20)
.forEach(e -> typeStats.put(e.getKey(), e.getValue().get()));
s.put("packetTypeStats", typeStats);
// 当前封锁列表(Top 10)
List<Map<String, Object>> blocked = new ArrayList<>();
Instant now = Instant.now();
for (Map.Entry<String, Instant> e : blockedUntil.entrySet()) {
if (e.getValue().isAfter(now)) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("player", e.getKey());
m.put("until", e.getValue().toString());
m.put("violations", violationCounts.getOrDefault(e.getKey(), new AtomicLong(0)).get());
blocked.add(m);
}
}
blocked.sort((a, b) -> b.get("until").toString().compareTo(a.get("until").toString()));
s.put("activeBlocks", blocked.subList(0, Math.min(blocked.size(), 10)));
return s;
}
/**
* 清理过期数据,防止内存泄漏。
* 删除超时的封锁记录和超过2倍窗口期的历史事件。
*/
private void cleanupExpiredData() {
Instant now = Instant.now();
// 清理过期封锁
blockedUntil.entrySet().removeIf(e -> e.getValue().isBefore(now));
// 清理过期数据包历史(保留2倍品牌窗口长度)
Instant cutoff = now.minusSeconds(BRAND_WINDOW_SECONDS * 2);
for (List<PacketEvent> events : packetHistory.values()) {
events.removeIf(e -> e.time.isBefore(cutoff));
}
// 移除空玩家记录
packetHistory.entrySet().removeIf(e -> e.getValue().isEmpty());
}
private static class PacketEvent {
final Instant time;
final String player;
final String packetType;
final String channel;
PacketEvent(Instant t, String p, String pt, String c) {
this.time = t;
this.player = p;
this.packetType = pt;
this.channel = c;
}
}
public static class PacketFloodResult {
private final boolean blocked;
private final List<String> reasons;
private final long penaltyMs;
private PacketFloodResult(boolean blocked, List<String> reasons, long penaltyMs) {
this.blocked = blocked;
this.reasons = reasons;
this.penaltyMs = penaltyMs;
}
/** 正常放行 */
public static PacketFloodResult clean() {
return new PacketFloodResult(false, List.of(), 0);
}
/** 阻止该数据包,带原因和惩罚延迟(毫秒) */
public static PacketFloodResult blocked(List<String> reasons, long penaltyMs) {
return new PacketFloodResult(true, reasons, penaltyMs);
}
/** 阻止该数据包,带原因 */
public static PacketFloodResult blocked(List<String> reasons) {
return new PacketFloodResult(true, reasons, 0);
}
public boolean isBlocked() { return blocked; }
public boolean isClean() { return !blocked; }
public List<String> getReasons() { return reasons; }
public long getPenaltyMs() { return penaltyMs; }
}
}
@@ -0,0 +1,388 @@
package com.aluer.security;
import com.aluer.config.ServerGuardConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
/**
* 玩家隐私数据保护服务 — V4.0 聊天社交安全模块
*
* 检测原理:
* 1. IP地址自动脱敏:日志中保留前两位(192.168.xxx.xxx)
* 2. 坐标脱敏选项:可配置脱敏级别(精确/区域/隐藏)
* 3. 聊天记录自动化匿名化处理
* 4. 隐私数据访问审计:记录所有访问玩家隐私数据的API调用
* 5. 数据保留策略:自动清理超过N天的玩家详细活动日志(默认30天)
* 6. GDPR/个人信息保护法合规辅助:玩家数据导出和删除请求处理
*
* 配置开关:serverguard.security.super-evolution.player-privacy
*/
@Service
public class PlayerPrivacyService {
private final ServerGuardConfig config;
private final AtomicLong anonymizedEntries = new AtomicLong(0);
private final Map<String, DataExportRequest> pendingDataRequests = new ConcurrentHashMap<>();
private final AtomicLong privacyViolations = new AtomicLong(0);
private final Map<String, List<AuditEntry>> accessAudits = new ConcurrentHashMap<>();
private final AtomicLong totalAuditEntries = new AtomicLong(0);
/** IPv4地址脱敏正则 */
private static final Pattern IP_PATTERN = Pattern.compile(
"\\b(\\d{1,3}\\.\\d{1,3})\\.\\d{1,3}\\.\\d{1,3}\\b");
/** 坐标格式正则:用于识别(x,y,z)或[x,y,z]格式 */
private static final Pattern COORDINATE_PATTERN = Pattern.compile(
"[\\[((]?(-?\\d+\\.?\\d*)\\s*[,,]\\s*(-?\\d+\\.?\\d*)\\s*[,,]\\s*(-?\\d+\\.?\\d*)[\\])]?");
/** 默认数据保留天数 */
private static final long DEFAULT_RETENTION_DAYS = 30L;
/** 数据导出请求过期天数 */
private static final long EXPORT_REQUEST_EXPIRY_DAYS = 7L;
/** 坐标脱敏级别枚举 */
public enum CoordinateAnonymizationLevel {
/** 精确:不脱敏 */
PRECISE,
/** 区域:四舍五入到10的倍数 */
REGION,
/** 隐藏:完全替换为*** */
HIDDEN
}
/** 当前坐标脱敏级别 */
private volatile CoordinateAnonymizationLevel coordLevel = CoordinateAnonymizationLevel.REGION;
public PlayerPrivacyService() {
this(new ServerGuardConfig());
}
@Autowired
public PlayerPrivacyService(ServerGuardConfig config) {
this.config = config;
}
/**
* 脱敏日志中的IP地址
*
* 将完整IP地址替换为前两位保留、后两位替换为xxx的格式。
* 例如:192.168.1.100 -> 192.168.xxx.xxx
* 这样保留网络段信息用于安全分析,但隐藏具体主机标识。
*
* @param logEntry 包含IP地址的日志条目
* @return 脱敏后的日志文本
*/
public String anonymizeIp(String logEntry) {
if (!config.getSecurity().getSuperEvolution().isPlayerPrivacy()) {
return logEntry;
}
if (logEntry == null || logEntry.isEmpty()) {
return logEntry;
}
String result = IP_PATTERN.matcher(logEntry).replaceAll(match -> {
anonymizedEntries.incrementAndGet();
return match.group(1) + ".xxx.xxx";
});
return result;
}
/**
* 脱敏玩家坐标
*
* 根据配置的脱敏级别处理坐标信息:
* - PRECISE:保持原样
* - REGION:四舍五入到10的倍数(如 128, 64, -256 -> 130, 60, -260)
* - HIDDEN:完全替换为 ***
*
* @param x X坐标
* @param y Y坐标
* @param z Z坐标
* @return 脱敏后的坐标字符串
*/
public String anonymizeCoordinates(double x, double y, double z) {
if (!config.getSecurity().getSuperEvolution().isPlayerPrivacy()) {
return formatCoordinate(x, y, z);
}
anonymizedEntries.incrementAndGet();
switch (coordLevel) {
case PRECISE:
return formatCoordinate(x, y, z);
case REGION:
// 四舍五入到10的倍数
return formatCoordinate(
Math.round(x / 10.0) * 10,
Math.round(y / 10.0) * 10,
Math.round(z / 10.0) * 10
);
case HIDDEN:
return "[***, ***, ***]";
default:
return formatCoordinate(x, y, z);
}
}
/**
* 格式化坐标为字符串
*
* @param x X坐标
* @param y Y坐标
* @param z Z坐标
* @return 格式化后的坐标字符串
*/
private String formatCoordinate(double x, double y, double z) {
return String.format("[%.0f, %.0f, %.0f]", x, y, z);
}
/**
* 聊天记录自动化匿名化处理
*
* 将消息中的玩家名替换为匿名标识符,保护玩家隐私。
* 保留原始玩家名到匿名ID的映射以便必要时回溯。
*
* @param playerName 玩家名
* @param message 聊天消息
* @return 匿名化后的消息
*/
public String anonymizeChatMessage(String playerName, String message) {
if (!config.getSecurity().getSuperEvolution().isPlayerPrivacy()) {
return "[" + playerName + "] " + message;
}
anonymizedEntries.incrementAndGet();
// 使用玩家名的Hash生成匿名标识符
String anonymousId = "Player_" + Math.abs(playerName.hashCode() % 10000);
// 对消息中的坐标进行脱敏
String processedMessage = COORDINATE_PATTERN.matcher(message).replaceAll(match -> {
try {
double cx = Double.parseDouble(match.group(1));
double cy = Double.parseDouble(match.group(2));
double cz = Double.parseDouble(match.group(3));
return anonymizeCoordinates(cx, cy, cz);
} catch (NumberFormatException e) {
return match.group();
}
});
return "[" + anonymousId + "] " + processedMessage;
}
/**
* 记录隐私数据访问审计
*
* 每次API调用访问玩家隐私数据时记录操作者、玩家目标、数据类型和访问时间。
* 用于合规审计和异常检测。
*
* @param accessor 访问者(API调用方)
* @param targetPlayer 被访问的玩家
* @param dataType 访问的数据类型(如IP、坐标、聊天记录等)
*/
public void auditAccess(String accessor, String targetPlayer, String dataType) {
if (!config.getSecurity().getSuperEvolution().isPlayerPrivacy()) {
return;
}
AuditEntry entry = new AuditEntry(accessor, targetPlayer, dataType, Instant.now());
accessAudits.computeIfAbsent(targetPlayer, k -> new ArrayList<>()).add(entry);
totalAuditEntries.incrementAndGet();
// 限制单个玩家的审计记录数量
List<AuditEntry> entries = accessAudits.get(targetPlayer);
while (entries.size() > 500) {
entries.remove(0);
}
}
/**
* 提交玩家数据导出请求(GDPR第20条:数据可携带权)
*
* @param playerName 玩家名
* @param requestor 请求者标识
* @return 请求ID
*/
public String requestDataExport(String playerName, String requestor) {
String requestId = UUID.randomUUID().toString();
DataExportRequest request = new DataExportRequest(
requestId, playerName, requestor, "EXPORT", Instant.now());
pendingDataRequests.put(requestId, request);
return requestId;
}
/**
* 提交玩家数据删除请求(GDPR第17条:被遗忘权)
*
* @param playerName 玩家名
* @param requestor 请求者标识
* @return 请求ID
*/
public String requestDataDeletion(String playerName, String requestor) {
String requestId = UUID.randomUUID().toString();
DataExportRequest request = new DataExportRequest(
requestId, playerName, requestor, "DELETION", Instant.now());
pendingDataRequests.put(requestId, request);
return requestId;
}
/**
* 获取待处理的数据请求列表
*
* @return 待处理的数据请求列表
*/
public List<DataExportRequest> getPendingRequests() {
return new ArrayList<>(pendingDataRequests.values());
}
/**
* 标记数据请求为已处理
*
* @param requestId 请求ID
* @return true表示处理成功
*/
public boolean completeRequest(String requestId) {
DataExportRequest request = pendingDataRequests.get(requestId);
if (request != null) {
request.completed = true;
request.completedAt = Instant.now();
return true;
}
return false;
}
/**
* 检查并清理过期的数据请求
*
* 超过7天未处理的数据导出/删除请求自动移除,释放内存。
*/
public void cleanupExpiredRequests() {
Instant cutoff = Instant.now().minus(EXPORT_REQUEST_EXPIRY_DAYS, ChronoUnit.DAYS);
pendingDataRequests.entrySet().removeIf(e ->
e.getValue().createdAt.isBefore(cutoff));
}
/**
* 记录隐私违规事件
*
* 当检测到未经授权的隐私数据访问时记录违规。
*
* @param description 违规描述
*/
public void recordPrivacyViolation(String description) {
privacyViolations.incrementAndGet();
}
/**
* 设置坐标脱敏级别
*
* @param level 脱敏级别
*/
public void setCoordinateAnonymizationLevel(CoordinateAnonymizationLevel level) {
this.coordLevel = level;
}
/**
* 获取当前坐标脱敏级别
*
* @return 当前脱敏级别
*/
public CoordinateAnonymizationLevel getCoordinateAnonymizationLevel() {
return coordLevel;
}
/**
* 获取针对特定玩家的审计记录
*
* @param playerName 玩家名
* @return 审计条目列表
*/
public List<AuditEntry> getAuditForPlayer(String playerName) {
List<AuditEntry> entries = accessAudits.get(playerName);
return entries == null ? List.of() : new ArrayList<>(entries);
}
/**
* 获取服务运行状态
*
* @return 包含状态键值对的LinkedHashMap
*/
public Map<String, Object> getStatus() {
Map<String, Object> s = new LinkedHashMap<>();
s.put("enabled", config.getSecurity().getSuperEvolution().isPlayerPrivacy());
s.put("anonymizedEntries", anonymizedEntries.get());
s.put("pendingDataRequests", pendingDataRequests.size());
s.put("exportRequests", pendingDataRequests.values().stream()
.filter(r -> "EXPORT".equals(r.type)).count());
s.put("deletionRequests", pendingDataRequests.values().stream()
.filter(r -> "DELETION".equals(r.type)).count());
s.put("privacyViolations", privacyViolations.get());
s.put("coordinateLevel", coordLevel.name());
s.put("retentionDays", DEFAULT_RETENTION_DAYS);
s.put("totalAuditEntries", totalAuditEntries.get());
s.put("auditedPlayers", accessAudits.size());
return s;
}
/**
* 隐私数据访问审计条目
*/
public static class AuditEntry {
private final String accessor;
private final String targetPlayer;
private final String dataType;
private final Instant timestamp;
public AuditEntry(String accessor, String targetPlayer, String dataType, Instant timestamp) {
this.accessor = accessor;
this.targetPlayer = targetPlayer;
this.dataType = dataType;
this.timestamp = timestamp;
}
public String getAccessor() { return accessor; }
public String getTargetPlayer() { return targetPlayer; }
public String getDataType() { return dataType; }
public Instant getTimestamp() { return timestamp; }
}
/**
* 数据导出/删除请求
*/
public static class DataExportRequest {
private final String requestId;
private final String playerName;
private final String requestor;
private final String type; // EXPORT 或 DELETION
private final Instant createdAt;
private volatile boolean completed = false;
private volatile Instant completedAt;
public DataExportRequest(String requestId, String playerName, String requestor,
String type, Instant createdAt) {
this.requestId = requestId;
this.playerName = playerName;
this.requestor = requestor;
this.type = type;
this.createdAt = createdAt;
}
public String getRequestId() { return requestId; }
public String getPlayerName() { return playerName; }
public String getRequestor() { return requestor; }
public String getType() { return type; }
public Instant getCreatedAt() { return createdAt; }
public boolean isCompleted() { return completed; }
public Instant getCompletedAt() { return completedAt; }
}
}
@@ -68,6 +68,40 @@ public class DashboardController {
private final ConnectionThrottleService connectionThrottleService;
private final BackupIntegrityService backupIntegrityService;
private final AntiSkinSpoofService antiSkinSpoofService;
// V4.0 反作弊扩展模块
private final AntiKillAuraService antiKillAuraService;
private final AntiReachService antiReachService;
private final AntiSpeedService antiSpeedService;
private final AntiJesusService antiJesusService;
private final AntiNoFallService antiNoFallService;
private final AntiScaffoldService antiScaffoldService;
// V4.0 玩家行为安全模块
private final AntiNukerService antiNukerService;
private final AntiAutoClickerService antiAutoClickerService;
private final AntiChestStealService antiChestStealService;
private final AntiAutoFishService antiAutoFishService;
private final AntiInventoryManipulationService antiInventoryManipulationService;
private final AntiBaritoneService antiBaritoneService;
// V4.0 服务器保护模块
private final PacketFloodProtectionService packetFloodProtectionService;
private final AntiSignExploitService antiSignExploitService;
private final AntiBookBanService antiBookBanService;
private final AntiResourcePackExploitService antiResourcePackExploitService;
private final AntiTabCompleteCrashService antiTabCompleteCrashService;
private final AntiOfflineModeSpoofService antiOfflineModeSpoofService;
// V4.0 访问控制模块
private final OPPrivilegeMonitorService opPrivilegeMonitorService;
private final ConfigTamperDetectionService configTamperDetectionService;
private final BackdoorPluginScannerService backdoorPluginScannerService;
private final AntiVPNProxyService antiVPNProxyService;
private final AntiAltAccountService antiAltAccountService;
private final AntiNameSpoofService antiNameSpoofService;
// V4.0 聊天社交安全模块
private final ChatFloodProtectionService chatFloodProtectionService;
private final AntiAdvertisementService antiAdvertisementService;
private final AntiPhishingLinkService antiPhishingLinkService;
private final AntiCommandAbuseService antiCommandAbuseService;
private final PlayerPrivacyService playerPrivacyService;
@SuppressWarnings("java:S107")
public DashboardController(
@@ -113,7 +147,36 @@ public class DashboardController {
PluginVerificationService pluginVerificationService,
ConnectionThrottleService connectionThrottleService,
BackupIntegrityService backupIntegrityService,
AntiSkinSpoofService antiSkinSpoofService) {
AntiSkinSpoofService antiSkinSpoofService,
AntiKillAuraService antiKillAuraService,
AntiReachService antiReachService,
AntiSpeedService antiSpeedService,
AntiJesusService antiJesusService,
AntiNoFallService antiNoFallService,
AntiScaffoldService antiScaffoldService,
AntiNukerService antiNukerService,
AntiAutoClickerService antiAutoClickerService,
AntiChestStealService antiChestStealService,
AntiAutoFishService antiAutoFishService,
AntiInventoryManipulationService antiInventoryManipulationService,
AntiBaritoneService antiBaritoneService,
PacketFloodProtectionService packetFloodProtectionService,
AntiSignExploitService antiSignExploitService,
AntiBookBanService antiBookBanService,
AntiResourcePackExploitService antiResourcePackExploitService,
AntiTabCompleteCrashService antiTabCompleteCrashService,
AntiOfflineModeSpoofService antiOfflineModeSpoofService,
OPPrivilegeMonitorService opPrivilegeMonitorService,
ConfigTamperDetectionService configTamperDetectionService,
BackdoorPluginScannerService backdoorPluginScannerService,
AntiVPNProxyService antiVPNProxyService,
AntiAltAccountService antiAltAccountService,
AntiNameSpoofService antiNameSpoofService,
ChatFloodProtectionService chatFloodProtectionService,
AntiAdvertisementService antiAdvertisementService,
AntiPhishingLinkService antiPhishingLinkService,
AntiCommandAbuseService antiCommandAbuseService,
PlayerPrivacyService playerPrivacyService) {
this.rconClient = rconClient;
this.profiler = profiler;
this.backupService = backupService;
@@ -157,6 +220,35 @@ public class DashboardController {
this.connectionThrottleService = connectionThrottleService;
this.backupIntegrityService = backupIntegrityService;
this.antiSkinSpoofService = antiSkinSpoofService;
this.antiKillAuraService = antiKillAuraService;
this.antiReachService = antiReachService;
this.antiSpeedService = antiSpeedService;
this.antiJesusService = antiJesusService;
this.antiNoFallService = antiNoFallService;
this.antiScaffoldService = antiScaffoldService;
this.antiNukerService = antiNukerService;
this.antiAutoClickerService = antiAutoClickerService;
this.antiChestStealService = antiChestStealService;
this.antiAutoFishService = antiAutoFishService;
this.antiInventoryManipulationService = antiInventoryManipulationService;
this.antiBaritoneService = antiBaritoneService;
this.packetFloodProtectionService = packetFloodProtectionService;
this.antiSignExploitService = antiSignExploitService;
this.antiBookBanService = antiBookBanService;
this.antiResourcePackExploitService = antiResourcePackExploitService;
this.antiTabCompleteCrashService = antiTabCompleteCrashService;
this.antiOfflineModeSpoofService = antiOfflineModeSpoofService;
this.opPrivilegeMonitorService = opPrivilegeMonitorService;
this.configTamperDetectionService = configTamperDetectionService;
this.backdoorPluginScannerService = backdoorPluginScannerService;
this.antiVPNProxyService = antiVPNProxyService;
this.antiAltAccountService = antiAltAccountService;
this.antiNameSpoofService = antiNameSpoofService;
this.chatFloodProtectionService = chatFloodProtectionService;
this.antiAdvertisementService = antiAdvertisementService;
this.antiPhishingLinkService = antiPhishingLinkService;
this.antiCommandAbuseService = antiCommandAbuseService;
this.playerPrivacyService = playerPrivacyService;
}
@GetMapping("/status")
@@ -518,4 +610,159 @@ public class DashboardController {
public Map<String, Object> getAntiSkinSpoofStatus() {
return antiSkinSpoofService.getStatus();
}
// --- V4.0 反作弊扩展 API ---
@GetMapping("/security/anti-kill-aura/status")
public Map<String, Object> getAntiKillAuraStatus() {
return antiKillAuraService.getStatus();
}
@GetMapping("/security/anti-reach/status")
public Map<String, Object> getAntiReachStatus() {
return antiReachService.getStatus();
}
@GetMapping("/security/anti-speed/status")
public Map<String, Object> getAntiSpeedStatus() {
return antiSpeedService.getStatus();
}
@GetMapping("/security/anti-jesus/status")
public Map<String, Object> getAntiJesusStatus() {
return antiJesusService.getStatus();
}
@GetMapping("/security/anti-no-fall/status")
public Map<String, Object> getAntiNoFallStatus() {
return antiNoFallService.getStatus();
}
@GetMapping("/security/anti-scaffold/status")
public Map<String, Object> getAntiScaffoldStatus() {
return antiScaffoldService.getStatus();
}
// --- V4.0 玩家行为安全 API ---
@GetMapping("/security/anti-nuker/status")
public Map<String, Object> getAntiNukerStatus() {
return antiNukerService.getStatus();
}
@GetMapping("/security/anti-auto-clicker/status")
public Map<String, Object> getAntiAutoClickerStatus() {
return antiAutoClickerService.getStatus();
}
@GetMapping("/security/anti-chest-steal/status")
public Map<String, Object> getAntiChestStealStatus() {
return antiChestStealService.getStatus();
}
@GetMapping("/security/anti-auto-fish/status")
public Map<String, Object> getAntiAutoFishStatus() {
return antiAutoFishService.getStatus();
}
@GetMapping("/security/anti-inventory-manipulation/status")
public Map<String, Object> getAntiInventoryManipulationStatus() {
return antiInventoryManipulationService.getStatus();
}
@GetMapping("/security/anti-baritone/status")
public Map<String, Object> getAntiBaritoneStatus() {
return antiBaritoneService.getStatus();
}
// --- V4.0 服务器保护模块 API ---
@GetMapping("/security/packet-flood/status")
public Map<String, Object> getPacketFloodStatus() {
return packetFloodProtectionService.getStatus();
}
@GetMapping("/security/anti-sign-exploit/status")
public Map<String, Object> getAntiSignExploitStatus() {
return antiSignExploitService.getStatus();
}
@GetMapping("/security/anti-book-ban/status")
public Map<String, Object> getAntiBookBanStatus() {
return antiBookBanService.getStatus();
}
@GetMapping("/security/anti-resource-pack/status")
public Map<String, Object> getAntiResourcePackStatus() {
return antiResourcePackExploitService.getStatus();
}
@GetMapping("/security/anti-tab-complete/status")
public Map<String, Object> getAntiTabCompleteStatus() {
return antiTabCompleteCrashService.getStatus();
}
@GetMapping("/security/anti-offline-spoof/status")
public Map<String, Object> getAntiOfflineSpoofStatus() {
return antiOfflineModeSpoofService.getStatus();
}
// --- V4.0 访问控制模块 API ---
@GetMapping("/security/op-monitor/status")
public Map<String, Object> getOPMonitorStatus() {
return opPrivilegeMonitorService.getStatus();
}
@GetMapping("/security/config-tamper/status")
public Map<String, Object> getConfigTamperStatus() {
return configTamperDetectionService.getStatus();
}
@GetMapping("/security/backdoor-scanner/status")
public Map<String, Object> getBackdoorScannerStatus() {
return backdoorPluginScannerService.getStatus();
}
@GetMapping("/security/anti-vpn/status")
public Map<String, Object> getAntiVPNStatus() {
return antiVPNProxyService.getStatus();
}
@GetMapping("/security/anti-alt/status")
public Map<String, Object> getAntiAltStatus() {
return antiAltAccountService.getStatus();
}
@GetMapping("/security/anti-name-spoof/status")
public Map<String, Object> getAntiNameSpoofStatus() {
return antiNameSpoofService.getStatus();
}
// --- V4.0 聊天社交安全模块 API ---
@GetMapping("/security/chat-flood/status")
public Map<String, Object> getChatFloodStatus() {
return chatFloodProtectionService.getStatus();
}
@GetMapping("/security/anti-advertisement/status")
public Map<String, Object> getAntiAdvertisementStatus() {
return antiAdvertisementService.getStatus();
}
@GetMapping("/security/anti-phishing/status")
public Map<String, Object> getAntiPhishingStatus() {
return antiPhishingLinkService.getStatus();
}
@GetMapping("/security/anti-command-abuse/status")
public Map<String, Object> getAntiCommandAbuseStatus() {
return antiCommandAbuseService.getStatus();
}
@GetMapping("/security/player-privacy/status")
public Map<String, Object> getPlayerPrivacyStatus() {
return playerPrivacyService.getStatus();
}
}