Mobile Jon's headlines

HEADLINES:

HEADLINES:

Building a Windows 365 Custom Image

Mobile Jon's Blog

How to Secure Local Administrators with Workspace ONE

IMG_0002

Recently, I had a client (who is not using Windows Autopilot) had a new requirement from a client to lockdown freeware/shareware. Other people in the UEM space know during automated enrollment, the Azure AD-joined user will get local administrator out of the box. Now, we will show how to secure local administrators in Workspace ONE. They needed to lockdown local administrator by removing it from Azure AD users and capitalize on a managed account. That feature looks like this:

The admin toggle button for dropship provisioning on Workspace ONE

Today, we will cover the solution we shipped, which is a few pieces (PowerShell script, sensors, and a Freestyle Orchestrator workflow.) Additionally, we will welcome LAPS into the conversation. We expect this to be coupled with stuff like a ITSM application approval template, which will lead to deploying new required apps via Workspace ONE and/or a solid EPM solution.

Securing Local Administrators via PowerShell

You would think that removing user accounts easily would be no big deal. Except, Azure AD creates some fun little challenges for us. When you have Azure AD accounts, this is what happens to us:

The dreaded failed to compare two elements in an array issue

The specific error you get is: “Failed to compare two elements in the array” which happens from this specific code below. This is because of the Azure AD accounts being added to local administrator causing issues:
$administrators = Get-LocalGroupMember -Group “Administrators”
foreach ($member in $administrators) {
$memberName = $member.Name

# Handle different account name formats (local vs domain)
$localAccountName = “$env:COMPUTERNAME\$username”
$isLocalAccount = $memberName -eq $localAccountName
$isLocalAdmin = $memberName -eq $username -or $isLocalAccount
$isAdmin = $memberName -eq “Administrator” -or $memberName -eq “$env:COMPUTERNAME\Administrator”

if (-not $isLocalAdmin -and -not $isAdmin) {
try {
Remove-LocalGroupMember -Group “Administrators” -Member $member.Name -ErrorAction Stop
Write-Host “Removed $($member.Name) from the Administrators group.”
} catch {
Write-Host “Error removing $($member.Name): $_”
}
}
}

We enhanced our logic to a full solution, which we split into two scripts. The first one:

  1. Create the user account if it doesn’t exist and add to local admins
  2. If it does exist, makes sure its part of local admins
  3. Adds the user to the administrators group if it is not currently a member:
# Define the user account details
$username = "MyAdminUsername"
$password = ConvertTo-SecureString "MyAdminUsernamePassword" -AsPlainText -Force
$description = "$username user account"

# Check if the user already exists
$userExists = Get-LocalUser -Name $username -ErrorAction SilentlyContinue
if (-not $userExists) {
    # Create the new local user
    New-LocalUser -Name $username -Password $password -Description $description -AccountNeverExpires -PasswordNeverExpires
    Write-Host "User $username created."

    # Add the user to the Administrators group
    Add-LocalGroupMember -Group "Administrators" -Member $username
    Write-Host "User $username added to Administrators group."
} else {
    Write-Host "User $username already exists."

    # Check if the user is a part of the Administrators group
    $isAdminMember = $false
    $administrators = Get-LocalGroupMember -Group "Administrators"
    foreach ($member in $administrators) {
        if ($member.Name -eq "localhost\$username" -or $member.Name -eq "$env:COMPUTERNAME\$username") {
            $isAdminMember = $true
            break
        }
    }

    # Add the user to the Administrators group if not a member
    if (-not $isAdminMember) {
        Add-LocalGroupMember -Group "Administrators" -Member $username
        Write-Host "User $username added to Administrators group."
    } else {
        Write-Host "User $username is already a member of the Administrators group."
    }
}

Then the second script would do our cleanup tasks:

  1. Disable the built-in administrator account
  2. Remove all users from the admin group besides the built-in and our custom admin account
  3. Run a secondary check in the event any initial groups were missed
$username = "MyLocalAdminAccount"
# Disable the built-in Administrator account
$adminAccount = Get-LocalUser -Name "Administrator"
if ($adminAccount.Enabled -eq $true) {
    Disable-LocalUser -Name "Administrator"
    Write-Host "Built-in Administrator account disabled."
}
# Remove all users from the Administrators group except for Administrator and my custom local admin account
$groupName = "Administrators"
$excludedSIDs = @() # Add any excluded SIDs here

# ADSI group object for the Administrators group
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$groupName,group")

