mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
fix(agent): harden Shift+F10 console watcher (review round 2)
- Surface WMI watcher arm failures: new ConsoleBypassWatcher.WatcherArmFailed event -> host emits collector_degraded (reason=watcher_arm_failed) via CollectorDegradationReporter, so the backend can tell "no console seen" from "live detector never started" instead of both looking like a clean run. - Close a startup blind gap: arm the live Win32_ProcessStartTrace FIRST, then run the startup snapshot. A cmd spawned between snapshot and arm was previously missed; the _seen set dedups the now-possible overlap. - Fix command-line classification for combined cmd switches: strip the executable token, then match /c|/k anywhere in the arguments (was anchored to whitespace), so e.g. `cmd /d/q/c exit 9` is correctly scripted (was false high-confidence). - Docs: replace stale parent-based wording (AgentConfigResponse, the Web analyzer toggle help text, the prefetch coverageNote) with the interactive-session model. Tests: combined-switch + StripExecutable cases added. V2.Core 1505/1505. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+12
@@ -143,14 +143,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
[InlineData(@"""C:\WINDOWS\system32\cmd.exe""", false)]
|
||||
[InlineData(@"cmd.exe /d", false)] // /d skips AutoRun — still interactive
|
||||
[InlineData(@"C:\cmd\cmd.exe", false)] // folder named 'cmd', not a switch
|
||||
[InlineData(@"""C:/k/cmd.exe""", false)] // forward-slash path stripped before scan
|
||||
[InlineData("", false)]
|
||||
[InlineData(@"cmd.exe /c whoami", true)]
|
||||
[InlineData(@"cmd.exe /C whoami", true)]
|
||||
[InlineData(@"cmd.exe /k", true)]
|
||||
[InlineData(@"cmd.exe /d/q/c exit 9", true)] // combined switches — the field case
|
||||
[InlineData(@"cmd.exe /D/Q/K stay", true)]
|
||||
[InlineData(@"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", true)]
|
||||
public void HasScriptArgument_detects_run_command_switch(string commandLine, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ConsoleBypassWatcher.HasScriptArgument(commandLine));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"C:\WINDOWS\system32\cmd.exe", "")]
|
||||
[InlineData(@"cmd.exe /d/q/c exit 9", "/d/q/c exit 9")]
|
||||
[InlineData(@"""C:\Program Files\x\cmd.exe"" /c y", @" /c y")]
|
||||
public void StripExecutable_removes_leading_exe_token(string commandLine, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ConsoleBypassWatcher.StripExecutable(commandLine));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
"coarse: a console executed on this device; cannot distinguish a human Shift+F10 from a " +
|
||||
"legitimate install-launched cmd once ESP is running (cmd.exe shares one prefetch artifact)";
|
||||
private const string CoverageNote =
|
||||
"complements the live winlogon-parent watcher by covering the pre-agent OOBE window";
|
||||
"complements the live console watcher by covering the pre-agent OOBE window";
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
|
||||
+59
-23
@@ -47,9 +47,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
internal static readonly string[] WatchedProcessNames = { "cmd.exe" };
|
||||
|
||||
// cmd switches that turn the shell into a one-shot scripted invocation (install/script), as
|
||||
// opposed to a bare interactive console. Matched as a whitespace-delimited /c or /k token.
|
||||
// opposed to a bare interactive console. Matched as a /c or /k token anywhere in the ARGUMENTS
|
||||
// (the executable path is stripped first) so combined switches like /d/q/c are caught too.
|
||||
private static readonly Regex ScriptSwitchRegex =
|
||||
new Regex(@"(?:^|\s)/[ck]\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
new Regex(@"/[ck]\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Func<int, ProcessProbe?> _processProbe;
|
||||
@@ -67,6 +68,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
/// </summary>
|
||||
public event EventHandler<ConsoleSpawnInfo>? BypassConsoleDetected;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the WMI <c>Win32_ProcessStartTrace</c> watcher fails to arm — the LIVE detector
|
||||
/// is dead and only the one-shot startup probe ran. The host surfaces this as a
|
||||
/// <c>collector_degraded</c> event so the backend can tell "no console seen" from "detector
|
||||
/// never started".
|
||||
/// </summary>
|
||||
public event EventHandler<Exception>? WatcherArmFailed;
|
||||
|
||||
/// <param name="processProbe">Resolves the command line + owner for a live PID; returns null
|
||||
/// when the process has already exited (the instant-close race). Defaults to a WMI query.</param>
|
||||
public ConsoleBypassWatcher(AgentLogger logger, Func<int, ProcessProbe?>? processProbe = null)
|
||||
@@ -83,8 +92,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
_started = true;
|
||||
}
|
||||
|
||||
// 1) Startup probe — a console already open before the watcher armed. Win32_Process exposes
|
||||
// SessionId directly, so the same classification path is reused.
|
||||
// 1) ETW-backed push on console creation — armed FIRST so a console spawned during the
|
||||
// startup snapshot below is still caught by the live trace. The _seen set dedups the
|
||||
// overlap (a console that starts mid-arming is seen by both). Arming after the snapshot
|
||||
// would leave a blind gap for a fast cmd that starts between snapshot and arm.
|
||||
try
|
||||
{
|
||||
var query = new WqlEventQuery(
|
||||
"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = 'cmd.exe'");
|
||||
_startWatcher = new ManagementEventWatcher(query);
|
||||
_startWatcher.EventArrived += OnProcessStartTrace;
|
||||
_startWatcher.Start();
|
||||
_logger.Info("[ConsoleBypassWatcher] watching Win32_ProcessStartTrace for cmd.exe (interactive-session + bare command line)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[ConsoleBypassWatcher] could not start WMI process-start watcher " +
|
||||
$"({ex.GetType().Name}: {ex.Message}); relying on the startup probe only.");
|
||||
_startWatcher = null;
|
||||
// Surface the dead live detector so the backend can distinguish it from a clean run.
|
||||
var handler = WatcherArmFailed;
|
||||
try { handler?.Invoke(this, ex); } catch { /* never throw from Start */ }
|
||||
}
|
||||
|
||||
// 2) Startup probe — catch a console already open before the watcher armed. Win32_Process
|
||||
// exposes SessionId directly, so the same classification path is reused. Runs AFTER the
|
||||
// arm so there is no gap; overlap with the live trace is deduped by _seen.
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
@@ -107,23 +140,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
{
|
||||
_logger.Warning($"[ConsoleBypassWatcher] startup probe failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// 2) ETW-backed push on console creation — no polling.
|
||||
try
|
||||
{
|
||||
var query = new WqlEventQuery(
|
||||
"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = 'cmd.exe'");
|
||||
_startWatcher = new ManagementEventWatcher(query);
|
||||
_startWatcher.EventArrived += OnProcessStartTrace;
|
||||
_startWatcher.Start();
|
||||
_logger.Info("[ConsoleBypassWatcher] watching Win32_ProcessStartTrace for cmd.exe (interactive-session + bare command line)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[ConsoleBypassWatcher] could not start WMI process-start watcher " +
|
||||
$"({ex.GetType().Name}: {ex.Message}); relying on the startup probe only.");
|
||||
_startWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProcessStartTrace(object sender, EventArrivedEventArgs e)
|
||||
@@ -205,9 +221,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
}
|
||||
|
||||
/// <summary>True when the command line carries a <c>/c</c> or <c>/k</c> run-command switch
|
||||
/// (a scripted invocation), false for a bare interactive shell.</summary>
|
||||
/// (a scripted invocation), false for a bare interactive shell. The executable token is
|
||||
/// stripped first so a path can never spoof a switch, and switches may be combined
|
||||
/// (e.g. <c>cmd /d/q/c exit 9</c>).</summary>
|
||||
internal static bool HasScriptArgument(string commandLine)
|
||||
=> !string.IsNullOrEmpty(commandLine) && ScriptSwitchRegex.IsMatch(commandLine);
|
||||
{
|
||||
if (string.IsNullOrEmpty(commandLine)) return false;
|
||||
return ScriptSwitchRegex.IsMatch(StripExecutable(commandLine));
|
||||
}
|
||||
|
||||
/// <summary>Returns the command line with the leading executable token (quoted or
|
||||
/// whitespace-delimited) removed, so switch detection only inspects the arguments.</summary>
|
||||
internal static string StripExecutable(string commandLine)
|
||||
{
|
||||
var s = commandLine.TrimStart();
|
||||
if (s.Length == 0) return string.Empty;
|
||||
if (s[0] == '"')
|
||||
{
|
||||
int end = s.IndexOf('"', 1);
|
||||
return end >= 0 ? s.Substring(end + 1) : string.Empty;
|
||||
}
|
||||
int space = s.IndexOf(' ');
|
||||
return space >= 0 ? s.Substring(space + 1) : string.Empty;
|
||||
}
|
||||
|
||||
private static ProcessProbe? DefaultProbe(int pid)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -52,6 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_post = new InformationalEventPost(ingress, clock, logger);
|
||||
_watcher = watcher ?? new ConsoleBypassWatcher(logger);
|
||||
_watcher.BypassConsoleDetected += OnBypassConsoleDetected;
|
||||
_watcher.WatcherArmFailed += OnWatcherArmFailed;
|
||||
}
|
||||
|
||||
public void Start() => _watcher.Start();
|
||||
@@ -108,10 +110,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
// The WMI live watcher could not arm — surface it as collector_degraded so the backend can
|
||||
// tell "no console detected" from "live detector never started" (MON-D1 pattern).
|
||||
private void OnWatcherArmFailed(object? sender, Exception ex)
|
||||
{
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed", ex);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
try { _watcher.BypassConsoleDetected -= OnBypassConsoleDetected; } catch { }
|
||||
try { _watcher.WatcherArmFailed -= OnWatcherArmFailed; } catch { }
|
||||
try { _watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1150
|
||||
1155
|
||||
|
||||
@@ -1 +1 @@
|
||||
1457
|
||||
1458
|
||||
|
||||
@@ -468,14 +468,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool KeepAwakeDuringUserEsp { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to detect a SYSTEM console opened during enrollment — the classic Shift+F10 OOBE
|
||||
/// bypass (winlogon.exe spawning cmd.exe as SYSTEM). Gates BOTH the live ConsoleBypass host
|
||||
/// (cmd.exe with parent winlogon.exe) and the startup ConsolePrefetchScanner (CMD.EXE-*.pf
|
||||
/// ran after boot). Detection is best-effort, not gapless — a press before the agent installs
|
||||
/// is only coarsely covered by the prefetch artifact. Security-sensitive; not an IAgentAnalyzer
|
||||
/// toggle in the usual sense (it also gates a collector host).
|
||||
/// Default: true (opt-out per tenant — a Shift+F10 SYSTEM console during enrollment is a finding
|
||||
/// worth surfacing by default; tenants that knowingly use Shift+F10 for support can disable it).
|
||||
/// Whether to detect an interactive console opened during enrollment — the classic Shift+F10 OOBE
|
||||
/// bypass. Gates BOTH the live ConsoleBypass host (an interactive-session cmd.exe with a bare,
|
||||
/// non-scripted command line; stopped on real-user desktop arrival) and the startup
|
||||
/// ConsolePrefetchScanner (CMD.EXE-*.pf ran after boot). Detection is best-effort, not gapless —
|
||||
/// a press before the agent installs is only coarsely covered by the prefetch artifact.
|
||||
/// Security-sensitive; not an IAgentAnalyzer toggle in the usual sense (it also gates a collector host).
|
||||
/// Default: true (opt-out per tenant — a Shift+F10 console during enrollment is a finding worth
|
||||
/// surfacing by default; tenants that knowingly use Shift+F10 for support can disable it).
|
||||
/// </summary>
|
||||
public bool EnableConsoleBypassDetection { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -298,10 +298,11 @@ export default function AgentAnalyzersSection({
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-3">Detect OOBE Console Access (Shift+F10)</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Flags a SYSTEM command prompt opened during enrollment — the classic Shift+F10 OOBE bypass
|
||||
(winlogon.exe spawning cmd.exe as SYSTEM). Emits a Warning event with the full process
|
||||
signature. A startup scan of the cmd.exe prefetch artifact additionally covers a console
|
||||
opened before the agent installed.
|
||||
Flags an interactive command prompt opened during enrollment — the classic Shift+F10 OOBE
|
||||
bypass. Detects an interactive-session cmd.exe with a bare (non-scripted) command line and
|
||||
emits a Warning event with the process signature; it stops once the real-user desktop arrives
|
||||
(Shift+F10 no longer possible). A startup scan of the cmd.exe prefetch artifact additionally
|
||||
covers a console opened before the agent installed.
|
||||
</p>
|
||||
<p className="text-sm text-amber-600 mb-4">
|
||||
Best-effort, not gapless: a Shift+F10 pressed very early in OOBE may only be detected
|
||||
|
||||
Reference in New Issue
Block a user