~/2025/10/08/powershell-windows-discover-local-admins-across-every-workstation.md
PowerShell: Windows – Discover Local Admins Across Every Workstation
--- author: Tom Lasswell date: read: 5 min in: [ps, scripts] tags: [powershell, windows, discovery, active-directory] ---
$ grep -n '^#' post.md
Local admin sprawl is one of those problems that accumulates quietly. A help desk tech adds themselves to the local Administrators group to fix one thing during a busy incident, a former contractor's personal account never got removed, an old imaging process baked in an account nobody remembers the purpose of. None of it looks dangerous in isolation, and all of it adds up to lateral movement paths an attacker will find faster than you will. This script walks the local Administrators group on every workstation you point it at and flags any member that isn't on your approved allowlist, so cleanup becomes a list to work through instead of a mystery to solve.
Two things make this harder than a one-line Get-LocalGroupMember -Group "Administrators". The group name is localized (it isn't "Administrators" on a German or French build), so the script asks for the group by its well-known SID, S-1-5-32-544. And Get-LocalGroupMember has a long-standing bug, tracked in the PowerShell repository as issues #2996, #7105 and #21617, where a member SID that no longer resolves makes the whole call throw Failed to compare two elements in the array. That's common on machines that were moved between domains or joined to Microsoft Entra ID, which are exactly the machines you most want to audit. When that happens the script falls back to net.exe localgroup for that computer and marks the rows so you know which method produced them.
Requirements
- PowerShell 5.1 or later on the machine running the script; PowerShell remoting (WinRM) enabled and reachable on every target. Windows client editions don't enable remoting by default, so push it with
Enable-PSRemotingor Group Policy first. - Windows 10 / Server 2016 or later on targets for the built-in
Microsoft.PowerShell.LocalAccountsmodule. It's not available in 32-bit PowerShell on a 64-bit system, which doesn't matter here because remoting lands in the 64-bitMicrosoft.PowerShellendpoint. - An account in the Administrators or Remote Management Users group on each target (the default endpoint only admits Administrators). In practice that means a dedicated audit account, not your daily one.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
-ComputerName | String | No | One or more computer names to check. If omitted, -ComputerListPath is used instead. |
-ComputerListPath | String | No | Path to a text file with one computer name per line. |
-ExpectedAdmins | String | No | Approved members. Each entry can be a short name (Domain Admins), a qualified name (CORP\Domain Admins) or a SID. Short names are compared against the part after the backslash. Defaults to Administrator and Domain Admins. |
-OutputPath | String | No | Path to the CSV of flagged (unapproved) members. Defaults to .\unapproved-local-admins.csv. |
-ThrottleLimit | Int | No | Maximum number of concurrent remote connections. Defaults to 32. |
Usage
Check a handful of workstations against the default allowlist:
.\Find-UnapprovedLocalAdmins.ps1 -ComputerName WKS-0142, WKS-0198, WKS-0203
Check every enabled workstation in an OU, with your own approved accounts (including a Windows LAPS-managed account and a workstation admin group):
$workstations = Get-ADComputer -Filter 'Enabled -eq $true' -SearchBase "OU=Workstations,DC=corp,DC=example,DC=com" |
Select-Object -ExpandProperty Name
.\Find-UnapprovedLocalAdmins.ps1 -ComputerName $workstations -ExpectedAdmins "Administrator", "Domain Admins", "CORP\Workstation Admins", "lapsadmin"
Sample console output:
Checking 3 computer(s) with a throttle limit of 32 ...
WKS-0142: 1 unapproved member(s) found
WKS-0198: no unapproved members (net.exe fallback)
WARNING: Failed WKS-0203: Connecting to remote server WKS-0203 failed with the following error message : WinRM cannot complete the operation...
Flagged members written to .\unapproved-local-admins.csv
WARNING: Unreachable or failed computers: WKS-0203
And the CSV row behind the first line:
"ComputerName","Name","SID","ObjectClass","PrincipalSource","Method"
"WKS-0142","CORP\jsmith","S-1-5-21-<domain>-4127","User","ActiveDirectory","Get-LocalGroupMember"
Script
<#
.SYNOPSIS
Enumerates the local Administrators group on a list of Windows computers and
flags members that are not on an approved allowlist.
.DESCRIPTION
Over PowerShell remoting, reads the members of the built-in Administrators group
by its well-known SID (S-1-5-32-544) so it works on localized builds. If
Get-LocalGroupMember throws (for example on the orphaned-SID bug, "Failed to
compare two elements in the array"), falls back to net.exe localgroup on that
computer. Compares each member with -ExpectedAdmins and writes unapproved members
to a CSV.
.PARAMETER ComputerName
One or more computer names to check.
.PARAMETER ComputerListPath
Path to a text file with one computer name per line, used when -ComputerName
is not supplied.
.PARAMETER ExpectedAdmins
Approved members as short names, DOMAIN\name, or SIDs.
.PARAMETER OutputPath
Path to the CSV of flagged members. Defaults to .\unapproved-local-admins.csv.
.PARAMETER ThrottleLimit
Maximum number of concurrent remote connections. Defaults to 32.
.EXAMPLE
.\Find-UnapprovedLocalAdmins.ps1 -ComputerListPath C:\Lists\workstations.txt -ExpectedAdmins "Administrator", "Domain Admins"
.NOTES
Author : Thomas Lasswell (https://www.techcolumnist.com)
Version : 1.0 (2025-10-08)
Requires: PowerShell remoting on targets; Windows 10 / Server 2016+ for Get-LocalGroupMember
#>
[CmdletBinding()]
param(
[string[]]$ComputerName,
[string]$ComputerListPath,
[string[]]$ExpectedAdmins = @("Administrator", "Domain Admins"),
[string]$OutputPath = ".\unapproved-local-admins.csv",
[int]$ThrottleLimit = 32
)
if (-not $ComputerName) {
if (-not $ComputerListPath -or -not (Test-Path -Path $ComputerListPath)) {
throw "Supply -ComputerName or a valid -ComputerListPath."
}
$ComputerName = Get-Content -Path $ComputerListPath | Where-Object { $_.Trim() -ne "" } | ForEach-Object { $_.Trim() }
}
$ComputerName = @($ComputerName | Sort-Object -Unique)
Write-Host "Checking $($ComputerName.Count) computer(s) with a throttle limit of $ThrottleLimit ..."
# The script block that runs on each remote computer.
$membershipScript = {
$adminSid = "S-1-5-32-544"
try {
Get-LocalGroupMember -SID $adminSid -ErrorAction Stop | ForEach-Object {
[pscustomobject]@{
ComputerName = $env:COMPUTERNAME
Name = $_.Name
SID = $_.SID.Value
ObjectClass = $_.ObjectClass
PrincipalSource = "$($_.PrincipalSource)"
Method = "Get-LocalGroupMember"
}
}
} catch {
# Fallback: resolve the localized group name from the SID and parse net.exe output.
$sidObject = New-Object System.Security.Principal.SecurityIdentifier($adminSid)
$groupName = ($sidObject.Translate([System.Security.Principal.NTAccount]).Value -split "\\")[-1]
$output = @(& net.exe localgroup "$groupName")
$dashIndex = -1
for ($i = 0; $i -lt $output.Count; $i++) {
if ($output[$i] -match "^-{5,}") {
$dashIndex = $i
break
}
}
if ($dashIndex -ge 0) {
# Members follow the dashed line; the last non-empty line is the completion message.
$output[($dashIndex + 1)..($output.Count - 1)] |
Where-Object { $_.Trim() -ne "" } |
Select-Object -SkipLast 1 |
ForEach-Object {
[pscustomobject]@{
ComputerName = $env:COMPUTERNAME
Name = $_.Trim()
SID = $null
ObjectClass = $null
PrincipalSource = $null
Method = "net.exe"
}
}
}
}
}
# Return $true when a member matches any allowlist entry by SID, full name or short name.
function Test-Approved {
param (
$Member,
[string[]]$Allowlist
)
$shortName = ($Member.Name -split "\\")[-1]
foreach ($entry in $Allowlist) {
if ($Member.SID -and $entry -eq $Member.SID) { return $true }
if ($entry -eq $Member.Name) { return $true }
if ($entry -notlike "*\*" -and $entry -eq $shortName) { return $true }
}
return $false
}
$parentJob = Invoke-Command -ComputerName $ComputerName -ScriptBlock $membershipScript -AsJob -ThrottleLimit $ThrottleLimit
$parentJob | Wait-Job | Out-Null
$flagged = New-Object System.Collections.Generic.List[object]
$failures = New-Object System.Collections.Generic.List[string]
foreach ($child in $parentJob.ChildJobs) {
$target = $child.Location
if ($child.State -eq "Failed") {
$failures.Add($target)
Write-Warning "Failed $target`: $($child.JobStateInfo.Reason.Message)"
continue
}
$members = @(Receive-Job -Job $child -ErrorAction SilentlyContinue |
Select-Object -Property ComputerName, Name, SID, ObjectClass, PrincipalSource, Method)
$unapproved = @($members | Where-Object { -not (Test-Approved -Member $_ -Allowlist $ExpectedAdmins) })
$note = if ($members.Count -gt 0 -and $members[0].Method -eq "net.exe") { " (net.exe fallback)" } else { "" }
if ($unapproved.Count -gt 0) {
$flagged.AddRange([object[]]$unapproved)
Write-Host "$target`: $($unapproved.Count) unapproved member(s) found$note"
} else {
Write-Host "$target`: no unapproved members$note"
}
}
Remove-Job -Job $parentJob -Force
$flagged | Export-Csv -Path $OutputPath -NoTypeInformation
Write-Host "Flagged members written to $OutputPath"
if ($failures.Count -gt 0) {
Write-Warning "Unreachable or failed computers: $($failures -join ', ')"
}
Notes
Get-LocalGroupMemberreturns the group's direct members. IfDomain AdminsorCORP\Workstation Adminsis a member, the report shows the group, not the people in it. Resolve those separately withGet-ADGroupMember -Identity "Workstation Admins" -Recursive; that's usually where the real sprawl hides.- Rows marked
net.exehave no SID orPrincipalSource, only the name asnet.exeprints it. A computer that needed the fallback almost certainly has an unresolvable member in the group; find it, confirm the old domain or account is really gone, and remove it; the GitHub reports tie the error to unresolvable members, soGet-LocalGroupMembershould work again once they're gone. PrincipalSourcetells you where a member comes from. The CSV holds the enum value:Local,ActiveDirectory,AzureAD(Microsoft Entra ID),MicrosoftAccount, orUnknown(documented for Windows 10 / Server 2016 and later). AMicrosoftAccountadmin on a corporate workstation is almost always worth a conversation.- Short-name matching is convenient but loose: an allowlisted
Administratoralso matches a same-named account in a different domain. UseDOMAIN\nameor SIDs in-ExpectedAdminswhen that distinction matters. - The built-in local Administrator account shouldn't have the same password everywhere. Windows LAPS is built into Windows 11 23H2 and later (and earlier builds with the April 11, 2023 update), backs the password up to Active Directory or Microsoft Entra ID, and rotates it. The legacy Microsoft LAPS MSI is deprecated and blocked from installing on Windows 11 23H2 and later. If you use a custom LAPS-managed account name, add it to
-ExpectedAdmins. - I run this monthly and diff the output against the previous run rather than treating every flagged account as new. A shrinking diff is the real signal that the cleanup is working. The software inventory script uses the same fan-out pattern.