try {
    # Get members of the group
    $members = @($group.Invoke("Members"))
    foreach ($member in $members) {
        # Get the name or SID of the member
        $memberName = $member.GetType().InvokeMember("Name", 'GetProperty', $null, $member, $null)

        if ($memberName -like 'S-1-*' -and -not ($excludedSIDs -contains $memberName)) {
            # If member is a SID and not in excluded list, attempt to remove
            try {
                $identity = "WinNT://$memberName"
                $group.Remove($identity)
                Write-Host "Removed SID: $memberName from the $groupName group."
            } catch {
                Write-Host "Failed to remove SID $memberName from the $groupName group: $_"
            }
        } else {
            Write-Host "Skipping member: $memberName"
        }
    }
} catch {
    Write-Host "An error occurred: $_"
}

Write-Host "Group modification completed."
##Run Secondary Check##
$administrators = Get-LocalGroupMember -Group "Administrators" 
foreach ($member in $administrators) {
    $memberName = $member.Name

    # Handle different account name formats (local vs domain)
    $localAccountName = "$env:COMPUTERNAME\$username"
    $isLocalAccount = $memberName -eq $localAccountName
    $isLocalAdminAccount = $memberName -eq $username -or $isLocalAccount
    $isAdmin = $memberName -eq "Administrator" -or $memberName -eq "$env:COMPUTERNAME\Administrator"

    if (-not $isLocalAdminAccount -and -not $isAdmin) {
        try {
            Remove-LocalGroupMember -Group "Administrators" -Member $member.Name -ErrorAction Stop
            Write-Host "Removed $($member.Name) from the Administrators group."
        } catch {
            Write-Host "Error removing $($member.Name): $_"
        }
    }
}

Building Sensors to Monitor the Local Administrator Situation

Freestyle Orchestrator doesn’t manage “contains” conditions too well, so we will build two different sensors to handle this situation:

First, we build a sensor to tell us if the local administrator we created is there or not:

# Initialize the variable to check for localadmin's presence
$managedLocalAdmin = $false

# Define the username to check for in the Administrators group
$username = "yourUsernameHere"  # Replace 'yourUsernameHere' with the actual username

# Get members of the local Administrators group
$administrators = Get-LocalGroupMember -Group "Administrators"

# Extract member names and check for the specified $username
$adminNames = $administrators | ForEach-Object {
    if ($_.Name -match $username) {
        $managedLocalAdmin = $true
    }
    $_.Name
}

echo $managedLocalAdmin

We also create a second sensor to collect the list of local administrators for auditing and reporting purposes e.g. our workflow logic isn’t doing what it should:

# Get members of the local Administrators group
$administrators = Get-LocalGroupMember -Group "Administrators"

# Extract member names and concatenate into a single string
$adminNames = $administrators | ForEach-Object { $_.Name }
$localAdmins = $adminNames -join ", "

# Output the single string
echo $localAdmins

We ended up adding a 3rd sensor to build a stronger workflow that will validate if our custom admin account is part of local administrators or not:

$userName = "MyLocalAdminAccount"
$groupName = "Administrators"
$userExists = $False
$isAdmin = $False

# Check if the user exists
try {
    $user = Get-LocalUser -Name $userName
    $userExists = $True
} catch {
    Write-Host "User $userName does not exist."
}

# If user exists, check if they are a member of the Administrators group
if ($userExists) {
    $group = [ADSI]("WinNT://$env:COMPUTERNAME/$groupName,group")
    try {
        $members = $group.Invoke("Members") | ForEach-Object {
            $_.GetType().InvokeMember("Name", 'GetProperty', $null, $_, $null)
        }
        if ($userName -in $members) {
            $isAdmin = $True
        }
    } catch {
        Write-Host "Error checking group membership: $_"
    }
}
echo $isAdmin

Windows Local Administrator Password Solution (LAPS)

Before we setup LAPS (Local Administrator Password Solution), we should explain at a high-level what it does.

What is Windows Local Administrator Password Solution (LAPS)?

LAPS essentially will manage your service account credentials, enforce strength, encryption, and write it up to AD or Entra ID to be fetched in a variety of ways. It’s the perfect compliment to our workflow, but unfortunately cannot do any of the things our scripts are doing e.g. disabling accounts, creating accounts, etc.

Some of things LAPS offers:

  • Ability to log into and recover devices in a bad state.
  • Fine-grained security model to enforce password encryption and ACLs.
  • Supports RBAC model for securing passwords stored in Entra ID.
  • Can rotate the existing password at deployment.
  • Supports built-in or custom accounts.
  • Automatic rotating at set password ages with complexity standards

The settings themselves vary based on your deployment type as you can see below:

Setting nameAzure-joinedHybrid-joined
BackupDirectoryYesYes
PasswordAgeDaysYesYes
PasswordLengthYesYes
PasswordComplexityYesYes
PasswordExpirationProtectionEnabledNoYes
AdministratorAccountNameYesYes
ADPasswordEncryptionEnabledNoYes
ADPasswordEncryptionPrincipalNoYes
ADEncryptedPasswordHistorySizeNoYes
PostAuthenticationResetDelayYesYes
PostAuthenticationActionsYesYes
ResetPasswordYesYes
ResetPasswordStatusYesYes

