From 88dfd331dde5cbb87c83c0a6f796f4b85a204275 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 30 Aug 2026 01:00:29 +0200 Subject: [PATCH] fix(security): LocalAdminAnalyzer inventories disabled accounts and reads Administrators-group membership from the SAM (NetLocalGroupGetMembers on S-1-5-32-544); a dormant /active:no admin backdoor now surfaces as unexpected_admin_members (+40, Error), built-in Administrator enabled-state reported for evidence Co-Authored-By: Claude Fable 5 --- docs/agent/local-admin-analyzer.md | 97 +++++++++ docs/index.md | 1 + docs/log.md | 4 + rules/analyze/ANALYZE-ID-002.json | 6 +- rules/dist/analyze-rules.json | 6 +- ...LocalAdminAnalyzerEvaluateAccountsTests.cs | 199 ++++++++++++++++++ .../Interop/LocalGroupNativeMethods.cs | 178 ++++++++++++++++ .../Telemetry/Analyzers/LocalAdminAnalyzer.cs | 178 ++++++++++++++-- 8 files changed, 642 insertions(+), 27 deletions(-) create mode 100644 docs/agent/local-admin-analyzer.md create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerEvaluateAccountsTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/LocalGroupNativeMethods.cs diff --git a/docs/agent/local-admin-analyzer.md b/docs/agent/local-admin-analyzer.md new file mode 100644 index 00000000..02cf8120 --- /dev/null +++ b/docs/agent/local-admin-analyzer.md @@ -0,0 +1,97 @@ +--- +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. +resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs +tags: + - agent + - security + - local-admin + - analyze-rules + - bypass-detection +timestamp: 2026-08-30T00:00:00+02:00 +--- + +# Local Admin Analyzer + +`LocalAdminAnalyzer` emits one `local_admin_analysis` event at agent startup and one at +shutdown. The pair is a before/after inventory of local accounts on the device; the backend +rule `ANALYZE-ID-002` alerts when the finding is `unexpected_local_admins_detected`. +The threat model is the classic Autopilot bypass: a person with a Shift+F10 SYSTEM console +during OOBE/ESP who creates a persistent local administrator. + +# Schema + +## Checks + +| Check | Source | What it yields | +|---|---|---| +| `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` | + +## 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. +`defaultuser1/2` stay allowlisted deliberately: they do appear during OOBE enrollment. + +## Evaluation (`EvaluateAccounts`, pure) + +* Every local account is evaluated, **disabled ones included**; the disabled state is reported + per account instead of excluding the account. +* An 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. +* `unexpected_accounts` = accounts not matching the allowed list (any state). +* `unexpected_admin_members` = unexpected accounts holding Administrators membership, plus + local-domain members not on the allowed list that WMI did not return at all (WMI failure + or provider gap — the SAM is authoritative for membership). +* Members outside the machine domain (Entra role SIDs such as `S-1-12-1-…` on Entra-joined + devices, domain groups on hybrid devices, unresolved/deleted SIDs) are **listed for delta + comparison but never scored** — they are expected on joined devices. +* `builtin_administrator_enabled` = `!Disabled` of the RID-500 account (null when absent). + Reported, not scored: whether OOBE enables it transiently is not established; the field + collects evidence first. + +## Scoring + +| Signal | Weight | +|---|---| +| `BypassNRO = 1` | +20 | +| any `unexpected_accounts` | +40 | +| any `unexpected_admin_members` | +40 | +| unexpected account with a matching `C:\Users` folder | +40 | + +Capped at 100. `0` → Info `no_unexpected_admins_detected`; `<40` → Info +`bypass_nro_flag_only`; `40–79` → Warning, `≥80` → Error, both +`unexpected_local_admins_detected`. A dormant backdoor with admin membership therefore lands +at 80 (Error) without ever having a profile folder. + +## Failure behaviour + +WMI and the SAM read are independent; either failing is logged at Warning with the native +status (`NET_API_STATUS=…`) and the other source still evaluates. The event carries +`administrators_group.enumerated=false` + `error_code` so a "no members" result is never +mistaken for an empty group. + +# Examples + +`net user backdoor P@ss /add /active:no` + `net localgroup Administrators backdoor /add` +during OOBE, re-enabled by a scheduled task after enrollment: at both scans the account is +disabled; before this change it was skipped entirely. Now `unexpected_accounts=[backdoor]`, +`unexpected_admin_members=[backdoor]`, `account_details[backdoor].disabled=true` → +confidence 80, Error. + +`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. + +# Citations + +* `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` +* `rules/analyze/ANALYZE-ID-002.json` diff --git a/docs/index.md b/docs/index.md index 6dd32ddb..70687d05 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,6 +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. * [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 7a6d900a..1cfde6e3 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-30 (3) + +* **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) * **Update**: `agent/cmtrace-time-resolution.md` — new "Line parsing (bounded cost)" section: `CmTraceLogParser.TryParseLine` is no longer one greedy-`.*` regex (quadratic on hostile lines, no timeout) but a linear first-open / last-parseable-trailer search with a `\G`-anchored, timeout-backed trailer regex that returns exactly what the old regex did; the agent's multiline buffer gains a 1 MB char cap next to the 100-line cap, both logged at Warning, and the tail of a dropped entry is skipped instead of raw-matched. diff --git a/rules/analyze/ANALYZE-ID-002.json b/rules/analyze/ANALYZE-ID-002.json index de020e91..f7e9ffeb 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.1.0", + "version": "1.2.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), 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), 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", @@ -79,7 +79,7 @@ "steps": [ "Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)", "Review the enrollment timeline for Shift+F10 or other OOBE escape indicators", - "Check whether the unexpected account has admin group membership", + "Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)", "Consider re-imaging the device if unauthorized admin access is confirmed" ] } diff --git a/rules/dist/analyze-rules.json b/rules/dist/analyze-rules.json index ff2cbd04..aeb206b9 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.1.0", + "version": "1.2.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), 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), 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", @@ -2840,7 +2840,7 @@ "steps": [ "Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)", "Review the enrollment timeline for Shift+F10 or other OOBE escape indicators", - "Check whether the unexpected account has admin group membership", + "Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)", "Consider re-imaging the device if unauthorized admin access is confirmed" ] } 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 new file mode 100644 index 00000000..68f63e88 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Analyzers/LocalAdminAnalyzerEvaluateAccountsTests.cs @@ -0,0 +1,199 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers +{ + /// + /// LocalAdminAnalyzer.EvaluateAccounts — the account/admin-membership evaluation contract. + /// Pins the two evasions that the old name-only, enabled-only scan missed: a disabled + /// (dormant) backdoor account, and Administrators membership as an independent signal. + /// All names are synthetic; the machine name is a fixture constant. + /// + public sealed class LocalAdminAnalyzerEvaluateAccountsTests + { + private const string Machine = "DESKTOP-TEST01"; + private const string MachineSidPrefix = "S-1-5-21-111-222-333-"; + + private static readonly List Allowed = new List + { + "Administrator", "Guest", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0", "defaultuser1", "adm-*" + }; + + private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, bool disabled, int rid) => + new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid }; + + private static LocalGroupMember LocalMember(string name, int rid) => + new LocalGroupMember { DomainAndName = Machine + "\\" + name, Sid = MachineSidPrefix + rid, SidUsage = 1 }; + + // -------------------------------------------------------------- disabled backdoor + + [Fact] + public void Disabled_unexpected_account_is_still_reported() + { + var accounts = new List + { + Account("Administrator", disabled: true, rid: 500), + Account("backdoor", disabled: true, rid: 1001) + }; + + var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, new List(), true, Machine, Allowed); + + Assert.Equal(new[] { "backdoor" }, result.Unexpected); + Assert.Empty(result.UnexpectedAdminMembers); + var detail = result.AccountDetails.Single(d => (string)d["name"] == "backdoor"); + Assert.True((bool)detail["disabled"]); + Assert.False((bool)detail["administrators_member"]); + } + + [Fact] + public void Disabled_unexpected_account_with_admin_membership_is_an_unexpected_admin_member() + { + var accounts = new List + { + Account("Administrator", disabled: true, rid: 500), + Account("backdoor", disabled: true, rid: 1001) + }; + var members = new List + { + LocalMember("Administrator", 500), + LocalMember("backdoor", 1001) + }; + + var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, members, true, Machine, Allowed); + + Assert.Equal(new[] { "backdoor" }, result.Unexpected); + Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers); + var detail = result.AccountDetails.Single(d => (string)d["name"] == "backdoor"); + Assert.True((bool)detail["administrators_member"]); + } + + // -------------------------------------------------------------- allowed list + + [Fact] + public void Allowed_admin_members_are_not_flagged_but_listed() + { + var accounts = new List + { + Account("Administrator", disabled: false, rid: 500), + Account("adm-4711", disabled: false, rid: 1002) + }; + var members = new List + { + LocalMember("Administrator", 500), + LocalMember("adm-4711", 1002) + }; + + var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, members, true, Machine, Allowed); + + Assert.Empty(result.Unexpected); + Assert.Empty(result.UnexpectedAdminMembers); + Assert.Equal(2, result.AdministratorsGroupMembers.Count); + Assert.True(result.AccountDetails.All(d => (bool)d["administrators_member"])); + } + + // -------------------------------------------------------------- non-local members + + [Fact] + public void Members_outside_the_machine_domain_are_listed_but_never_flagged() + { + var accounts = new List + { + Account("Administrator", disabled: true, rid: 500) + }; + var members = new List + { + LocalMember("Administrator", 500), + // Entra role SIDs on an Entra-joined device resolve to nothing — the API returns the SID + new LocalGroupMember { DomainAndName = "S-1-12-1-123-456-789-1011", Sid = "S-1-12-1-123-456-789-1011", SidUsage = 8 }, + // Domain group on a hybrid device + new LocalGroupMember { DomainAndName = "CORP\\Workstation Admins", Sid = "S-1-5-21-9-9-9-1105", SidUsage = 2 } + }; + + var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, members, true, Machine, Allowed); + + Assert.Empty(result.Unexpected); + Assert.Empty(result.UnexpectedAdminMembers); + Assert.Equal(3, result.AdministratorsGroupMembers.Count); + Assert.Contains("S-1-12-1-123-456-789-1011", result.AdministratorsGroupMembers); + } + + // -------------------------------------------------------------- WMI gap + + [Fact] + public void Local_admin_member_missing_from_the_wmi_inventory_is_still_flagged() + { + // WMI failed or did not list the account — the SAM group membership alone is enough. + var members = new List + { + LocalMember("Administrator", 500), + LocalMember("backdoor", 1001) + }; + + var result = LocalAdminAnalyzer.EvaluateAccounts( + new List(), members, true, Machine, Allowed); + + Assert.Empty(result.Unexpected); + Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers); + } + + [Fact] + public void Membership_matches_by_sid_when_the_member_name_differs_in_case() + { + var accounts = new List { Account("BackDoor", false, 1001) }; + var members = new List { LocalMember("backdoor", 1001) }; + + var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, members, true, Machine, Allowed); + + Assert.Equal(new[] { "BackDoor" }, result.UnexpectedAdminMembers); + } + + // -------------------------------------------------------------- built-in Administrator + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void Builtin_administrator_enabled_state_is_reported_by_rid_500(bool disabled, bool expectedEnabled) + { + var accounts = new List + { + Account("Administrator", disabled, rid: 500), + Account("Guest", disabled: true, rid: 501) + }; + + var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, new List(), true, Machine, Allowed); + + Assert.Equal(expectedEnabled, result.BuiltInAdministratorEnabled); + Assert.Empty(result.Unexpected); + } + + // -------------------------------------------------------------- native path (Windows only, net48) + + [Fact] + public void Native_administrators_enumeration_succeeds_and_contains_the_builtin_administrator() + { + // Proves the P/Invoke marshalling end-to-end on the executing machine: the SAM + // always holds the RID-500 Administrator in BUILTIN\Administrators. + var result = LocalGroupNativeMethods.GetAdministratorsMembers(); + + Assert.True(result.Succeeded, $"NET_API_STATUS={result.ErrorCode}"); + Assert.False(string.IsNullOrEmpty(result.GroupName)); + Assert.Contains(result.Members, m => m.Sid != null && m.Sid.EndsWith("-500")); + Assert.All(result.Members, m => Assert.False(string.IsNullOrEmpty(m.DomainAndName))); + } + + [Fact] + public void Builtin_administrator_state_is_null_when_rid_500_is_absent() + { + var accounts = new List { Account("Guest", true, 501) }; + + var result = LocalAdminAnalyzer.EvaluateAccounts(accounts, new List(), false, Machine, Allowed); + + Assert.Null(result.BuiltInAdministratorEnabled); + Assert.False(result.AdministratorsGroupEnumerated); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/LocalGroupNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/LocalGroupNativeMethods.cs new file mode 100644 index 00000000..7b5d3e07 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/LocalGroupNativeMethods.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop +{ + /// + /// One member of a local group as returned by NetLocalGroupGetMembers level 2. + /// is DOMAIN\name for resolvable members; for members + /// whose SID cannot be resolved on the device (Entra role SIDs on an Entra-joined device, + /// deleted accounts) it falls back to the SID string so the member is still visible. + /// + internal sealed class LocalGroupMember + { + public string Sid { get; set; } + public string DomainAndName { get; set; } + public int SidUsage { get; set; } + + /// Domain part of (empty when there is none). + public string Domain + { + get + { + var idx = (DomainAndName ?? string.Empty).IndexOf('\\'); + return idx > 0 ? DomainAndName.Substring(0, idx) : string.Empty; + } + } + + /// Account part of (the whole string when there is no domain). + public string Name + { + get + { + var value = DomainAndName ?? string.Empty; + var idx = value.IndexOf('\\'); + return idx >= 0 && idx < value.Length - 1 ? value.Substring(idx + 1) : value; + } + } + } + + /// Result of . + internal sealed class LocalGroupMembersResult + { + public string GroupName { get; set; } + public List Members { get; set; } = new List(); + + /// NET_API_STATUS of the enumeration; 0 = success. + public int ErrorCode { get; set; } + + public bool Succeeded => ErrorCode == 0; + } + + /// + /// Win32 P/Invoke declarations for local group membership queries + /// (NetLocalGroupGetMembers). Used by LocalAdminAnalyzer to enumerate the + /// members of the built-in Administrators group directly from the SAM — independent of + /// WMI and of account enabled/disabled state, so a dormant (disabled) admin account is + /// still reported. The group is addressed by its well-known SID (S-1-5-32-544) so the + /// localized group name ("Administratoren", "Administrateurs") does not matter. + /// + internal static class LocalGroupNativeMethods + { + private const uint MAX_PREFERRED_LENGTH = 0xFFFFFFFF; + + /// LOCALGROUP_MEMBERS_INFO_2 — SID, SID usage and DOMAIN\name of a member. + [StructLayout(LayoutKind.Sequential)] + private struct LOCALGROUP_MEMBERS_INFO_2 + { + public IntPtr lgrmi2_sid; + public int lgrmi2_sidusage; + [MarshalAs(UnmanagedType.LPWStr)] + public string lgrmi2_domainandname; + } + + [DllImport("netapi32.dll", CharSet = CharSet.Unicode)] + private static extern int NetLocalGroupGetMembers( + string servername, + string localgroupname, + int level, + out IntPtr bufptr, + uint prefmaxlen, + out int entriesread, + out int totalentries, + IntPtr resumehandle); + + [DllImport("netapi32.dll")] + private static extern int NetApiBufferFree(IntPtr buffer); + + /// + /// Resolves the localized name of the built-in Administrators group from its well-known + /// SID. Falls back to the English name when the translation is unavailable. + /// + public static string ResolveAdministratorsGroupName() + { + try + { + var sid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + var account = (NTAccount)sid.Translate(typeof(NTAccount)); + var value = account.Value ?? string.Empty; // "BUILTIN\Administrators" + var idx = value.IndexOf('\\'); + var name = idx >= 0 && idx < value.Length - 1 ? value.Substring(idx + 1) : value; + return string.IsNullOrEmpty(name) ? "Administrators" : name; + } + catch + { + return "Administrators"; + } + } + + /// + /// Enumerates the members of the built-in Administrators group on the local machine. + /// Never throws: a failed enumeration returns an empty member list with the native + /// error code in so the caller can log it. + /// + public static LocalGroupMembersResult GetAdministratorsMembers() + { + var result = new LocalGroupMembersResult { GroupName = ResolveAdministratorsGroupName() }; + var buffer = IntPtr.Zero; + + try + { + var status = NetLocalGroupGetMembers( + null, result.GroupName, 2, out buffer, MAX_PREFERRED_LENGTH, + out var entriesRead, out _, IntPtr.Zero); + + if (status != 0) + { + result.ErrorCode = status; + return result; + } + + var entrySize = Marshal.SizeOf(typeof(LOCALGROUP_MEMBERS_INFO_2)); + for (var i = 0; i < entriesRead; i++) + { + var entryPtr = new IntPtr(buffer.ToInt64() + (long)i * entrySize); + var entry = (LOCALGROUP_MEMBERS_INFO_2)Marshal.PtrToStructure(entryPtr, typeof(LOCALGROUP_MEMBERS_INFO_2)); + + string sid = null; + try + { + if (entry.lgrmi2_sid != IntPtr.Zero) + sid = new SecurityIdentifier(entry.lgrmi2_sid).Value; + } + catch + { + // Malformed SID — keep the member with whatever name the API returned. + } + + var domainAndName = entry.lgrmi2_domainandname; + if (string.IsNullOrEmpty(domainAndName)) + domainAndName = sid ?? string.Empty; + + result.Members.Add(new LocalGroupMember + { + Sid = sid, + DomainAndName = domainAndName, + SidUsage = entry.lgrmi2_sidusage + }); + } + + return result; + } + catch (Exception ex) + { + // Marshalling failure or missing export — report as a generic failure code + // (HRESULT when available) so the analyzer logs it instead of reporting an empty group as fact. + result.ErrorCode = ex.HResult != 0 ? ex.HResult : -1; + result.Members.Clear(); + return result; + } + finally + { + if (buffer != IntPtr.Zero) NetApiBufferFree(buffer); + } + } + } +} 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 9712a28e..499100a3 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 @@ -19,13 +19,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers /// /// Checks performed: /// 1. BypassNRO registry flag (HKLM\...\OOBE\BypassNRO = 1) - /// 2. Unexpected local user accounts (via WMI Win32_UserAccount) - /// 3. Unexpected C:\Users profile directories + /// 2. Unexpected local user accounts (via WMI Win32_UserAccount) — disabled accounts + /// included: a backdoor created with /active:no and re-enabled after enrollment + /// is dormant at both scans and would otherwise never surface + /// 3. Administrators-group membership (NetLocalGroupGetMembers on S-1-5-32-544) — + /// an unexpected account that also holds admin membership is the actual backdoor + /// 4. Unexpected C:\Users profile directories /// /// Confidence scoring: - /// BypassNRO = 1 → +20 (low indicator) - /// Unexpected local account found → +40 (medium indicator) - /// Account + matching C:\Users profile → +40 (high indicator, profile overlap) + /// BypassNRO = 1 → +20 (low indicator) + /// Unexpected local account found → +40 (medium indicator) + /// Unexpected account is an Administrators member → +40 (high indicator) + /// Account + matching C:\Users profile → +40 (high indicator, profile overlap) + /// + /// The enabled-state of the built-in Administrator (RID 500) is reported + /// (builtin_administrator_enabled) but not scored: whether OOBE enables it + /// transiently is not established, so the field first collects evidence. /// /// Emits a single "local_admin_analysis" event at startup and at shutdown, /// enabling delta detection between pre- and post-enrollment state. @@ -146,6 +155,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers if (accountsResult.Unexpected.Count > 0) confidenceScore += 40; + // An unexpected account that is also a member of Administrators is the + // actual backdoor (enabled or dormant) — high indicator on its own. + if (accountsResult.UnexpectedAdminMembers.Count > 0) + confidenceScore += 40; + // Profile overlap: unexpected account AND matching C:\Users folder bool profileOverlap = accountsResult.Unexpected.Any(a => profilesResult.Unexpected.Any(p => @@ -183,6 +197,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers $"{Name}: confidence={confidenceScore}, finding={findingLabel}, " + $"bypassNro={bypassNroResult.Value}, " + $"unexpectedAccounts={accountsResult.Unexpected.Count}, " + + $"unexpectedAdminMembers={accountsResult.UnexpectedAdminMembers.Count}, " + + $"adminGroupEnumerated={accountsResult.AdministratorsGroupEnumerated}, " + + $"builtinAdministratorEnabled={accountsResult.BuiltInAdministratorEnabled?.ToString() ?? "unknown"}, " + $"unexpectedProfiles={profilesResult.Unexpected.Count}"); var data = new Dictionary @@ -203,8 +220,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers } }, { "unexpected_accounts", accountsResult.Unexpected }, + { "unexpected_admin_members", accountsResult.UnexpectedAdminMembers }, { "unexpected_profiles", profilesResult.Unexpected }, { "accounts_checked", accountsResult.AllChecked }, + { "account_details", accountsResult.AccountDetails }, + { "administrators_group", new Dictionary + { + { "enumerated", accountsResult.AdministratorsGroupEnumerated }, + { "error_code", accountsResult.AdministratorsGroupErrorCode }, + { "members", accountsResult.AdministratorsGroupMembers } + } + }, + { "builtin_administrator_enabled", accountsResult.BuiltInAdministratorEnabled }, { "profiles_found", profilesResult.AllFound } } } @@ -327,33 +354,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers private LocalAccountCheckResult CheckLocalAdminAccounts(List allowedAccounts) { - var allChecked = new List(); - var unexpected = new List(); + var accounts = new List(); try { using (var searcher = new ManagementObjectSearcher( - "SELECT Name, Disabled FROM Win32_UserAccount WHERE LocalAccount = True")) + "SELECT Name, Disabled, SID FROM Win32_UserAccount WHERE LocalAccount = True")) { foreach (ManagementObject obj in searcher.Get()) { var name = obj["Name"]?.ToString() ?? string.Empty; var disabled = obj["Disabled"] != null && Convert.ToBoolean(obj["Disabled"]); + var sid = obj["SID"]?.ToString(); if (string.IsNullOrEmpty(name)) continue; - allChecked.Add(name); - - // Skip disabled accounts — they cannot be used to log in - if (disabled) - continue; - - if (!IsAllowed(name, allowedAccounts)) - { - unexpected.Add(name); - _logger.Debug($"{Name}: Unexpected local account: {name}"); - } + accounts.Add(new LocalAccountInfo { Name = name, Disabled = disabled, Sid = sid }); } } } @@ -362,7 +379,100 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers _logger.Warning($"{Name}: Failed to enumerate local accounts via WMI: {ex.Message}"); } - return new LocalAccountCheckResult { AllChecked = allChecked, Unexpected = unexpected }; + // 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(); + if (!group.Succeeded) + _logger.Warning($"{Name}: Failed to enumerate members of '{group.GroupName}': NET_API_STATUS={group.ErrorCode}"); + + var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowedAccounts); + result.AdministratorsGroupErrorCode = group.ErrorCode; + + foreach (var name in result.Unexpected) + _logger.Debug($"{Name}: Unexpected local account: {name}"); + foreach (var name in result.UnexpectedAdminMembers) + _logger.Debug($"{Name}: Unexpected Administrators member: {name}"); + + return result; + } + + /// + /// Pure evaluation of the account inventory against the allowed list — separated from + /// the WMI / SAM reads for unit-testing (InternalsVisibleTo). + /// + /// Rules: + /// - 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; + /// - 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 + /// listed for delta comparison but never flagged — they are expected on joined devices. + /// + internal static LocalAccountCheckResult EvaluateAccounts( + IList accounts, + IList adminMembers, + bool adminGroupEnumerated, + string machineName, + List allowedAccounts) + { + var result = new LocalAccountCheckResult { AdministratorsGroupEnumerated = adminGroupEnumerated }; + + var adminSids = new HashSet(StringComparer.OrdinalIgnoreCase); + var adminLocalNames = new HashSet(StringComparer.OrdinalIgnoreCase); + 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); + } + + foreach (var account in accounts ?? new List()) + { + var isAdmin = (!string.IsNullOrEmpty(account.Sid) && adminSids.Contains(account.Sid)) + || adminLocalNames.Contains(account.Name); + + result.AllChecked.Add(account.Name); + result.AccountDetails.Add(new Dictionary + { + { "name", account.Name }, + { "disabled", account.Disabled }, + { "administrators_member", isAdmin } + }); + + if (account.Sid != null && account.Sid.EndsWith("-500", StringComparison.Ordinal)) + result.BuiltInAdministratorEnabled = !account.Disabled; + + if (IsAllowed(account.Name, allowedAccounts)) + continue; + + result.Unexpected.Add(account.Name); + if (isAdmin) + result.UnexpectedAdminMembers.Add(account.Name); + } + + // 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) + { + if (IsAllowed(name, allowedAccounts)) + continue; + if (result.UnexpectedAdminMembers.Any(u => string.Equals(u, name, StringComparison.OrdinalIgnoreCase))) + continue; + result.UnexpectedAdminMembers.Add(name); + } + + return result; + } + + private static bool IsLocalMember(LocalGroupMember member, string machineName) + { + var domain = member.Domain; + if (string.IsNullOrEmpty(domain)) + return false; // unresolved SID (Entra role, deleted account) — not a local account + return string.Equals(domain, machineName, StringComparison.OrdinalIgnoreCase); } private UserProfileCheckResult CheckUserProfiles(List allowedAccounts) @@ -494,10 +604,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers public bool KeyExists { get; set; } } - private class LocalAccountCheckResult + /// One local account as returned by Win32_UserAccount. + internal sealed class LocalAccountInfo { + public string Name { get; set; } + public bool Disabled { get; set; } + public string Sid { get; set; } + } + + internal sealed class LocalAccountCheckResult + { + /// All local account names (enabled and disabled). public List AllChecked { get; set; } = new List(); + + /// Account names not on the allowed list, disabled ones included. public List Unexpected { get; set; } = new List(); + + /// Unexpected accounts that are members of the Administrators group. + public List UnexpectedAdminMembers { get; set; } = new List(); + + /// Per-account state: name, disabled, administrators_member. + public List> AccountDetails { get; set; } = new List>(); + + /// Every Administrators member (DOMAIN\name or SID), local or not — for delta comparison. + public List AdministratorsGroupMembers { get; set; } = new List(); + + public bool AdministratorsGroupEnumerated { get; set; } + public int AdministratorsGroupErrorCode { get; set; } + + /// Enabled state of the built-in Administrator (RID 500); null when not found. + public bool? BuiltInAdministratorEnabled { get; set; } } private class UserProfileCheckResult