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)." },
];