Deploying LAPS for Entra ID for Workspace ONE

Before you can get started, make sure you log into “Device Settings” in the Entra console and enable LAPS as you can see below:

How to enable LAPS in the Entra Console

I covered a few months ago, that you could now deploy CSPs much easier in Workspace ONE with their new Windows BETA profiles, but during this process I found a gap. You cannot use them in workflows, as the workflows don’t see them as payloads.

Below, is my code that I used (you can replace “replace” with “delete” for the remove settings). I ended up having to create it as a custom settings profile for it to work with Freestyle Orchestrator:

<Replace>
<CmdID>XXXXXXXXXXXXXXX</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/LAPS/Policies/BackupDirectory</LocURI>
</Target>
<Meta>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Data>1</Data>
</Item>
</Replace>
<Replace>
<CmdID>XXXXXXXXXXXXXXX
</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/LAPS/Policies/PasswordAgeDays</LocURI>
</Target>
<Meta>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Data>30</Data>
</Item>
</Replace>
<Replace>
<CmdID>XXXXXXXXXXXXXXX</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/LAPS/Policies/PasswordComplexity</LocURI>
</Target>
<Meta>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Data>4</Data>
</Item>
</Replace>
<Replace>
<CmdID>XXXXXXXXXXXXXXX</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/LAPS/Policies/PasswordLength</LocURI>
</Target>
<Meta>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Data>14</Data>
</Item>
</Replace>
<Replace>
<CmdID>XXXXXXXXXXXXXXX</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/LAPS/Policies/AdministratorAccountName</LocURI>
</Target>
<Meta>
<Format xmlns="syncml:metinf">chr</Format></Meta>
<Data>MyLocalAdminAccount</Data>
</Item>
</Replace>
<Replace><CmdID>XXXXXXXXXXXXXXX</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/LAPS/Policies/PasswordExpirationProtectionEnabled</LocURI>
</Target>
<Meta>
<Format xmlns="syncml:metinf">bool</Format>
</Meta>
<Data>true</Data>
</Item>
</Replace>
<Replace>
<CmdID>XXXXXXXXXXXXXXX</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/LAPS/Policies/PostAuthenticationActions</LocURI>
</Target>
<Meta>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Data>5</Data>
</Item>
</Replace>
<Exec>
<CmdID>XXXXXXXXXXXXXXX</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/LAPS/Actions/ResetPassword</LocURI>
</Target>
</Item>
</Exec>

Building the Admin Account Management Workflow

Our workflow is pretty easy overall. We build a simple condition that uses two of our sensors in an “OR” statement. Basically, it says if the local admin account doesn’t exist OR isn’t part of local administrators, run the script to create it (which also adds it to the group). Here is the trick, you expand additional settings to ensure desired state management. This will monitor if the state of the sensor changes and will execute the script making it stronger:

Setting your sensors to build conditional statements in Workspace ONE Workflows

After that, it will install our custom profile to enable LAPS and then run the admin cleanup script to remove any potential residuals if necessary:

The full workflow diagram for our password rotation solution

To certify things are working properly, you can go back to the Entra Console in the “Local administrator password recovery” section and see your devices present in there:

A look at the local password recovery menu in Entra ID

You can also get the password via PowerShell like this:

Connect-MgGraph -TenantId b20f5886-bddf-43bb-aee6-dda0c87c5fa2 -ClientId 9fa98e34-277f-47fa-9847-e36bdf6bca1f
Get-LapsAADPassword -DeviceIds dfc6d5f0-225a-4b46-adcf-73a349a31e70 -IncludePasswords -AsPlainText

DeviceName             : LAPSAAD
DeviceId               : dfc6d5f0-225a-4b46-adcf-73a349a31e70
Account                : LapsAdmin
Password               : g4q22s[Xz8}!T32[4;Z#0M}v35udF[xB0}iB;P@xk%9E9Tgw,W]7)vx9O!-
PasswordExpirationTime : 4/22/2023 8:45:29 AM
PasswordUpdateTime     : 4/11/2023 8:45:29 AM

Final Thoughts

So, this took a little bit of work, but overall is an easy yet elegant solution. We built code that solved the problem without a ton of engineering effort. I have one takeaway around Freestyle Orchestrator.

They need to fix/improve the contains/includes conditional statements, which is why I wrote a basic true/false sensor to augment that aspect of things. Overall, I really love Freestyle Orchestrator and I can design around these small challenges, but it would be good improvements to make.

I hope everyone enjoyed this short article as it helps fix something that many companies have to address in combination with a good EPM/app approval process.

Facebook
Twitter
LinkedIn

Let me know what you think

Discover more from Mobile Jon's Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading

Scroll to Top