超进化 v3.2: Minecraft专属安全模块 — X-ray透视/飞行外挂/物品复制/崩溃漏洞/卡服机检测
新增5个Minecraft反作弊安全模块 + 21项测试 + 5个API端点: - AntiXrayDetectionService: 钻石/远古残骸挖掘比检测,直线挖掘模式识别 - AntiFlyDetectionService: 垂直/水平速度检测,飞行外挂判定 - AntiDupeDetectionService: 物品堆叠异常/高价值物品暴涨/9种复制方法检测 - CrashExploitProtectionService: 12种崩溃漏洞防护(超大包/NBT炸弹/书与笔攻击等) - LagMachineDetectionService: Observer链/TNT堆/红石密度/实体泛滥检测 DashboardController新增 /api/security/anti-xray|anti-fly|anti-dupe|crash-exploit|lag-machine 端点
This commit is contained in:
@@ -810,7 +810,7 @@ serverguard
|
|||||||
|
|
||||||
### 安全防护层 (`com.aluer.security`)
|
### 安全防护层 (`com.aluer.security`)
|
||||||
|
|
||||||
这是系统中规模最大的包,包含 60+ 个安全服务(含 20 个超进化 v3.1 新增模块):
|
这是系统中规模最大的包,包含 65+ 个安全服务(含 25 个超进化 v3.2 新增模块):
|
||||||
|
|
||||||
| 类别 | 类 | 功能 |
|
| 类别 | 类 | 功能 |
|
||||||
|------|-----|------|
|
|------|-----|------|
|
||||||
@@ -880,6 +880,11 @@ serverguard
|
|||||||
| | `IncidentResponseService` | 5套响应剧本(DDoS/爆破/入侵/MC漏洞/数据泄露) |
|
| | `IncidentResponseService` | 5套响应剧本(DDoS/爆破/入侵/MC漏洞/数据泄露) |
|
||||||
| | `ThreatHuntingService` | 10种主动狩猎规则(持久化/横向移动/提权/挖矿/WebShell) |
|
| | `ThreatHuntingService` | 10种主动狩猎规则(持久化/横向移动/提权/挖矿/WebShell) |
|
||||||
| | `ComplianceScannerService` | 7大类20+项合规检查,自动评分(0-100%) |
|
| | `ComplianceScannerService` | 7大类20+项合规检查,自动评分(0-100%) |
|
||||||
|
| **Minecraft专属** | `AntiXrayDetectionService` | X-ray透视检测:钻石/石头比率分析、直线挖掘、黑暗环境精准采矿 |
|
||||||
|
| | `AntiFlyDetectionService` | 飞行/速度/传送外挂检测:垂直限速、水平限速、留空时间、无摔伤检测 |
|
||||||
|
| | `AntiDupeDetectionService` | 物品复制检测:堆叠溢出、高价值物品暴涨、9种已知复制方法监测 |
|
||||||
|
| | `CrashExploitProtectionService` | 崩溃漏洞防护:超大包/负长度/NBT嵌套/书与笔/Sign炸弹/Tile Entity洪水 |
|
||||||
|
| | `LagMachineDetectionService` | 卡服机检测:Observer链/TNT堆叠/红石密度/实体刷怪/掉落方块洪流 |
|
||||||
|
|
||||||
### 运营控制层 (`com.aluer.console`)
|
### 运营控制层 (`com.aluer.console`)
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -667,7 +667,7 @@ serverguard
|
|||||||
|
|
||||||
### Security Defense Layer (`com.aluer.security`)
|
### 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 |
|
| 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) |
|
| | `IncidentResponseService` | 5 playbooks (DDoS/brute force/intrusion/MC exploit/data breach) |
|
||||||
| | `ThreatHuntingService` | 10 proactive hunt rules (persistence/lateral/privilege/crypto/webshell) |
|
| | `ThreatHuntingService` | 10 proactive hunt rules (persistence/lateral/privilege/crypto/webshell) |
|
||||||
| | `ComplianceScannerService` | 7 categories 20+ checks, auto compliance scoring (0-100%) |
|
| | `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`)
|
### Operations Control Layer (`com.aluer.console`)
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,16 @@ export ALUER_WEBHOOK_SLACK="https://hooks.slack.com/services/xxx/yyy/zzz"
|
|||||||
| `/api/security/threat-hunting/status` | GET | 威胁狩猎状态(最近狩猎结果) |
|
| `/api/security/threat-hunting/status` | GET | 威胁狩猎状态(最近狩猎结果) |
|
||||||
| `/api/security/compliance/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堆、红石密度) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 配置说明
|
## 配置说明
|
||||||
|
|||||||
@@ -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<String, PlayerInventorySnapshot> inventorySnapshots = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, List<DupeEvent>> dupeEvents = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Instant> 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<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", "DRAGON_EGG", "TRIDENT",
|
||||||
|
"SHULKER_SHELL", "ENDER_PEARL", "BLAZE_ROD", "GUNPOWDER"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final Set<DupeMethod> 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<String> 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<String> 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<String, Object> getStatus() {
|
||||||
|
Map<String, Object> 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<Map<String, Object>> recent = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, List<DupeEvent>> e : dupeEvents.entrySet()) {
|
||||||
|
for (DupeEvent event : e.getValue()) {
|
||||||
|
Map<String, Object> 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<String> reasons;
|
||||||
|
|
||||||
|
DupeEvent(Instant timestamp, String playerName, String itemType, int oldCount, int newCount,
|
||||||
|
String containerType, List<String> 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<String> reasons;
|
||||||
|
|
||||||
|
private DupeCheckResult(boolean flagged, boolean suspicious, int score, List<String> 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<String> 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<String> getReasons() { return reasons; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, PlayerMovementData> movementData = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Instant> 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<String> 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<String, Object> getStatus() {
|
||||||
|
Map<String, Object> status = new LinkedHashMap<>();
|
||||||
|
status.put("trackedPlayers", movementData.size());
|
||||||
|
status.put("flaggedPlayers", flaggedPlayers.size());
|
||||||
|
status.put("totalDetections", totalDetections.get());
|
||||||
|
|
||||||
|
List<Map<String, Object>> topSuspicious = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, PlayerMovementData> 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<String, Object> 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<String> reasons;
|
||||||
|
|
||||||
|
private FlyCheckResult(boolean flagged, boolean suspicious, int score, List<String> 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<String> 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<String> getReasons() { return reasons; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, PlayerMiningData> miningData = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Instant> 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<String> 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<String> COMMON_BLOCKS = Set.of(
|
||||||
|
"STONE", "DEEPSLATE", "ANDESITE", "DIORITE", "GRANITE",
|
||||||
|
"NETHERRACK", "BLACKSTONE", "BASALT", "TUFF", "CALCITE",
|
||||||
|
"DIRT", "GRAVEL", "SAND", "SANDSTONE"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final Set<String> 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<String> 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<String, Object> getStatus() {
|
||||||
|
Map<String, Object> status = new LinkedHashMap<>();
|
||||||
|
status.put("trackedPlayers", miningData.size());
|
||||||
|
status.put("flaggedPlayers", flaggedPlayers.size());
|
||||||
|
status.put("totalDetections", totalDetections.get());
|
||||||
|
|
||||||
|
List<Map<String, Object>> topMiners = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, PlayerMiningData> e : miningData.entrySet()) {
|
||||||
|
PlayerMiningData d = e.getValue();
|
||||||
|
Map<String, Object> 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<BlockRecord> 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<String> reasons;
|
||||||
|
|
||||||
|
private XrayCheckResult(boolean flagged, boolean suspicious, int score, List<String> 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<String> 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<String> getReasons() { return reasons; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, List<CrashAttempt>> crashAttempts = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Instant> 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<String, CrashExploit> 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<String> 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<String, CrashExploit> 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<String> 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<String> 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<String, Object> getStatus() {
|
||||||
|
Map<String, Object> 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<Map<String, Object>> recent = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, List<CrashAttempt>> e : crashAttempts.entrySet()) {
|
||||||
|
for (CrashAttempt a : e.getValue()) {
|
||||||
|
Map<String, Object> 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<String> reasons;
|
||||||
|
|
||||||
|
CrashAttempt(Instant timestamp, String sourceIP, String packetType, int packetSize, List<String> 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<String> reasons;
|
||||||
|
|
||||||
|
private CrashCheckResult(boolean blocked, boolean detected, boolean suspicious, String message, int score, List<String> 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<String> reasons, int score) {
|
||||||
|
return new CrashCheckResult(false, false, true, null, score, reasons); }
|
||||||
|
public static CrashCheckResult detected(List<String> 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<String> getReasons() { return reasons; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, List<LagEvent>> lagEvents = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Instant> flaggedPlayers = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, ChunkActivity> 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<String> 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<String> 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<String> 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<String, Object> getStatus() {
|
||||||
|
Map<String, Object> status = new LinkedHashMap<>();
|
||||||
|
status.put("totalDetections", totalDetections.get());
|
||||||
|
status.put("trackedChunks", chunkActivity.size());
|
||||||
|
status.put("flaggedPlayers", flaggedPlayers.size());
|
||||||
|
|
||||||
|
List<Map<String, Object>> topLagChunks = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, ChunkActivity> e : chunkActivity.entrySet()) {
|
||||||
|
ChunkActivity c = e.getValue();
|
||||||
|
if (c.lagBlockCount > 10) {
|
||||||
|
Map<String, Object> 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<Map<String, Object>> recent = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, List<LagEvent>> e : lagEvents.entrySet()) {
|
||||||
|
for (LagEvent event : e.getValue()) {
|
||||||
|
Map<String, Object> 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<String, Integer> 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<String> reasons;
|
||||||
|
|
||||||
|
LagEvent(Instant timestamp, String playerName, String chunkKey, String blockType, List<String> 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<String> reasons;
|
||||||
|
|
||||||
|
private LagCheckResult(boolean flagged, boolean suspicious, int score, List<String> 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<String> 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<String> getReasons() { return reasons; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,12 @@ public class DashboardController {
|
|||||||
private final ThreatHuntingService threatHuntingService;
|
private final ThreatHuntingService threatHuntingService;
|
||||||
private final ComplianceScannerService complianceScannerService;
|
private final ComplianceScannerService complianceScannerService;
|
||||||
private final AntiGriefDetectionService antiGriefDetectionService;
|
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")
|
@SuppressWarnings("java:S107")
|
||||||
public DashboardController(
|
public DashboardController(
|
||||||
@@ -89,7 +95,12 @@ public class DashboardController {
|
|||||||
IncidentResponseService incidentResponseService,
|
IncidentResponseService incidentResponseService,
|
||||||
ThreatHuntingService threatHuntingService,
|
ThreatHuntingService threatHuntingService,
|
||||||
ComplianceScannerService complianceScannerService,
|
ComplianceScannerService complianceScannerService,
|
||||||
AntiGriefDetectionService antiGriefDetectionService) {
|
AntiGriefDetectionService antiGriefDetectionService,
|
||||||
|
AntiXrayDetectionService antiXrayDetectionService,
|
||||||
|
AntiFlyDetectionService antiFlyDetectionService,
|
||||||
|
AntiDupeDetectionService antiDupeDetectionService,
|
||||||
|
CrashExploitProtectionService crashExploitProtectionService,
|
||||||
|
LagMachineDetectionService lagMachineDetectionService) {
|
||||||
this.rconClient = rconClient;
|
this.rconClient = rconClient;
|
||||||
this.profiler = profiler;
|
this.profiler = profiler;
|
||||||
this.backupService = backupService;
|
this.backupService = backupService;
|
||||||
@@ -122,6 +133,11 @@ public class DashboardController {
|
|||||||
this.threatHuntingService = threatHuntingService;
|
this.threatHuntingService = threatHuntingService;
|
||||||
this.complianceScannerService = complianceScannerService;
|
this.complianceScannerService = complianceScannerService;
|
||||||
this.antiGriefDetectionService = antiGriefDetectionService;
|
this.antiGriefDetectionService = antiGriefDetectionService;
|
||||||
|
this.antiXrayDetectionService = antiXrayDetectionService;
|
||||||
|
this.antiFlyDetectionService = antiFlyDetectionService;
|
||||||
|
this.antiDupeDetectionService = antiDupeDetectionService;
|
||||||
|
this.crashExploitProtectionService = crashExploitProtectionService;
|
||||||
|
this.lagMachineDetectionService = lagMachineDetectionService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
@@ -424,4 +440,31 @@ public class DashboardController {
|
|||||||
public Map<String, Object> getAntiGriefStatus() {
|
public Map<String, Object> getAntiGriefStatus() {
|
||||||
return antiGriefDetectionService.getStatus();
|
return antiGriefDetectionService.getStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Minecraft专项安全 API ---
|
||||||
|
|
||||||
|
@GetMapping("/security/anti-xray/status")
|
||||||
|
public Map<String, Object> getAntiXrayStatus() {
|
||||||
|
return antiXrayDetectionService.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/security/anti-fly/status")
|
||||||
|
public Map<String, Object> getAntiFlyStatus() {
|
||||||
|
return antiFlyDetectionService.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/security/anti-dupe/status")
|
||||||
|
public Map<String, Object> getAntiDupeStatus() {
|
||||||
|
return antiDupeDetectionService.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/security/crash-exploit/status")
|
||||||
|
public Map<String, Object> getCrashExploitStatus() {
|
||||||
|
return crashExploitProtectionService.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/security/lag-machine/status")
|
||||||
|
public Map<String, Object> getLagMachineStatus() {
|
||||||
|
return lagMachineDetectionService.getStatus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user