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:
Oliver Kieselbach
2026-06-19 13:15:32 +02:00
parent d5cb5ef532
commit fb5c206018
8 changed files with 97 additions and 38 deletions
@@ -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));
}
}
}
@@ -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;
@@ -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