fix(agent): rework Shift+F10 console detection — parent-free discriminator + desktop-arrival gate

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-19 12:47:07 +02:00
parent 2e3c0b8db6
commit d5cb5ef532
8 changed files with 303 additions and 143 deletions
@@ -9,13 +9,16 @@ using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
{
/// <summary>
/// ConsoleBypassWatcher classification core — driven via the internal <c>HandleStart</c> 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 <c>HandleStart</c>/<c>Classify</c>
/// 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
/// <c>cmd /c</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.
/// </summary>
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<ConsoleSpawnInfo> Detected { get; } = new List<ConsoleSpawnInfo>();
public ConsoleBypassWatcher Sut { get; }
public Rig(Func<int, string?> parentResolver)
public Rig(Func<int, ProcessProbe?> 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<int, ProcessProbe?> 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<int, ProcessProbe?> 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<int, string?> { [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));
}
}
}
@@ -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
{
/// <summary>
/// Live watcher for a SYSTEM console opened during enrollment — the classic <b>Shift+F10</b>
/// OOBE bypass, where <c>winlogon.exe</c> spawns a <c>cmd.exe</c> running as SYSTEM. The
/// discriminator is not the keystroke (un-hookable across sessions from session 0) but the
/// <b>process signature</b>: a watched console process whose <i>parent</i> is <c>winlogon.exe</c>.
/// 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 <b>Shift+F10</b>
/// OOBE bypass (a <c>cmd.exe</c> a human can type into, opened before the real desktop exists).
/// <para>
/// Mirrors <see cref="Office.OfficeProcessWatcher"/>: a true ETW-backed push via WMI
/// <c>Win32_ProcessStartTrace</c> (no idle polling) plus a one-shot startup probe over
/// <c>Win32_Process</c> 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.
/// <b>Discriminator (no parent dependency).</b> Field experience (session f240a555, Win11 24H2)
/// showed the Shift+F10 cmd is NOT a child of a long-lived <c>winlogon.exe</c> — 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:
/// <list type="bullet">
/// <item><b><see cref="ManagementEventWatcher"/> trace gives <c>SessionID</c> race-free</b> — an
/// interactive console runs in an interactive session (≠ 0); Intune/IME install <c>cmd /c</c>
/// run as SYSTEM in session 0. So <c>SessionID == 0</c> is dropped without even probing.</item>
/// <item><b>The console's own command line</b> (queried for the just-started PID): a bare
/// <c>cmd.exe</c> with no <c>/c</c>/<c>/k</c> script argument is an interactive shell; a
/// <c>cmd /c "..."</c> is a scripted invocation (install/script) and is ignored.</item>
/// </list>
/// </para>
/// <para>
/// <b>Coverage</b> 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 <see cref="Analyzers.ConsolePrefetchScanner"/>. The emitted event documents this gap.
/// <b>Instant-close fallback.</b> Reading the command line still races if the process exits before
/// the probe. In that case (<c>SessionID ≠ 0</c> but command line unreadable) the console is still
/// surfaced, tagged <c>confidence:"low"</c> — better than missing it. A truly race-free capture of
/// the command line would require the ETW Kernel-Process provider (deferred).
/// </para>
/// <para>
/// <b>Lifecycle.</b> 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 <see cref="Analyzers.ConsolePrefetchScanner"/>.
/// </para>
/// </summary>
public sealed class ConsoleBypassWatcher : IDisposable
{
/// <summary>
/// Console executables treated as a bypass console when parented to winlogon. v1 watches only
/// <c>cmd.exe</c> (the exact Shift+F10 spawn); the list is the single seam to broaden later
/// (e.g. <c>powershell.exe</c>) without touching the watcher logic.
/// Console executables watched. v1 watches only <c>cmd.exe</c> (the exact Shift+F10 spawn); the
/// list is the single seam to broaden later (e.g. <c>powershell.exe</c>).
/// </summary>
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<int, string?> _parentNameResolver;
private readonly Func<int, ProcessProbe?> _processProbe;
private readonly object _lock = new object();
private readonly HashSet<int> _seen = new HashSet<int>();
@@ -49,15 +61,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
private bool _disposed;
/// <summary>
/// Raised once per process when a watched console spawns with <c>winlogon.exe</c> as its
/// parent. The host translates this into a Warning <c>oobe_console_spawned</c> 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 <c>oobe_console_spawned</c> event.
/// </summary>
public event EventHandler<ConsoleSpawnInfo>? BypassConsoleDetected;
public ConsoleBypassWatcher(AgentLogger logger, Func<int, string?>? parentNameResolver = null)
/// <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)
{
_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
}
/// <summary>
/// Classification core (internal for tests): resolves the parent name and raises
/// <see cref="BypassConsoleDetected"/> 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 <paramref name="sessionId"/>,
/// then probes the live process for its command line; raises <see cref="BypassConsoleDetected"/>
/// once per PID for an interactive console (bare command line → high confidence) or an
/// unreadable interactive-session console (instant-close → low confidence). Scripted
/// invocations (<c>cmd /c</c>) and session-0 (service) cmd are ignored.
/// </summary>
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 }
/// <summary>
/// Pure classification (internal for tests): session-0 → Ignore; a scripted <c>cmd /c</c> →
/// Ignore; a bare command line → InteractiveConsole (high); an unreadable command line in an
/// interactive session → UnclassifiedInteractive (low, the instant-close fallback).
/// </summary>
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;
}
/// <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>
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
}
}
/// <summary>Immutable detail record for a detected bypass console spawn.</summary>
/// <summary>Command line + owner for a probed PID; either may be null (best-effort).</summary>
public sealed class ProcessProbe
{
public ProcessProbe(string? commandLine, string? owner)
{
CommandLine = commandLine;
Owner = owner;
}
public string? CommandLine { get; }
public string? Owner { get; }
}
/// <summary>Immutable detail record for a detected interactive console spawn.</summary>
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; }
/// <summary>"high" (bare interactive shell) or "low" (interactive session, command line unreadable).</summary>
public string Confidence { get; }
public string Classification { get; }
public string DetectedVia { get; }
}
}
@@ -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
@@ -13,11 +13,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// <summary>
/// Lifecycle host for the <see cref="ConsoleBypassWatcher"/>. Opt-OUT per tenant
/// (AnalyzerConfiguration.EnableConsoleBypassDetection, default ON): created by
/// <c>DefaultComponentFactory</c> unless the flag is explicitly disabled. Owns the watcher and translates each
/// detected SYSTEM-console spawn into a Warning <c>oobe_console_spawned</c> event on the single
/// rail. The event carries the full process signature plus an honest <c>coverageNote</c> /
/// <c>coverageComplete:false</c> — the live watcher only sees consoles spawned after the agent
/// started; the pre-agent OOBE window is covered (coarsely) by the ConsolePrefetchScanner.
/// <c>DefaultComponentFactory</c> unless the flag is explicitly disabled. Owns the watcher and
/// translates each detected interactive-console spawn into a Warning <c>oobe_console_spawned</c>
/// event on the single rail. The event carries the process signature (session, owner, command line,
/// confidence) plus an honest <c>coverageNote</c> / <c>coverageComplete:false</c> — 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 <see cref="Stop"/> once the
/// real-user desktop arrives (Shift+F10 no longer possible) to avoid post-enrollment false positives.
/// </summary>
internal sealed class ConsoleBypassHost : ICollectorHost
{
@@ -56,6 +58,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
public void Stop() => Dispose();
/// <summary>
/// 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.
/// </summary>
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<string, object>
{
{ "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 },
@@ -1 +1 @@
1144
1150
@@ -1 +1 @@
1456
1457
@@ -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
@@ -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)." },
];