using System; using System.Collections.Generic; using System.Security.Cryptography; using Microsoft.Extensions.Logging; using VanadiumGuard.Protocol; using VanadiumGuard.Server.Policy; using VanadiumGuard.Server.Sessions; namespace VanadiumGuard.Server.Challenges { /// /// Issues memory challenges and grades the answers. /// /// A challenge asks the client to hash a random region of a known module with a random /// salt. Three outcomes matter: the right hash means a real guard is running inside a /// real image; the wrong hash means the memory it read does not match what shipped; /// no answer at all means the guard is not running, which is graded by the liveness /// sweeper rather than here. /// /// public sealed class ChallengeService { /// How long a client has to answer before the challenge is written off. public static readonly TimeSpan AnswerWindow = TimeSpan.FromSeconds(60); private const int MinReadLength = 256; private readonly ILogger _logger; private readonly IReferenceImageStore _images; private readonly PolicyEngine _policy; public ChallengeService(ILogger logger, IReferenceImageStore images, PolicyEngine policy) { _logger = logger; _images = images; _policy = policy; } /// Builds the next challenge for a session, or null if none can be issued. public MemoryChallenge? Issue(GuardSession session, GuardPolicy policy) { string[] modules = policy.ChallengeableModules; if (modules.Length == 0) return null; // Try each module once, in random order, so an unavailable reference image for // one module does not silently stop challenges altogether. foreach (string module in Shuffle(modules)) { long size = _images.Size(session.GameBuild, module); if (size < MinReadLength) continue; int length = RandomNumberGenerator.GetInt32(MinReadLength, ProtocolConstants.MaxChallengeReadLength + 1); if (length > size) length = (int)Math.Min(size, ProtocolConstants.MaxChallengeReadLength); uint rva = (uint)RandomNumberGenerator.GetInt32(0, (int)Math.Min(int.MaxValue, size - length + 1)); var salt = new byte[16]; RandomNumberGenerator.Fill(salt); var challenge = new MemoryChallenge { ChallengeId = Guid.NewGuid().ToString("N"), Module = module, Rva = rva, Length = length, Salt = Convert.ToHexString(salt).ToLowerInvariant(), IssuedUtc = DateTimeOffset.UtcNow, }; session.AddChallenge(challenge); return challenge; } return null; } /// Grades answers, recording detections for anything that does not add up. public void Grade(GuardSession session, IReadOnlyList answers) { foreach (ChallengeAnswer answer in answers) { MemoryChallenge? challenge = session.ClaimChallenge(answer.ChallengeId); if (challenge == null) { // An answer to something never asked, or asked twice. Not worth a // detection on its own, but worth knowing about. _logger.LogInformation( "Unsolicited challenge answer {Id} from session {Session}", answer.ChallengeId, session.SessionId); continue; } if (!answer.Readable) { _policy.RecordServerSide( session, DetectionCode.ChallengeUnreadable, challenge.Module, 60, new Dictionary { ["error"] = answer.Error ?? "unspecified", ["rva"] = challenge.Rva.ToString(), }); continue; } byte[]? reference = _images.Read(session.GameBuild, challenge.Module, challenge.Rva, challenge.Length); if (reference == null) { // No reference image, so this answer proves liveness but nothing else. // Explicitly NOT a detection: failing players because the operator has // not published a build manifest would be the worst kind of bug. _logger.LogDebug( "No reference for build={Build} module={Module}; challenge {Id} counts as liveness only", session.GameBuild, challenge.Module, challenge.ChallengeId); continue; } string expected = RequestSigner.HashWithSalt(Convert.FromHexString(challenge.Salt), reference); if (string.Equals(expected, answer.Hash, StringComparison.OrdinalIgnoreCase)) continue; _policy.RecordServerSide( session, DetectionCode.ChallengeFailed, challenge.Module, 95, new Dictionary { ["rva"] = "0x" + challenge.Rva.ToString("X"), ["length"] = challenge.Length.ToString(), ["expected"] = expected, ["received"] = answer.Hash, }); } } private static IEnumerable Shuffle(string[] source) { var copy = (string[])source.Clone(); for (int i = copy.Length - 1; i > 0; i--) { int j = RandomNumberGenerator.GetInt32(i + 1); (copy[i], copy[j]) = (copy[j], copy[i]); } return copy; } } }