mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+225
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
-17
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user