fix(agent): ImeProcessWatcher verifies process identity before attaching and re-arms discovery after an exit — it attached to the first IntuneManagementExtension name match, so a standard user starting a renamed binary during AccountSetup could forge an ime_process_exited Warning and mute the real signal for the rest of the session (CWE-807), and even a legitimate IME service restart was never watched again; 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 OnImeExited restarts the discovery timer (ReferenceEquals guard against duplicate events)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-30 17:07:18 +02:00
parent c84a1c23eb
commit 44dd8161df
6 changed files with 560 additions and 17 deletions
+69
View File
@@ -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).
+1
View File
@@ -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.
+4
View File
@@ -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.
@@ -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
{
/// <summary>
/// CWE-807 hardening of <see cref="ImeProcessWatcher"/>: 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.
/// </summary>
[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<Process> Running = new List<Process>();
public readonly Dictionary<int, ImeProcessCandidate> Facts = new Dictionary<int, ImeProcessCandidate>();
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);
}
/// <summary>A stand-in process that blocks on stdin until killed; identity facts are injected.</summary>
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<FakeSignalIngressSink.PostedSignal> 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<bool> 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<IReadOnlyDictionary<string, object>>(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);
}
}
}
@@ -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
{
/// <summary>
/// Identity facts about one process named <c>IntuneManagementExtension</c>, as observed by
/// <see cref="ImeProcessWatcher"/> before it decides whether to attach.
/// </summary>
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; }
/// <summary>Terminal-services session; the IME service runs in session 0. -1 = unknown.</summary>
public int SessionId { get; }
/// <summary>Full image path (Win32 form), or null when it could not be resolved.</summary>
public string ImagePath { get; }
public DateTime StartTimeUtc { get; }
}
/// <summary>
/// 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
/// <c>IntuneManagementExtension.exe</c> (CWE-807 — the same class as the agent instance guard).
/// A watcher that attaches to the first name match would (a) report a forged
/// <c>ime_process_exited</c> 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 (<c>%ProgramFiles(x86)%\Microsoft Intune Management Extension\</c>, admin-writable only).
/// </summary>
public static class ImeProcessIdentity
{
public const string InstallFolderName = "Microsoft Intune Management Extension";
/// <summary>
/// Install roots the IME image may live under. IME is a 32-bit service, so on x64/ARM64 it is
/// <c>%ProgramFiles(x86)%</c>; <c>%ProgramFiles%</c> is included for completeness (x86 OS,
/// or a 32-bit agent host whose ProgramFiles already resolves to the (x86) folder).
/// </summary>
public static IReadOnlyList<string> DefaultTrustedRoots()
{
var roots = new List<string>(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;
}
/// <summary>
/// True when the candidate is trustworthy as the real IME service process: session 0 and an
/// image path directly under one of <paramref name="trustedRoots"/>. 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 (<c>...Extension\</c> prefix), so a sibling folder
/// like <c>Microsoft Intune Management Extension2\</c> does not pass.
/// </summary>
public static bool IsTrusted(ImeProcessCandidate candidate, IReadOnlyList<string> 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;
}
/// <summary>
/// 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.
/// </summary>
public static ImeProcessCandidate? SelectPreferred(IEnumerable<ImeProcessCandidate> candidates, IReadOnlyList<string> 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;
}
/// <summary>
/// Reads the identity facts of a live process. Uses <c>QueryFullProcessImageName</c> so the
/// path resolves across bitness (IME is 32-bit; the agent may be 64-bit) — <c>MainModule</c>
/// throws in that case. Any failure yields an untrusted candidate (session -1 / null path),
/// never an exception.
/// </summary>
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);
}
}
}
}
@@ -11,9 +11,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// 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.
/// <para>
/// Identity is established by <see cref="ImeProcessIdentity"/> (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.
/// </para>
/// </summary>
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<string> _trustedRoots;
private readonly HashSet<int> _rejectedPids = new HashSet<int>();
private Timer _discoveryTimer;
private Process _imeProcess;
private bool _disposed;
private readonly object _lock = new object();
/// <summary>Test seam: enumerates the name-matched processes (default: Process.GetProcessesByName).</summary>
internal Func<Process[]> ProcessSource { get; set; } = () => Process.GetProcessesByName(ImeProcessName);
/// <summary>Test seam: reads a candidate's identity facts (default: <see cref="ImeProcessIdentity.Probe"/>).</summary>
internal Func<Process, ImeProcessCandidate> IdentityProbe { get; set; } = ImeProcessIdentity.Probe;
public ImeProcessWatcher(
string sessionId,
string tenantId,
InformationalEventPost post,
AgentLogger logger)
AgentLogger logger,
IReadOnlyList<string> trustedRoots = null)
{
_sessionId = sessionId;
_tenantId = tenantId;
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_trustedRoots = trustedRoots ?? ImeProcessIdentity.DefaultTrustedRoots();
}
/// <summary>PID of the attached process, or null while discovering.</summary>
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));
}
/// <summary>Runs one discovery pass synchronously (tests).</summary>
internal void TryAttachNow() => TryAttach(null);
/// <summary>
/// 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.
/// </summary>
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<Process>();
if (candidates.Length == 0)
return;
var proc = candidates[0];
for (int i = 1; i < candidates.Length; i++)
candidates[i].Dispose();
var facts = new List<ImeProcessCandidate>(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 ?? "<unresolved>"})");
}
}
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
{