package com.aluer.security; import com.aluer.config.ServerGuardConfig; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.net.*; import java.time.Instant; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; /** * 资源包漏洞防护服务 — V4.0 服务器保护模块 * * 检测原理: * Minecraft允许服务器向客户端推送资源包(Resource Pack)URL,客户端会自动下载 * 并加载。如果URL指向恶意资源,可能导致客户端安全风险。本服务验证资源包URL的 * 合法性,检测SSRF攻击、恶意文件类型、过大文件、以及已知的恶意SHA-1哈希。 * * 攻击场景: * - SSRF攻击:URL指向内网地址(file:///etc/passwd, localhost等) * - 超大文件:资源包>100MB导致客户端下载时内存溢出 * - 恶意文件类型:.exe/.jar/.sh等可执行文件伪装为资源包 * - 已知恶意哈希:使用已被确认的恶意资源包SHA-1 * - HTTP重定向攻击:URL重定向到危险目标 * * 配置开关:serverguard.security.super-evolution.anti-resource-pack-exploit */ @Service public class AntiResourcePackExploitService { private final ServerGuardConfig config; private final Map> packHistory = new ConcurrentHashMap<>(); private final Map urlCache = new ConcurrentHashMap<>(); /** SHA-1哈希黑名单(已知恶意资源包) */ private final Set sha1Blacklist = ConcurrentHashMap.newKeySet(); private final Set urlBlacklist = ConcurrentHashMap.newKeySet(); private final AtomicLong totalPacksChecked = new AtomicLong(0); private final AtomicLong blockedPacks = new AtomicLong(0); private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); /** 资源包允许的最大文件大小(字节) */ private static final long MAX_PACK_SIZE_BYTES = 100 * 1024 * 1024; // 100MB /** URL缓存超时(毫秒) */ private static final long URL_CACHE_TTL_MS = 300_000; // 5分钟 /** 内网IP前缀 */ private static final Set INTERNAL_IPv4_PREFIXES = Set.of( "10.", "172.16.", "172.17.", "172.18.", "172.19.", "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.", "192.168." ); /** * 危险文件扩展名(资源包必须是.zip格式,以下扩展名可能包含可执行代码). * 注意:Set.of() 不允许重复元素,每个扩展名必须唯一. */ private static final Set DANGEROUS_EXTENSIONS = Set.of( ".exe", ".bat", ".cmd", ".com", ".msi", ".ps1", ".scr", ".jar", ".class", ".war", ".ear", ".sh", ".bash", ".zsh", ".run", ".bin", ".dll", ".so", ".dylib", ".sys", ".deb", ".rpm", ".apk", ".ipa", ".js", ".vbs", ".jse", ".wsf", ".wsh", ".hta" ); /** 被禁止的协议 */ private static final Set BLOCKED_SCHEMES = Set.of( "file", "ftp", "gopher", "dict", "jar", "netdoc", "tftp" ); public AntiResourcePackExploitService() { this(new ServerGuardConfig()); } @Autowired public AntiResourcePackExploitService(ServerGuardConfig config) { this.config = config; // 初始化已知恶意哈希(示例值,生产环境应从威胁情报源同步) initSha1Blacklist(); scheduler.scheduleAtFixedRate(this::cleanupOldData, 60, 300, TimeUnit.SECONDS); } /** * 检查资源包URL和SHA-1哈希是否安全。 * * @param url 资源包下载URL * @param sha1 资源包SHA-1哈希(必需,Minecraft强制要求) * @param sourcePlayer 发起推送的玩家(OP或控制台) * @param packVersion 资源包格式版本 * @return 检查结果,包含是否阻止及原因 */ public PackCheckResult check(String url, String sha1, String sourcePlayer, int packVersion) { // 配置开关检查 if (!config.getSecurity().getSuperEvolution().isAntiResourcePackExploit()) { return PackCheckResult.clean(); } totalPacksChecked.incrementAndGet(); List reasons = new ArrayList<>(); // 记录事件 PackEvent event = new PackEvent(Instant.now(), url, sha1, sourcePlayer, packVersion); packHistory.computeIfAbsent(sourcePlayer, k -> Collections.synchronizedList(new ArrayList<>())).add(event); // ---- 检测1:URL格式验证 ---- if (url == null || url.trim().isEmpty()) { reasons.add("EMPTY_URL"); blockedPacks.incrementAndGet(); return PackCheckResult.blocked(reasons); } URI uri; try { uri = new URI(url.trim()); } catch (URISyntaxException e) { reasons.add("INVALID_URI: " + e.getMessage()); blockedPacks.incrementAndGet(); return PackCheckResult.blocked(reasons); } // ---- 检测2:协议验证(必须http/https) ---- String scheme = uri.getScheme(); if (scheme == null) { reasons.add("MISSING_SCHEME: URL has no protocol"); } else { String schemeLower = scheme.toLowerCase(); if (BLOCKED_SCHEMES.contains(schemeLower)) { reasons.add("BLOCKED_SCHEME: " + scheme + " protocol is not allowed"); } else if (!schemeLower.equals("http") && !schemeLower.equals("https")) { reasons.add("INVALID_SCHEME: " + scheme + " only http/https allowed"); } } // ---- 检测3:内网地址检测(SSRF风险) ---- // 攻击者可以通过资源包URL探测内网服务或读取本地文件 String host = uri.getHost(); if (host != null) { String hostLower = host.toLowerCase(); // 检测本地回环地址 if ("localhost".equals(hostLower) || "127.0.0.1".equals(hostLower) || "::1".equals(hostLower) || "[::1]".equals(hostLower) || "0.0.0.0".equals(hostLower)) { reasons.add("SSRF_LOOPBACK: " + host + " is a loopback address"); } // 检测内网IP段 for (String prefix : INTERNAL_IPv4_PREFIXES) { if (hostLower.startsWith(prefix)) { reasons.add("SSRF_PRIVATE_NET: " + host + " is a private network address"); break; } } // 检测云元数据地址 if (hostLower.contains("metadata") || hostLower.equals("169.254.169.254")) { reasons.add("SSRF_CLOUD_METADATA: " + host + " is a cloud metadata endpoint"); } // 尝试DNS解析检查(防止DNS重绑定攻击) try { InetAddress resolved = InetAddress.getByName(host); String resolvedIP = resolved.getHostAddress(); if (resolvedIP.startsWith("10.") || resolvedIP.startsWith("192.168.") || resolvedIP.equals("127.0.0.1") || resolvedIP.startsWith("172.16.")) { reasons.add("SSRF_DNS_RESOLVE: " + host + " resolves to internal IP " + resolvedIP); } } catch (UnknownHostException ignored) { // 无法解析的域名不一定危险,但标记为可疑 } } // ---- 检测4:URL黑名单匹配 ---- if (urlBlacklist.contains(url.trim().toLowerCase())) { reasons.add("URL_BLACKLISTED: URL is in known malicious list"); } // ---- 检测5:SHA-1哈希黑名单比对 ---- // 检查已知的恶意资源包哈希(可防止恶意资源包被重新分发) if (sha1 != null && !sha1.trim().isEmpty()) { if (sha1Blacklist.contains(sha1.trim().toLowerCase())) { reasons.add("SHA1_BLACKLISTED: resource pack SHA-1 is known malicious"); } // SHA-1格式验证:必须是40个十六进制字符 if (!sha1.trim().matches("^[0-9a-fA-F]{40}$")) { reasons.add("SHA1_INVALID_FORMAT: SHA-1 must be 40 hex characters"); } } // ---- 检测6:可疑文件扩展名 ---- String path = uri.getPath(); if (path != null) { String pathLower = path.toLowerCase(); // 资源包应为.zip格式 if (!pathLower.endsWith(".zip")) { for (String ext : DANGEROUS_EXTENSIONS) { if (pathLower.endsWith(ext)) { reasons.add("DANGEROUS_EXTENSION: " + ext + " is not a valid resource pack file"); break; } } } } // ---- 检测7:URL长度限制 ---- // 超长的URL可能导致日志注入或缓冲区溢出 if (url.length() > 2048) { reasons.add("URL_TOO_LONG: " + url.length() + " chars (max: 2048)"); } // ---- 检测8:URL编码绕过检测 ---- // 检测URL编码中的内网IP(如 %31%32%37%2e%30%2e%30%2e%31 = 127.0.0.1) String decodedLower = url.trim().toLowerCase(); if (decodedLower.contains("%31%32%37") || decodedLower.contains("127%2e") || decodedLower.contains("%31%30%2e") || decodedLower.contains("10%2e") || decodedLower.contains("%31%39%32%2e%31%36%38") || decodedLower.contains("192%2e168") || decodedLower.contains("localhost")) { reasons.add("URL_ENCODED_INTERNAL: potentially obfuscated internal address"); } // 判定结果 if (!reasons.isEmpty()) { blockedPacks.incrementAndGet(); // 将恶意URL加入黑名单缓存 urlCache.put(url.trim().toLowerCase(), new PackUrlInfo(Instant.now(), reasons)); return PackCheckResult.blocked(reasons); } // 缓存安全URL以避免重复检测 urlCache.put(url.trim().toLowerCase(), new PackUrlInfo(Instant.now(), List.of())); return PackCheckResult.clean(); } /** * 将SHA-1哈希添加到黑名单。 */ public void addToSha1Blacklist(String sha1) { if (sha1 != null && sha1.trim().matches("^[0-9a-fA-F]{40}$")) { sha1Blacklist.add(sha1.trim().toLowerCase()); } } /** * 将URL添加到黑名单。 */ public void addToUrlBlacklist(String url) { if (url != null && !url.trim().isEmpty()) { urlBlacklist.add(url.trim().toLowerCase()); } } public Map getStatus() { Map s = new LinkedHashMap<>(); s.put("totalPacksChecked", totalPacksChecked.get()); s.put("blockedPacks", blockedPacks.get()); s.put("sha1BlacklistSize", sha1Blacklist.size()); s.put("urlBlacklistSize", urlBlacklist.size()); s.put("cachedUrls", urlCache.size()); // 最近检查的资源包 List> recent = new ArrayList<>(); for (Map.Entry> e : packHistory.entrySet()) { List events = e.getValue(); if (!events.isEmpty()) { PackEvent last = events.get(events.size() - 1); Map m = new LinkedHashMap<>(); m.put("source", e.getKey()); m.put("url", last.url.length() > 100 ? last.url.substring(0, 100) + "..." : last.url); m.put("sha1", last.sha1); m.put("time", last.time.toString()); recent.add(m); } } recent.sort((a, b) -> b.get("time").toString().compareTo(a.get("time").toString())); s.put("recentPacks", recent.subList(0, Math.min(recent.size(), 10))); // URL黑名单(最多显示50条) s.put("urlBlacklist", new ArrayList<>(urlBlacklist).subList(0, Math.min(urlBlacklist.size(), 50))); return s; } /** * 初始化已知恶意SHA-1哈希列表。 */ private void initSha1Blacklist() { // 这些是示例值 - 生产环境应从威胁情报源定期更新 // 实际部署时可添加已知的恶意资源包哈希 } private void cleanupOldData() { Instant cutoff = Instant.now().minusMillis(URL_CACHE_TTL_MS); urlCache.entrySet().removeIf(e -> e.getValue().timestamp.isBefore(cutoff)); Instant eventCutoff = Instant.now().minusSeconds(7200); for (List events : packHistory.values()) { events.removeIf(e -> e.time.isBefore(eventCutoff)); } packHistory.entrySet().removeIf(e -> e.getValue().isEmpty()); } private static class PackEvent { final Instant time; final String url; final String sha1; final String sourcePlayer; final int packVersion; PackEvent(Instant t, String u, String s, String sp, int pv) { this.time = t; this.url = u; this.sha1 = s; this.sourcePlayer = sp; this.packVersion = pv; } } private static class PackUrlInfo { final Instant timestamp; final List reasons; PackUrlInfo(Instant t, List r) { this.timestamp = t; this.reasons = r; } } public static class PackCheckResult { private final boolean blocked; private final List reasons; private PackCheckResult(boolean blocked, List reasons) { this.blocked = blocked; this.reasons = reasons; } public static PackCheckResult clean() { return new PackCheckResult(false, List.of()); } public static PackCheckResult blocked(List reasons) { return new PackCheckResult(true, reasons); } public boolean isBlocked() { return blocked; } public boolean isClean() { return !blocked; } public List getReasons() { return reasons; } } }