From 44dd8161dff8c7bf18227add40a67d7fdf0e65bd Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 30 Aug 2026 17:07:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(agent):=20ImeProcessWatcher=20verifies=20pr?= =?UTF-8?q?ocess=20identity=20before=20attaching=20and=20re-arms=20discove?= =?UTF-8?q?ry=20after=20an=20exit=20=E2=80=94=20it=20attached=20to=20the?= =?UTF-8?q?=20first=20IntuneManagementExtension=20name=20match,=20so=20a?= =?UTF-8?q?=20standard=20user=20starting=20a=20renamed=20binary=20during?= =?UTF-8?q?=20AccountSetup=20could=20forge=20an=20ime=5Fprocess=5Fexited?= =?UTF-8?q?=20Warning=20and=20mute=20the=20real=20signal=20for=20the=20res?= =?UTF-8?q?t=20of=20the=20session=20(CWE-807),=20and=20even=20a=20legitima?= =?UTF-8?q?te=20IME=20service=20restart=20was=20never=20watched=20again;?= =?UTF-8?q?=20ImeProcessIdentity=20requires=20session=200=20+=20image=20un?= =?UTF-8?q?der=20the=20IME=20install=20root=20(QueryFullProcessImageName,?= =?UTF-8?q?=20cross-bitness),=20prefers=20the=20oldest=20trusted=20candida?= =?UTF-8?q?te,=20logs=20untrusted=20matches=20once=20per=20PID,=20and=20On?= =?UTF-8?q?ImeExited=20restarts=20the=20discovery=20timer=20(ReferenceEqua?= =?UTF-8?q?ls=20guard=20against=20duplicate=20events)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/agent/ime-process-watcher.md | 69 ++++++ docs/index.md | 1 + docs/log.md | 4 + .../Ime/ImeProcessWatcherIdentityTests.cs | 225 ++++++++++++++++++ .../Enrollment/Ime/ImeProcessIdentity.cs | 172 +++++++++++++ .../Enrollment/Ime/ImeProcessWatcher.cs | 106 +++++++-- 6 files changed, 560 insertions(+), 17 deletions(-) create mode 100644 docs/agent/ime-process-watcher.md create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeProcessWatcherIdentityTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessIdentity.cs diff --git a/docs/agent/ime-process-watcher.md b/docs/agent/ime-process-watcher.md new file mode 100644 index 00000000..785031ba --- /dev/null +++ b/docs/agent/ime-process-watcher.md @@ -0,0 +1,69 @@ +--- +type: concept +title: IME Process Watcher — Identity Before Attach, Re-Arm After Exit +description: Why ImeProcessWatcher attaches only to a process that runs in session 0 with its image under the IME install root — a process name is not an identity — and why a reported exit re-arms discovery instead of ending it. +resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs +tags: [agent, ime, process, security, cwe-807, reliability] +timestamp: 2026-08-30 +--- + +# Problem + +`ImeProcessWatcher` reports `ime_process_exited` (Warning — read as an IME crash indicator in the +timeline) by subscribing to `Process.Exited` of `IntuneManagementExtension.exe`. Until 2026-08-30 it +attached to `Process.GetProcessesByName(...)[0]` and stopped discovery for good once attached. + +A process name is not a protected namespace. During AccountSetup (and on Device Preparation flows) +a standard-user session is live and can start any binary renamed to `IntuneManagementExtension.exe`. +Two consequences (CWE-807 — the same class as the agent instance guard and the local-admin dynamic +allowance): + +* **Forged signal** — the fake exits, the SYSTEM agent emits a Warning with the fake's PID/exit + code, and failure diagnosis is misdirected. +* **Muted signal** — attached to the fake, discovery is stopped; the real service is never watched, + and a genuine IME crash later in the enrollment is never reported. Even without an attacker the + one-shot design missed every IME exit after the first (an IME service restart is not rare). + +# Schema + +`ImeProcessIdentity` establishes identity by facts a standard user cannot forge: + +| Check | Source | Why it holds | +|---|---|---| +| `SessionId == 0` | `Process.SessionId` | Only services and their children run in session 0; a user session cannot start a process there. | +| image under `%ProgramFiles(x86)%\Microsoft Intune Management Extension\` (also `%ProgramW6432%`/`%ProgramFiles%` variants) | `QueryFullProcessImageName` (cross-bitness — IME is 32-bit, the agent may be 64-bit; `MainModule` throws there), `MainModule.FileName` fallback | Program Files is admin-writable only. Segment-aware prefix (`...Extension\`), `GetFullPath`-normalised, so a sibling folder or `..` does not pass. | +| file name exactly `IntuneManagementExtension.exe` | image path | `GetProcessesByName` matches the name without extension. | + +Unknown facts (session `-1`, unresolved path) are **untrusted** — a false negative only delays +attach by one 5 s discovery tick, a false positive mutes the signal for the session. + +Among trusted candidates the **oldest** by `StartTime` wins (the SCM-started service predates +anything transient). Untrusted name matches are logged once per PID as a Warning in the agent log +(`ignoring untrusted IntuneManagementExtension.exe candidate (PID, session, image)`) and never +attached; no timeline event is raised for them. + +**Re-arm:** `OnImeExited` detaches, emits the event, and restarts the discovery timer. Only the +currently attached `Process` object is honoured (`ReferenceEquals`), so a late `Exited` from a +replaced or disposed handle cannot produce a duplicate event. A process that exits between probe +and subscription is handled by an explicit `HasExited` check after subscribing. + +The watcher owns the `Process` handles its source returns (like `GetProcessesByName`) and disposes +them — tests must hand it fresh handles (`Process.GetProcessById`), not the ones they hold. + +# Examples + +* Fake started in session 1 from `Downloads\IntuneManagementExtension.exe` before the service is up → + never attached; its exit emits nothing; the service arriving later is attached normally. +* Service exits (crash or restart) → `ime_process_exited` (PID, exit code, uptime), discovery + re-armed, the restarted service is attached on the next tick and its later exit is reported too. +* Fake and service both running, fake listed first → the service is attached. + +Covered by `ImeProcessWatcherIdentityTests` (pure identity table + stand-in `cmd.exe` processes +with injected identity facts). + +# Citations + +* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessIdentity.cs` +* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs` +* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeProcessWatcherIdentityTests.cs` +* Related: [Local Admin Analyzer](local-admin-analyzer.md) (CWE-807 dynamic allowance). diff --git a/docs/index.md b/docs/index.md index 0f03a279..991c9c86 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,7 @@ https://docs.autopilotmonitor.com (separate repository). * [IME Log Tracker — Anchored Patterns and the Bounded Read Loop](agent/ime-log-tracker-matching-budget.md) - The IME Logs folder is user-writable, so the SYSTEM tracker must stay linear on hostile input: every shipped pattern starts with `^` (a non-match is O(prefix)), no chained unbounded scans, a byte-bounded line reader (32 MB — real `Get policies` lines reach 5.6 MB) with exact bookmarks, a 2 s per-line matching budget, held-back unterminated tails, and one Warning per pass instead of silent Debug timeouts. * [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm. * [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop. +* [IME Process Watcher — Identity Before Attach, Re-Arm After Exit](agent/ime-process-watcher.md) - `ime_process_exited` comes only from a process that runs in session 0 with its image under the IME install root (QueryFullProcessImageName, cross-bitness) — a process name is not an identity (CWE-807: a standard user can start a renamed binary during AccountSetup); the oldest trusted candidate wins, untrusted matches are logged once per PID and never attached, and a reported exit re-arms discovery so an IME restart and a later crash are both seen. * [Local Admin Analyzer](agent/local-admin-analyzer.md) - What local_admin_analysis asserts at enrollment start/end: every local account inventoried, disabled ones included (a /active:no backdoor is dormant at both scans), Administrators membership read from the SAM by well-known SID (NetLocalGroupGetMembers, WMI-independent), unexpected admin members scored +40, built-ins matched by well-known RID (localized Guest/Administrator never flagged), shutdown-time dynamic allowance never covers a local SAM account (CWE-807), junctions and $GUID folders are not profiles, non-local members (Entra role SIDs) listed but never flagged, built-in Administrator enabled-state reported but not scored. * [Power-State Watcher](agent/power-state-watcher.md) - Live AC↔battery transitions and 50/30/15% downward battery-threshold crossings during enrollment via a WMI Win32_PowerManagementEvent push subscription (zero polling, never armed on battery-less devices): the power_state_change event (transition field, per-level lifetime latch, 5s debounce, emission cap) and the analyze rules ANALYZE-DEV-009 (battery <15%, high) / ANALYZE-DEV-010 (AC→battery, warning) with on_event interim triggers. * [System Timeline Watcher](agent/system-timeline-watcher.md) - Ground truth for two timeline blind spots from one System-channel EventLogWatcher with pre-agent backfill: system_clock_changed (Kernel-General 1 — oldTime/newTime/signed timeDeltaMs/reason/setting process; 2 s micro-slew floor, ≥5 min Warning+immediate) and system_sleep_episode (Power-Troubleshooter 1 sleep/hibernate + Kernel-Power 507 Modern Standby; authoritative enteredAt/exitedAt payload instants, 60 s Modern-Standby floor). Consumed by the TimeJumpBadge clock-set cause, the SessionInfoCard standby note, time-attribution SleepSpans (AttributionVersion 3) and ANALYZE-DEV-012. diff --git a/docs/log.md b/docs/log.md index 544d8561..6298d240 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-30 (11) + +* **New**: `agent/ime-process-watcher.md` — `ImeProcessWatcher` no longer attaches to the first `IntuneManagementExtension` name match: `ImeProcessIdentity` requires session 0 + image under the IME install root (`QueryFullProcessImageName`, cross-bitness), prefers the oldest trusted candidate, logs untrusted matches once per PID, and re-arms discovery after every reported exit (previously one-shot — a fake in a user session could forge `ime_process_exited` and mute the real signal for the session; even an IME service restart was never watched again). Registered in `index.md`. + ## 2026-08-30 (10) * **Update**: `agent/ime-log-tracker-matching-budget.md` — the per-line budget sums only `Regex.Match` time; the previous wall-clock window also counted match handlers and scheduler stalls and broke twice on genuine lines of a stalled Hyper-V guest (session 946ccbd6). `backend/ime-pattern-health.md` — `ime_pattern_hits` is Debug severity (operator signal, hidden from the default timeline; the IME Tracker card and the ingest fold read it regardless). The session page's "IME Tracker" card moved to the right column. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeProcessWatcherIdentityTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeProcessWatcherIdentityTests.cs new file mode 100644 index 00000000..44a681a8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeProcessWatcherIdentityTests.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using Xunit; +using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime +{ + /// + /// CWE-807 hardening of : a process is only ever attached when + /// its identity (session 0 + image under the IME install root) says it is the service, and a + /// reported exit re-arms discovery so the next IME instance is watched too. + /// + [Collection("SerialThreading")] // Process.Exited is delivered on the shared ThreadPool + public sealed class ImeProcessWatcherIdentityTests + { + private const string Root = @"C:\Program Files (x86)\Microsoft Intune Management Extension"; + private static readonly string[] Roots = { Root }; + private static readonly DateTime T0 = new DateTime(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc); + + private static ImeProcessCandidate C(int pid, int session, string path, int startOffsetSeconds = 0) => + new ImeProcessCandidate(pid, session, path, T0.AddSeconds(startOffsetSeconds)); + + // ---- pure identity logic ---- + + [Theory] + [InlineData(0, Root + @"\Microsoft.Management.Services.IntuneWindowsAgent\IntuneManagementExtension.exe", true)] + [InlineData(0, Root + @"\IntuneManagementExtension.exe", true)] + [InlineData(0, @"c:\program files (x86)\microsoft intune management extension\INTUNEMANAGEMENTEXTENSION.EXE", true)] + [InlineData(1, Root + @"\IntuneManagementExtension.exe", false)] // user session + [InlineData(2, @"C:\Users\user\Downloads\IntuneManagementExtension.exe", false)] // spoof + [InlineData(0, @"C:\Users\user\Downloads\IntuneManagementExtension.exe", false)] // wrong root + [InlineData(0, Root + @"2\IntuneManagementExtension.exe", false)] // sibling folder + [InlineData(0, Root + @"\..\Evil\IntuneManagementExtension.exe", false)] // traversal + [InlineData(0, Root + @"\IntuneManagementExtensionX.exe", false)] // name variant + [InlineData(-1, Root + @"\IntuneManagementExtension.exe", false)] // session unknown + [InlineData(0, "", false)] // path unresolved + public void IsTrusted_requires_session0_and_install_root(int session, string path, bool expected) + { + Assert.Equal(expected, ImeProcessIdentity.IsTrusted(C(1, session, path), Roots)); + } + + [Fact] + public void IsTrusted_rejects_null_path() + { + Assert.False(ImeProcessIdentity.IsTrusted(C(1, 0, null!), Roots)); + } + + [Fact] + public void SelectPreferred_skips_untrusted_and_picks_oldest_trusted() + { + var fake = C(10, 1, @"C:\Users\user\IntuneManagementExtension.exe", startOffsetSeconds: -100); // oldest, but a spoof + var young = C(20, 0, Root + @"\IntuneManagementExtension.exe", startOffsetSeconds: 50); + var service = C(30, 0, Root + @"\IntuneManagementExtension.exe", startOffsetSeconds: 0); + + var chosen = ImeProcessIdentity.SelectPreferred(new[] { fake, young, service }, Roots); + + Assert.Equal(30, chosen?.Pid); + Assert.Null(ImeProcessIdentity.SelectPreferred(new[] { fake }, Roots)); + } + + [Fact] + public void DefaultTrustedRoots_are_under_program_files() + { + var roots = ImeProcessIdentity.DefaultTrustedRoots(); + Assert.NotEmpty(roots); + Assert.All(roots, r => Assert.EndsWith(ImeProcessIdentity.InstallFolderName, r)); + } + + [Fact] + public void Probe_reads_real_facts_for_the_current_process() + { + var me = ImeProcessIdentity.Probe(Process.GetCurrentProcess()); + Assert.Equal(Process.GetCurrentProcess().Id, me.Pid); + Assert.True(me.SessionId >= 0); + Assert.False(string.IsNullOrEmpty(me.ImagePath)); + Assert.EndsWith(".exe", me.ImagePath, StringComparison.OrdinalIgnoreCase); + } + + // ---- watcher behaviour with real (stand-in) processes ---- + + private sealed class Fixture : IDisposable + { + public readonly TempDirectory Tmp = new TempDirectory(); + public readonly FakeSignalIngressSink Ingress = new FakeSignalIngressSink(); + public readonly ImeProcessWatcher Watcher; + public readonly List Running = new List(); + public readonly Dictionary Facts = new Dictionary(); + + public Fixture() + { + var logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Watcher = new ImeProcessWatcher("session-ime", "tenant-1", new InformationalEventPost(Ingress, new VirtualClock(T0)), logger, Roots); + // Fresh handles, like Process.GetProcessesByName: the watcher owns and disposes what it is given. + Watcher.ProcessSource = () => Running + .Where(p => { try { return !p.HasExited; } catch { return false; } }) + .Select(p => Process.GetProcessById(p.Id)) + .ToArray(); + Watcher.IdentityProbe = p => Facts.TryGetValue(p.Id, out var f) ? f : ImeProcessIdentity.Probe(p); + } + + /// A stand-in process that blocks on stdin until killed; identity facts are injected. + public Process Spawn(bool trusted) + { + var psi = new ProcessStartInfo("cmd.exe", "/c pause") + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + var p = Process.Start(psi); + Running.Add(p); + Facts[p.Id] = trusted + ? new ImeProcessCandidate(p.Id, 0, Root + @"\IntuneManagementExtension.exe", p.StartTime.ToUniversalTime()) + : new ImeProcessCandidate(p.Id, 1, @"C:\Users\user\Downloads\IntuneManagementExtension.exe", p.StartTime.ToUniversalTime()); + return p; + } + + public IReadOnlyList ExitEvents() => + Ingress.Posted + .Where(s => s.Kind == DecisionSignalKind.InformationalEvent + && s.Payload != null + && s.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.ImeProcessExited) + .ToList(); + + public void Dispose() + { + Watcher.Dispose(); + foreach (var p in Running) + { + try { if (!p.HasExited) p.Kill(); } catch { } + try { p.Dispose(); } catch { } + } + Tmp.Dispose(); + } + } + + private static void WaitUntil(Func cond, int timeoutMs = 10000) + { + var sw = Stopwatch.StartNew(); + while (!cond() && sw.ElapsedMilliseconds < timeoutMs) + Thread.Sleep(25); + Assert.True(cond(), "condition not met within timeout"); + } + + [Fact] + public void Spoofed_process_is_never_attached_and_its_exit_emits_nothing() + { + using var f = new Fixture(); + var fake = f.Spawn(trusted: false); + + f.Watcher.TryAttachNow(); + f.Watcher.TryAttachNow(); + Assert.Null(f.Watcher.AttachedProcessId); + + fake.Kill(); + fake.WaitForExit(); + Thread.Sleep(200); + Assert.Empty(f.ExitEvents()); + + // The real service arriving later is still attached — discovery was never consumed. + var real = f.Spawn(trusted: true); + f.Watcher.TryAttachNow(); + Assert.Equal(real.Id, f.Watcher.AttachedProcessId); + } + + [Fact] + public void Real_process_exit_is_reported_and_discovery_rearms_for_the_next_instance() + { + using var f = new Fixture(); + var first = f.Spawn(trusted: true); + + f.Watcher.TryAttachNow(); + Assert.Equal(first.Id, f.Watcher.AttachedProcessId); + + first.Kill(); + first.WaitForExit(); + WaitUntil(() => f.ExitEvents().Count == 1); + Assert.Null(f.Watcher.AttachedProcessId); + + // IME restarted (or a spoof took the name first — still ignored). + var spoof = f.Spawn(trusted: false); + var second = f.Spawn(trusted: true); + f.Watcher.TryAttachNow(); + Assert.Equal(second.Id, f.Watcher.AttachedProcessId); + + second.Kill(); + second.WaitForExit(); + WaitUntil(() => f.ExitEvents().Count == 2); + + spoof.Kill(); + spoof.WaitForExit(); + Thread.Sleep(200); + Assert.Equal(2, f.ExitEvents().Count); + + var pids = f.ExitEvents() + .Select(e => Assert.IsAssignableFrom>(e.TypedPayload)["pid"]) + .ToList(); + Assert.Equal(new object[] { first.Id, second.Id }, pids); + } + + [Fact] + public void With_spoof_and_service_both_running_the_service_wins_regardless_of_order() + { + using var f = new Fixture(); + var spoof = f.Spawn(trusted: false); // listed first + var real = f.Spawn(trusted: true); + + f.Watcher.TryAttachNow(); + + Assert.Equal(real.Id, f.Watcher.AttachedProcessId); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessIdentity.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessIdentity.cs new file mode 100644 index 00000000..394a8d40 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessIdentity.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Identity facts about one process named IntuneManagementExtension, as observed by + /// before it decides whether to attach. + /// + public readonly struct ImeProcessCandidate + { + public ImeProcessCandidate(int pid, int sessionId, string imagePath, DateTime startTimeUtc) + { + Pid = pid; + SessionId = sessionId; + ImagePath = imagePath; + StartTimeUtc = startTimeUtc; + } + + public int Pid { get; } + /// Terminal-services session; the IME service runs in session 0. -1 = unknown. + public int SessionId { get; } + /// Full image path (Win32 form), or null when it could not be resolved. + public string ImagePath { get; } + public DateTime StartTimeUtc { get; } + } + + /// + /// Process-name matching is not a protected namespace: during AccountSetup / Device Preparation + /// a standard user session is live and can start any binary renamed to + /// IntuneManagementExtension.exe (CWE-807 — the same class as the agent instance guard). + /// A watcher that attaches to the first name match would (a) report a forged + /// ime_process_exited Warning into the tenant timeline and (b) stop discovering the real + /// service. This helper establishes identity by properties a standard user cannot forge: + /// the process runs in session 0 (services only) AND its image lives under the IME install + /// root (%ProgramFiles(x86)%\Microsoft Intune Management Extension\, admin-writable only). + /// + public static class ImeProcessIdentity + { + public const string InstallFolderName = "Microsoft Intune Management Extension"; + + /// + /// Install roots the IME image may live under. IME is a 32-bit service, so on x64/ARM64 it is + /// %ProgramFiles(x86)%; %ProgramFiles% is included for completeness (x86 OS, + /// or a 32-bit agent host whose ProgramFiles already resolves to the (x86) folder). + /// + public static IReadOnlyList DefaultTrustedRoots() + { + var roots = new List(2); + foreach (var env in new[] { "ProgramFiles(x86)", "ProgramW6432", "ProgramFiles" }) + { + var basePath = Environment.GetEnvironmentVariable(env); + if (string.IsNullOrEmpty(basePath)) continue; + var root = Path.Combine(basePath, InstallFolderName); + if (!roots.Contains(root, StringComparer.OrdinalIgnoreCase)) + roots.Add(root); + } + return roots; + } + + /// + /// True when the candidate is trustworthy as the real IME service process: session 0 and an + /// image path directly under one of . Unknown session + /// (-1) or an unresolvable image path is NOT trusted — a false negative only delays + /// attach by one discovery tick, a false positive mutes the signal for the session. + /// Path comparison is segment-aware (...Extension\ prefix), so a sibling folder + /// like Microsoft Intune Management Extension2\ does not pass. + /// + public static bool IsTrusted(ImeProcessCandidate candidate, IReadOnlyList trustedRoots) + { + if (candidate.SessionId != 0) return false; + if (string.IsNullOrEmpty(candidate.ImagePath) || trustedRoots == null) return false; + + string full; + try { full = Path.GetFullPath(candidate.ImagePath); } + catch { return false; } + + // Only the exact on-disk name — a differently-cased extension or a trailing-space + // variant would still be a name match for GetProcessesByName but is not the service. + if (!string.Equals(Path.GetFileName(full), "IntuneManagementExtension.exe", StringComparison.OrdinalIgnoreCase)) + return false; + + foreach (var root in trustedRoots) + { + if (string.IsNullOrEmpty(root)) continue; + string rootFull; + try { rootFull = Path.GetFullPath(root).TrimEnd('\\', '/') + Path.DirectorySeparatorChar; } + catch { continue; } + if (full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + /// + /// Picks the candidate to attach to: the oldest trusted process. The service instance is + /// started by the SCM long before any transient tool could appear under the same trusted + /// root, and when IME restarts the old instance is already gone from the list. + /// Returns null when nothing is trusted. + /// + public static ImeProcessCandidate? SelectPreferred(IEnumerable candidates, IReadOnlyList trustedRoots) + { + ImeProcessCandidate? best = null; + foreach (var c in candidates) + { + if (!IsTrusted(c, trustedRoots)) continue; + if (best == null || c.StartTimeUtc < best.Value.StartTimeUtc) + best = c; + } + return best; + } + + /// + /// Reads the identity facts of a live process. Uses QueryFullProcessImageName so the + /// path resolves across bitness (IME is 32-bit; the agent may be 64-bit) — MainModule + /// throws in that case. Any failure yields an untrusted candidate (session -1 / null path), + /// never an exception. + /// + public static ImeProcessCandidate Probe(Process process) + { + int pid = -1, session = -1; + string path = null; + DateTime start = DateTime.MaxValue; + + try { pid = process.Id; } catch { } + try { session = process.SessionId; } catch { } + try { start = process.StartTime.ToUniversalTime(); } catch { } + try { path = QueryImagePath(pid); } catch { } + if (path == null) + { + try { path = process.MainModule?.FileName; } catch { } + } + + return new ImeProcessCandidate(pid, session, path, start); + } + + private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder exeName, ref int size); + + private static string QueryImagePath(int pid) + { + if (pid <= 0) return null; + var handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); + if (handle == IntPtr.Zero) return null; + try + { + var sb = new StringBuilder(1024); + int size = sb.Capacity; + return QueryFullProcessImageName(handle, 0, sb, ref size) && size > 0 ? sb.ToString(0, size) : null; + } + finally + { + CloseHandle(handle); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs index 3d0a07f8..694dae5f 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs @@ -11,9 +11,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime { /// /// Watches for IntuneManagementExtension.exe process exit. - /// Discovers the process once (with retry every 5s), then attaches Process.Exited — - /// no continuous polling after the process is found. - /// Emits ime_process_exited when the process terminates. + /// Discovers the process (retry every 5s), then attaches Process.Exited — + /// no continuous polling while attached. Emits ime_process_exited when the process terminates, + /// then re-arms discovery so an IME restart is attached again and a later crash is reported too. + /// + /// Identity is established by (session 0 + image under the IME + /// install root), not by process name alone: a standard user can run any binary renamed to + /// IntuneManagementExtension.exe during AccountSetup, and attaching to it would forge a crash + /// Warning and mute the real signal (CWE-807). Untrusted name matches are logged once per PID + /// and never attached. + /// /// public class ImeProcessWatcher : IDisposable { @@ -24,32 +31,51 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime private readonly string _tenantId; private readonly InformationalEventPost _post; private readonly AgentLogger _logger; + private readonly IReadOnlyList _trustedRoots; + private readonly HashSet _rejectedPids = new HashSet(); private Timer _discoveryTimer; private Process _imeProcess; private bool _disposed; private readonly object _lock = new object(); + /// Test seam: enumerates the name-matched processes (default: Process.GetProcessesByName). + internal Func ProcessSource { get; set; } = () => Process.GetProcessesByName(ImeProcessName); + + /// Test seam: reads a candidate's identity facts (default: ). + internal Func IdentityProbe { get; set; } = ImeProcessIdentity.Probe; + public ImeProcessWatcher( string sessionId, string tenantId, InformationalEventPost post, - AgentLogger logger) + AgentLogger logger, + IReadOnlyList trustedRoots = null) { _sessionId = sessionId; _tenantId = tenantId; _post = post ?? throw new ArgumentNullException(nameof(post)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _trustedRoots = trustedRoots ?? ImeProcessIdentity.DefaultTrustedRoots(); + } + + /// PID of the attached process, or null while discovering. + internal int? AttachedProcessId + { + get { lock (_lock) { return _imeProcess?.Id; } } } public void Start() { - _logger.Info($"ImeProcessWatcher: starting (discovery every {DiscoveryIntervalSeconds}s until {ImeProcessName}.exe is found)"); + _logger.Info($"ImeProcessWatcher: starting (discovery every {DiscoveryIntervalSeconds}s until {ImeProcessName}.exe is found; trusted roots: {string.Join("; ", _trustedRoots)})"); _discoveryTimer = new Timer(TryAttach, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(DiscoveryIntervalSeconds)); } + /// Runs one discovery pass synchronously (tests). + internal void TryAttachNow() => TryAttach(null); + /// - /// Tries to find and attach to IntuneManagementExtension.exe. + /// Tries to find and attach to the real IntuneManagementExtension.exe. /// Stops the discovery timer once attached — switches to pure event-driven from there. /// private void TryAttach(object state) @@ -60,31 +86,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime return; Process[] candidates = null; + Process chosen = null; try { - candidates = Process.GetProcessesByName(ImeProcessName); + candidates = ProcessSource() ?? Array.Empty(); if (candidates.Length == 0) return; - var proc = candidates[0]; - for (int i = 1; i < candidates.Length; i++) - candidates[i].Dispose(); + var facts = new List(candidates.Length); + foreach (var p in candidates) + { + var c = IdentityProbe(p); + if (ImeProcessIdentity.IsTrusted(c, _trustedRoots)) + { + facts.Add(c); + } + else if (c.Pid > 0 && _rejectedPids.Add(c.Pid)) + { + // One line per PID: a user-session process carrying the IME name is either + // a spoof attempt or a stray tool — either way not the service, never attached. + _logger.Warning($"ImeProcessWatcher: ignoring untrusted {ImeProcessName}.exe candidate (PID={c.Pid}, session={c.SessionId}, image={c.ImagePath ?? ""})"); + } + } - proc.EnableRaisingEvents = true; - proc.Exited += OnImeExited; - _imeProcess = proc; + var preferred = ImeProcessIdentity.SelectPreferred(facts, _trustedRoots); + if (preferred == null) + return; + + foreach (var p in candidates) + { + int pid = -1; + try { pid = p.Id; } catch { } + if (chosen == null && pid == preferred.Value.Pid) + chosen = p; + else + p.Dispose(); + } + if (chosen == null) + return; + + chosen.EnableRaisingEvents = true; + chosen.Exited += OnImeExited; + _imeProcess = chosen; // Suspend discovery timer — now event-driven _discoveryTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _logger.Info($"ImeProcessWatcher: attached to {ImeProcessName}.exe (PID={proc.Id})"); + _logger.Info($"ImeProcessWatcher: attached to {ImeProcessName}.exe (PID={chosen.Id}, session={preferred.Value.SessionId}, image={preferred.Value.ImagePath})"); + + // The process may have exited between the probe and the subscription — Exited + // fires for an already-exited process only if EnableRaisingEvents was set before + // exit; check explicitly so a race does not leave the watcher attached to a corpse. + if (chosen.HasExited) + ThreadPool.QueueUserWorkItem(_ => OnImeExited(chosen, EventArgs.Empty)); } catch (Exception ex) { _logger.Debug($"ImeProcessWatcher: could not attach to {ImeProcessName}.exe: {ex.Message}"); - // Dispose any leftover handles from the failed attempt - if (candidates != null) + if (_imeProcess == null && candidates != null) foreach (var p in candidates) try { p.Dispose(); } catch { } } @@ -96,6 +156,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime if (!(sender is Process proc)) return; + // Detach + re-arm discovery. Only the currently attached process counts — a late + // Exited from a process already replaced or disposed is ignored (no duplicate event). + lock (_lock) + { + if (_disposed || !ReferenceEquals(proc, _imeProcess)) + return; + try { proc.Exited -= OnImeExited; } catch { } + _imeProcess = null; + _discoveryTimer?.Change(TimeSpan.FromSeconds(DiscoveryIntervalSeconds), TimeSpan.FromSeconds(DiscoveryIntervalSeconds)); + } + int pid = -1; int exitCode = -1; double uptimeSeconds = 0; @@ -103,8 +174,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime try { pid = proc.Id; } catch { } try { exitCode = proc.ExitCode; } catch { } try { uptimeSeconds = (proc.ExitTime - proc.StartTime).TotalSeconds; } catch { } + try { proc.Dispose(); } catch { } - _logger.Info($"ImeProcessWatcher: {ImeProcessName}.exe exited (PID={pid}, exit={exitCode}, uptime={uptimeSeconds:F0}s)"); + _logger.Info($"ImeProcessWatcher: {ImeProcessName}.exe exited (PID={pid}, exit={exitCode}, uptime={uptimeSeconds:F0}s) — discovery re-armed"); _post.Emit(new EnrollmentEvent {