~/2025/10/08/powershell-windows-discover-local-admins-across-every-workstation.md

PowerShell: Windows – Discover Local Admins Across Every Workstation

---
author: 
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-PSRemoting or Group Policy first.
  • Windows 10 / Server 2016 or later on targets for the built-in Microsoft.PowerShell.LocalAccounts module. It's not available in 32-bit PowerShell on a 64-bit system, which doesn't matter here because remoting lands in the 64-bit Microsoft.PowerShell endpoint.
  • 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

NameTypeRequiredDescription
-ComputerNameStringNoOne or more computer names to check. If omitted, -ComputerListPath is used instead.
-ComputerListPathStringNoPath to a text file with one computer name per line.
-ExpectedAdminsStringNoApproved 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.
-OutputPathStringNoPath to the CSV of flagged (unapproved) members. Defaults to .\unapproved-local-admins.csv.
-ThrottleLimitIntNoMaximum number of concurrent remote connections. Defaults to 32.

Usage

Check a handful of workstations against the default allowlist:

powershell
.\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):

powershell
$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:

text
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:

text
"ComputerName","Name","SID","ObjectClass","PrincipalSource","Method"
"WKS-0142","CORP\jsmith","S-1-5-21-<domain>-4127","User","ActiveDirectory","Get-LocalGroupMember"

Script

powershell
<#
.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-LocalGroupMember returns the group's direct members. If Domain Admins or CORP\Workstation Admins is a member, the report shows the group, not the people in it. Resolve those separately with Get-ADGroupMember -Identity "Workstation Admins" -Recursive; that's usually where the real sprawl hides.
  • Rows marked net.exe have no SID or PrincipalSource, only the name as net.exe prints 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, so Get-LocalGroupMember should work again once they're gone.
  • PrincipalSource tells you where a member comes from. The CSV holds the enum value: Local, ActiveDirectory, AzureAD (Microsoft Entra ID), MicrosoftAccount, or Unknown (documented for Windows 10 / Server 2016 and later). A MicrosoftAccount admin on a corporate workstation is almost always worth a conversation.
  • Short-name matching is convenient but loose: an allowlisted Administrator also matches a same-named account in a different domain. Use DOMAIN\name or SIDs in -ExpectedAdmins when 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.

Source