From d5cb5ef5325b8b021643dc2f3bd4fea6feef06fb Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 19 Jun 2026 12:47:07 +0200 Subject: [PATCH] =?UTF-8?q?fix(agent):=20rework=20Shift+F10=20console=20de?= =?UTF-8?q?tection=20=E2=80=94=20parent-free=20discriminator=20+=20desktop?= =?UTF-8?q?-arrival=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field session f240a555 (Win11 24H2) disproved the parent==winlogon assumption: the Shift+F10 cmd's parent (pid 9016) was a short-lived non-winlogon launcher that exited within ms ("parent resolve failed ... is not running"), and the cmd ran as defaultuser0, not SYSTEM. The conservative "can't confirm winlogon -> don't flag" path therefore dropped a real Shift+F10 console. ConsoleBypassWatcher rewritten to be parent-free: - Gate race-free on SessionID != 0 (interactive session) from the trace — IME/ Intune install `cmd /c` run as SYSTEM in session 0 and are dropped without a probe. - Confirm on the cmd's OWN command line: bare (no /c|/k) = interactive shell -> confidence=high; a scripted `cmd /c` is ignored. - Instant-close race (command line unreadable before the probe) -> still surfaced as confidence=low (interactive_session_unclassified) rather than missed. - Event data now: sessionId, owner, commandLine, confidence, classification, parentProcessId (dropped parentProcessName/parentMatchesWinlogon). Lifecycle gate: ConsoleBypassHost.StopForDesktopArrival() wired into the DesktopArrival onRealUserOwnerObserved callback — once the real-user desktop arrives (excludes SYSTEM + defaultuser0) Shift+F10 is no longer possible, so the watcher stops and cannot flag ordinary post-enrollment user consoles. AccountSetup phase alone is too early (the portal shows it before the real desktop, while Shift+F10 still works), so desktop arrival is the binding stop signal. Tests rewritten: Classify + HasScriptArgument (pure), high/low-confidence, session-0 ignore, scripted ignore, dedup, owner enrichment. V2.Core 1499/1499. Co-Authored-By: Claude Opus 4.8 --- .../Security/ConsoleBypassWatcherTests.cs | 141 +++++++---- .../Security/ConsoleBypassWatcher.cs | 232 ++++++++++++------ .../Orchestration/DefaultComponentFactory.cs | 23 +- .../Orchestration/Hosts/ConsoleBypassHost.cs | 34 ++- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../AutopilotMonitor.Shared/Constants.cs | 10 +- .../app/gather-rules/eventTypes.ts | 2 +- 8 files changed, 303 insertions(+), 143 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Security/ConsoleBypassWatcherTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Security/ConsoleBypassWatcherTests.cs index 7c78b2ba..1d5b201c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Security/ConsoleBypassWatcherTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Security/ConsoleBypassWatcherTests.cs @@ -9,13 +9,16 @@ using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security { /// - /// ConsoleBypassWatcher classification core — driven via the internal HandleStart with an - /// injected parent-name resolver (no real WMI / processes). Asserts the winlogon-parent - /// discriminator: a cmd.exe parented to winlogon.exe raises exactly one detection; any other - /// parent (or an unresolvable one) is ignored, keeping the false-positive rate near zero. + /// ConsoleBypassWatcher classification — driven via the internal HandleStart/Classify + /// with an injected process probe (no real WMI / processes). Asserts the parent-free discriminator: + /// an interactive-session cmd with a bare command line is flagged (high confidence); a scripted + /// cmd /c and a session-0 (service) cmd are ignored; an interactive-session cmd whose command + /// line cannot be read (instant-close race) is still surfaced at low confidence. /// public sealed class ConsoleBypassWatcherTests { + private const string BareCmd = @"C:\WINDOWS\system32\cmd.exe"; + private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info); private sealed class Rig : IDisposable @@ -24,96 +27,130 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security public List Detected { get; } = new List(); public ConsoleBypassWatcher Sut { get; } - public Rig(Func parentResolver) + public Rig(Func probe) { - Sut = new ConsoleBypassWatcher(NewLogger(_tmp.Path), parentResolver); + Sut = new ConsoleBypassWatcher(NewLogger(_tmp.Path), probe); Sut.BypassConsoleDetected += (_, info) => Detected.Add(info); } public void Dispose() { Sut.Dispose(); _tmp.Dispose(); } } - [Fact] - public void Parent_winlogon_raises_one_detection_with_full_signature() - { - using var rig = new Rig(parentPid => parentPid == 612 ? "winlogon" : "explorer"); + private static Func Probe(string? commandLine, string? owner = @"PC\defaultuser0") + => _ => new ProcessProbe(commandLine, owner); - rig.Sut.HandleStart(pid: 7244, parentPid: 612, sessionId: 1, detectedVia: "process_start_trace"); + private static readonly Func GoneProbe = _ => null; + + // ---------------------------------------------------------------- HandleStart end-to-end + + [Fact] + public void Interactive_session_bare_cmd_raises_high_confidence_with_signature() + { + using var rig = new Rig(Probe(BareCmd, owner: @"PC\defaultuser0")); + + rig.Sut.HandleStart(pid: 9636, parentPid: 9016, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace"); var info = Assert.Single(rig.Detected); Assert.Equal("cmd.exe", info.ProcessName); - Assert.Equal(7244, info.ProcessId); - Assert.Equal("winlogon.exe", info.ParentProcessName); - Assert.Equal(612, info.ParentProcessId); + Assert.Equal(9636, info.ProcessId); + Assert.Equal(9016, info.ParentProcessId); Assert.Equal(1, info.SessionId); - Assert.Equal("process_start_trace", info.DetectedVia); + Assert.Equal("high", info.Confidence); + Assert.Equal("interactive_console", info.Classification); + Assert.Equal(@"PC\defaultuser0", info.Owner); + Assert.Equal(BareCmd, info.CommandLine); } [Fact] - public void Parent_winlogon_match_is_case_insensitive() + public void Session_zero_cmd_is_ignored_even_when_bare() { - using var rig = new Rig(_ => "WinLogon"); - rig.Sut.HandleStart(7244, 612, 1, "startup_probe"); - Assert.Single(rig.Detected); + // IME / Intune install cmd run as SYSTEM in session 0 — never an interactive console. + using var rig = new Rig(Probe(BareCmd)); + rig.Sut.HandleStart(1234, 1, sessionId: 0, ownerSidHint: null, detectedVia: "process_start_trace"); + Assert.Empty(rig.Detected); } - [Fact] - public void Non_winlogon_parent_is_ignored() + [Theory] + [InlineData(@"C:\WINDOWS\system32\cmd.exe /c ""install.bat""")] + [InlineData(@"cmd.exe /C ping")] + [InlineData(@"cmd.exe /k doskey")] + public void Scripted_cmd_is_ignored(string commandLine) { - using var rig = new Rig(_ => "explorer"); // ordinary install-launched cmd - rig.Sut.HandleStart(7244, 999, 1, "process_start_trace"); + using var rig = new Rig(Probe(commandLine)); + rig.Sut.HandleStart(2222, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace"); Assert.Empty(rig.Detected); } [Fact] - public void Unresolvable_parent_is_ignored() + public void Instant_close_unreadable_commandline_raises_low_confidence() { - using var rig = new Rig(_ => null); - rig.Sut.HandleStart(7244, 612, 1, "process_start_trace"); - Assert.Empty(rig.Detected); + // Win32_Process query loses the race (process already exited). SessionID from the trace is + // still known, so the console is surfaced — low confidence, owner from the trace SID hint. + using var rig = new Rig(GoneProbe); + + rig.Sut.HandleStart(9636, 9016, sessionId: 1, ownerSidHint: "S-1-5-18", detectedVia: "process_start_trace"); + + var info = Assert.Single(rig.Detected); + Assert.Equal("low", info.Confidence); + Assert.Equal("interactive_session_unclassified", info.Classification); + Assert.Equal("S-1-5-18", info.Owner); + Assert.Null(info.CommandLine); } [Fact] - public void Parent_resolver_throwing_is_swallowed_and_not_flagged() + public void Owner_prefers_probe_over_trace_hint() { - using var rig = new Rig(_ => throw new InvalidOperationException("parent gone")); - rig.Sut.HandleStart(7244, 612, 1, "process_start_trace"); - Assert.Empty(rig.Detected); // conservative: cannot confirm winlogon → no false positive + using var rig = new Rig(Probe(BareCmd, owner: @"PC\defaultuser0")); + rig.Sut.HandleStart(9636, 9016, sessionId: 1, ownerSidHint: "S-1-5-18", detectedVia: "process_start_trace"); + Assert.Equal(@"PC\defaultuser0", Assert.Single(rig.Detected).Owner); } [Fact] public void Same_pid_seen_twice_raises_only_once() { - // The startup probe and the live trace can both observe the same still-running console. - using var rig = new Rig(_ => "winlogon"); - rig.Sut.HandleStart(7244, 612, 1, "startup_probe"); - rig.Sut.HandleStart(7244, 612, 1, "process_start_trace"); + // The startup probe and the live trace can both observe the same still-open console. + using var rig = new Rig(Probe(BareCmd)); + rig.Sut.HandleStart(9636, 9016, 1, null, "startup_probe"); + rig.Sut.HandleStart(9636, 9016, 1, null, "process_start_trace"); Assert.Single(rig.Detected); } [Fact] - public void Non_winlogon_start_does_not_suppress_a_later_winlogon_start_with_same_pid() + public void Invalid_pid_is_ignored() { - // A non-winlogon cmd (pid 7244) exits; the OS reuses pid 7244 for a real Shift+F10 - // console. The first non-match must NOT have marked the pid seen. - var parentNames = new Dictionary { [999] = "explorer", [612] = "winlogon" }; - using var rig = new Rig(parentPid => parentNames[parentPid]); - - rig.Sut.HandleStart(7244, 999, 1, "process_start_trace"); // ordinary cmd → ignored - rig.Sut.HandleStart(7244, 612, 1, "process_start_trace"); // reused pid, real bypass - - Assert.Single(rig.Detected); - Assert.Equal(612, rig.Detected[0].ParentProcessId); + using var rig = new Rig(Probe(BareCmd)); + rig.Sut.HandleStart(0, 9016, 1, null, "process_start_trace"); + Assert.Empty(rig.Detected); } + // ---------------------------------------------------------------- Classify (pure) + [Theory] - [InlineData(0, 612)] - [InlineData(7244, 0)] - public void Invalid_pids_are_ignored(int pid, int parentPid) + [InlineData(0, BareCmd, "Ignore")] // service session + [InlineData(1, null, "UnclassifiedInteractive")] // instant-close + [InlineData(1, BareCmd, "InteractiveConsole")] // bare shell + [InlineData(1, @"cmd.exe /c foo", "Ignore")] // scripted + [InlineData(2, @"C:\x\cmd.exe /K bar", "Ignore")] // scripted, other session + public void Classify_maps_session_and_commandline(int sessionId, string? commandLine, string expected) { - using var rig = new Rig(_ => "winlogon"); - rig.Sut.HandleStart(pid, parentPid, 1, "process_start_trace"); - Assert.Empty(rig.Detected); + Assert.Equal(expected, ConsoleBypassWatcher.Classify(sessionId, commandLine).ToString()); + } + + // ---------------------------------------------------------------- HasScriptArgument (pure) + + [Theory] + [InlineData(@"C:\WINDOWS\system32\cmd.exe", false)] + [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("", false)] + [InlineData(@"cmd.exe /c whoami", true)] + [InlineData(@"cmd.exe /C whoami", true)] + [InlineData(@"cmd.exe /k", 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)); } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Security/ConsoleBypassWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Security/ConsoleBypassWatcher.cs index 0b47cee5..9167d35d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Security/ConsoleBypassWatcher.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Security/ConsoleBypassWatcher.cs @@ -1,46 +1,58 @@ #nullable enable using System; using System.Collections.Generic; -using System.Diagnostics; using System.Management; +using System.Security.Principal; +using System.Text.RegularExpressions; using AutopilotMonitor.Agent.V2.Core.Logging; namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security { /// - /// Live watcher for a SYSTEM console opened during enrollment — the classic Shift+F10 - /// OOBE bypass, where winlogon.exe spawns a cmd.exe running as SYSTEM. The - /// discriminator is not the keystroke (un-hookable across sessions from session 0) but the - /// process signature: a watched console process whose parent is winlogon.exe. - /// That parent cleanly separates the bypass console from ordinary install-launched cmd (parented - /// to IME / a script host), keeping the false-positive rate near zero. + /// Live watcher for an interactive console opened during enrollment — the classic Shift+F10 + /// OOBE bypass (a cmd.exe a human can type into, opened before the real desktop exists). /// - /// Mirrors : a true ETW-backed push via WMI - /// Win32_ProcessStartTrace (no idle polling) plus a one-shot startup probe over - /// Win32_Process so a console still open when the agent boots (realistic during OOBE) is - /// still caught. Fail-soft — if the WMI watcher cannot start it is logged and the startup probe - /// still covers the already-open case; no polling fallback by design. + /// Discriminator (no parent dependency). Field experience (session f240a555, Win11 24H2) + /// showed the Shift+F10 cmd is NOT a child of a long-lived winlogon.exe — its parent is a + /// short-lived launcher that exits within milliseconds, so resolving the parent name races and the + /// parent is not winlogon anyway. Two signals that do NOT depend on the parent: + /// + /// trace gives SessionID race-free — an + /// interactive console runs in an interactive session (≠ 0); Intune/IME install cmd /c + /// run as SYSTEM in session 0. So SessionID == 0 is dropped without even probing. + /// The console's own command line (queried for the just-started PID): a bare + /// cmd.exe with no /c//k script argument is an interactive shell; a + /// cmd /c "..." is a scripted invocation (install/script) and is ignored. + /// /// /// - /// Coverage is best-effort: the agent installs during ESP, so a Shift+F10 pressed earlier - /// in OOBE (before the agent exists) is invisible here — that pre-agent window is covered, coarsely, - /// by the . The emitted event documents this gap. + /// Instant-close fallback. Reading the command line still races if the process exits before + /// the probe. In that case (SessionID ≠ 0 but command line unreadable) the console is still + /// surfaced, tagged confidence:"low" — better than missing it. A truly race-free capture of + /// the command line would require the ETW Kernel-Process provider (deferred). + /// + /// + /// Lifecycle. The host stops this watcher once the real-user desktop has arrived (wired in + /// the composition root): from that point Shift+F10 is no longer possible, so any later cmd is an + /// ordinary user action — stopping avoids false positives over the agent's remaining lifetime. + /// The pre-agent OOBE window is covered separately by . /// /// public sealed class ConsoleBypassWatcher : IDisposable { /// - /// Console executables treated as a bypass console when parented to winlogon. v1 watches only - /// cmd.exe (the exact Shift+F10 spawn); the list is the single seam to broaden later - /// (e.g. powershell.exe) without touching the watcher logic. + /// Console executables watched. v1 watches only cmd.exe (the exact Shift+F10 spawn); the + /// list is the single seam to broaden later (e.g. powershell.exe). /// internal static readonly string[] WatchedProcessNames = { "cmd.exe" }; - internal const string ParentProcessName = "winlogon.exe"; - private const string WinlogonNoExt = "winlogon"; + // 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. + private static readonly Regex ScriptSwitchRegex = + new Regex(@"(?:^|\s)/[ck]\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); private readonly AgentLogger _logger; - private readonly Func _parentNameResolver; + private readonly Func _processProbe; private readonly object _lock = new object(); private readonly HashSet _seen = new HashSet(); @@ -49,15 +61,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security private bool _disposed; /// - /// Raised once per process when a watched console spawns with winlogon.exe as its - /// parent. The host translates this into a Warning oobe_console_spawned event. + /// Raised once per process when a watched console is classified as an interactive console + /// (high confidence) or an unclassifiable interactive-session console (low confidence). The + /// host translates this into a Warning oobe_console_spawned event. /// public event EventHandler? BypassConsoleDetected; - public ConsoleBypassWatcher(AgentLogger logger, Func? parentNameResolver = null) + /// 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. + public ConsoleBypassWatcher(AgentLogger logger, Func? processProbe = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _parentNameResolver = parentNameResolver ?? DefaultResolveParentName; + _processProbe = processProbe ?? DefaultProbe; } public void Start() @@ -68,9 +83,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security _started = true; } - // 1) Startup probe — a console already open before the watcher armed (Shift+F10 pressed - // while the agent was installing). Win32_Process exposes the parent PID + session - // directly, so the same classification path is reused. + // 1) Startup probe — a console already open before the watcher armed. Win32_Process exposes + // SessionId directly, so the same classification path is reused. try { using var searcher = new ManagementObjectSearcher( @@ -84,7 +98,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security ToInt(mo["ProcessId"]), ToInt(mo["ParentProcessId"]), ToInt(mo["SessionId"]), - "startup_probe"); + ownerSidHint: null, + detectedVia: "startup_probe"); } } } @@ -101,7 +116,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security _startWatcher = new ManagementEventWatcher(query); _startWatcher.EventArrived += OnProcessStartTrace; _startWatcher.Start(); - _logger.Info("[ConsoleBypassWatcher] watching Win32_ProcessStartTrace for cmd.exe (parent winlogon.exe)"); + _logger.Info("[ConsoleBypassWatcher] watching Win32_ProcessStartTrace for cmd.exe (interactive-session + bare command line)"); } catch (Exception ex) { @@ -118,7 +133,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security var pid = ToInt(e.NewEvent?["ProcessID"]); var parentPid = ToInt(e.NewEvent?["ParentProcessID"]); var sessionId = ToInt(e.NewEvent?["SessionID"]); - HandleStart(pid, parentPid, sessionId, "process_start_trace"); + var ownerSid = TryReadSid(e.NewEvent?["Sid"]); // race-free owner from the trace + HandleStart(pid, parentPid, sessionId, ownerSid, "process_start_trace"); } catch (Exception ex) { @@ -127,65 +143,121 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security } /// - /// Classification core (internal for tests): resolves the parent name and raises - /// exactly once per PID when the parent is winlogon. - /// A non-winlogon parent (ordinary install-launched cmd) is ignored. + /// Classification core (internal for tests). Gates race-free on , + /// then probes the live process for its command line; raises + /// once per PID for an interactive console (bare command line → high confidence) or an + /// unreadable interactive-session console (instant-close → low confidence). Scripted + /// invocations (cmd /c) and session-0 (service) cmd are ignored. /// - internal void HandleStart(int pid, int parentPid, int sessionId, string detectedVia) + internal void HandleStart(int pid, int parentPid, int sessionId, string? ownerSidHint, string detectedVia) { - if (pid <= 0 || parentPid <= 0) return; - + if (pid <= 0) return; lock (_lock) { if (_disposed) return; } - string? parentName; - try { parentName = _parentNameResolver(parentPid); } - catch (Exception ex) - { - // Parent already gone / inaccessible — cannot confirm winlogon, so stay conservative - // and do not flag (avoids a false positive). winlogon never exits during enrollment. - // Deliberately NOT marked seen: the PID may be reused later by a real winlogon-parented - // console, which must still be detected. - _logger.Debug($"[ConsoleBypassWatcher] parent resolve failed for pid={pid} parent={parentPid}: {ex.Message}"); - return; - } + // Race-free gate: an interactive console is never in the service session. This drops the + // bulk of legitimate IME/Intune install cmd (SYSTEM, session 0) without even probing. + if (sessionId == 0) return; - if (string.IsNullOrEmpty(parentName) || - !parentName!.Equals(WinlogonNoExt, StringComparison.OrdinalIgnoreCase)) - { - // Not a Shift+F10 SYSTEM console. Do NOT mark the PID seen — an ordinary cmd that - // exits could see its PID reused by a later genuine winlogon-parented console, which - // must not be suppressed by this non-match. - return; - } + var probe = _processProbe(pid); // null when the process already exited (instant-close) + var verdict = Classify(sessionId, probe?.CommandLine); + if (verdict == ConsoleVerdict.Ignore) return; // scripted invocation (cmd /c ...) - // Confirmed bypass console — dedup ONLY here, so the startup probe and the live trace - // observing the same console collapse to a single detection, while a non-winlogon start - // with the same PID never pre-empts a real one. + // Dedup only on a flagged hit, so the startup probe + live trace observing the same console + // collapse to one detection, and an ignored start never pre-empts a later real one. lock (_lock) { if (_disposed) return; if (!_seen.Add(pid)) return; } + bool high = verdict == ConsoleVerdict.InteractiveConsole; var info = new ConsoleSpawnInfo( processName: "cmd.exe", processId: pid, - parentProcessName: ParentProcessName, parentProcessId: parentPid, sessionId: sessionId, + owner: probe?.Owner ?? ownerSidHint, + commandLine: probe?.CommandLine, + confidence: high ? "high" : "low", + classification: high ? "interactive_console" : "interactive_session_unclassified", detectedVia: detectedVia); - _logger.Warning($"[ConsoleBypassWatcher] SYSTEM console detected: cmd.exe (PID={pid}) " + - $"parent winlogon.exe (PID={parentPid}), session={sessionId}, via={detectedVia} — possible Shift+F10"); + _logger.Warning($"[ConsoleBypassWatcher] interactive console detected: cmd.exe (PID={pid}) " + + $"session={sessionId}, confidence={info.Confidence}, via={detectedVia} — possible Shift+F10"); try { BypassConsoleDetected?.Invoke(this, info); } catch (Exception ex) { _logger.Warning($"[ConsoleBypassWatcher] handler threw: {ex.Message}"); } } - private static string? DefaultResolveParentName(int parentPid) + internal enum ConsoleVerdict { Ignore, InteractiveConsole, UnclassifiedInteractive } + + /// + /// Pure classification (internal for tests): session-0 → Ignore; a scripted cmd /c → + /// Ignore; a bare command line → InteractiveConsole (high); an unreadable command line in an + /// interactive session → UnclassifiedInteractive (low, the instant-close fallback). + /// + internal static ConsoleVerdict Classify(int sessionId, string? commandLine) { - using var proc = Process.GetProcessById(parentPid); - return proc.ProcessName; // "winlogon" (no extension) + if (sessionId == 0) return ConsoleVerdict.Ignore; + if (commandLine == null) return ConsoleVerdict.UnclassifiedInteractive; + if (HasScriptArgument(commandLine)) return ConsoleVerdict.Ignore; + return ConsoleVerdict.InteractiveConsole; + } + + /// True when the command line carries a /c or /k run-command switch + /// (a scripted invocation), false for a bare interactive shell. + internal static bool HasScriptArgument(string commandLine) + => !string.IsNullOrEmpty(commandLine) && ScriptSwitchRegex.IsMatch(commandLine); + + private static ProcessProbe? DefaultProbe(int pid) + { + try + { + using var searcher = new ManagementObjectSearcher( + $"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {pid}"); + using var results = searcher.Get(); + foreach (ManagementObject mo in results) + { + using (mo) + { + var commandLine = mo["CommandLine"]?.ToString(); + return new ProcessProbe(commandLine, TryGetOwner(mo)); + } + } + } + catch (Exception) + { + // Process gone or query failed — treated as the instant-close fallback (low confidence). + } + return null; + } + + private static string? TryGetOwner(ManagementObject process) + { + try + { + var outParams = process.InvokeMethod("GetOwner", null, null); + if (outParams != null && Convert.ToInt32(outParams["ReturnValue"]) == 0) + { + var domain = outParams["Domain"]?.ToString(); + var user = outParams["User"]?.ToString(); + if (!string.IsNullOrEmpty(user)) + return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}"; + } + } + catch { /* best-effort enrichment */ } + return null; + } + + private static string? TryReadSid(object? sidValue) + { + try + { + if (sidValue is byte[] bytes && bytes.Length > 0) + return new SecurityIdentifier(bytes, 0).Value; + } + catch { /* best-effort enrichment */ } + return null; } private static int ToInt(object? value) @@ -213,25 +285,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security } } - /// Immutable detail record for a detected bypass console spawn. + /// Command line + owner for a probed PID; either may be null (best-effort). + public sealed class ProcessProbe + { + public ProcessProbe(string? commandLine, string? owner) + { + CommandLine = commandLine; + Owner = owner; + } + + public string? CommandLine { get; } + public string? Owner { get; } + } + + /// Immutable detail record for a detected interactive console spawn. public sealed class ConsoleSpawnInfo { - public ConsoleSpawnInfo(string processName, int processId, string parentProcessName, - int parentProcessId, int sessionId, string detectedVia) + public ConsoleSpawnInfo(string processName, int processId, int parentProcessId, int sessionId, + string? owner, string? commandLine, string confidence, string classification, string detectedVia) { ProcessName = processName; ProcessId = processId; - ParentProcessName = parentProcessName; ParentProcessId = parentProcessId; SessionId = sessionId; + Owner = owner; + CommandLine = commandLine; + Confidence = confidence; + Classification = classification; DetectedVia = detectedVia; } public string ProcessName { get; } public int ProcessId { get; } - public string ParentProcessName { get; } public int ParentProcessId { get; } public int SessionId { get; } + public string? Owner { get; } + public string? CommandLine { get; } + /// "high" (bare interactive shell) or "low" (interactive session, command line unreadable). + public string Confidence { get; } + public string Classification { get; } public string DetectedVia { get; } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index b149a4a7..2d307ac9 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -143,6 +143,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration // captures `realmJoinHost` by reference (closure over the local) — null at this // point, set a few lines later before the detector can ever fire. RealmJoinHost? realmJoinHost = null; + // Captured by the desktop-arrival observer below (null at this point, assigned a few lines + // later before the detector can fire). When the real-user desktop arrives, Shift+F10 is no + // longer possible, so the console watcher is stopped to avoid post-enrollment false positives. + ConsoleBypassHost? consoleBypassHost = null; var desktopArrivalHost = new DesktopArrivalHost( logger, ingress, @@ -152,6 +156,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration tenantId: tenantId, onRealUserOwnerObserved: owner => { + // The DesktopArrivalDetector validates a REAL user owner (excludes SYSTEM and + // defaultuser0), so this firing means we are past the OOBE / autologon phase where + // Shift+F10 works — stop the console watcher (no-op if not created / already stopped). + consoleBypassHost?.StopForDesktopArrival(); + if (realmJoinHost == null) return; if (UserSidResolver.TryResolveSid(owner, out var sid) && !string.IsNullOrEmpty(sid)) { @@ -203,18 +212,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration // OOBE-console / Shift+F10 detection — opt-OUT per tenant (portal toggle → // AnalyzerConfiguration.EnableConsoleBypassDetection, default ON). The LIVE half: a WMI - // Win32_ProcessStartTrace watcher that flags a cmd.exe spawned with parent winlogon.exe - // (the SYSTEM-console fingerprint) as a Warning oobe_console_spawned. The STARTUP-FORENSIC - // half (ConsolePrefetchScanner, covering the pre-agent OOBE window) is registered by the - // AgentAnalyzerManager under the same flag. Detection is best-effort, not gapless. + // Win32_ProcessStartTrace watcher that flags an interactive-session cmd.exe with a bare + // (non-scripted) command line as a Warning oobe_console_spawned. Stopped on real-user desktop + // arrival (see the desktop-arrival observer above) since Shift+F10 is gone by then. The + // STARTUP-FORENSIC half (ConsolePrefetchScanner, covering the pre-agent OOBE window) is + // registered by the AgentAnalyzerManager under the same flag. Detection is best-effort. if (analyzers.EnableConsoleBypassDetection) { - hosts.Add(new ConsoleBypassHost( + consoleBypassHost = new ConsoleBypassHost( sessionId: sessionId, tenantId: tenantId, ingress: ingress, clock: clock, - logger: logger)); + logger: logger); + hosts.Add(consoleBypassHost); } // Single-rail refactor (plan §5.8) — DeviceInfoCollector existed in V2.Core but had diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConsoleBypassHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConsoleBypassHost.cs index 9639e0a7..ef1aed70 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConsoleBypassHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConsoleBypassHost.cs @@ -13,11 +13,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// /// Lifecycle host for the . Opt-OUT per tenant /// (AnalyzerConfiguration.EnableConsoleBypassDetection, default ON): created by - /// DefaultComponentFactory unless the flag is explicitly disabled. Owns the watcher and translates each - /// detected SYSTEM-console spawn into a Warning oobe_console_spawned event on the single - /// rail. The event carries the full process signature plus an honest coverageNote / - /// coverageComplete:false — the live watcher only sees consoles spawned after the agent - /// started; the pre-agent OOBE window is covered (coarsely) by the ConsolePrefetchScanner. + /// DefaultComponentFactory unless the flag is explicitly disabled. Owns the watcher and + /// translates each detected interactive-console spawn into a Warning oobe_console_spawned + /// event on the single rail. The event carries the process signature (session, owner, command line, + /// confidence) plus an honest coverageNote / coverageComplete:false — the live watcher + /// only sees consoles spawned after the agent started; the pre-agent OOBE window is covered + /// (coarsely) by the ConsolePrefetchScanner. The composition root calls once the + /// real-user desktop arrives (Shift+F10 no longer possible) to avoid post-enrollment false positives. /// internal sealed class ConsoleBypassHost : ICollectorHost { @@ -56,6 +58,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration public void Stop() => Dispose(); + /// + /// Lifecycle gate: called by the composition root when the real-user desktop has arrived. + /// From that point Shift+F10 is no longer possible, so any further cmd is an ordinary user + /// action — stopping the watcher avoids flagging it. Idempotent. + /// + public void StopForDesktopArrival() + { + if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) return; + _logger.Info("[ConsoleBypassHost] real-user desktop arrived — Shift+F10 no longer possible, stopping console watcher"); + Dispose(); + } + private void OnBypassConsoleDetected(object? sender, ConsoleSpawnInfo info) { try @@ -68,18 +82,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration Severity = EventSeverity.Warning, Source = Name, Phase = EnrollmentPhase.Unknown, - Message = $"SYSTEM console spawned during enrollment: {info.ProcessName} " + - $"(parent {info.ParentProcessName}) — possible Shift+F10", + Message = $"Interactive console spawned during enrollment: {info.ProcessName} " + + $"(session {info.SessionId}, {info.Confidence} confidence) — possible Shift+F10", ImmediateUpload = true, Data = new Dictionary { { "decision", "console_spawn_detected" }, { "processName", info.ProcessName }, { "processId", info.ProcessId }, - { "parentProcessName", info.ParentProcessName }, { "parentProcessId", info.ParentProcessId }, - { "parentMatchesWinlogon", true }, { "sessionId", info.SessionId }, + { "owner", info.Owner ?? "unknown" }, + { "commandLine", info.CommandLine ?? "unavailable" }, + { "confidence", info.Confidence }, + { "classification", info.Classification }, { "detectedVia", info.DetectedVia }, { "coverageNote", CoverageNote }, { "coverageComplete", false }, diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index fdab865f..765f5bf7 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -1144 +1150 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 471d5488..107359ca 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1456 +1457 diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 88f62214..9bedd6be 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -451,9 +451,13 @@ namespace AutopilotMonitor.Shared public const string AutoLogonAnalysis = "autologon_analysis"; // OOBE-console / Shift+F10 detection (per-tenant opt-OUT, AnalyzerConfiguration. // EnableConsoleBypassDetection, default ON). Two complementary Warning signals: - // oobe_console_spawned — LIVE: a cmd.exe spawned with parent winlogon.exe while the - // agent runs (the Shift+F10 SYSTEM-console fingerprint). Precise, - // low false-positive. Misses presses BEFORE the agent installed. + // oobe_console_spawned — LIVE: an interactive-session cmd.exe (SessionID != 0) with a + // bare, non-scripted command line (no /c|/k) — an interactive + // console a human can type into. confidence=high; an instant-close + // cmd whose command line was unreadable is confidence=low. Stopped + // on real-user desktop arrival (Shift+F10 gone). NOT parent-based: + // the Shift+F10 cmd's parent is a short-lived non-winlogon launcher + // (field 24H2). Misses presses BEFORE the agent installed. // console_prefetch_detected — STARTUP FORENSIC: a CMD.EXE-*.pf prefetch artifact whose // last-run is after boot. Covers the pre-agent OOBE window the // live watcher cannot see, but cannot attribute the run to diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index 4557428e..8ee2ab75 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -133,7 +133,7 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [ { value: "provisioning_package_scan", label: "provisioning_package_scan", category: "diagnostics", description: "Single per-device provisioning-package (PPKG) scan at DeviceSetup-phase start. Carries an artifacts[] array — one entry per registry package, .ppkg file, or Recovery\\Customizations residue (each with a scalar identity) — plus .ppkg file metadata, registry package metadata, and best-effort content indicators. The PPKG analyze rules (ANALYZE-SEC-005/006) iterate the array against an allow-list regex. PPKGs can be legitimate bulk enrollment or a manipulation vector." }, { value: "oobe_console_spawned", label: "oobe_console_spawned", category: "diagnostics", - description: "LIVE detection of a SYSTEM console opened during enrollment — the Shift+F10 OOBE bypass (cmd.exe spawned with parent winlogon.exe). Warning severity. On by default, opt-out per tenant (EnableConsoleBypassDetection). Best-effort: only consoles spawned after the agent started are observable (coverageComplete=false)." }, + description: "LIVE detection of an interactive console opened during enrollment — the Shift+F10 OOBE bypass. Flags an interactive-session cmd.exe (SessionID != 0) with a bare, non-scripted command line (no /c or /k); confidence=high, or confidence=low when the command line was unreadable (instant-close). Warning severity. On by default, opt-out per tenant (EnableConsoleBypassDetection). Stopped once the real-user desktop arrives (Shift+F10 no longer possible). Best-effort, not parent-based: only consoles spawned after the agent started are observable (coverageComplete=false)." }, { value: "console_prefetch_detected", label: "console_prefetch_detected", category: "diagnostics", description: "STARTUP-FORENSIC complement to oobe_console_spawned: a CMD.EXE-*.pf prefetch artifact whose last-run is after boot, covering the pre-agent OOBE window. Warning severity. Attribution is coarse — cmd.exe shares one prefetch file, so once ESP runs the timestamp cannot be attributed to Shift+F10 vs. a legitimate install-launched cmd (coverageComplete=false)." }, ];