Shore Up
An inspector with a clipboard walking a long row of numbered doors, noting on each which master keys open it.
WindowsSecurity

Audit Local Administrators Across Many Windows Machines

Ketan Aagja9 min read
No ratings yet

Before you run this

This is a read-only audit. It pulls a list of computers from Active Directory, connects to each one over PowerShell Remoting (WinRM), reads the membership of the local Administrators group, and writes everything to a single CSV you can open in Excel. It creates nothing and changes nothing on the target machines, so there is no destructive step and nothing to roll back — the only thing produced is the report file on your own workstation.

It does need privileges. To enumerate the local Administrators group on a remote machine you must be a local administrator on that target, which in a domain usually means running the script as a Domain Admin or as a delegated account that is in the local Administrators group of the machines in scope. You also need:

  • PowerShell Remoting (WinRM) enabled and reachable on the targets. It is on by default on Windows Server 2012+, and is commonly pushed to clients by GPO. If it is not enabled, this script cannot reach those machines and will simply report them as errors.
  • The ActiveDirectory module (RSAT) on the machine you run this from, to query Get-ADComputer.
  • Run PowerShell as Administrator on your own workstation, in a session whose credentials have admin rights on the targets.

Read the script before you run it, and test it against a single machine or a small test OU first — set $SearchBase to an OU with two or three lab machines and confirm the CSV looks right before you point it at your whole estate. Fanning Invoke-Command across hundreds of hosts at once generates real network and WinRM load, so run the first full sweep in a quiet window.

Assumed environment: an Active Directory domain; Windows Server 2016/2019/2022 and Windows 10/11 targets; Windows PowerShell 5.1 on the admin workstation; WinRM already working. Nothing here depends on PowerShell 7, but it runs there too.

The approach

There are two mainstream ways to read a local group remotely: Get-LocalGroupMember (from the built-in Microsoft.PowerShell.LocalAccounts module) and the old net localgroup Administrators. I'll use Get-LocalGroupMember because it returns structured objects — name, object class, and whether the member is Local, ActiveDirectory, or Azure/MicrosoftAccount — which is exactly what you want in an audit. I mention the net localgroup fallback below because there is a real edge case where it helps.

The pattern is: get the computer names from AD, then run Get-LocalGroupMember inside an Invoke-Command scriptblock on each target, so the enumeration happens locally on each machine and only the results come back over the wire.

The script

Save this as audit-local-admins.ps1. Replace the three values at the top with your own — the OU to pull computers from, where to write the CSV, and (if yours is renamed) the local group name.

# audit-local-admins.ps1
# Read-only report of local Administrators group membership across many machines.

Import-Module ActiveDirectory

# --- Adjust these three lines ---
$SearchBase = "OU=Servers,DC=example,DC=com"        # OU whose computers you want to audit
$OutputCsv  = "C:\path\to\local-admins-report.csv"  # where the report is written
$GroupName  = "Administrators"                        # local group to read

# Pull enabled computer accounts from the chosen OU
$computers = Get-ADComputer -SearchBase $SearchBase -Filter { Enabled -eq $true } |
             Select-Object -ExpandProperty DNSHostName

# This runs ON each target machine
$scriptBlock = {
    param($group)
    Get-LocalGroupMember -Group $group | ForEach-Object {
        [PSCustomObject]@{
            Member     = $_.Name           # DOMAIN\user, MACHINE\user, or a group
            MemberType = $_.ObjectClass    # User or Group
            Source     = $_.PrincipalSource # Local / ActiveDirectory / etc.
        }
    }
}

$results = foreach ($c in $computers) {

    # Skip machines that don't answer a ping to avoid long WinRM timeouts
    if (-not (Test-Connection -ComputerName $c -Count 1 -Quiet)) {
        [PSCustomObject]@{ Computer=$c; Member='(unreachable)'; MemberType=''; Source='' }
        continue
    }

    try {
        Invoke-Command -ComputerName $c -ScriptBlock $scriptBlock `
            -ArgumentList $GroupName -ErrorAction Stop |
            ForEach-Object {
                [PSCustomObject]@{
                    Computer   = $c
                    Member     = $_.Member
                    MemberType = $_.MemberType
                    Source     = $_.Source
                }
            }
    }
    catch {
        # Record the failure instead of silently dropping the machine
        [PSCustomObject]@{
            Computer   = $c
            Member     = "(error: $($_.Exception.Message))"
            MemberType = ''
            Source     = ''
        }
    }
}

$results | Sort-Object Computer, Member |
    Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8

Write-Host "Report written to $OutputCsv"

A few notes on the non-obvious parts:

  • The Test-Connection guard is there so one offline laptop doesn't cost you a 60-second WinRM timeout. If your environment blocks ICMP, delete that block and let Invoke-Command fail into the catch instead.
  • Every machine ends up in the CSV one way or another — reachable machines contribute their members, unreachable and failing ones contribute a single explanatory row. That way an empty result never quietly hides a host.

One known wrinkle

Get-LocalGroupMember has a long-standing bug where it throws (a "principal could not be resolved" / SID error) if the group contains an orphaned SID — a member whose account was deleted and left a bare SID behind. On a host that hits this, the whole enumeration fails and you'll see it in the catch output. If you run into it, swap the scriptblock's command for the classic:

$scriptBlock = {
    param($group)
    net localgroup $group
}

net localgroup returns plain text rather than objects and won't give you the MemberType/Source columns, but it will list orphaned SIDs happily. Confirm the exact behaviour in Microsoft's docs for Get-LocalGroupMember on Microsoft Learn before you decide which to standardise on.

Running it against many machines efficiently

For a handful to a few hundred hosts, the loop above is fine. If you're auditing thousands and the sequential pace is too slow, the standard tool is Invoke-Command's own fan-out: it accepts an array of computer names in -ComputerName and runs them in parallel up to a throttle limit. That is the mainstream faster path; I'm keeping the per-machine loop here because it gives you clean per-host error handling, which matters more in an audit than raw speed. Don't reach for third-party parallel tricks — the built-in fan-out is well understood and supported.

Verify it worked

The report is only useful if you trust it, so spot-check it:

  1. Open the CSV. Confirm you have rows for the machines you expected and that the Member column shows real principals (e.g. EXAMPLE\Domain Admins, EXAMPLE\srv-admins, a local account).

  2. Pick one machine from the report and check it directly. On that host:

    Get-LocalGroupMember -Group Administrators
    

    The members should match the rows for that computer in your CSV.

  3. Scan for the rows you care about most: any (error: ...) or (unreachable) entries tell you which machines the audit couldn't cover — those are gaps, not clean results, and worth chasing. And any unexpected local account or personal user account sitting in Administrators is exactly what this exercise is meant to surface.

Undo

There is nothing to undo on the targets — the script never wrote to them. The only artefact is local-admins-report.csv on your workstation; delete it when you're done if it contains sensitive membership you don't want lying around.

Making it a routine

Once you trust the output, the normal way to keep it current is a Scheduled Task that runs the script on a schedule under a service account with the necessary rights, dropping a dated CSV to a share your security team reads. Register it with Register-ScheduledTask (see Microsoft Learn for the exact parameters) and have the task run powershell.exe -File C:\path\to\audit-local-admins.ps1. Comparing this week's report against last week's is how you catch the local-admin that quietly appeared on a server between audits.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.