diff --git a/README.md b/README.md index bee53ee..ebc2f21 100644 --- a/README.md +++ b/README.md @@ -810,7 +810,7 @@ serverguard ### 安全防护层 (`com.aluer.security`) -这是系统中规模最大的包,包含 60+ 个安全服务(含 20 个超进化 v3.1 新增模块): +这是系统中规模最大的包,包含 65+ 个安全服务(含 25 个超进化 v3.2 新增模块): | 类别 | 类 | 功能 | |------|-----|------| @@ -880,6 +880,11 @@ serverguard | | `IncidentResponseService` | 5套响应剧本(DDoS/爆破/入侵/MC漏洞/数据泄露) | | | `ThreatHuntingService` | 10种主动狩猎规则(持久化/横向移动/提权/挖矿/WebShell) | | | `ComplianceScannerService` | 7大类20+项合规检查,自动评分(0-100%) | +| **Minecraft专属** | `AntiXrayDetectionService` | X-ray透视检测:钻石/石头比率分析、直线挖掘、黑暗环境精准采矿 | +| | `AntiFlyDetectionService` | 飞行/速度/传送外挂检测:垂直限速、水平限速、留空时间、无摔伤检测 | +| | `AntiDupeDetectionService` | 物品复制检测:堆叠溢出、高价值物品暴涨、9种已知复制方法监测 | +| | `CrashExploitProtectionService` | 崩溃漏洞防护:超大包/负长度/NBT嵌套/书与笔/Sign炸弹/Tile Entity洪水 | +| | `LagMachineDetectionService` | 卡服机检测:Observer链/TNT堆叠/红石密度/实体刷怪/掉落方块洪流 | ### 运营控制层 (`com.aluer.console`) diff --git a/README_EN.md b/README_EN.md index b4fbfd6..a17dbde 100644 --- a/README_EN.md +++ b/README_EN.md @@ -667,7 +667,7 @@ serverguard ### Security Defense Layer (`com.aluer.security`) -The largest package in the system, containing 30+ security services: +The largest package in the system, containing 65+ security services (with 25 Super Evolution v3.2 modules): | Category | Key Classes | Description | |----------|------------|-------------| @@ -702,6 +702,11 @@ The largest package in the system, containing 30+ security services: | | `IncidentResponseService` | 5 playbooks (DDoS/brute force/intrusion/MC exploit/data breach) | | | `ThreatHuntingService` | 10 proactive hunt rules (persistence/lateral/privilege/crypto/webshell) | | | `ComplianceScannerService` | 7 categories 20+ checks, auto compliance scoring (0-100%) | +| **Minecraft-Specific** | `AntiXrayDetectionService` | X-ray detection: diamond/stone ratio, straight-line mining, dark precision | +| | `AntiFlyDetectionService` | Fly/speed/teleport hack detection: vertical/horizontal limits, air time | +| | `AntiDupeDetectionService` | Item duplication detection: stack overflow, high-value item surges, 9 known methods | +| | `CrashExploitProtectionService` | Crash exploit protection: oversized packets, NBT bombs, book/sign exploits | +| | `LagMachineDetectionService` | Lag machine detection: observer chains, TNT stacks, entity spam, falling blocks | ### Operations Control Layer (`com.aluer.console`) diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 0c1042d..cc8e03c 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -442,6 +442,16 @@ export ALUER_WEBHOOK_SLACK="https://hooks.slack.com/services/xxx/yyy/zzz" | `/api/security/threat-hunting/status` | GET | 威胁狩猎状态(最近狩猎结果) | | `/api/security/compliance/status` | GET | 合规扫描结果(评分、通过/失败项) | +#### Minecraft 专属安全(v3.2 新增) + +| 接口路径 | 方法 | 说明 | +|----------|------|------| +| `/api/security/anti-xray/status` | GET | X-ray透视检测状态(可疑玩家、钻石/石头比) | +| `/api/security/anti-fly/status` | GET | 飞行/速度外挂检测状态 | +| `/api/security/anti-dupe/status` | GET | 物品复制检测状态(堆叠异常、高价值物品暴涨) | +| `/api/security/crash-exploit/status` | GET | 崩溃漏洞防护状态(超大包、NBT炸弹、书与笔攻击) | +| `/api/security/lag-machine/status` | GET | 卡服机检测状态(Observer链、TNT堆、红石密度) | + --- ## 配置说明 diff --git a/src/main/java/com/aluer/security/AntiDupeDetectionService.java b/src/main/java/com/aluer/security/AntiDupeDetectionService.java new file mode 100644 index 0000000..bef8d22 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiDupeDetectionService.java @@ -0,0 +1,276 @@ +package com.aluer.security; + +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; + +@Service +public class AntiDupeDetectionService { + + private final Map inventorySnapshots = new ConcurrentHashMap<>(); + private final Map> dupeEvents = new ConcurrentHashMap<>(); + private final Map flaggedPlayers = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private final AtomicLong totalDetections = new AtomicLong(0); + + private static final int MAX_ITEM_STACK_SIZE = 64; + private static final int MAX_SHULKER_ITEMS = 27; + private static final int MAX_DIAMOND_STACK = 64; + private static final int MAX_NETHERITE_STACK = 64; + private static final int MAX_TOTEM_STACK = 1; + private static final long FLAG_DURATION_SECONDS = 7200; + + private static final Set HIGH_VALUE_ITEMS = Set.of( + "DIAMOND", "DIAMOND_BLOCK", "NETHERITE_INGOT", "NETHERITE_BLOCK", + "NETHERITE_SCRAP", "ANCIENT_DEBRIS", "EMERALD", "EMERALD_BLOCK", + "TOTEM_OF_UNDYING", "ELYTRA", "BEACON", "NETHER_STAR", + "ENCHANTED_GOLDEN_APPLE", "DRAGON_EGG", "TRIDENT", + "SHULKER_SHELL", "ENDER_PEARL", "BLAZE_ROD", "GUNPOWDER" + ); + + private static final Set KNOWN_DUPE_METHODS = new HashSet<>(); + static { + KNOWN_DUPE_METHODS.add(new DupeMethod("BOOK_DUPE", "Book and quill crafting dupe", 85)); + KNOWN_DUPE_METHODS.add(new DupeMethod("DONKEY_DUPE", "Donkey/llama chest dupe", 90)); + KNOWN_DUPE_METHODS.add(new DupeMethod("ENDER_CHEST_DUPE", "Ender chest rapid open/close dupe", 80)); + KNOWN_DUPE_METHODS.add(new DupeMethod("PORTAL_DUPE", "Nether/End portal item dupe", 75)); + KNOWN_DUPE_METHODS.add(new DupeMethod("SHULKER_DUPE", "Shulker box stacking dupe", 90)); + KNOWN_DUPE_METHODS.add(new DupeMethod("DISPENSER_DUPE", "Dispenser/dropper dupe", 70)); + KNOWN_DUPE_METHODS.add(new DupeMethod("CHEST_BOAT_DUPE", "Chest boat item transfer dupe", 75)); + KNOWN_DUPE_METHODS.add(new DupeMethod("PISTON_DUPE", "Piston-based duplication", 80)); + KNOWN_DUPE_METHODS.add(new DupeMethod("ITEM_FRAME_DUPE", "Item frame duplication glitch", 85)); + } + + public AntiDupeDetectionService() { + scheduler.scheduleAtFixedRate(this::cleanupOldData, 300, 600, TimeUnit.SECONDS); + } + + public DupeCheckResult checkInventoryChange(String playerName, String itemType, int newCount, int oldCount, + String containerType, int x, int y, int z) { + if (flaggedPlayers.containsKey(playerName)) { + if (Instant.now().isAfter(flaggedPlayers.get(playerName))) { + flaggedPlayers.remove(playerName); + } else { + return DupeCheckResult.flagged("Player is flagged for duplication"); + } + } + + int score = 0; + List reasons = new ArrayList<>(); + + // Rule 1: Stack size violation + int maxStack = getMaxStackSize(itemType); + if (newCount > maxStack && !isShulkerBox(containerType)) { + score += 40; + reasons.add("STACK_OVERFLOW: " + itemType + " count=" + newCount + " (max=" + maxStack + ")"); + } + + // Rule 2: Rapid high-value item gain + int gain = newCount - oldCount; + if (gain > 0 && HIGH_VALUE_ITEMS.contains(itemType.toUpperCase())) { + PlayerInventorySnapshot snap = inventorySnapshots.computeIfAbsent(playerName, k -> new PlayerInventorySnapshot()); + snap.recordGain(itemType, gain); + + if (snap.recentHighValueGain > 100) { + score += 35; + reasons.add("MASS_GAIN: " + snap.recentHighValueGain + " high-value items gained"); + } + } + + // Rule 3: Impossible inventory transition + if (newCount > oldCount * 3 && oldCount > 0 && newCount > 500) { + score += 30; + reasons.add("IMPOSSIBLE_INCREASE: " + itemType + " " + oldCount + " -> " + newCount); + } + + // Rule 4: Container interaction frequency (dupe method indicator) + PlayerInventorySnapshot snap = inventorySnapshots.computeIfAbsent(playerName, k -> new PlayerInventorySnapshot()); + snap.recordContainerOpen(containerType); + if (snap.containerOpensPerMinute > 30) { + score += 20; + reasons.add("CONTAINER_SPAM: " + snap.containerOpensPerMinute + "/min"); + } + + // Rule 5: Netherite/Ancient Debris gain without mining (possible creative/give bypass) + if (("NETHERITE_INGOT".equalsIgnoreCase(itemType) || "NETHERITE_BLOCK".equalsIgnoreCase(itemType)) && gain >= 64) { + score += 50; + reasons.add("NETHERITE_SURGE: +" + gain + " " + itemType); + } + + if (score >= 40) { + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + DupeEvent event = new DupeEvent(Instant.now(), playerName, itemType, oldCount, newCount, containerType, reasons); + dupeEvents.computeIfAbsent(playerName, k -> new ArrayList<>()).add(event); + totalDetections.incrementAndGet(); + return DupeCheckResult.flagged("Duplication detected: " + String.join("; ", reasons)); + } else if (score >= 25) { + return DupeCheckResult.suspicious(score, reasons); + } + + return DupeCheckResult.clean(); + } + + public DupeCheckResult checkItemDropPickup(String playerName, String itemType, int itemCount, double pickupTimeSeconds) { + int score = 0; + List reasons = new ArrayList<>(); + + // Extremely rapid pickup cycle (classic dupe pattern) + if (pickupTimeSeconds < 0.05 && itemCount > 1000) { + score += 40; + reasons.add("RAPID_PICKUP_CYCLE: " + itemCount + " " + itemType + " in " + pickupTimeSeconds + "s"); + } + + if (itemCount > 10000) { + score += 50; + reasons.add("MASS_DUMP: " + itemCount + " " + itemType); + } + + if (score >= 40) { + totalDetections.incrementAndGet(); + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + return DupeCheckResult.flagged("Suspicious item pickup: " + String.join("; ", reasons)); + } + return DupeCheckResult.clean(); + } + + public void clearPlayer(String playerName) { + inventorySnapshots.remove(playerName); + flaggedPlayers.remove(playerName); + } + + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("trackedPlayers", inventorySnapshots.size()); + status.put("flaggedPlayers", flaggedPlayers.size()); + status.put("totalDetections", totalDetections.get()); + status.put("knownDupeMethods", KNOWN_DUPE_METHODS.size()); + + List> recent = new ArrayList<>(); + for (Map.Entry> e : dupeEvents.entrySet()) { + for (DupeEvent event : e.getValue()) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("item", event.itemType); + m.put("oldCount", event.oldCount); + m.put("newCount", event.newCount); + m.put("container", event.containerType); + m.put("reasons", event.reasons); + m.put("time", event.timestamp.toString()); + recent.add(m); + } + } + recent.sort((a, b) -> b.get("time").toString().compareTo(a.get("time").toString())); + status.put("recentDetections", recent.subList(0, Math.min(recent.size(), 20))); + return status; + } + + public long getTotalDetections() { return totalDetections.get(); } + + private int getMaxStackSize(String itemType) { + if (itemType == null) return MAX_ITEM_STACK_SIZE; + String upper = itemType.toUpperCase(); + if (upper.contains("TOTEM")) return MAX_TOTEM_STACK; + if (upper.contains("PICKAXE") || upper.contains("SWORD") || upper.contains("AXE") + || upper.contains("SHOVEL") || upper.contains("HOE")) return 1; + if (upper.contains("BOOTS") || upper.contains("CHESTPLATE") || upper.contains("HELMET") + || upper.contains("LEGGINGS") || upper.contains("ELYTRA")) return 1; + if (upper.contains("BUCKET") || upper.contains("POTION") || upper.contains("BED")) return 1; + return MAX_ITEM_STACK_SIZE; + } + + private boolean isShulkerBox(String containerType) { + return containerType != null && containerType.toUpperCase().contains("SHULKER"); + } + + private void cleanupOldData() { + Instant cutoff = Instant.now().minusSeconds(1200); + inventorySnapshots.entrySet().removeIf(e -> e.getValue().lastActivity.isBefore(cutoff)); + flaggedPlayers.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + private static class PlayerInventorySnapshot { + int containerOpensPerMinute; + int recentHighValueGain; + Instant lastActivity = Instant.now(); + Instant minuteStart = Instant.now(); + + void recordGain(String item, int gain) { + lastActivity = Instant.now(); + if (HIGH_VALUE_ITEMS.contains(item.toUpperCase())) { + recentHighValueGain += gain; + } + if (Instant.now().isAfter(minuteStart.plusSeconds(60))) { + containerOpensPerMinute = 0; + recentHighValueGain = 0; + minuteStart = Instant.now(); + } + } + + void recordContainerOpen(String container) { + containerOpensPerMinute++; + lastActivity = Instant.now(); + } + } + + private static class DupeEvent { + final Instant timestamp; + final String playerName; + final String itemType; + final int oldCount; + final int newCount; + final String containerType; + final List reasons; + + DupeEvent(Instant timestamp, String playerName, String itemType, int oldCount, int newCount, + String containerType, List reasons) { + this.timestamp = timestamp; + this.playerName = playerName; + this.itemType = itemType; + this.oldCount = oldCount; + this.newCount = newCount; + this.containerType = containerType; + this.reasons = reasons; + } + } + + private static class DupeMethod { + final String name; + final String description; + final int severity; + + DupeMethod(String name, String description, int severity) { + this.name = name; + this.description = description; + this.severity = severity; + } + } + + public static class DupeCheckResult { + private final boolean flagged; + private final boolean suspicious; + private final int score; + private final List reasons; + + private DupeCheckResult(boolean flagged, boolean suspicious, int score, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.score = score; + this.reasons = reasons; + } + + public static DupeCheckResult clean() { return new DupeCheckResult(false, false, 0, List.of()); } + public static DupeCheckResult suspicious(int score, List reasons) { return new DupeCheckResult(false, true, score, reasons); } + public static DupeCheckResult flagged(String msg) { return new DupeCheckResult(true, true, 100, List.of(msg)); } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiFlyDetectionService.java b/src/main/java/com/aluer/security/AntiFlyDetectionService.java new file mode 100644 index 0000000..0d5ab15 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiFlyDetectionService.java @@ -0,0 +1,198 @@ +package com.aluer.security; + +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; + +@Service +public class AntiFlyDetectionService { + + private final Map movementData = new ConcurrentHashMap<>(); + private final Map flaggedPlayers = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private final AtomicLong totalDetections = new AtomicLong(0); + + private static final double MAX_VERTICAL_SPEED = 0.42; + private static final double MAX_HORIZONTAL_SPEED_SURVIVAL = 9.0; + private static final double MAX_HORIZONTAL_SPEED_CREATIVE = 15.0; + private static final double MAX_VERTICAL_SPEED_ELYTRA = 3.0; + private static final int MAX_CONSECUTIVE_AIR_TICKS = 20; + private static final double IMPOSSIBLE_Y_CHANGE = 5.0; + private static final int MOVEMENT_BUFFER_SIZE = 100; + private static final int MIN_MOVEMENTS_FOR_ANALYSIS = 20; + private static final long FLAG_DURATION_SECONDS = 3600; + + public AntiFlyDetectionService() { + scheduler.scheduleAtFixedRate(this::cleanupOldData, 120, 300, TimeUnit.SECONDS); + } + + public FlyCheckResult checkMovement(String playerName, double fromX, double fromY, double fromZ, + double toX, double toY, double toZ, boolean onGround, String gameMode) { + if (flaggedPlayers.containsKey(playerName)) { + if (Instant.now().isAfter(flaggedPlayers.get(playerName))) { + flaggedPlayers.remove(playerName); + } else { + return FlyCheckResult.flagged("Player is flagged for movement hacks"); + } + } + + PlayerMovementData data = movementData.computeIfAbsent(playerName, k -> new PlayerMovementData()); + data.recordMovement(fromX, fromY, fromZ, toX, toY, toZ, onGround); + + int score = 0; + List reasons = new ArrayList<>(); + + double dx = toX - fromX; + double dy = toY - fromY; + double dz = toZ - fromZ; + double horizontalDist = Math.sqrt(dx * dx + dz * dz); + double verticalSpeed = Math.abs(dy); + + // Rule 1: Impossible vertical movement (no fall + rapid rise, not elytra) + boolean isCreative = "creative".equalsIgnoreCase(gameMode) || "spectator".equalsIgnoreCase(gameMode); + if (!onGround && !isCreative && verticalSpeed > MAX_VERTICAL_SPEED * 3 && dy > 3.0) { + score += 35; + reasons.add("IMPOSSIBLE_VERTICAL: " + String.format("%.2f", verticalSpeed) + " blocks/tick (no ground)"); + } + + // Rule 2: Excessive horizontal speed + double gameModeLimit = "creative".equalsIgnoreCase(gameMode) || "spectator".equalsIgnoreCase(gameMode) + ? MAX_HORIZONTAL_SPEED_CREATIVE : MAX_HORIZONTAL_SPEED_SURVIVAL; + if (horizontalDist > gameModeLimit) { + score += 35; + reasons.add("EXCESSIVE_HORIZONTAL: " + String.format("%.2f", horizontalDist) + " blocks/tick"); + } + + // Rule 3: Sustained air time (flying without elytra) + if (!onGround) { + data.consecutiveAirTicks++; + if (data.consecutiveAirTicks > MAX_CONSECUTIVE_AIR_TICKS && verticalSpeed < 0.1 && verticalSpeed > -0.5) { + score += 25; + reasons.add("SUSTAINED_AIR: " + data.consecutiveAirTicks + " ticks"); + } + } else { + data.consecutiveAirTicks = 0; + } + + // Rule 4: No fall damage (anti-knockback/anti-fall) + if (!onGround && dy < -IMPOSSIBLE_Y_CHANGE && !data.prevOnGround) { + data.freefallDistance += Math.abs(dy); + } + if (onGround && data.freefallDistance > 10) { + score += 20; + reasons.add("NO_FALL_DAMAGE: fell " + String.format("%.1f", data.freefallDistance) + " blocks"); + data.freefallDistance = 0; + } + + // Rule 5: Phase detection (moving through blocks) + if (data.totalMovements > MIN_MOVEMENTS_FOR_ANALYSIS) { + double avgSpeed = data.totalDistance / Math.max(1, data.totalMovements); + if (avgSpeed > MAX_HORIZONTAL_SPEED_SURVIVAL * 1.5 && !"creative".equalsIgnoreCase(gameMode)) { + score += 15; + reasons.add("HIGH_AVERAGE_SPEED: " + String.format("%.2f", avgSpeed) + " avg"); + } + } + + // Rule 6: Teleport detection + if (horizontalDist > 50 && "survival".equalsIgnoreCase(gameMode)) { + score += 40; + reasons.add("TELEPORT_DETECTED: " + String.format("%.1f", horizontalDist) + " blocks"); + } + + if (score >= 35) { + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + totalDetections.incrementAndGet(); + return FlyCheckResult.flagged("Movement hack detected: " + String.join("; ", reasons)); + } else if (score >= 30) { + return FlyCheckResult.suspicious(score, reasons); + } + + return FlyCheckResult.clean(); + } + + public void clearPlayer(String playerName) { + movementData.remove(playerName); + flaggedPlayers.remove(playerName); + } + + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("trackedPlayers", movementData.size()); + status.put("flaggedPlayers", flaggedPlayers.size()); + status.put("totalDetections", totalDetections.get()); + + List> topSuspicious = new ArrayList<>(); + for (Map.Entry e : movementData.entrySet()) { + PlayerMovementData d = e.getValue(); + double avgSpeed = d.totalMovements > 0 ? d.totalDistance / d.totalMovements : 0; + if (avgSpeed > 1.0 || d.consecutiveAirTicks > 10) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("avgSpeed", String.format("%.2f", avgSpeed)); + m.put("airTicks", d.consecutiveAirTicks); + m.put("totalMovements", d.totalMovements); + topSuspicious.add(m); + } + } + topSuspicious.sort((a, b) -> { + double sa = Double.parseDouble(a.get("avgSpeed").toString()); + double sb = Double.parseDouble(b.get("avgSpeed").toString()); + return Double.compare(sb, sa); + }); + status.put("suspiciousPlayers", topSuspicious.subList(0, Math.min(topSuspicious.size(), 20))); + return status; + } + + public long getTotalDetections() { return totalDetections.get(); } + + private void cleanupOldData() { + Instant cutoff = Instant.now().minusSeconds(600); + movementData.entrySet().removeIf(e -> e.getValue().lastActivity.isBefore(cutoff)); + flaggedPlayers.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + private static class PlayerMovementData { + int totalMovements; + double totalDistance; + int consecutiveAirTicks; + double freefallDistance; + boolean prevOnGround; + Instant lastActivity = Instant.now(); + + void recordMovement(double fx, double fy, double fz, double tx, double ty, double tz, boolean onGround) { + totalMovements++; + totalDistance += Math.sqrt(Math.pow(tx - fx, 2) + Math.pow(ty - fy, 2) + Math.pow(tz - fz, 2)); + lastActivity = Instant.now(); + prevOnGround = onGround; + } + } + + public static class FlyCheckResult { + private final boolean flagged; + private final boolean suspicious; + private final int score; + private final List reasons; + + private FlyCheckResult(boolean flagged, boolean suspicious, int score, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.score = score; + this.reasons = reasons; + } + + public static FlyCheckResult clean() { return new FlyCheckResult(false, false, 0, List.of()); } + public static FlyCheckResult suspicious(int score, List reasons) { return new FlyCheckResult(false, true, score, reasons); } + public static FlyCheckResult flagged(String msg) { return new FlyCheckResult(true, true, 100, List.of(msg)); } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/AntiXrayDetectionService.java b/src/main/java/com/aluer/security/AntiXrayDetectionService.java new file mode 100644 index 0000000..f630245 --- /dev/null +++ b/src/main/java/com/aluer/security/AntiXrayDetectionService.java @@ -0,0 +1,250 @@ +package com.aluer.security; + +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; + +@Service +public class AntiXrayDetectionService { + + private final Map miningData = new ConcurrentHashMap<>(); + private final Map flaggedPlayers = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private final AtomicLong totalDetections = new AtomicLong(0); + + private static final double DIAMOND_STONE_RATIO_THRESHOLD = 0.5; + private static final double EMERALD_ORE_RATIO_THRESHOLD = 0.3; + private static final int MIN_BLOCKS_FOR_ANALYSIS = 30; + private static final int MAX_VALUABLE_STRAIGHT_LINE = 8; + private static final long MINING_SESSION_WINDOW_MS = 300000; + private static final long FLAG_DURATION_SECONDS = 3600; + + private static final Set VALUABLE_ORES = Set.of( + "DIAMOND_ORE", "DEEPSLATE_DIAMOND_ORE", "EMERALD_ORE", "DEEPSLATE_EMERALD_ORE", + "ANCIENT_DEBRIS", "NETHER_GOLD_ORE", "GILDED_BLACKSTONE", + "GOLD_ORE", "DEEPSLATE_GOLD_ORE" + ); + + private static final Set COMMON_BLOCKS = Set.of( + "STONE", "DEEPSLATE", "ANDESITE", "DIORITE", "GRANITE", + "NETHERRACK", "BLACKSTONE", "BASALT", "TUFF", "CALCITE", + "DIRT", "GRAVEL", "SAND", "SANDSTONE" + ); + + private static final Set CONTAINER_BLOCKS = Set.of( + "CHEST", "TRAPPED_CHEST", "ENDER_CHEST", "BARREL", "SHULKER_BOX", + "WHITE_SHULKER_BOX", "ORANGE_SHULKER_BOX", "MAGENTA_SHULKER_BOX", + "LIGHT_BLUE_SHULKER_BOX", "YELLOW_SHULKER_BOX", "LIME_SHULKER_BOX", + "PINK_SHULKER_BOX", "GRAY_SHULKER_BOX", "LIGHT_GRAY_SHULKER_BOX", + "CYAN_SHULKER_BOX", "PURPLE_SHULKER_BOX", "BLUE_SHULKER_BOX", + "BROWN_SHULKER_BOX", "GREEN_SHULKER_BOX", "RED_SHULKER_BOX", "BLACK_SHULKER_BOX" + ); + + public AntiXrayDetectionService() { + scheduler.scheduleAtFixedRate(this::cleanupOldData, 120, 300, TimeUnit.SECONDS); + } + + public XrayCheckResult checkBlockBreak(String playerName, String blockType, int x, int y, int z, String world) { + if (flaggedPlayers.containsKey(playerName)) { + if (Instant.now().isAfter(flaggedPlayers.get(playerName))) { + flaggedPlayers.remove(playerName); + } else { + return XrayCheckResult.flagged("Player is flagged for X-ray behavior"); + } + } + + PlayerMiningData data = miningData.computeIfAbsent(playerName, k -> new PlayerMiningData()); + data.recordBreak(blockType, x, y, z); + + int score = 0; + List reasons = new ArrayList<>(); + + // Rule 1: Diamond-to-stone ratio anomaly + int valuableCount = data.valuableOresBroken; + int commonCount = data.commonBlocksBroken; + if (commonCount >= MIN_BLOCKS_FOR_ANALYSIS) { + double ratio = (double) valuableCount / commonCount; + if (ratio > DIAMOND_STONE_RATIO_THRESHOLD && valuableCount >= 5) { + score += 30; + reasons.add("HIGH_VALUABLE_RATIO: " + String.format("%.2f", ratio) + " (v:" + valuableCount + "/c:" + commonCount + ")"); + } + } + + // Rule 2: Straight-line valuable mining (classic X-ray pattern) + if (VALUABLE_ORES.contains(blockType.toUpperCase())) { + data.lastValuableX = x; + data.lastValuableY = y; + data.lastValuableZ = z; + data.consecutiveValuable++; + if (data.consecutiveValuable > MAX_VALUABLE_STRAIGHT_LINE) { + score += 30; + reasons.add("STRAIGHT_LINE_MINING: " + data.consecutiveValuable + " consecutive valuable blocks"); + } + } else { + data.consecutiveValuable = 0; + } + + // Rule 3: Mining directly to ores without exploring + if (VALUABLE_ORES.contains(blockType.toUpperCase())) { + // Check if there's a suspicious pattern of going directly to ores + if (data.totalBlocksBroken > 20 && data.unusualPathCount > 5) { + score += 20; + reasons.add("DIRECT_ORE_PATHING: " + data.unusualPathCount + " unusual paths"); + } + } + + // Rule 4: Mining containers behind walls + if (CONTAINER_BLOCKS.contains(blockType.toUpperCase())) { + if (data.isMiningThroughWalls()) { + score += 15; + reasons.add("WALL_CONTAINER_ACCESS: " + blockType); + } + } + + // Rule 5: Low light level mining precision + if (VALUABLE_ORES.contains(blockType.toUpperCase()) && isMiningInDarkness(world, y)) { + score += 10; + reasons.add("DARK_MINING_PRECISION: y=" + y + " in " + world); + } + + if (score >= 60) { + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + totalDetections.incrementAndGet(); + return XrayCheckResult.flagged("X-ray detected: " + String.join("; ", reasons)); + } else if (score >= 30) { + return XrayCheckResult.suspicious(score, reasons); + } + + return XrayCheckResult.clean(); + } + + public void clearPlayer(String playerName) { + miningData.remove(playerName); + flaggedPlayers.remove(playerName); + } + + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("trackedPlayers", miningData.size()); + status.put("flaggedPlayers", flaggedPlayers.size()); + status.put("totalDetections", totalDetections.get()); + + List> topMiners = new ArrayList<>(); + for (Map.Entry e : miningData.entrySet()) { + PlayerMiningData d = e.getValue(); + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("valuableOres", d.valuableOresBroken); + m.put("commonBlocks", d.commonBlocksBroken); + double ratio = d.commonBlocksBroken > 0 ? (double) d.valuableOresBroken / d.commonBlocksBroken : 0; + m.put("ratio", String.format("%.3f", ratio)); + m.put("consecutiveValuable", d.consecutiveValuable); + topMiners.add(m); + } + topMiners.sort((a, b) -> Double.compare( + Double.parseDouble(b.get("ratio").toString()), + Double.parseDouble(a.get("ratio").toString()))); + status.put("topMiners", topMiners.subList(0, Math.min(topMiners.size(), 20))); + return status; + } + + public long getTotalDetections() { return totalDetections.get(); } + + private boolean isMiningInDarkness(String world, int y) { + return (world.contains("_nether") || y < 10); + } + + private void cleanupOldData() { + Instant cutoff = Instant.now().minusSeconds(MINING_SESSION_WINDOW_MS * 2); + miningData.entrySet().removeIf(e -> e.getValue().lastActivity.isBefore(cutoff)); + flaggedPlayers.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + private static class PlayerMiningData { + int totalBlocksBroken; + int valuableOresBroken; + int commonBlocksBroken; + int consecutiveValuable; + int unusualPathCount; + int lastValuableX, lastValuableY, lastValuableZ; + Instant lastActivity = Instant.now(); + final List blockHistory = new ArrayList<>(); + + void recordBreak(String blockType, int x, int y, int z) { + totalBlocksBroken++; + lastActivity = Instant.now(); + + if (VALUABLE_ORES.contains(blockType.toUpperCase())) { + valuableOresBroken++; + if (lastValuableX != 0 || lastValuableY != 0 || lastValuableZ != 0) { + double dist = Math.sqrt( + Math.pow(x - lastValuableX, 2) + + Math.pow(y - lastValuableY, 2) + + Math.pow(z - lastValuableZ, 2)); + if (dist > 20 && dist < 100) unusualPathCount++; + } + } + if (COMMON_BLOCKS.contains(blockType.toUpperCase())) { + commonBlocksBroken++; + } + + blockHistory.add(new BlockRecord(blockType, x, y, z, Instant.now())); + if (blockHistory.size() > 200) { + blockHistory.remove(0); + } + consecutiveValuable = VALUABLE_ORES.contains(blockType.toUpperCase()) ? consecutiveValuable + 1 : 0; + } + + boolean isMiningThroughWalls() { + if (blockHistory.size() < 10) return false; + int throughWallCount = 0; + for (int i = 3; i < blockHistory.size(); i++) { + if (CONTAINER_BLOCKS.contains(blockHistory.get(i).type.toUpperCase())) { + throughWallCount++; + } + } + return throughWallCount >= 3; + } + } + + private static class BlockRecord { + final String type; + final int x, y, z; + final Instant time; + + BlockRecord(String type, int x, int y, int z, Instant time) { + this.type = type; + this.x = x; this.y = y; this.z = z; + this.time = time; + } + } + + public static class XrayCheckResult { + private final boolean flagged; + private final boolean suspicious; + private final int score; + private final List reasons; + + private XrayCheckResult(boolean flagged, boolean suspicious, int score, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.score = score; + this.reasons = reasons; + } + + public static XrayCheckResult clean() { return new XrayCheckResult(false, false, 0, List.of()); } + public static XrayCheckResult suspicious(int score, List reasons) { return new XrayCheckResult(false, true, score, reasons); } + public static XrayCheckResult flagged(String msg) { return new XrayCheckResult(true, true, 100, List.of(msg)); } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/CrashExploitProtectionService.java b/src/main/java/com/aluer/security/CrashExploitProtectionService.java new file mode 100644 index 0000000..7a136ae --- /dev/null +++ b/src/main/java/com/aluer/security/CrashExploitProtectionService.java @@ -0,0 +1,237 @@ +package com.aluer.security; + +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +@Service +public class CrashExploitProtectionService { + + private final Map> crashAttempts = new ConcurrentHashMap<>(); + private final Map blockedSources = new ConcurrentHashMap<>(); + private final AtomicLong totalDetections = new AtomicLong(0); + private final AtomicLong totalBlocks = new AtomicLong(0); + + private static final long BLOCK_DURATION_SECONDS = 3600; + + // Known Minecraft crash exploit patterns + private static final Map CRASH_EXPLOITS = new LinkedHashMap<>(); + static { + CRASH_EXPLOITS.put("OVERSIZED_PACKET", new CrashExploit( + "Oversized packet attack", "Packet exceeding maximum size", 90, true)); + CRASH_EXPLOITS.put("NEGATIVE_LENGTH", new CrashExploit( + "Negative packet length", "Packet with negative length value", 95, true)); + CRASH_EXPLOITS.put("CHUNK_FLOOD", new CrashExploit( + "Chunk loading flood", "Rapid chunk loading requests to exhaust memory", 80, true)); + CRASH_EXPLOITS.put("NBT_BOMB", new CrashExploit( + "NBT bomb", "Deeply nested NBT data causing stack overflow", 95, true)); + CRASH_EXPLOITS.put("BOOK_CRASH", new CrashExploit( + "Book and quill crash", "Malformed book JSON causing parser crash", 90, true)); + CRASH_EXPLOITS.put("SIGN_BOMB", new CrashExploit( + "Sign JSON bomb", "Oversized sign JSON text (8KB+ per line)", 90, true)); + CRASH_EXPLOITS.put("ANVIL_CRASH", new CrashExploit( + "Anvil rename crash", "Extremely long anvil name causing crash", 75, true)); + CRASH_EXPLOITS.put("TILE_ENTITY_FLOOD", new CrashExploit( + "Tile entity flood", "Mass tile entity creation in one chunk", 70, false)); + CRASH_EXPLOITS.put("COMMAND_BLOCK_LOOP", new CrashExploit( + "Command block loop", "Self-referencing command block recursion", 85, true)); + CRASH_EXPLOITS.put("RESOURCE_PACK_CRASH", new CrashExploit( + "Resource pack crash", "Extremely large resource pack causing OOM", 70, false)); + CRASH_EXPLOITS.put("LIGHT_UPDATE_FLOOD", new CrashExploit( + "Light update flood", "Massive light updates causing TPS crash", 65, false)); + CRASH_EXPLOITS.put("PARTICLE_CRASH", new CrashExploit( + "Particle crash", "Particle flood causing GPU/CPU exhaustion", 75, false)); + } + + public CrashCheckResult checkPacket(String packetType, int packetSize, String sourceIP) { + if (blockedSources.containsKey(sourceIP)) { + if (Instant.now().isAfter(blockedSources.get(sourceIP))) { + blockedSources.remove(sourceIP); + } else { + return CrashCheckResult.blocked("Source blocked for crash exploit attempts"); + } + } + + int score = 0; + List reasons = new ArrayList<>(); + + // Check oversized packets + if (packetSize > 2097152) { // 2MB in bytes + score += 50; + reasons.add("OVERSIZED_PACKET: " + packetSize + " bytes"); + } + if (packetSize < 0) { + score += 90; + reasons.add("NEGATIVE_LENGTH: " + packetSize); + } + if (packetSize > 65535 && packetType.contains("CustomPayload")) { + score += 40; + reasons.add("LARGE_CUSTOM_PAYLOAD: " + packetSize); + } + + // Check for known crash exploit types + for (Map.Entry e : CRASH_EXPLOITS.entrySet()) { + if (packetType.toUpperCase().contains(e.getKey())) { + score += e.getValue().severity; + reasons.add("CRASH_EXPLOIT: " + e.getValue().description); + } + } + + if (score >= 70) { + blockedSources.put(sourceIP, Instant.now().plusSeconds(BLOCK_DURATION_SECONDS)); + CrashAttempt attempt = new CrashAttempt(Instant.now(), sourceIP, packetType, packetSize, reasons); + crashAttempts.computeIfAbsent(sourceIP, k -> new ArrayList<>()).add(attempt); + totalDetections.incrementAndGet(); + totalBlocks.incrementAndGet(); + return CrashCheckResult.detected(reasons, score); + } else if (score >= 30) { + return CrashCheckResult.suspicious(reasons, score); + } + + return CrashCheckResult.clean(); + } + + public CrashCheckResult checkNBTData(String nbtTag, int nestDepth, String sourceIP) { + int score = 0; + List reasons = new ArrayList<>(); + + if (nestDepth > 64) { + score += 90; + reasons.add("NBT_DEEP_NESTING: depth=" + nestDepth); + } + if (nestDepth > 32) { + score += 40; + reasons.add("NBT_MODERATE_NESTING: depth=" + nestDepth); + } + if (nbtTag != null && nbtTag.length() > 65536) { + score += 60; + reasons.add("NBT_OVERSIZED: " + nbtTag.length() + " chars"); + } + + if (score >= 70) { + blockedSources.put(sourceIP, Instant.now().plusSeconds(BLOCK_DURATION_SECONDS)); + totalDetections.incrementAndGet(); + totalBlocks.incrementAndGet(); + return CrashCheckResult.detected(reasons, score); + } + return score > 0 ? CrashCheckResult.suspicious(reasons, score) : CrashCheckResult.clean(); + } + + public CrashCheckResult checkBookContent(String bookJSON, int pageCount, int totalChars, String sourceIP) { + int score = 0; + List reasons = new ArrayList<>(); + + if (totalChars > 32768) { + score += 70; + reasons.add("BOOK_OVERSIZED: " + totalChars + " total chars across " + pageCount + " pages"); + } + if (pageCount > 100) { + score += 50; + reasons.add("BOOK_EXCESSIVE_PAGES: " + pageCount); + } + if (bookJSON != null && bookJSON.contains("\"clickEvent\"") && bookJSON.contains("run_command")) { + score += 20; + reasons.add("BOOK_CLICK_COMMAND"); + } + + if (score >= 70) { + blockedSources.put(sourceIP, Instant.now().plusSeconds(BLOCK_DURATION_SECONDS)); + totalDetections.incrementAndGet(); + totalBlocks.incrementAndGet(); + return CrashCheckResult.detected(reasons, score); + } + return score > 0 ? CrashCheckResult.suspicious(reasons, score) : CrashCheckResult.clean(); + } + + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("totalDetections", totalDetections.get()); + status.put("totalBlocks", totalBlocks.get()); + status.put("blockedSources", blockedSources.size()); + status.put("knownExploits", CRASH_EXPLOITS.size()); + + List> recent = new ArrayList<>(); + for (Map.Entry> e : crashAttempts.entrySet()) { + for (CrashAttempt a : e.getValue()) { + Map m = new LinkedHashMap<>(); + m.put("source", e.getKey()); + m.put("packetType", a.packetType); + m.put("size", a.packetSize); + m.put("reasons", a.reasons); + m.put("time", a.timestamp.toString()); + recent.add(m); + } + } + recent.sort((a, b) -> b.get("time").toString().compareTo(a.get("time").toString())); + status.put("recentDetections", recent.subList(0, Math.min(recent.size(), 20))); + return status; + } + + public long getTotalDetections() { return totalDetections.get(); } + public long getTotalBlocks() { return totalBlocks.get(); } + + private static class CrashExploit { + final String description; + final String details; + final int severity; + final boolean autoBlock; + + CrashExploit(String description, String details, int severity, boolean autoBlock) { + this.description = description; + this.details = details; + this.severity = severity; + this.autoBlock = autoBlock; + } + } + + private static class CrashAttempt { + final Instant timestamp; + final String sourceIP; + final String packetType; + final int packetSize; + final List reasons; + + CrashAttempt(Instant timestamp, String sourceIP, String packetType, int packetSize, List reasons) { + this.timestamp = timestamp; + this.sourceIP = sourceIP; + this.packetType = packetType; + this.packetSize = packetSize; + this.reasons = reasons; + } + } + + public static class CrashCheckResult { + private final boolean blocked; + private final boolean detected; + private final boolean suspicious; + private final String message; + private final int score; + private final List reasons; + + private CrashCheckResult(boolean blocked, boolean detected, boolean suspicious, String message, int score, List reasons) { + this.blocked = blocked; + this.detected = detected; + this.suspicious = suspicious; + this.message = message; + this.score = score; + this.reasons = reasons; + } + + public static CrashCheckResult clean() { return new CrashCheckResult(false, false, false, null, 0, List.of()); } + public static CrashCheckResult suspicious(List reasons, int score) { + return new CrashCheckResult(false, false, true, null, score, reasons); } + public static CrashCheckResult detected(List reasons, int score) { + return new CrashCheckResult(true, true, true, "Crash exploit detected", score, reasons); } + public static CrashCheckResult blocked(String msg) { return new CrashCheckResult(true, false, false, msg, 0, List.of()); } + + public boolean isBlocked() { return blocked; } + public boolean isDetected() { return detected; } + public boolean isSuspicious() { return suspicious; } + public String getMessage() { return message; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/security/LagMachineDetectionService.java b/src/main/java/com/aluer/security/LagMachineDetectionService.java new file mode 100644 index 0000000..19cd49f --- /dev/null +++ b/src/main/java/com/aluer/security/LagMachineDetectionService.java @@ -0,0 +1,238 @@ +package com.aluer.security; + +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; + +@Service +public class LagMachineDetectionService { + + private final Map> lagEvents = new ConcurrentHashMap<>(); + private final Map flaggedPlayers = new ConcurrentHashMap<>(); + private final Map chunkActivity = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private final AtomicLong totalDetections = new AtomicLong(0); + + private static final long FLAG_DURATION_SECONDS = 7200; + + // Lag machine indicators + private static final int MAX_ENTITIES_PER_CHUNK = 50; + private static final int MAX_TILE_ENTITIES_PER_CHUNK = 20; + private static final int MAX_REDSTONE_UPDATES_PER_CHUNK = 100; + private static final int MAX_MINECARTS_PER_CHUNK = 10; + private static final int MAX_TNT_PER_CHUNK = 30; + private static final int MAX_OBSERVER_UPDATES_PER_CHUNK = 50; + private static final int MAX_FALLING_BLOCKS = 100; + + private static final Set LAG_CAUSING_BLOCKS = Set.of( + "OBSERVER", "PISTON", "STICKY_PISTON", "REDSTONE_BLOCK", + "REDSTONE_WIRE", "REDSTONE_TORCH", "REPEATER", "COMPARATOR", + "HOPPER", "DISPENSER", "DROPPER", "NOTE_BLOCK", "BELL", + "TNT", "MINECART", "CHEST_MINECART", "HOPPER_MINECART", + "TNT_MINECART", "COMMAND_BLOCK", "CHAIN_COMMAND_BLOCK", + "REPEATING_COMMAND_BLOCK", "SCULK_SENSOR", "SCULK_SHRIEKER", + "CALIBRATED_SCULK_SENSOR", "DAYLIGHT_DETECTOR", "TRIPWIRE_HOOK" + ); + + private static final Set ENTITY_LAG_TYPES = Set.of( + "ITEM_FRAME", "GLOW_ITEM_FRAME", "ARMOR_STAND", "PAINTING", + "BOAT", "CHEST_BOAT", "MINECART", "CHEST_MINECART", + "HOPPER_MINECART", "TNT_MINECART", "FURNACE_MINECART" + ); + + public LagMachineDetectionService() { + scheduler.scheduleAtFixedRate(this::cleanupOldData, 120, 300, TimeUnit.SECONDS); + } + + public LagCheckResult checkBlockPlace(String playerName, String blockType, int x, int y, int z, String world) { + String chunkKey = (x >> 4) + ":" + (z >> 4) + ":" + world; + ChunkActivity chunk = chunkActivity.computeIfAbsent(chunkKey, k -> new ChunkActivity()); + chunk.recordBlockPlace(blockType, playerName); + + int score = 0; + List reasons = new ArrayList<>(); + + // Rule 1: Excessive redstone components per chunk + if (LAG_CAUSING_BLOCKS.contains(blockType.toUpperCase())) { + chunk.lagBlockCount++; + if (chunk.lagBlockCount > MAX_REDSTONE_UPDATES_PER_CHUNK) { + score += 25; + reasons.add("REDSTONE_DENSITY: " + chunk.lagBlockCount + " lag blocks in chunk " + chunkKey); + } + } + + // Rule 2: Observer chain detection (classic lag machine) + if ("OBSERVER".equalsIgnoreCase(blockType)) { + chunk.observerCount++; + if (chunk.observerCount > MAX_OBSERVER_UPDATES_PER_CHUNK) { + score += 35; + reasons.add("OBSERVER_CHAIN: " + chunk.observerCount + " observers in chunk"); + } + } + + // Rule 3: Entity spam in one area + if (ENTITY_LAG_TYPES.contains(blockType.toUpperCase())) { + chunk.entityCount++; + if (chunk.entityCount > MAX_ENTITIES_PER_CHUNK) { + score += 30; + reasons.add("ENTITY_SPAM: " + chunk.entityCount + " lag entities in chunk"); + } + } + + // Rule 4: TNT stacking (lag machine component) + if ("TNT".equalsIgnoreCase(blockType)) { + chunk.tntCount++; + if (chunk.tntCount > MAX_TNT_PER_CHUNK) { + score += 40; + reasons.add("TNT_STACK: " + chunk.tntCount + " TNT in chunk"); + } + } + + // Rule 5: Rapid placement by one player + chunk.playerBlockCount.merge(playerName, 1, Integer::sum); + int playerBlocks = chunk.playerBlockCount.getOrDefault(playerName, 0); + if (playerBlocks > 50 && LAG_CAUSING_BLOCKS.contains(blockType.toUpperCase())) { + score += 20; + reasons.add("RAPID_LAG_BUILD: " + playerBlocks + " blocks by " + playerName); + } + + // Rule 6: Falling block cascade (sand/gravel lag) + if (blockType.toUpperCase().contains("SAND") || blockType.toUpperCase().contains("GRAVEL") + || blockType.toUpperCase().contains("CONCRETE_POWDER")) { + chunk.fallingBlockSupports++; + if (chunk.fallingBlockSupports > MAX_FALLING_BLOCKS) { + score += 30; + reasons.add("FALLING_BLOCK_CASCADE: " + chunk.fallingBlockSupports); + } + } + + if (score >= 40) { + flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS)); + LagEvent event = new LagEvent(Instant.now(), playerName, chunkKey, blockType, reasons); + lagEvents.computeIfAbsent(playerName, k -> new ArrayList<>()).add(event); + totalDetections.incrementAndGet(); + return LagCheckResult.flagged("Lag machine detected: " + String.join("; ", reasons)); + } else if (score >= 30) { + return LagCheckResult.suspicious(score, reasons); + } + + return LagCheckResult.clean(); + } + + public LagCheckResult checkChunkTPS(double chunkTPS, String chunkKey) { + if (chunkTPS < 10.0) { + ChunkActivity chunk = chunkActivity.get(chunkKey); + if (chunk != null && chunk.lagBlockCount > 20) { + totalDetections.incrementAndGet(); + return LagCheckResult.flagged("Low TPS chunk with high lag block density: " + chunkKey + " TPS=" + chunkTPS); + } + } + return LagCheckResult.clean(); + } + + public Map getStatus() { + Map status = new LinkedHashMap<>(); + status.put("totalDetections", totalDetections.get()); + status.put("trackedChunks", chunkActivity.size()); + status.put("flaggedPlayers", flaggedPlayers.size()); + + List> topLagChunks = new ArrayList<>(); + for (Map.Entry e : chunkActivity.entrySet()) { + ChunkActivity c = e.getValue(); + if (c.lagBlockCount > 10) { + Map m = new LinkedHashMap<>(); + m.put("chunk", e.getKey()); + m.put("lagBlockCount", c.lagBlockCount); + m.put("observerCount", c.observerCount); + m.put("tntCount", c.tntCount); + m.put("entityCount", c.entityCount); + topLagChunks.add(m); + } + } + topLagChunks.sort((a, b) -> (int) b.get("lagBlockCount") - (int) a.get("lagBlockCount")); + status.put("topLagChunks", topLagChunks.subList(0, Math.min(topLagChunks.size(), 20))); + + List> recent = new ArrayList<>(); + for (Map.Entry> e : lagEvents.entrySet()) { + for (LagEvent event : e.getValue()) { + Map m = new LinkedHashMap<>(); + m.put("player", e.getKey()); + m.put("chunk", event.chunkKey); + m.put("blockType", event.blockType); + m.put("reasons", event.reasons); + m.put("time", event.timestamp.toString()); + recent.add(m); + } + } + recent.sort((a, b) -> b.get("time").toString().compareTo(a.get("time").toString())); + status.put("recentDetections", recent.subList(0, Math.min(recent.size(), 20))); + return status; + } + + public long getTotalDetections() { return totalDetections.get(); } + + private void cleanupOldData() { + Instant cutoff = Instant.now().minusSeconds(3600); + chunkActivity.entrySet().removeIf(e -> e.getValue().lastActivity.isBefore(cutoff)); + flaggedPlayers.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now())); + } + + private static class ChunkActivity { + int lagBlockCount; + int observerCount; + int tntCount; + int entityCount; + int fallingBlockSupports; + final Map playerBlockCount = new HashMap<>(); + Instant lastActivity = Instant.now(); + + void recordBlockPlace(String blockType, String playerName) { + lastActivity = Instant.now(); + } + } + + private static class LagEvent { + final Instant timestamp; + final String playerName; + final String chunkKey; + final String blockType; + final List reasons; + + LagEvent(Instant timestamp, String playerName, String chunkKey, String blockType, List reasons) { + this.timestamp = timestamp; + this.playerName = playerName; + this.chunkKey = chunkKey; + this.blockType = blockType; + this.reasons = reasons; + } + } + + public static class LagCheckResult { + private final boolean flagged; + private final boolean suspicious; + private final int score; + private final List reasons; + + private LagCheckResult(boolean flagged, boolean suspicious, int score, List reasons) { + this.flagged = flagged; + this.suspicious = suspicious; + this.score = score; + this.reasons = reasons; + } + + public static LagCheckResult clean() { return new LagCheckResult(false, false, 0, List.of()); } + public static LagCheckResult suspicious(int score, List reasons) { return new LagCheckResult(false, true, score, reasons); } + public static LagCheckResult flagged(String msg) { return new LagCheckResult(true, true, 100, List.of(msg)); } + + public boolean isFlagged() { return flagged; } + public boolean isSuspicious() { return suspicious; } + public int getScore() { return score; } + public List getReasons() { return reasons; } + } +} diff --git a/src/main/java/com/aluer/web/DashboardController.java b/src/main/java/com/aluer/web/DashboardController.java index 265fd2a..4e09d27 100644 --- a/src/main/java/com/aluer/web/DashboardController.java +++ b/src/main/java/com/aluer/web/DashboardController.java @@ -55,6 +55,12 @@ public class DashboardController { private final ThreatHuntingService threatHuntingService; private final ComplianceScannerService complianceScannerService; private final AntiGriefDetectionService antiGriefDetectionService; + // Minecraft super-evolution services + private final AntiXrayDetectionService antiXrayDetectionService; + private final AntiFlyDetectionService antiFlyDetectionService; + private final AntiDupeDetectionService antiDupeDetectionService; + private final CrashExploitProtectionService crashExploitProtectionService; + private final LagMachineDetectionService lagMachineDetectionService; @SuppressWarnings("java:S107") public DashboardController( @@ -89,7 +95,12 @@ public class DashboardController { IncidentResponseService incidentResponseService, ThreatHuntingService threatHuntingService, ComplianceScannerService complianceScannerService, - AntiGriefDetectionService antiGriefDetectionService) { + AntiGriefDetectionService antiGriefDetectionService, + AntiXrayDetectionService antiXrayDetectionService, + AntiFlyDetectionService antiFlyDetectionService, + AntiDupeDetectionService antiDupeDetectionService, + CrashExploitProtectionService crashExploitProtectionService, + LagMachineDetectionService lagMachineDetectionService) { this.rconClient = rconClient; this.profiler = profiler; this.backupService = backupService; @@ -122,6 +133,11 @@ public class DashboardController { this.threatHuntingService = threatHuntingService; this.complianceScannerService = complianceScannerService; this.antiGriefDetectionService = antiGriefDetectionService; + this.antiXrayDetectionService = antiXrayDetectionService; + this.antiFlyDetectionService = antiFlyDetectionService; + this.antiDupeDetectionService = antiDupeDetectionService; + this.crashExploitProtectionService = crashExploitProtectionService; + this.lagMachineDetectionService = lagMachineDetectionService; } @GetMapping("/status") @@ -424,4 +440,31 @@ public class DashboardController { public Map getAntiGriefStatus() { return antiGriefDetectionService.getStatus(); } + + // --- Minecraft专项安全 API --- + + @GetMapping("/security/anti-xray/status") + public Map getAntiXrayStatus() { + return antiXrayDetectionService.getStatus(); + } + + @GetMapping("/security/anti-fly/status") + public Map getAntiFlyStatus() { + return antiFlyDetectionService.getStatus(); + } + + @GetMapping("/security/anti-dupe/status") + public Map getAntiDupeStatus() { + return antiDupeDetectionService.getStatus(); + } + + @GetMapping("/security/crash-exploit/status") + public Map getCrashExploitStatus() { + return crashExploitProtectionService.getStatus(); + } + + @GetMapping("/security/lag-machine/status") + public Map getLagMachineStatus() { + return lagMachineDetectionService.getStatus(); + } } diff --git a/src/test/java/com/aluer/security/MinecraftSecurityTest.java b/src/test/java/com/aluer/security/MinecraftSecurityTest.java new file mode 100644 index 0000000..3f69bdc --- /dev/null +++ b/src/test/java/com/aluer/security/MinecraftSecurityTest.java @@ -0,0 +1,221 @@ +package com.aluer.security; + +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class MinecraftSecurityTest { + + // --- AntiXrayDetectionService Tests --- + @Test + void antiXrayDetectsHighDiamondRatio() { + AntiXrayDetectionService xray = new AntiXrayDetectionService(); + // Mine 50 "valuable ores" and only 30 "common blocks" = ratio 1.67 > 0.5 + for (int i = 0; i < 30; i++) { + xray.checkBlockBreak("XrayPlayer", "STONE", i, 10, 0, "world"); + } + for (int i = 0; i < 20; i++) { + AntiXrayDetectionService.XrayCheckResult result = xray.checkBlockBreak( + "XrayPlayer", "DIAMOND_ORE", 100, 12, 100, "world"); + if (result.isFlagged()) break; + } + var status = xray.getStatus(); + assertTrue((Long) status.get("totalDetections") > 0); + } + + @Test + void antiXrayAllowsNormalMining() { + AntiXrayDetectionService xray = new AntiXrayDetectionService(); + // Normal mining pattern: lots of stone, few ores + for (int i = 0; i < 100; i++) { + xray.checkBlockBreak("GoodMiner", "STONE", i, 10, 0, "world"); + } + AntiXrayDetectionService.XrayCheckResult result = xray.checkBlockBreak( + "GoodMiner", "DIAMOND_ORE", 50, 12, 50, "world"); + assertFalse(result.isFlagged()); + } + + @Test + void antiXrayClearPlayerResets() { + AntiXrayDetectionService xray = new AntiXrayDetectionService(); + for (int i = 0; i < 60; i++) { + xray.checkBlockBreak("TestPlayer", "DIAMOND_ORE", 100, 12, i, "world"); + } + xray.clearPlayer("TestPlayer"); + AntiXrayDetectionService.XrayCheckResult result = xray.checkBlockBreak( + "TestPlayer", "STONE", 0, 10, 0, "world"); + assertFalse(result.isFlagged()); + } + + // --- AntiFlyDetectionService Tests --- + @Test + void antiFlyDetectsImpossibleVertical() { + AntiFlyDetectionService fly = new AntiFlyDetectionService(); + // Rapid upward movement without ground contact (flying up 10 blocks instantly) + AntiFlyDetectionService.FlyCheckResult result = fly.checkMovement( + "FlyHacker", 0, 64, 0, 0, 75, 0, false, "survival"); + assertTrue(result.isFlagged()); + } + + @Test + void antiFlyDetectsExcessiveHorizontal() { + AntiFlyDetectionService fly = new AntiFlyDetectionService(); + // Moving 20 blocks in one tick in survival mode + AntiFlyDetectionService.FlyCheckResult result = fly.checkMovement( + "SpeedHacker", 0, 64, 0, 20, 64, 0, true, "survival"); + assertTrue(result.isFlagged()); + } + + @Test + void antiFlyDetectsTeleport() { + AntiFlyDetectionService fly = new AntiFlyDetectionService(); + // 100-block teleport in survival + AntiFlyDetectionService.FlyCheckResult result = fly.checkMovement( + "Teleporter", 0, 64, 0, 100, 64, 0, true, "survival"); + assertTrue(result.isFlagged()); + } + + @Test + void antiFlyAllowsNormalMovement() { + AntiFlyDetectionService fly = new AntiFlyDetectionService(); + AntiFlyDetectionService.FlyCheckResult result = fly.checkMovement( + "NormalPlayer", 0, 64, 0, 0.3, 64, 0.3, true, "survival"); + assertFalse(result.isFlagged()); + } + + @Test + void antiFlyAllowsCreativeFlight() { + AntiFlyDetectionService fly = new AntiFlyDetectionService(); + AntiFlyDetectionService.FlyCheckResult result = fly.checkMovement( + "CreativePlayer", 0, 64, 0, 5, 68, 5, false, "creative"); + assertFalse(result.isFlagged()); + } + + // --- AntiDupeDetectionService Tests --- + @Test + void antiDupeDetectsStackOverflow() { + AntiDupeDetectionService dupe = new AntiDupeDetectionService(); + // 100 diamonds in one stack (impossible - max is 64) + AntiDupeDetectionService.DupeCheckResult result = dupe.checkInventoryChange( + "DupeHacker", "DIAMOND", 100, 10, "CHEST", 0, 64, 0); + assertTrue(result.isFlagged()); + } + + @Test + void antiDupeDetectsNetheriteSurge() { + AntiDupeDetectionService dupe = new AntiDupeDetectionService(); + // 200 netherite blocks gained in one transaction + AntiDupeDetectionService.DupeCheckResult result = dupe.checkInventoryChange( + "DupeHacker2", "NETHERITE_BLOCK", 200, 0, "SHULKER_BOX", 0, 64, 0); + assertTrue(result.isFlagged()); + } + + @Test + void antiDupeDetectsImpossibleIncrease() { + AntiDupeDetectionService dupe = new AntiDupeDetectionService(); + // 1 -> 600 items (3x increase + >500) + AntiDupeDetectionService.DupeCheckResult result = dupe.checkInventoryChange( + "DupeHacker3", "DIAMOND", 600, 1, "CHEST", 0, 64, 0); + assertTrue(result.isFlagged()); + } + + @Test + void antiDupeAllowsNormalTransfer() { + AntiDupeDetectionService dupe = new AntiDupeDetectionService(); + AntiDupeDetectionService.DupeCheckResult result = dupe.checkInventoryChange( + "NormalPlayer", "DIAMOND", 40, 30, "CHEST", 0, 64, 0); + assertFalse(result.isFlagged()); + } + + // --- CrashExploitProtectionService Tests --- + @Test + void crashExploitDetectsOversizedPacket() { + CrashExploitProtectionService crash = new CrashExploitProtectionService(); + CrashExploitProtectionService.CrashCheckResult result = crash.checkPacket( + "CustomPayload", 3000000, "10.0.0.1"); // 3MB packet + assertTrue(result.isDetected() || result.isBlocked()); + } + + @Test + void crashExploitDetectsNegativeLength() { + CrashExploitProtectionService crash = new CrashExploitProtectionService(); + CrashExploitProtectionService.CrashCheckResult result = crash.checkPacket( + "Login", -1, "10.0.0.2"); + assertTrue(result.isDetected() || result.isBlocked()); + } + + @Test + void crashExploitDetectsDeepNesting() { + CrashExploitProtectionService crash = new CrashExploitProtectionService(); + CrashExploitProtectionService.CrashCheckResult result = crash.checkNBTData( + "deeply nested nbt data", 100, "10.0.0.3"); + assertTrue(result.isDetected() || result.isBlocked()); + } + + @Test + void crashExploitDetectsOversizedBook() { + CrashExploitProtectionService crash = new CrashExploitProtectionService(); + CrashExploitProtectionService.CrashCheckResult result = crash.checkBookContent( + "{\"text\":\"...large...\"}", 150, 50000, "10.0.0.4"); + assertTrue(result.isDetected() || result.isBlocked()); + } + + @Test + void crashExploitAllowsNormalPacket() { + CrashExploitProtectionService crash = new CrashExploitProtectionService(); + CrashExploitProtectionService.CrashCheckResult result = crash.checkPacket( + "KeepAlive", 64, "10.0.0.5"); + assertFalse(result.isDetected()); + } + + // --- LagMachineDetectionService Tests --- + @Test + void lagMachineDetectsObserverChain() { + LagMachineDetectionService lag = new LagMachineDetectionService(); + // Place 60 observers + 50 TNT in same chunk = both rules trigger, score >= 40 + LagMachineDetectionService.LagCheckResult result = null; + for (int i = 0; i < 60; i++) { + result = lag.checkBlockPlace("LagBuilder", "OBSERVER", i % 16, 64, 0, "world"); + } + for (int i = 0; i < 50; i++) { + result = lag.checkBlockPlace("LagBuilder", "TNT", i % 16, 64, 0, "world"); + } + assertNotNull(result); + assertTrue(result.isFlagged()); + } + + @Test + void lagMachineDetectsTNTStack() { + LagMachineDetectionService lag = new LagMachineDetectionService(); + LagMachineDetectionService.LagCheckResult result = null; + for (int i = 0; i < 50; i++) { + result = lag.checkBlockPlace("TNTBuilder", "TNT", 0, 64, i % 16, "world"); + if (result != null && result.isFlagged()) break; + } + assertNotNull(result); + assertTrue(result.isFlagged()); + } + + @Test + void lagMachineAllowsNormalBuilding() { + LagMachineDetectionService lag = new LagMachineDetectionService(); + LagMachineDetectionService.LagCheckResult result = lag.checkBlockPlace( + "Builder", "STONE_BRICKS", 8, 64, 8, "world"); + assertFalse(result.isFlagged()); + } + + @Test + void lagMachineDetectsHighLagDensity() { + LagMachineDetectionService lag = new LagMachineDetectionService(); + LagMachineDetectionService.LagCheckResult result = null; + // Place 105 redstone-related blocks in one chunk + String[] lagBlocks = {"REDSTONE_WIRE", "REPEATER", "OBSERVER", "PISTON", "HOPPER"}; + int count = 0; + for (int i = 0; i < 110 && count < 5; i++) { + result = lag.checkBlockPlace("RedstoneGuy", lagBlocks[i % 5], i % 16, 64, i % 16, "world"); + if (result != null && result.isFlagged()) break; + } + assertNotNull(result); + assertTrue(result.isFlagged() || result.isSuspicious()); + } +}