mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user