diff --git a/docs/agent/local-admin-analyzer.md b/docs/agent/local-admin-analyzer.md
index 02cf8120..60e26e01 100644
--- a/docs/agent/local-admin-analyzer.md
+++ b/docs/agent/local-admin-analyzer.md
@@ -1,7 +1,7 @@
---
type: Concept
title: Local Admin Analyzer — account inventory, dormant accounts and Administrators membership
-description: What the local_admin_analysis event asserts about a device at enrollment start and completion, why disabled accounts are inventoried (a /active:no backdoor re-enabled after enrollment is dormant at both scans), how Administrators-group membership is read from the SAM by well-known SID (NetLocalGroupGetMembers, independent of WMI and account state), which members are scored versus only listed, and why the built-in Administrator's enabled state is reported but not scored.
+description: What the local_admin_analysis event asserts about a device at enrollment start and completion, why disabled accounts are inventoried (a /active:no backdoor re-enabled after enrollment is dormant at both scans), how Administrators-group membership is read from the SAM by well-known SID (NetLocalGroupGetMembers, independent of WMI and account state), why built-ins are matched by well-known RID (localized Guest/Administrator), why the shutdown-time dynamic allowance never covers a local SAM account (CWE-807 — the login state is attacker-controlled), which folders are not profiles, which members are scored versus only listed, and why the built-in Administrator's enabled state is reported but not scored.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs
tags:
- agent
@@ -29,16 +29,47 @@ during OOBE/ESP who creates a persistent local administrator.
| `bypass_nro` | `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE\BypassNRO` | value + `flagged` |
| Local accounts | WMI `Win32_UserAccount WHERE LocalAccount = True` (Name, Disabled, SID) | `accounts_checked`, `account_details` (name / disabled / administrators_member), `unexpected_accounts`, `builtin_administrator_enabled` |
| Administrators membership | `NetLocalGroupGetMembers` level 2 on the group resolved from `S-1-5-32-544` (`LocalGroupNativeMethods`) | `administrators_group.{enumerated,error_code,members}`, `unexpected_admin_members` |
-| Profile folders | `C:\Users` top-level directories | `profiles_found`, `unexpected_profiles` |
+| Profile folders | `C:\Users` top-level directories, junctions and `$<32 hex>` folders skipped | `profiles_found`, `unexpected_profiles` |
## Allowed list
Built-in names (`Administrator`, `Guest`, `DefaultAccount`, `WDAGUtilityAccount`,
-`defaultuser0/1/2`, the non-account folders `Public`, `Default`, `Default User`, `All Users`)
-plus the tenant's *Allowed Local Accounts* (glob `*`/`?`, case-insensitive). At shutdown the
-logged-in users (explorer.exe owners outside session 0) are added dynamically.
+`defaultuser0/1/2`, `kioskUser0` — the Assigned Access autologon account of kiosk profiles —
+and the non-account folders `Public`, `Default`, `Default User`, `All Users`) plus the
+tenant's *Allowed Local Accounts* (glob `*`/`?`, case-insensitive).
`defaultuser1/2` stay allowlisted deliberately: they do appear during OOBE enrollment.
+**Well-known RIDs.** Built-in accounts are additionally recognised by RID on the machine SID
+(`S-1-5-21-…-500` Administrator, `-501` Guest, `-503` DefaultAccount, `-504`
+WDAGUtilityAccount), for the WMI inventory and for the SAM-member fallback alike. Localized
+installs name them `Gast`, `Invité`, `Гость`, `Administrateur`, … — disabled by default and
+therefore invisible before disabled accounts were inventoried; without the RID match every
+non-English device would flag its own Guest. Name matching is unchanged (an ordinary-RID
+account named `Gast` is still unexpected).
+
+## Dynamic allowance at shutdown
+
+The shutdown run adds the logged-in users (explorer.exe owners outside session 0) to the
+allowed list — but **only names that are not a local SAM account**. The WMI inventory is read
+first; a logged-in name found in it is rejected for both the account and the profile check
+and reported as `logged_in_local_accounts` (Warning in the agent log).
+
+Why this is safe for false positives: in real sessions the logged-in identity is an Entra or
+domain user (user-driven, hybrid, or an Entra user signing in to a self-deploying device
+after ESP). Those never appear in `Win32_UserAccount`; the allowance only ever mattered for
+their `C:\Users` folder — and `unexpected_profiles` alone scores 0. A logged-in *local*
+account is the one case the allowance changed the score, and that is the attack: an account
+created after the startup scan that signs itself in before the shutdown scan (CWE-807 —
+exempting on a condition the adversary controls). Kiosk autologon (`kioskUser0`) is covered
+by the built-in list instead.
+
+## Profile folders that are not profiles
+
+Junctions (`All Users`, `Default User` and localized variants such as `Все пользователи`,
+all pointing at ProgramData / Default) and `$<32 hex>` folders are skipped entirely — neither
+can be the profile of a local account (a SAM account name is at most 20 characters), so they
+can never contribute to the profile-overlap score; listing them was payload noise.
+
## Evaluation (`EvaluateAccounts`, pure)
* Every local account is evaluated, **disabled ones included**; the disabled state is reported
@@ -85,6 +116,16 @@ disabled; before this change it was skipped entirely. Now `unexpected_accounts=[
`unexpected_admin_members=[backdoor]`, `account_details[backdoor].disabled=true` →
confidence 80, Error.
+`net user backdoor P@ss /add` + `net localgroup Administrators backdoor /add` during ESP,
+then an interactive sign-in as `backdoor` (fast user switching) before enrollment completes:
+the shutdown scan finds `backdoor` in the SAM inventory → not allowed dynamically →
+`unexpected_accounts=[backdoor]`, `unexpected_admin_members=[backdoor]`, profile overlap →
+confidence 100, Error, `logged_in_local_accounts=[backdoor]`. Before this change the login
+alone silenced the finding to 0.
+
+German device, tenant allowed list empty: `Gast` (RID 501, disabled) and `Administrator`
+are inventoried and matched by RID → `unexpected_accounts=[]`, confidence 0.
+
`net localgroup Administrators adm-helpdesk /add` where `adm-*` is allowlisted: not flagged
(the allowed list is the tenant's statement of intent), but `administrators_group.members`
shows the new membership in the shutdown event for delta review.
@@ -94,4 +135,5 @@ shows the new membership in the shutdown event for delta review.
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/LocalGroupNativeMethods.cs`
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerEvaluateAccountsTests.cs`
+* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerDynamicAllowanceTests.cs`
* `rules/analyze/ANALYZE-ID-002.json`
diff --git a/docs/index.md b/docs/index.md
index 70687d05..5b037c21 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -31,7 +31,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable; plus the linear-cost CMTrace line parser (first open / last parseable trailer, timeout-backed) and the agent multiline caps.
* [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.
-* [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, non-local members (Entra role SIDs) listed but never flagged, built-in Administrator enabled-state reported but not scored.
+* [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.
* [DO GroupId Auto-Set](agent/do-group-id-auto-set.md) - EnableDoGroupIdAutoSet (ConfigVersion 40): the agent seeds the Delivery Optimization DOGroupId policy with a deterministic network-fingerprint GUID (default gateway IPv4 + SendARP MAC, RealmJoin-compatible byte layout) so same-network devices form one DO peering group during ESP; ownership-tracked via the StartupEventGate fingerprint, foreign DOGroupId/DOGroupIdSource policies always win, no random-GUID fallback.
diff --git a/docs/log.md b/docs/log.md
index 1cfde6e3..05218e4c 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -2,6 +2,8 @@
## 2026-08-30 (3)
+* **Update**: `agent/local-admin-analyzer.md` — built-ins matched by well-known RID (500/501/503/504) so localized Guest/Administrator are not flagged now that disabled accounts are inventoried; `kioskUser0` built-in; shutdown-time dynamic allowance restricted to non-SAM identities (a logged-in local account is never exempted — CWE-807) with new `logged_in_local_accounts` field; junctions and `$<32 hex>` folders skipped by the profile check.
+
* **New**: `agent/local-admin-analyzer.md` — `LocalAdminAnalyzer` no longer skips disabled accounts (a `/active:no` backdoor re-enabled after enrollment was invisible at both scans) and reads Administrators-group membership from the SAM via `NetLocalGroupGetMembers` on `S-1-5-32-544` (`LocalGroupNativeMethods`, WMI-independent). New event fields `unexpected_admin_members` (+40), `account_details`, `administrators_group.{enumerated,error_code,members}`, `builtin_administrator_enabled` (reported, not scored). Non-local members (Entra role SIDs, domain groups) are listed, never flagged. `ANALYZE-ID-002` 1.2.0 explanation names the new fields. Registered in `index.md`.
## 2026-08-30 (2)
diff --git a/rules/analyze/ANALYZE-ID-002.json b/rules/analyze/ANALYZE-ID-002.json
index f7e9ffeb..3ab4bc28 100644
--- a/rules/analyze/ANALYZE-ID-002.json
+++ b/rules/analyze/ANALYZE-ID-002.json
@@ -5,7 +5,7 @@
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
"severity": "high",
"category": "identity",
- "version": "1.2.0",
+ "version": "1.3.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -56,7 +56,7 @@
}
],
"confidenceThreshold": 50,
- "explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
+ "explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
"remediation": [
{
"title": "Review the unexpected accounts",
diff --git a/rules/dist/analyze-rules.json b/rules/dist/analyze-rules.json
index aeb206b9..055b0b57 100644
--- a/rules/dist/analyze-rules.json
+++ b/rules/dist/analyze-rules.json
@@ -2766,7 +2766,7 @@
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
"severity": "high",
"category": "identity",
- "version": "1.2.0",
+ "version": "1.3.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -2817,7 +2817,7 @@
}
],
"confidenceThreshold": 50,
- "explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
+ "explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
"remediation": [
{
"title": "Review the unexpected accounts",
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerDynamicAllowanceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerDynamicAllowanceTests.cs
new file mode 100644
index 00000000..7ff0f46b
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerDynamicAllowanceTests.cs
@@ -0,0 +1,97 @@
+#nullable enable
+using System.Collections.Generic;
+using System.IO;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
+{
+ ///
+ /// LocalAdminAnalyzer shutdown-time dynamic allowance and profile-folder filtering.
+ /// Pins the CWE-807 fix: a logged-in user is only exempted when the name is NOT a local
+ /// SAM account — a backdoor account that signs itself in before the shutdown scan must
+ /// still be scored. All names are synthetic.
+ ///
+ public sealed class LocalAdminAnalyzerDynamicAllowanceTests
+ {
+ private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, int rid = 1001) =>
+ new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = false, Sid = "S-1-5-21-111-222-333-" + rid };
+
+ private static readonly List Inventory = new List
+ {
+ Account("Administrator", 500),
+ Account("Guest", 501),
+ Account("defaultuser0", 1000),
+ Account("backdoor", 1001)
+ };
+
+ // -------------------------------------------------------------- logged-in split
+
+ [Fact]
+ public void Entra_or_domain_user_is_allowed_dynamically()
+ {
+ var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "A0D715F", "jane.doe" }, Inventory);
+
+ Assert.Equal(new[] { "A0D715F", "jane.doe" }, split.Allowed);
+ Assert.Empty(split.LocalAccounts);
+ }
+
+ [Fact]
+ public void Logged_in_local_account_is_never_allowed_dynamically()
+ {
+ var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "backdoor" }, Inventory);
+
+ Assert.Empty(split.Allowed);
+ Assert.Equal(new[] { "backdoor" }, split.LocalAccounts);
+ }
+
+ [Fact]
+ public void Local_account_match_is_case_insensitive()
+ {
+ var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "BACKDOOR" }, Inventory);
+
+ Assert.Empty(split.Allowed);
+ Assert.Equal(new[] { "BACKDOOR" }, split.LocalAccounts);
+ }
+
+ [Fact]
+ public void Mixed_logins_are_split_and_blank_names_dropped()
+ {
+ var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "kftest", "", "backdoor", " " }, Inventory);
+
+ Assert.Equal(new[] { "kftest" }, split.Allowed);
+ Assert.Equal(new[] { "backdoor" }, split.LocalAccounts);
+ }
+
+ [Fact]
+ public void No_logins_or_no_inventory_yield_empty_or_all_allowed()
+ {
+ var none = LocalAdminAnalyzer.SplitLoggedInUsers(null, Inventory);
+ Assert.Empty(none.Allowed);
+ Assert.Empty(none.LocalAccounts);
+
+ // WMI failed: nothing to compare against — the SAM membership check still covers admins.
+ var noInventory = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "backdoor" }, null);
+ Assert.Equal(new[] { "backdoor" }, noInventory.Allowed);
+ }
+
+ // -------------------------------------------------------------- profile folders
+
+ [Theory]
+ [InlineData("All Users", FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Hidden, true)]
+ [InlineData("Default User", FileAttributes.Directory | FileAttributes.ReparsePoint, true)]
+ [InlineData("Все пользователи", FileAttributes.Directory | FileAttributes.ReparsePoint, true)]
+ [InlineData("$4720C0479E8F4B51ABD4C5195A3F6399", FileAttributes.Directory, true)]
+ [InlineData("$4720c0479e8f4b51abd4c5195a3f6399", FileAttributes.Directory, true)]
+ [InlineData("Public", FileAttributes.Directory, false)]
+ [InlineData("Default", FileAttributes.Directory | FileAttributes.Hidden, false)]
+ [InlineData("backdoor", FileAttributes.Directory, false)]
+ [InlineData("$backdoor", FileAttributes.Directory, false)]
+ [InlineData("$4720C0479E8F4B51ABD4C5195A3F639", FileAttributes.Directory, false)] // 31 hex
+ [InlineData("$4720C0479E8F4B51ABD4C5195A3F6399G", FileAttributes.Directory, false)] // non-hex
+ public void Non_profile_folders_are_recognised(string name, FileAttributes attributes, bool expected)
+ {
+ Assert.Equal(expected, LocalAdminAnalyzer.IsNonProfileFolder(name, attributes));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerEvaluateAccountsTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerEvaluateAccountsTests.cs
index 68f63e88..10362cb6 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerEvaluateAccountsTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerEvaluateAccountsTests.cs
@@ -151,6 +151,76 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
Assert.Equal(new[] { "BackDoor" }, result.UnexpectedAdminMembers);
}
+ // -------------------------------------------------------------- localized built-ins (RID)
+
+ [Fact]
+ public void Localized_builtin_accounts_are_allowed_by_well_known_rid()
+ {
+ // German / French install: the English names are on the allowed list, the local
+ // names are not — before the RID match every disabled Guest was an unexpected account.
+ var accounts = new List
+ {
+ Account("Administrateur", disabled: true, rid: 500),
+ Account("Gast", disabled: true, rid: 501),
+ Account("Standardkonto", disabled: true, rid: 503),
+ Account("WDAGDienstprogramm", disabled: true, rid: 504),
+ Account("defaultuser0", disabled: false, rid: 1000)
+ };
+ var members = new List { LocalMember("Administrateur", 500) };
+
+ var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, members, true, Machine, Allowed);
+
+ Assert.Empty(result.Unexpected);
+ Assert.Empty(result.UnexpectedAdminMembers);
+ Assert.Equal(5, result.AllChecked.Count);
+ }
+
+ [Fact]
+ public void Localized_builtin_admin_member_missing_from_wmi_is_not_flagged()
+ {
+ var members = new List
+ {
+ LocalMember("Administrateur", 500),
+ LocalMember("backdoor", 1001)
+ };
+
+ var result = LocalAdminAnalyzer.EvaluateAccounts(
+ new List(), members, true, Machine, Allowed);
+
+ Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
+ }
+
+ [Fact]
+ public void Well_known_rid_does_not_cover_ordinary_accounts_or_foreign_sids()
+ {
+ Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "500"));
+ Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "501"));
+ Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "503"));
+ Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "504"));
+ Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "502"));
+ Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1500"));
+ Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1001"));
+ Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-12-1-1-2-3-500"));
+ Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-5-32-544"));
+ Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix));
+ Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(null));
+ Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(""));
+ }
+
+ [Fact]
+ public void Account_named_like_a_builtin_but_with_an_ordinary_rid_is_still_matched_by_name_only()
+ {
+ // Name-based allowance is unchanged: the RID match only ADDS coverage.
+ var accounts = new List
+ {
+ Account("Gast", disabled: false, rid: 1001)
+ };
+
+ var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, new List(), true, Machine, Allowed);
+
+ Assert.Equal(new[] { "Gast" }, result.Unexpected);
+ }
+
// -------------------------------------------------------------- built-in Administrator
[Theory]
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs
index 499100a3..3853cd9f 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs
@@ -36,6 +36,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
/// (builtin_administrator_enabled) but not scored: whether OOBE enables it
/// transiently is not established, so the field first collects evidence.
///
+ /// Built-in accounts are recognised by well-known RID (500/501/503/504 on the machine SID)
+ /// in addition to their English names, so localized installs (Gast, Invité, Administrateur)
+ /// do not flag their own disabled Guest/Administrator.
+ ///
+ /// At shutdown the logged-in users are allowed dynamically — but only identities that are
+ /// NOT local SAM accounts (Entra / domain users, whose profile folder is the only artifact
+ /// they leave). A logged-in local account is exactly the delta this analyzer exists to
+ /// catch and is never exempted by its own login (CWE-807: the login state is under the
+ /// adversary's control); it is reported as logged_in_local_accounts.
+ ///
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
/// enabling delta detection between pre- and post-enrollment state.
///
@@ -62,6 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
"defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment
"defaultuser1", // Sometimes seen in OOBE, but not always present
"defaultuser2", // Sometimes seen in OOBE, but not always present
+ "kioskUser0", // Assigned Access (kiosk profile) autologon account, created by Windows
"Public", // Profile folder (not a user account)
"Default", // Default user profile template
"Default User", // Symlink to Default in some Windows versions
@@ -111,9 +122,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{
_logger.Info($"{Name}: Running shutdown analysis");
- // At shutdown a user has logged in — their profile folder and account are expected.
- // Detect logged-in users via explorer.exe owner (same technique as DesktopArrivalDetector)
- // and add them dynamically so they don't trigger false positives.
+ // At shutdown a user has logged in — their profile folder is expected. Detect logged-in
+ // users via explorer.exe owner (same technique as DesktopArrivalDetector); RunAnalysis
+ // allows them dynamically unless the name is a local SAM account (see SplitLoggedInUsers).
var loggedInUsers = GetLoggedInUserNames();
// Phase=Unknown: this is an analysis event, not a phase declaration.
// Phase-tagged events are reserved for explicit phase transitions (esp_phase_changed etc.).
@@ -124,16 +135,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
// Core analysis
// -----------------------------------------------------------------------
- private void RunAnalysis(string trigger, EnrollmentPhase phase, List dynamicAllowedUsers = null)
+ private void RunAnalysis(string trigger, EnrollmentPhase phase, List loggedInUsers = null)
{
try
{
- // Build effective allowed list: static + optional dynamic (logged-in users at shutdown)
+ // The SAM inventory is read first: it decides which logged-in users may be allowed
+ // dynamically. A logged-in name that IS a local account is never exempted — its
+ // login state is under the adversary's control, and a local account created after
+ // the startup scan is precisely the delta this run exists to catch.
+ var localAccounts = ReadLocalAccounts();
+ var loggedIn = SplitLoggedInUsers(loggedInUsers, localAccounts);
+
+ foreach (var user in loggedIn.LocalAccounts)
+ _logger.Warning($"{Name}: Logged-in user '{user}' is a local account — not allowed dynamically");
+
+ // Build effective allowed list: static + dynamic (non-local logged-in users at shutdown)
var effectiveAllowed = _allowedAccounts;
- if (dynamicAllowedUsers != null && dynamicAllowedUsers.Count > 0)
+ if (loggedIn.Allowed.Count > 0)
{
effectiveAllowed = new List(_allowedAccounts);
- foreach (var user in dynamicAllowedUsers)
+ foreach (var user in loggedIn.Allowed)
{
if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase)))
{
@@ -144,7 +165,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
}
var bypassNroResult = CheckBypassNroRegistry();
- var accountsResult = CheckLocalAdminAccounts(effectiveAllowed);
+ var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveAllowed);
var profilesResult = CheckUserProfiles(effectiveAllowed);
int confidenceScore = 0;
@@ -210,7 +231,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{ "triggered_at", trigger },
{ "enrollment_phase_at_check", phase.ToString() },
{ "allowed_accounts", effectiveAllowed },
- { "dynamically_allowed_users", dynamicAllowedUsers ?? new List() },
+ { "dynamically_allowed_users", loggedIn.Allowed },
+ { "logged_in_local_accounts", loggedIn.LocalAccounts },
{ "checks", new Dictionary
{
{ "bypass_nro", new Dictionary
@@ -314,6 +336,82 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
return allowedAccounts.Any(a => MatchesAllowedEntry(name, a));
}
+ ///
+ /// Well-known built-in accounts of the local machine domain, recognised by RID so that
+ /// localized names (Gast, Invité, Administrateur, …) are not flagged:
+ /// 500 Administrator, 501 Guest, 503 DefaultAccount, 504 WDAGUtilityAccount.
+ /// Only S-1-5-21-… SIDs qualify (the WMI query is already restricted to local accounts).
+ /// Internal for direct unit-testing via InternalsVisibleTo.
+ ///
+ internal static bool IsWellKnownBuiltInRid(string sid)
+ {
+ if (string.IsNullOrEmpty(sid) || !sid.StartsWith("S-1-5-21-", StringComparison.Ordinal))
+ return false;
+
+ var dash = sid.LastIndexOf('-');
+ if (dash < 0 || dash == sid.Length - 1)
+ return false;
+
+ switch (sid.Substring(dash + 1))
+ {
+ case "500":
+ case "501":
+ case "503":
+ case "504":
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Splits the logged-in user names into those that may be allowed dynamically (not a
+ /// local SAM account — Entra / domain identities whose only artifact is a profile
+ /// folder) and those that are local accounts (never allowed; reported).
+ /// Pure; internal for direct unit-testing via InternalsVisibleTo.
+ ///
+ internal static LoggedInUserSplit SplitLoggedInUsers(IList loggedInUsers, IList localAccounts)
+ {
+ var split = new LoggedInUserSplit();
+ if (loggedInUsers == null)
+ return split;
+
+ var localNames = new HashSet(
+ (localAccounts ?? new List()).Select(a => a.Name),
+ StringComparer.OrdinalIgnoreCase);
+
+ foreach (var user in loggedInUsers)
+ {
+ if (string.IsNullOrWhiteSpace(user))
+ continue;
+ if (localNames.Contains(user))
+ split.LocalAccounts.Add(user);
+ else
+ split.Allowed.Add(user);
+ }
+
+ return split;
+ }
+
+ ///
+ /// Top-level entries of C:\Users that are not user profiles and are skipped entirely:
+ /// junctions (All Users, Default User and their localized variants point
+ /// at ProgramData / Default) and $<32 hex> folders. Neither can be the
+ /// profile of a local account — a SAM account name is at most 20 characters — so they
+ /// can never contribute to the profile-overlap score; listing them is pure noise.
+ /// Internal for direct unit-testing via InternalsVisibleTo.
+ ///
+ internal static bool IsNonProfileFolder(string folderName, FileAttributes attributes)
+ {
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ return true;
+
+ return folderName != null
+ && folderName.Length == 33
+ && folderName[0] == '$'
+ && folderName.Skip(1).All(Uri.IsHexDigit);
+ }
+
// -----------------------------------------------------------------------
// Individual checks
// -----------------------------------------------------------------------
@@ -352,7 +450,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
}
}
- private LocalAccountCheckResult CheckLocalAdminAccounts(List allowedAccounts)
+ /// Local account inventory via WMI; empty on failure (logged at Warning).
+ private List ReadLocalAccounts()
{
var accounts = new List();
@@ -379,6 +478,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
_logger.Warning($"{Name}: Failed to enumerate local accounts via WMI: {ex.Message}");
}
+ return accounts;
+ }
+
+ private LocalAccountCheckResult CheckLocalAdminAccounts(List accounts, List allowedAccounts)
+ {
// Administrators-group membership is read from the SAM, independent of WMI and of
// the enabled state — a dormant backdoor with admin rights is still a member.
var group = LocalGroupNativeMethods.GetAdministratorsMembers();
@@ -404,6 +508,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
/// - every local account is checked, disabled ones included (state is reported);
/// - a local account is an Administrators member when its SID matches a group member,
/// or (SID unavailable) its name matches a member of the local machine domain;
+ /// - an account is allowed by name (allowed list, globs) or by well-known RID
+ /// () — localized built-ins are never flagged;
/// - UnexpectedAdminMembers = unexpected accounts holding membership, plus local
/// members not on the allowed list that the WMI inventory did not return at all;
/// - members outside the local machine domain (Entra role SIDs, domain groups) are
@@ -420,13 +526,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
var adminSids = new HashSet(StringComparer.OrdinalIgnoreCase);
var adminLocalNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var adminLocalMembers = new List();
foreach (var member in adminMembers ?? new List())
{
result.AdministratorsGroupMembers.Add(member.DomainAndName ?? string.Empty);
if (!string.IsNullOrEmpty(member.Sid))
adminSids.Add(member.Sid);
if (IsLocalMember(member, machineName))
+ {
adminLocalNames.Add(member.Name);
+ adminLocalMembers.Add(member);
+ }
}
foreach (var account in accounts ?? new List())
@@ -445,7 +555,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
if (account.Sid != null && account.Sid.EndsWith("-500", StringComparison.Ordinal))
result.BuiltInAdministratorEnabled = !account.Disabled;
- if (IsAllowed(account.Name, allowedAccounts))
+ if (IsWellKnownBuiltInRid(account.Sid) || IsAllowed(account.Name, allowedAccounts))
continue;
result.Unexpected.Add(account.Name);
@@ -455,9 +565,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
// Local members the WMI inventory did not surface (WMI failure, or an account the
// provider does not list) — still an unexpected admin when not on the allowed list.
- foreach (var name in adminLocalNames)
+ foreach (var member in adminLocalMembers)
{
- if (IsAllowed(name, allowedAccounts))
+ var name = member.Name;
+ if (IsWellKnownBuiltInRid(member.Sid) || IsAllowed(name, allowedAccounts))
continue;
if (result.UnexpectedAdminMembers.Any(u => string.Equals(u, name, StringComparison.OrdinalIgnoreCase)))
continue;
@@ -497,6 +608,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
if (string.IsNullOrEmpty(folderName))
continue;
+ if (IsNonProfileFolder(folderName, new DirectoryInfo(dir).Attributes))
+ {
+ _logger.Debug($"{Name}: Skipping non-profile folder: {folderName}");
+ continue;
+ }
+
allFound.Add(folderName);
if (!IsAllowed(folderName, allowedAccounts))
@@ -636,6 +753,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
public bool? BuiltInAdministratorEnabled { get; set; }
}
+ /// Logged-in users at shutdown, split by whether the name is a local SAM account.
+ internal sealed class LoggedInUserSplit
+ {
+ /// Not a local account (Entra / domain identity) — allowed dynamically.
+ public List Allowed { get; } = new List();
+
+ /// Local accounts that are logged in — never allowed, reported.
+ public List LocalAccounts { get; } = new List();
+ }
+
private class UserProfileCheckResult
{
public List AllFound { get; set; } = new List();