mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+89
-52
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+162
-70
@@ -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)." },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user