This commit is contained in:
skidfire
2026-09-06 19:59:27 -04:00
parent 55d8e11a5d
commit fe3468c65d
247 changed files with 338183 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
using System.Collections.Generic;
using VanadiumGuard.Protocol;
namespace VanadiumGuard.Server.Policy
{
/// <summary>
/// What each detection is worth, and whether it may ever act on its own.
/// <para>
/// This table is the whole reason the redesign exists. In the old build every signal was
/// worth the same thing - immediate death - so a Cheat Engine window and someone having
/// a file named injector.exe in Downloads had identical consequences. Here a signal has
/// a weight, and only a handful of signals are trusted enough to end a session by
/// themselves.
/// </para>
/// </summary>
public static class DetectionCatalog
{
public sealed class Entry
{
/// <summary>Score contribution at 100 confidence. Scores accumulate toward 100.</summary>
public double Weight { get; init; }
/// <summary>
/// Whether this code alone, at high confidence, justifies removing the player.
/// Reserved for direct evidence of code modification inside the game process.
/// </summary>
public bool Decisive { get; init; }
/// <summary>Human-readable, used in review tooling and audit logs.</summary>
public string Description { get; init; } = string.Empty;
}
private static readonly Dictionary<DetectionCode, Entry> Entries = new()
{
// Modules. Loading a known cheat framework into the process is strong, but the
// name match is still a name match, so it is not decisive on its own.
[DetectionCode.BlacklistedModuleLoaded] = new Entry
{
Weight = 55, Decisive = false, Description = "Known cheat or hooking library mapped into the game",
},
[DetectionCode.ModuleUnbackedByDisk] = new Entry
{
Weight = 45, Decisive = false, Description = "Module present without a loader notification (manual map)",
},
[DetectionCode.ModuleFromVolatilePath] = new Entry
{
Weight = 8, Decisive = false, Description = "Module loaded from a temp or downloads directory",
},
[DetectionCode.ModuleFromUntrustedPath] = new Entry
{
Weight = 5, Decisive = false, Description = "Module loaded from outside the trusted roots",
},
// External environment. Weak by construction: what is open on someone's second
// monitor is not evidence of what is happening inside the game.
[DetectionCode.BlacklistedProcess] = new Entry
{
Weight = 12, Decisive = false, Description = "A listed tool is running on the machine",
},
[DetectionCode.BlacklistedWindow] = new Entry
{
Weight = 10, Decisive = false, Description = "A listed tool has a visible window",
},
[DetectionCode.DebuggerAttached] = new Entry
{
Weight = 60, Decisive = false, Description = "A debugger is attached to the game process",
},
// Code integrity. This is the evidence worth acting on: something modified
// executable memory inside the process, which nothing legitimate does here.
[DetectionCode.NtExportPrologueModified] = new Entry
{
Weight = 70, Decisive = false, Description = "Syscall stub prologue rewritten as a detour",
},
[DetectionCode.GameCodeModified] = new Entry
{
Weight = 85, Decisive = true, Description = "Game code section modified in memory",
},
[DetectionCode.Il2CppMethodHooked] = new Entry
{
Weight = 90, Decisive = true, Description = "Inline hook on a watched gameplay method",
},
// Managed integrity.
[DetectionCode.ForeignHarmonyPatch] = new Entry
{
Weight = 6, Decisive = false, Description = "Harmony patch from an unrecognised mod",
},
[DetectionCode.EnforcementMethodPatched] = new Entry
{
Weight = 95, Decisive = true, Description = "The guard or process exit path was patched",
},
// Guard integrity, raised server-side. Tampering with the guard is treated as
// seriously as tampering with the game, because it is done for the same reason.
[DetectionCode.ChallengeFailed] = new Entry
{
Weight = 90, Decisive = true, Description = "Memory challenge answered with the wrong hash",
},
[DetectionCode.ChallengeUnreadable] = new Entry
{
Weight = 20, Decisive = false, Description = "Client could not read a challenged region",
},
[DetectionCode.GuardSilent] = new Entry
{
Weight = 50, Decisive = false, Description = "Guard stopped heartbeating while the match was live",
},
[DetectionCode.SignatureInvalid] = new Entry
{
Weight = 70, Decisive = false, Description = "Request signature did not verify",
},
[DetectionCode.SequenceReplay] = new Entry
{
Weight = 70, Decisive = false, Description = "Replayed or reordered request sequence",
},
[DetectionCode.PolicyRefused] = new Entry
{
Weight = 15, Decisive = false, Description = "Client refused to apply the issued policy",
},
// Hardware bans. These are recorded for the audit trail, not to move a score:
// a machine ban is a decision that was already made by a person, and running it
// back through the scoring path would let a threshold second-guess them.
[DetectionCode.MachineBanned] = new Entry
{
Weight = 0, Decisive = false, Description = "Session opened from a banned machine",
},
[DetectionCode.MachineLinkedToBan] = new Entry
{
Weight = 0, Decisive = false, Description = "Machine shares hardware with a banned machine",
},
[DetectionCode.MachineUnidentifiable] = new Entry
{
Weight = 0, Decisive = false, Description = "Client could not produce a machine identity",
},
// Device attestation. A session that proved it held a device key and then stopped
// signing with it is claiming to be somewhere it is not, which is the one thing
// the HMAC alone can never catch.
[DetectionCode.AttestationInvalid] = new Entry
{
Weight = 70, Decisive = false, Description = "Request not signed by the bound device key",
},
[DetectionCode.AttestationMissing] = new Entry
{
Weight = 0, Decisive = false, Description = "Session opened without a hardware-backed key",
},
};
public static Entry For(DetectionCode code)
{
return Entries.TryGetValue(code, out Entry? entry)
? entry
: new Entry { Weight = 0, Decisive = false, Description = "Unknown detection code" };
}
}
}
+163
View File
@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using VanadiumGuard.Protocol;
using VanadiumGuard.Server.Sessions;
namespace VanadiumGuard.Server.Policy
{
/// <summary>
/// Turns detections into a verdict. This is the only place in the system where a
/// decision about a player is made, and it runs on the server where the player cannot
/// reach it.
/// </summary>
public sealed class PolicyEngine
{
/// <summary>Score at which the session is ended.</summary>
public const int DisconnectThreshold = 80;
/// <summary>Score at which the player is told something is wrong but keeps playing.</summary>
public const int WarnThreshold = 40;
private readonly ILogger<PolicyEngine> _logger;
public PolicyEngine(ILogger<PolicyEngine> logger)
{
_logger = logger;
}
/// <summary>
/// Records a batch against the session and returns what the client should do.
/// </summary>
public Verdict Evaluate(GuardSession session, IReadOnlyList<Detection> detections)
{
bool decisive = false;
foreach (Detection detection in detections)
{
DetectionCatalog.Entry entry = DetectionCatalog.For(detection.Code);
if (entry.Weight <= 0)
{
_logger.LogDebug("Ignoring unweighted code {Code} from {Session}", detection.Code, session.SessionId);
continue;
}
int confidence = Math.Clamp(detection.Confidence, 0, 100);
// Confidence scales the weight. A detector rolled out at MaxConfidence 0
// contributes exactly nothing, which is what makes a safe rollout possible.
double score = entry.Weight * (confidence / 100.0);
session.Record(detection, score);
// Decisive codes still have to arrive at high confidence. A weak reading of
// a serious signal is evidence, not a conviction.
if (entry.Decisive && confidence >= 85)
{
decisive = true;
_logger.LogWarning(
"Decisive detection {Code} subject={Subject} conf={Confidence} player={Player} session={Session}",
detection.Code, detection.Subject, confidence, session.PlayerId, session.SessionId);
}
}
return VerdictFor(session, decisive);
}
/// <summary>Re-derives a verdict without adding anything, for heartbeats and status.</summary>
public Verdict Current(GuardSession session)
{
return VerdictFor(session, decisive: false);
}
private Verdict VerdictFor(GuardSession session, bool decisive)
{
// A forced verdict outranks the score. A hardware ban is not a risk judgement
// and must not be re-derived from one.
Verdict? forced = session.ForcedVerdict;
if (forced != null)
return forced;
if (session.Terminated)
{
return new Verdict
{
Action = VerdictAction.Disconnect,
ReasonCode = "VG-TERM",
PlayerMessage = "This session was ended by an anti-cheat check.",
};
}
int score = session.RiskScore();
if (decisive || score >= DisconnectThreshold)
{
session.MarkTerminated();
_logger.LogWarning(
"Disconnecting player={Player} session={Session} score={Score} decisive={Decisive}",
session.PlayerId, session.SessionId, score, decisive);
return new Verdict
{
Action = VerdictAction.Disconnect,
ReasonCode = "VG-" + score.ToString("D3"),
PlayerMessage =
"You have been removed from this session by an anti-cheat check. " +
"If you believe this is wrong, contact support and quote this code.",
// A short grace period so the client can flush the batch that triggered
// this. Without it the most important evidence is the evidence lost.
DelayMs = 2000,
};
}
if (score >= WarnThreshold)
{
return new Verdict
{
Action = VerdictAction.Warn,
ReasonCode = "VG-W" + score.ToString("D3"),
PlayerMessage =
"Close any debugging or memory tools before continuing. " +
"Leaving them open may end your session.",
};
}
return new Verdict { Action = VerdictAction.Continue };
}
/// <summary>
/// Raises a detection the server itself observed, e.g. a bad signature or a guard
/// that went quiet. These never come from the client, which is exactly why they are
/// worth something.
/// </summary>
public void RecordServerSide(GuardSession session, DetectionCode code, string subject, int confidence,
IDictionary<string, string>? evidence = null)
{
var detection = new Detection
{
Code = code,
Severity = Severity.High,
Confidence = confidence,
Subject = subject,
Detector = "server",
FirstObservedUtc = DateTimeOffset.UtcNow,
LastObservedUtc = DateTimeOffset.UtcNow,
};
if (evidence != null)
{
foreach (KeyValuePair<string, string> pair in evidence)
detection.Evidence[pair.Key] = pair.Value;
}
DetectionCatalog.Entry entry = DetectionCatalog.For(code);
session.Record(detection, entry.Weight * (confidence / 100.0));
_logger.LogWarning(
"Server-side detection {Code} subject={Subject} player={Player}",
code, subject, session.PlayerId);
}
}
}
+236
View File
@@ -0,0 +1,236 @@
using System;
using System.IO;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using VanadiumGuard.Protocol;
namespace VanadiumGuard.Server.Policy
{
/// <summary>
/// Serves the policy handed to clients at session open, reloading it from disk when the
/// file changes so rules can be adjusted without a deploy.
/// </summary>
public sealed class PolicyProvider
{
private readonly ILogger<PolicyProvider> _logger;
private readonly string _path;
private GuardPolicy _policy;
private DateTime _loadedStamp;
public PolicyProvider(ILogger<PolicyProvider> logger, string policyDirectory)
{
_logger = logger;
_path = Path.Combine(policyDirectory, "policy.json");
_policy = Load();
}
public GuardPolicy Current
{
get
{
ReloadIfChanged();
return _policy;
}
}
private void ReloadIfChanged()
{
try
{
if (!File.Exists(_path))
return;
DateTime stamp = File.GetLastWriteTimeUtc(_path);
if (stamp <= _loadedStamp)
return;
_policy = Load();
}
catch (Exception ex)
{
// Keep serving the last good policy. A malformed edit must not take the
// whole fleet offline.
_logger.LogError(ex, "Policy reload failed; continuing with the previous policy");
}
}
private GuardPolicy Load()
{
if (!File.Exists(_path))
{
_logger.LogWarning("No policy at {Path}; writing the built-in default", _path);
GuardPolicy fallback = Default();
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
File.WriteAllText(_path, JsonSerializer.Serialize(
fallback, new JsonSerializerOptions { WriteIndented = true }));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not write the default policy to {Path}", _path);
}
_loadedStamp = DateTime.UtcNow;
return fallback;
}
try
{
GuardPolicy? loaded = JsonSerializer.Deserialize<GuardPolicy>(
File.ReadAllText(_path),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (loaded == null)
throw new InvalidOperationException("policy.json deserialised to null");
_loadedStamp = File.GetLastWriteTimeUtc(_path);
_logger.LogInformation("Loaded policy version {Version}", loaded.PolicyVersion);
return loaded;
}
catch (Exception ex)
{
_logger.LogError(ex, "Policy at {Path} is invalid; using the built-in default", _path);
_loadedStamp = DateTime.UtcNow;
return Default();
}
}
/// <summary>
/// The shipped starting point.
/// <para>
/// Note what is missing relative to the old hard-coded lists. There is no filesystem
/// scanner, so nothing looks at what a player keeps in Documents or Downloads; that
/// was a large privacy cost for a signal that only ever proved someone had once
/// downloaded a tool. Generic process names are gone. Reverse-engineering tools that
/// developers use every day are present but weighted low, because owning IDA is not
/// evidence of cheating in a match.
/// </para>
/// </summary>
public static GuardPolicy Default()
{
return new GuardPolicy
{
PolicyVersion = "2024.09.05-1",
ReportIntervalMs = 5000,
HeartbeatIntervalMs = 15000,
Detectors = new[]
{
new DetectorSettings { Name = "module", Enabled = true, MaxConfidence = 100 },
new DetectorSettings { Name = "process", Enabled = true, MaxConfidence = 60 },
new DetectorSettings { Name = "window", Enabled = true, MaxConfidence = 60 },
new DetectorSettings { Name = "debugger", Enabled = true, MaxConfidence = 100 },
new DetectorSettings { Name = "nativehook", Enabled = true, MaxConfidence = 100 },
// Starts in observe-only mode. Every player with a second mod installed
// trips this, so it reports for a while and earns its confidence later.
new DetectorSettings { Name = "managedpatch", Enabled = true, MaxConfidence = 0 },
new DetectorSettings { Name = "il2cpp", Enabled = true, MaxConfidence = 100 },
},
ModuleAllowPrefixes = new[]
{
"vanadiumguard", "melonloader", "0harmony", "il2cpp", "unityplayer", "unityengine",
"gameassembly", "baselib", "unitycrashhandler", "recroom", "easyanticheat",
"system.", "microsoft.", "mono", "netstandard", "mscor", "clr", "coreclr",
"hostfxr", "hostpolicy", "ucrtbase", "vcruntime", "msvcp", "msvcr",
"ntdll", "kernel32", "kernelbase", "user32", "gdi32", "win32u", "advapi32",
"sechost", "rpcrt4", "combase", "ole32", "oleaut32", "shell32", "shlwapi",
"psapi", "winhttp", "wininet", "crypt32", "bcrypt", "ncrypt", "schannel",
"iphlpapi", "ws2_32", "mswsock", "dnsapi", "setupapi", "cfgmgr32", "devobj",
"powrprof", "secur32", "sspicli", "userenv", "profapi", "winmm", "dsound",
"xaudio", "x3daudio", "xinput", "dinput", "openal", "physx", "steam",
"nvapi", "nvcuda", "nvoglv", "cudart", "amdxc", "atiadl", "igd", "dxgi",
"d3d", "d3dcompiler", "dcomp", "dwmapi", "uxtheme", "propsys", "version",
"dbghelp", "dbgcore", "symsrv", "textinputframework", "coreuicomponents",
"coremessaging", "windows.storage", "wintypes", "apphelp", "avrt", "wtsapi32",
},
TrustedPathPrefixes = new[]
{
"\\windows\\system32\\",
"\\windows\\winsxs\\",
"\\program files\\",
"\\program files (x86)\\",
"\\steam\\steamapps\\",
"\\dotnet\\",
},
ModuleDenyList = new[]
{
"minhook", "easyhook", "blackbone", "detours", "polyhook",
"scyllahide", "scyllahook",
"frida-agent", "frida-gadget",
"cheatengine", "ceserver", "vehdebug", "speedhack",
"unityexplorer", "monoinjector", "melonsampler",
"vmthook", "internalhack", "externalhack",
},
// Exact image names only. Anything that is also a normal program name has
// been left off entirely rather than matched loosely.
ProcessDenyList = new[]
{
"cheatengine-x86_64.exe", "cheatengine-i386.exe",
"cheatengine-x86_64-SSE4-AVX2.exe", "ceserver.exe",
"x64dbg.exe", "x32dbg.exe", "ollydbg.exe", "immunitydebugger.exe",
"windbg.exe", "windbgx.exe",
"ida.exe", "ida64.exe", "idaq.exe", "idaq64.exe",
"ghidraRun.exe",
"dnSpy.exe", "dnSpy-x86.exe", "dnSpyEx.exe", "ILSpy.exe", "dotPeek64.exe",
"scylla.exe", "scylla_x64.exe", "ScyllaHide.exe",
"frida.exe", "frida-server.exe", "frida-trace.exe",
"artmoney.exe", "squalr.exe",
"HTTPDebuggerUI.exe", "HTTPDebuggerSvc.exe",
"extremeinjector.exe", "processhacker.exe", "SystemInformer.exe",
},
// A class alone never counts; the detector requires a title match to treat
// one of these as more than informational.
WindowClassDenyList = new[]
{
"TFormCheatEngine", "TMemoryBrowser", "Qt5152QWindowIcon", "OLLYDBG",
"ID Window", "WinDbgFrameClass",
},
WindowTitleDenyList = new[]
{
"Cheat Engine", "Memory Viewer", "Add Address Manually",
"x64dbg", "x32dbg", "OllyDbg", "Immunity Debugger",
"IDA -", "Ghidra:", "dnSpy", "ILSpy",
"HTTP Debugger", "Extreme Injector",
},
WatchedExports = new[]
{
new WatchedExport { Module = "ntdll.dll", Export = "NtProtectVirtualMemory" },
new WatchedExport { Module = "ntdll.dll", Export = "NtWriteVirtualMemory" },
new WatchedExport { Module = "ntdll.dll", Export = "NtReadVirtualMemory" },
new WatchedExport { Module = "ntdll.dll", Export = "NtCreateThreadEx" },
new WatchedExport { Module = "ntdll.dll", Export = "NtOpenProcess" },
new WatchedExport { Module = "ntdll.dll", Export = "LdrLoadDll" },
},
// Replace these with the real signatures for your build. They are examples,
// and a target that does not resolve is skipped rather than treated as a fault.
Il2CppTargets = new[]
{
new Il2CppTarget { TypeName = "UnityEngine.Camera", MethodName = "set_fieldOfView", ParameterCount = 1 },
},
AllowedPatchOwners = new[]
{
"vanadiumguard", "vanadium.patch", "melonloader",
},
ChallengeableModules = new[]
{
"GameAssembly.dll", "UnityPlayer.dll", "VanadiumGuard.dll",
},
};
}
}
}