fix(security): startup self-update no longer trusts the on-disk remote-config.json cache — Program.LoadCachedSelfUpdateContext (raw deserialize, bypassed the read-side strip) is removed, the boot-time check runs with allowDowngrade:false and the version-v2.json manifest hash as its only integrity gate; LatestAgentSha256 (update-ZIP hash, ranked above the manifest hash in SelfUpdater) joins the CacheConfig/LoadCachedConfig strip lists so neither the startup nor the FromCache runtime path can take a planted ZIP hash or downgrade flag; verify+extract already share one exclusive handle (1daf631a)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-30 13:53:00 +02:00
parent 1daf631a02
commit f432353efa
5 changed files with 45 additions and 43 deletions
@@ -17,7 +17,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
/// (RemoteConfigService.cs:234-311). The on-disk config cache MUST NOT persist — nor,
/// on read, trust — security-sensitive fields: a single MITM during OOBE could otherwise
/// plant attacker-controlled values (UnrestrictedMode / AllowAgentDowngrade /
/// LatestAgentExeSha256 / DeviceBlocked / DeviceKillSignal / UnblockAt) that survive every
/// LatestAgentExeSha256 / LatestAgentSha256 / DeviceBlocked / DeviceKillSignal / UnblockAt /
/// MigrateToApiBaseUrl) that survive every
/// future cold boot. The strip is defence-in-depth on BOTH paths — <c>CacheConfig</c> (write)
/// and <c>LoadCachedConfig</c> (read) — so both are asserted here.
/// <para>
@@ -59,6 +60,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
UnrestrictedMode = true,
AllowAgentDowngrade = true,
LatestAgentExeSha256 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
LatestAgentSha256 = "badc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffe",
DeviceBlocked = true,
DeviceKillSignal = true,
UnblockAt = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
@@ -75,13 +77,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.False(persisted.UnrestrictedMode);
Assert.False(persisted.AllowAgentDowngrade);
Assert.Null(persisted.LatestAgentExeSha256);
Assert.Null(persisted.LatestAgentSha256);
Assert.False(persisted.DeviceBlocked);
Assert.False(persisted.DeviceKillSignal);
Assert.Null(persisted.UnblockAt);
Assert.Null(persisted.MigrateToApiBaseUrl);
// The attacker EXE hash / re-home host must not appear anywhere in the persisted bytes.
// The attacker EXE hash / ZIP hash / re-home host must not appear anywhere in the persisted bytes.
Assert.DoesNotContain("deadbeef", raw);
Assert.DoesNotContain("badc0ffe", raw);
Assert.DoesNotContain("attacker.azurewebsites.net", raw);
// Benign fields survive the strip.
@@ -93,6 +97,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.True(config.UnrestrictedMode);
Assert.True(config.AllowAgentDowngrade);
Assert.Equal("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", config.LatestAgentExeSha256);
Assert.Equal("badc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffebadc0ffe", config.LatestAgentSha256);
Assert.True(config.DeviceBlocked);
Assert.True(config.DeviceKillSignal);
Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), config.UnblockAt);
@@ -105,6 +110,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
[InlineData("UnrestrictedMode")]
[InlineData("AllowAgentDowngrade")]
[InlineData("LatestAgentExeSha256")]
[InlineData("LatestAgentSha256")]
[InlineData("DeviceBlocked")]
[InlineData("DeviceKillSignal")]
[InlineData("UnblockAt")]
@@ -122,6 +128,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
UnrestrictedMode = true,
AllowAgentDowngrade = true,
LatestAgentExeSha256 = "0011223344556677889900112233445566778899001122334455667788990011",
LatestAgentSha256 = "1122334455667788990011223344556677889900112233445566778899001122",
DeviceBlocked = true,
DeviceKillSignal = true,
UnblockAt = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
@@ -143,6 +150,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
case "LatestAgentExeSha256":
Assert.Null(loaded.LatestAgentExeSha256);
break;
case "LatestAgentSha256":
// The update-ZIP hash outranks the version-v2.json manifest hash inside
// SelfUpdater — a planted value would let an attacker ZIP pass verification.
Assert.Null(loaded.LatestAgentSha256);
break;
case "DeviceBlocked":
Assert.False(loaded.DeviceBlocked);
break;
@@ -181,8 +193,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
NtpServer = "time.contoso.com",
LogLevel = "Debug",
DiagnosticsUploadMode = "OnFailure",
// A non-sensitive hash field (ZIP hash) that must NOT be stripped — only the
// EXE hash (LatestAgentExeSha256) is security-sensitive on the cache path.
// Both backend hashes are live-fetch-only — the round-trip must drop them
// while every benign field above survives untouched.
LatestAgentSha256 = "cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe",
};
@@ -199,7 +211,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal("time.contoso.com", loaded.NtpServer);
Assert.Equal("Debug", loaded.LogLevel);
Assert.Equal("OnFailure", loaded.DiagnosticsUploadMode);
Assert.Equal("cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe", loaded.LatestAgentSha256);
Assert.Null(loaded.LatestAgentSha256);
}
// ── Helpers ─────────────────────────────────────────────────────────
@@ -323,6 +323,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
// - AllowAgentDowngrade: gate for installing a lower agent version
// - LatestAgentExeSha256: backend-advertised EXE hash for runtime integrity
// check; a bad cached value would trigger a force-update to attacker bins
// - LatestAgentSha256: backend-advertised update ZIP hash; SelfUpdater ranks it
// ABOVE the version-v2.json manifest hash, so a planted cached value would
// make an attacker ZIP pass integrity verification (or pin the device by
// failing every genuine update). Without a live value the updater falls
// back to the manifest hash.
// - DeviceBlocked/DeviceKillSignal/UnblockAt: kill is only honoured from a
// live fetch — a planted cached kill would self-destruct every future
// session, a stale cached kill would outlive the admin removing the rule
@@ -332,6 +337,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
var liveUnrestricted = config.UnrestrictedMode;
var liveAllowDowngrade = config.AllowAgentDowngrade;
var liveExeHash = config.LatestAgentExeSha256;
var liveZipHash = config.LatestAgentSha256;
var liveBlocked = config.DeviceBlocked;
var liveKill = config.DeviceKillSignal;
var liveUnblockAt = config.UnblockAt;
@@ -339,6 +345,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
config.UnrestrictedMode = false;
config.AllowAgentDowngrade = false;
config.LatestAgentExeSha256 = null;
config.LatestAgentSha256 = null;
config.DeviceBlocked = false;
config.DeviceKillSignal = false;
config.UnblockAt = null;
@@ -347,6 +354,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
config.UnrestrictedMode = liveUnrestricted;
config.AllowAgentDowngrade = liveAllowDowngrade;
config.LatestAgentExeSha256 = liveExeHash;
config.LatestAgentSha256 = liveZipHash;
config.DeviceBlocked = liveBlocked;
config.DeviceKillSignal = liveKill;
config.UnblockAt = liveUnblockAt;
@@ -379,6 +387,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
config.UnrestrictedMode = false;
config.AllowAgentDowngrade = false;
config.LatestAgentExeSha256 = null;
config.LatestAgentSha256 = null;
config.DeviceBlocked = false;
config.DeviceKillSignal = false;
config.UnblockAt = null;
@@ -83,8 +83,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// <summary>
/// Optional: backend-provided SHA-256 hash of the latest agent ZIP.
/// Set from the last AgentConfigResponse before calling CheckAndApplyUpdateAsync.
/// Takes priority over the hash in version-v2.json (separate trust channel).
/// Set only from a LIVE authenticated AgentConfigResponse (never from the on-disk
/// remote-config.json cache — the field is stripped there) before calling
/// CheckAndApplyUpdateAsync. Takes priority over the hash in version-v2.json
/// (separate trust channel); when unset the manifest hash is the integrity gate.
/// </summary>
public static string BackendExpectedSha256 { get; set; }
@@ -294,7 +296,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (!string.IsNullOrEmpty(BackendExpectedSha256))
{
expectedSha256 = BackendExpectedSha256;
log("Self-update: using backend hash for integrity verification (cached config — trusted channel)");
log("Self-update: using backend hash for integrity verification (live remote config — separate trust channel)");
}
else if (!string.IsNullOrEmpty(manifestSha256))
{
@@ -40,6 +40,10 @@ namespace AutopilotMonitor.Agent.V2
private const string ImePatternsGuidPattern = @"(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})";
private const string ImePatternsGitHubUrl = "https://raw.githubusercontent.com/okieselbach/Autopilot-Monitor/refs/heads/main/rules/dist/ime-log-patterns.json";
// Debug-mode-only fallback source for IME patterns (offline --run-ime-matching). The
// runtime never reads this file directly — RemoteConfigService.LoadCachedConfig is the
// only production reader and strips the live-fetch-only security fields.
private const string CachedRemoteConfigPath = @"%ProgramData%\AutopilotMonitor\Config\remote-config.json";
internal static int RunImeMatchingMode(string[] args)
{
+10 -35
View File
@@ -35,7 +35,6 @@ namespace AutopilotMonitor.Agent.V2
/// <item>Register <c>ProcessExit</c> → writes <c>clean-exit.marker</c></item>
/// <item>Ensure agent directories exist</item>
/// <item><see cref="SelfUpdater.LogInit"/> + <see cref="SelfUpdater.CleanupPreviousUpdate"/></item>
/// <item>Load cached <c>remote-config.json</c> → <see cref="SelfUpdater.BackendExpectedSha256"/> + <c>AllowAgentDowngrade</c></item>
/// <item><see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> — on success restarts the process; on failure continues with current binary</item>
/// <item><see cref="DetectPreviousExit"/> — reads markers + event log to classify last shutdown</item>
/// <item>Resolve TenantId (registry → bootstrap-config.json fallback)</item>
@@ -60,7 +59,6 @@ namespace AutopilotMonitor.Agent.V2
private const string DefaultAgentSubdirectory = "Agent";
private const string DefaultStateSubdirectory = "State";
private const string DefaultSpoolSubdirectory = "Spool";
private const string CachedRemoteConfigPath = @"%ProgramData%\AutopilotMonitor\Config\remote-config.json";
public static int Main(string[] args)
{
@@ -132,23 +130,27 @@ namespace AutopilotMonitor.Agent.V2
tenantId: "(pre-runtime)",
agentVersion: GetAgentVersion());
// Startup self-update. Legacy parity: cleanup leftover .old files, load cached
// backend hash / downgrade policy, then attempt the update. Failures here never
// abort startup — we prefer to run the current version than delay.
// Startup self-update. Legacy parity: cleanup leftover .old files, then attempt the
// update against the version-v2.json manifest. Failures here never abort startup —
// we prefer to run the current version than delay.
SelfUpdater.LogInit(GetAgentVersion());
var agentDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty;
SelfUpdater.CleanupPreviousUpdate(agentDir, msg => { if (consoleMode) Console.Out.WriteLine(msg); });
var allowAgentDowngrade = LoadCachedSelfUpdateContext();
// SECURITY: the startup check runs pre-backend, so it deliberately reads NOTHING from
// the on-disk remote-config.json cache. AllowAgentDowngrade and the ZIP hash
// (LatestAgentSha256) are live-fetch-only fields (see RemoteConfigService.CacheConfig)
// — a planted cache file must neither lift the forward-only gate nor replace the
// version-v2.json manifest hash as the ZIP integrity check. Downgrades therefore only
// happen from the runtime trigger after an authenticated fetch.
try
{
SelfUpdater.CheckAndApplyUpdateAsync(
currentVersion: GetAgentVersion(),
agentDir: agentDir,
consoleMode: consoleMode,
allowDowngrade: allowAgentDowngrade).GetAwaiter().GetResult();
allowDowngrade: false).GetAwaiter().GetResult();
}
catch (Exception selfUpdateEx)
{
@@ -396,33 +398,6 @@ namespace AutopilotMonitor.Agent.V2
};
}
private static bool LoadCachedSelfUpdateContext()
{
try
{
var path = Environment.ExpandEnvironmentVariables(CachedRemoteConfigPath);
if (!File.Exists(path)) return false;
var json = File.ReadAllText(path);
var cached = Newtonsoft.Json.JsonConvert.DeserializeObject<AgentConfigResponse>(json);
if (cached == null) return false;
if (!string.IsNullOrEmpty(cached.LatestAgentSha256))
{
SelfUpdater.BackendExpectedSha256 = cached.LatestAgentSha256;
SelfUpdater.Log(
$"Self-update: loaded backend integrity hash from cached config (sha256={cached.LatestAgentSha256.Substring(0, Math.Min(12, cached.LatestAgentSha256.Length))}...)");
}
return cached.AllowAgentDowngrade;
}
catch (Exception ex)
{
SelfUpdater.Log($"Self-update: cached config read failed: {ex.Message}");
return false;
}
}
internal static string GetArgValue(string[] args, params string[] names)
{
if (args == null || args.Length < 2 || names == null) return null;