In partnership with

THE PROBLEM

Local administrator group membership is the single most abused privilege in Windows environments. A contractor gets added for a one-off install and never removed. A developer adds themselves to troubleshoot. A legacy service account sits in the group on forty servers because of a script somebody ran in 2019. Every one of those is a lateral movement path for an attacker who compromises a single workstation. Nobody audits this because checking forty servers by hand is miserable. This week we automate it.

THE SCRIPT

Save the script below to:

C:\Scripts\LocalAdminAudit.ps1

powershell

# Local Administrator Group Auditor
# Automate & Operate — automateandoperate.com

# Email settings — update these
$from = "[email protected]"
$to = "[email protected]"
$smtpServer = "your.smtp.server"
$smtpPort = 587
$username = "[email protected]"
$password = ConvertTo-SecureString "yourpassword" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)

$logFile = "C:\Logs\LocalAdminAudit.log"
$reportFile = "C:\Logs\LocalAdminAuditReport.csv"

# Servers to audit
$servers = @(
    "localhost",
    "SERVER01",
    "SERVER02",
    "SERVER03"
)

# Approved members — anything NOT on this list gets flagged
# Use the exact format returned by Get-LocalGroupMember (DOMAIN\Name or COMPUTER\Name)
$approvedMembers = @(
    "BUILTIN\Administrator",
    "YOURDOMAIN\Domain Admins",
    "YOURDOMAIN\Server Admins",
    "YOURDOMAIN\svc_backup"
)

# Create log folder if missing
if (-not (Test-Path "C:\Logs")) {
    New-Item -ItemType Directory -Path "C:\Logs"
}

$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$report = @()
$unauthorized = @()

foreach ($server in $servers) {
    try {
        $members = Invoke-Command -ComputerName $server -ScriptBlock {
            Get-LocalGroupMember -Group "Administrators" | ForEach-Object {
                [PSCustomObject]@{
                    Name        = $_.Name
                    ObjectClass = $_.ObjectClass
                    Source      = $_.PrincipalSource
                }
            }
        } -ErrorAction Stop

        foreach ($member in $members) {
            # Normalise local machine name to BUILTIN for comparison
            $normalisedName = $member.Name -replace "^$server\\", "BUILTIN\"

            $isApproved = $false
            foreach ($approved in $approvedMembers) {
                if ($normalisedName -eq $approved -or $member.Name -eq $approved) {
                    $isApproved = $true
                    break
                }
            }

            $status = if ($isApproved) { "APPROVED" } else { "UNAUTHORIZED" }

            $report += [PSCustomObject]@{
                Server      = $server
                Member      = $member.Name
                ObjectClass = $member.ObjectClass
                Source      = $member.Source
                Status      = $status
            }

            if (-not $isApproved) {
                $unauthorized += [PSCustomObject]@{
                    Server      = $server
                    Member      = $member.Name
                    ObjectClass = $member.ObjectClass
                }
            }

            Add-Content -Path $logFile -Value "$timestamp$server$($member.Name)$($member.ObjectClass)$status"
        }

    } catch {
        $report += [PSCustomObject]@{
            Server      = $server
            Member      = "UNREACHABLE"
            ObjectClass = "N/A"
            Source      = "N/A"
            Status      = "ERROR"
        }
        Add-Content -Path $logFile -Value "$timestamp$server — ERROR: $_"
    }
}

# Export full report
$report | Export-Csv -Path $reportFile -NoTypeInformation

# Build email body
$emailBody = "Local Administrator Audit Report — $timestamp`n"
$emailBody += "Servers audited: $($servers.Count)`n"
$emailBody += "Total memberships found: $($report.Count)`n"
$emailBody += "Unauthorized entries: $($unauthorized.Count)`n`n"

if ($unauthorized.Count -gt 0) {
    $emailBody += "UNAUTHORIZED LOCAL ADMINS — REVIEW IMMEDIATELY:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($entry in $unauthorized) {
        $emailBody += "Server: $($entry.Server)`n"
        $emailBody += "Member: $($entry.Member)`n"
        $emailBody += "Type:   $($entry.ObjectClass)`n"
        $emailBody += "-" * 40 + "`n"
    }
} else {
    $emailBody += "No unauthorized local administrators detected.`n"
}

