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
+152
View File
@@ -0,0 +1,152 @@
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
{
/// <summary>
/// Issues memory challenges and grades the answers.
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class ChallengeService
{
/// <summary>How long a client has to answer before the challenge is written off.</summary>
public static readonly TimeSpan AnswerWindow = TimeSpan.FromSeconds(60);
private const int MinReadLength = 256;
private readonly ILogger<ChallengeService> _logger;
private readonly IReferenceImageStore _images;
private readonly PolicyEngine _policy;
public ChallengeService(ILogger<ChallengeService> logger, IReferenceImageStore images, PolicyEngine policy)
{
_logger = logger;
_images = images;
_policy = policy;
}
/// <summary>Builds the next challenge for a session, or null if none can be issued.</summary>
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;
}
/// <summary>Grades answers, recording detections for anything that does not add up.</summary>
public void Grade(GuardSession session, IReadOnlyList<ChallengeAnswer> 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<string, string>
{
["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<string, string>
{
["rva"] = "0x" + challenge.Rva.ToString("X"),
["length"] = challenge.Length.ToString(),
["expected"] = expected,
["received"] = answer.Hash,
});
}
}
private static IEnumerable<string> 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;
}
}
}
@@ -0,0 +1,110 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using Microsoft.Extensions.Logging;
namespace VanadiumGuard.Server.Challenges
{
/// <summary>
/// Supplies the known-good bytes a challenge answer is checked against.
/// </summary>
public interface IReferenceImageStore
{
/// <summary>Returns the reference bytes, or null when this build or region is unknown.</summary>
byte[]? Read(string gameBuild, string module, uint rva, int length);
/// <summary>Size of the reference image, used to pick a valid random offset.</summary>
long Size(string gameBuild, string module);
}
/// <summary>
/// Reads reference images from disk, laid out as
/// <c>{ReferenceImageDirectory}/{gameBuild}/{module}.bin</c>.
/// <para>
/// Produce those files from the shipped binaries as part of your build pipeline: dump
/// each module's mapped image once, on a clean machine, and publish it alongside the
/// build. Without them the server cannot verify a challenge answer, and challenges
/// degrade to a liveness check only.
/// </para>
/// </summary>
public sealed class FileReferenceImageStore : IReferenceImageStore
{
private readonly ILogger<FileReferenceImageStore> _logger;
private readonly string _root;
private readonly ConcurrentDictionary<string, byte[]?> _cache = new ConcurrentDictionary<string, byte[]?>();
public FileReferenceImageStore(ILogger<FileReferenceImageStore> logger, string root)
{
_logger = logger;
_root = root;
}
public byte[]? Read(string gameBuild, string module, uint rva, int length)
{
byte[]? image = Image(gameBuild, module);
if (image == null)
return null;
if (rva + (uint)length > image.Length)
return null;
var slice = new byte[length];
Buffer.BlockCopy(image, (int)rva, slice, 0, length);
return slice;
}
public long Size(string gameBuild, string module)
{
return Image(gameBuild, module)?.LongLength ?? 0;
}
private byte[]? Image(string gameBuild, string module)
{
// Both components land in a file path, so anything that could escape the root
// is rejected outright rather than sanitised.
if (!IsSafeComponent(gameBuild) || !IsSafeComponent(module))
{
_logger.LogWarning("Rejected reference lookup for build={Build} module={Module}", gameBuild, module);
return null;
}
string key = gameBuild + "/" + module;
return _cache.GetOrAdd(key, _ =>
{
string path = Path.Combine(_root, gameBuild, module + ".bin");
if (!File.Exists(path))
{
_logger.LogWarning("No reference image at {Path}; challenges for it cannot be verified", path);
return null;
}
try
{
return File.ReadAllBytes(path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to read reference image {Path}", path);
return null;
}
});
}
private static bool IsSafeComponent(string value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
return false;
foreach (char c in value)
{
bool ok = char.IsLetterOrDigit(c) || c == '.' || c == '-' || c == '_';
if (!ok)
return false;
}
return !value.Contains("..", StringComparison.Ordinal);
}
}
}