$emailBody += "`nFULL MEMBERSHIP BY SERVER:`n"
$emailBody += "=" * 60 + "`n"
foreach ($server in $servers) {
    $serverEntries = $report | Where-Object { $_.Server -eq $server }
    $emailBody += "`n$server`n"
    foreach ($entry in $serverEntries) {
        $emailBody += "  [$($entry.Status)] $($entry.Member)`n"
    }
}

$emailBody += "`nFull CSV report saved to: $reportFile`n"
$emailBody += "`nTo remove a member:`n"
$emailBody += "Remove-LocalGroupMember -Group 'Administrators' -Member 'DOMAIN\User'`n`n"
$emailBody += "Automate & Operate — automateandoperate.com"

$subject = if ($unauthorized.Count -gt 0) {
    "ADMIN ALERT: $($unauthorized.Count) unauthorized local admin(s) across $($servers.Count) server(s)"
} else {
    "Local Admin Audit Complete — All memberships approved"
}

Send-MailMessage `
    -From $from -To $to `
    -Subject $subject `
    -Body $emailBody `
    -SmtpServer $smtpServer -Port $smtpPort `
    -UseSsl -Credential $credential

Update these lines:

  • $from / $to / $username / $password — your email details

  • $smtpServer — your SMTP server (e.g. smtp.office365.com)

  • $servers — your actual server names

  • $approvedMembers — this is the important one, see below

BUILDING YOUR APPROVED LIST

Don't guess at this. Run the script once with $approvedMembers = @() empty — everything will come back flagged as unauthorized. Open the CSV, review every entry, and decide what legitimately belongs there. Then paste the approved ones into the array.

To see the exact name format on a single server first:

powershell

Get-LocalGroupMember -Group "Administrators"

Note the format matters — YOURDOMAIN\Domain Admins won't match Domain Admins. Copy the names exactly as PowerShell returns them.

SETTING UP TASK SCHEDULER

  1. Press Windows key → type Task Scheduler → open it

  2. Click "Create Basic Task" → name it Local Admin Audit

  3. Trigger: Weekly → Monday → 6:30am

  4. Action: Start a program

  5. Program: powershell.exe

  6. Arguments:

-ExecutionPolicy Bypass -File "C:\Scripts\LocalAdminAudit.ps1"
  1. Click Next → Finish

Under Properties → General, check "Run with highest privileges" and set it to run under an account with remote admin rights on the target servers.

TEST IT

Open PowerShell as administrator and run:

powershell

powershell.exe -ExecutionPolicy Bypass -File "C:\Scripts\LocalAdminAudit.ps1"

Open C:\Logs\LocalAdminAuditReport.csv — every server, every member, and whether it's approved. Check C:\Logs\LocalAdminAudit.log for the run history.

If remote servers come back UNREACHABLE, PowerShell remoting isn't enabled. Run this once on each target server as administrator:

powershell

Enable-PSRemoting -Force

WHY THIS MATTERS

Attackers don't need domain admin to do serious damage — local admin on the right server is often enough to dump credentials, install persistence, and move sideways. The reason local admin sprawl goes unnoticed is that it happens one exception at a time, each of which seemed reasonable in isolation. A weekly report turns that invisible drift into a visible diff you actually review. When a new name appears on the list, you'll know within seven days instead of never.

THIS WEEK'S ACTION

Run it against three servers today with an empty approved list. Review what comes back. I'd wager you find at least one account that has no business being there — most people do the first time they look.

Reply to this email if you hit any issues — I read every reply.

Automate & Operate — automateandoperate.com

Speak naturally. Send without fixing.

Wispr Flow turns your voice into clean, professional text you can send the moment you stop talking. Not rough transcription you have to clean up. Actual polished text — ready for email, Slack, or any app.

Speak the way you think. Go on tangents. Change your mind mid-sentence. Flow strips the filler, fixes the grammar, and gives you text that reads like you spent five minutes writing it.

89% of messages sent with zero edits. Millions of professionals use Flow daily, including teams at OpenAI, Vercel, and Clay. Works on Mac, Windows, and iPhone.