Hardening: Using LGPO to enforce non-Intune supported settings on AAD devices. ACSC example.

There are a number of security baselines out there for Windows clients: Microsoft, CIS, NIST and ACSC to name a few. These baselines focus mainly on domain-joined Windows clients, however Microsoft do also release security baselines targeted at Intune/AAD-only clients through their Intune Security Baselines.

The reason Microsoft release separate baselines for AAD vs. AD joined systems is that not all settings designed for a domain-joined system make sense for an AAD-only device. For example, forcing an AAD-only system to back up their BitLocker recovery keys to an Active Directory just doesn’t make sense. If you want to know actual the differences at the setting level between the Microsoft’s AD and AAD baselines (including the ACSC baseline) at time of writing, you can download my comparison spreadsheet here Microsoft/Intune/ACSC Security Baseline Comparison for Windows 10 21H2

The issue lies when you need to apply a security setting and that setting isn’t available in Intune. I’m in Australia so I’m gong to use the full ACSC baseline as an example of doing this as this baseline is common here. Now you wouldn’t want to actually apply this full baseline to an AAD-only system due to reasons mentioned above, but its a good example as some settings in this baseline aren’t even registry keys and some of the registry keys fall outside of the registry areas supported by Intune ADMX backed policies. So its an interesting example to see if we can do it.

Step 1: Import Intune Supported Settings

I won’t go over these steps in detail as they are already covered well in a post by Martin Bengtsson.

  • Create a GPO in AD with all the settings as you normally would for a domain-joined system
  • Save the GPO as an XML report so that you can import the settings into Intune using Group Policy Analytics
  • Save the MDM supported settings into a Configuration Profile in Intune

Here is what the policy looks like in my lab. This only contains the settings that are Intune supported.

Step 2: Applying non-Intune Supported Settings

After reviewing the remaining settings not supported by MDM they consisted of:

I would like these settings to regularly re-apply on devices just like Configuration profiles. Bonus points if we can also apply them during ESP and I’d prefer to avoid custom scheduled tasks to re-run PowerShell scripts.

All this can be done quite easily by utilising LGPO.exe. Some things that you can’t normally configure using local group policy editor, like Group Policy Preferences, can be applied using local group policy from a backed up policy created in AD GPMC. Fortunately LGPO can do most of this with a few missing items taken care of with a script.

For this script to work you need at least v3.0 of LGPO as this version can auto-detect the needed Group Policy CSPs and activate them on the client, instead of having to specify them manually on the command line like with previous versions of LGPO. The script takes care of copying Script and Preference folders so you can use Group Policy Preferences and will also by default wipe any existing local policies first so the client policy ends up looking exactly like the policies in the GPOs folder (this can be overridden with the -Merge switch so that you can add policies instead of replace).

We use the following steps to apply the missing policies and keep them applied.

  • Create a GPO in AD with all the non-Intune supported settings as you normally would for a domain-joined system. This includes a Group Policy Preference for the missing registry key mentioned above.
  • Create a backup of the policy in GPMC.
  • Create a folder with the following contents (scripts provided at the end of post)
  • Copy the GPO backup from step 2 into the GPOs subfolder, mine looks like this. This folder can contain multiple GPOs and the script will process all of them. But be aware that any Preference items should be consolidated into one GPO due to a limitation of the script.
  • I want to apply this as a Win32 App so I can select it during ESP and include a detection script. So wrap the whole folder up into an Intune Win32 deployment using the Microsoft Win32 Content Prep Tool. I use the following command line to give me an applyLocalGPO.intunewin file ready for upload to Intune.
.\IntuneWinAppUtil.exe -c "[Path_to_script_folder]" -s "applyLocalGPO.ps1" -o .\
  • In the Intune console navigate to Apps > Windows > + Add and select Windows app (Win32)
  • Import the applyLocalGPO.intunewin file and create the app with the following properties. The Detection script and filter “Windows 10 and later” are provided at the end of the post

Once deployed local policy on the device will automatically refresh the deployed security settings on the device every 90 minutes with a 30 minute random offset by default. The detection script always returns failure so the local policy copy itself will be refreshed daily from Intune.

Monitoring

Because the detection script always returns failure the App will always show up as a failure in the Intune console.

To really see which devices had an actual script failure you can click on Device status and filter out Success and return code 0x87D1041C (which means Install completed but detection failed, which is expected). This will leave devices with failures to investigate.

Conclusion

This provides a simple way to maintain a set of policies and registry keys on a device that cannot be applied with Configuration profiles and without resorting to settings in code. Recurring application of policy without code and ability to apply during ESP so that an Autopilot client is secure from deployment is also a bonus.

Scripts

ApplyLocalGPO.ps1

<#
.SYNOPSIS
    Applies GPO backups to local GPO
.DESCRIPTION

    Behavior:
    Script will apply all policies in the .\GPOs folder to local policy.
    Policies are applied in named order, if you want to control order use a folder naming convention i.e. 00 - Policy1, 01 - Policy2
    Default behavior is to overwrite local policy with all the policies in the .\GPOs folder. See parameter to change this.

    Limitations:
    Only supports having GP Preferences consolidated in one GPO not spread across multiple GPOs. Last write wins.  

    Parameters:
    -Merge      (Optional) Script will merge the policies in .\GPOs folder to local policy instead of overriding local policy
    
.NOTES
    Blog: colinfordblog.wordpress.com
    Twitter: @colinford

    Version: 1.0 (Colin Ford) 12-Jul-2022
        - Original release
#> 

#region Parameters

param (
  [Parameter(Mandatory=$false)][switch]$Merge
)

#endregion

#region Constants

Set-Variable EXIT_SUCCESS   -Option Constant -Value 0       # Script exit code for success
Set-Variable EXIT_ERROR     -Option Constant -Value 1       # Script exit code for error, general

#endregion

#region Variables

$g_nExitCode    = $EXIT_SUCCESS                                 # Scripts current exit code. Eventually passed to Shutdown function.
$g_sScriptName  = $MyInvocation.MyCommand.Name                  # Name of script file
$g_sScriptPath  = $(split-path -parent $PSCommandPath)          # Path to script file
$g_sLogFile     = "$env:programdata\$g_sScriptName.log"         # Full path to log file. Default is %PROGRAMDATA%\[scriptname].log

$g_bOption_Merge = $PSBoundParameters.ContainsKey("Merge")      # True if -Merge key present, set here as PSBoundParameters scope does not extend to functions

#endregion

#region Code

$Main={    

  # Reset local policy by default so only these policies will apply
  #
  If (!($g_bOption_Merge)) {ResetLocalPolicy}
  
  # Apply all GPO backups from GPOs folder
  #
  Print "Apply GPOs"
  Start-Executable "`"$g_sScriptPath\lgpo.exe`"" "/g", "`"$g_sScriptPath\GPOs`""

  # Copy any files that LGPO doesn't copy for us e.g. Registry preference XML
  #
  Print "Searching for GPO files to copy locally"
  $oFolders = Get-ChildItem -Path "$g_sScriptPath\GPOs" -Recurse -Directory -Force -ErrorAction SilentlyContinue

  CopyGPOFiles $oFolders "Machine\\Preferences$" "$env:systemroot\System32\GroupPolicy\Machine\Preferences"
  CopyGPOFiles $oFolders "Machine\\Scripts$" "$env:systemroot\System32\GroupPolicy\Machine\Scripts"
  CopyGPOFiles $oFolders "User\\Preferences$" "$env:systemroot\System32\GroupPolicy\User\Preferences"
  CopyGPOFiles $oFolders "User\\Scripts$" "$env:systemroot\System32\GroupPolicy\User\Scripts"

  # Refresh policy
  #
  Start-Executable "gpupdate.exe" "/force"
}

#endregion

#region Functions

#--------------------------------------------------------------------------------------------------
# Removes all local GPO settings
#--------------------------------------------------------------------------------------------------
Function ResetLocalPolicy {
  Print "Resetting local group policy"
  Get-ChildItem -Path "$env:systemroot\System32\GroupPolicy" -Recurse | Remove-Item -Force -Recurse
  Get-ChildItem -Path "$env:systemroot\System32\GroupPolicyUsers" -Recurse | Remove-Item -Force -Recurse
}

#--------------------------------------------------------------------------------------------------
# Copy files into local GPO folders
#--------------------------------------------------------------------------------------------------
Function CopyGPOFiles ($oFolders, $sFolderMatch, $sTargetFolder) {
  
  # Loop through each of the folders in the folder object
  #
  ForEach ($oFolder in $oFolders) {
    
    # Process subfolder if it matches the pattern we are looking for
    #
    If ($oFolder.FullName -match $sFolderMatch) {
        Print "Checking if folder exists for $sTargetFolder"                
        If (!(Test-Path $sTargetFolder)) {
            Print "Folder does not exist, creating folder"
            New-Item -Path $sTargetFolder -ItemType "directory" -ErrorAction Continue
            
          } Else {
            Print "Folder already exists"
          }       

        Print "Copying $($oFolder.FullName)\* to $sTargetFolder"
        Copy-Item -Path "$($oFolder.FullName)\*" -Destination $sTargetFolder -Recurse -Force -ErrorAction Continue
      }
    }
}

#--------------------------------------------------------------------------------------------------
# Direct output
#--------------------------------------------------------------------------------------------------
Function Print ($text) {
    $CurrTime = get-date -format g
    Write-Host "$CurrTime   $text" 
}

#--------------------------------------------------------------------------------------------------
# Run an executable with parameters
#
# Modified from http://windowsitpro.com/powershell/running-executables-powershell
#--------------------------------------------------------------------------------------------------
Function Start-Executable {
  param(
    [String] $FilePath,
    [String[]] $ArgumentList
  )
  
  Print "Entering: Start-Executable"

  $OFS = " "
  $process = New-Object System.Diagnostics.Process
  $process.StartInfo.FileName = $FilePath
  $process.StartInfo.Arguments = $ArgumentList
  $process.StartInfo.UseShellExecute = $false
  $process.StartInfo.RedirectStandardOutput = $true
    
  Print "Command     : $($process.StartInfo.FileName)"
  Print "Arguments   : $($process.StartInfo.Arguments)"
  Print "ShellExec   : $($process.StartInfo.UseShellExecute)"
  Print "RedirStdOut : $($process.StartInfo.RedirectStandardOutput)"
  Print "Executing Command..."
  
  if ( $process.Start() ) {
    $output = $process.StandardOutput.ReadToEnd() `
      -replace "\r\n$",""
    if ( $output ) {
      if ( $output.Contains("`r`n") ) {
        $output -split "`r`n"
      }
      elseif ( $output.Contains("`n") ) {
        $output -split "`n"
      }
      else {
        $output
      }
    }
    $process.WaitForExit()
    & "$Env:SystemRoot\system32\cmd.exe" `
      /c exit $process.ExitCode

    Print "Process completed with exit code: $($process.ExitCode)"
    Print "Exiting: Start-Executable"
    Print ""
  }
}

#--------------------------------------------------------------------------------------------------
# Initialise script
#--------------------------------------------------------------------------------------------------
Function Initialise
{
    $nReturn = $EXIT_FAILURE

    # Re-launch script in 64-bit PowerShell if we running on a 64-bit device
    #
    If ($ENV:PROCESSOR_ARCHITEW6432 -eq "AMD64") {
      Try {
          &"$ENV:WINDIR\SysNative\WindowsPowershell\v1.0\PowerShell.exe" -File $PSCOMMANDPATH
      }
      Catch {
          Throw "Failed to start $PSCOMMANDPATH"
      }
      Exit
  }
    .{        
        Start-Transcript -Path $g_sLogFile -ErrorAction Continue
        $nReturn = $EXIT_SUCCESS
    } | Out-Null

    # Display banner
    #
    Print "------------------------------------------------"
    Print "Starting script"
    Print "------------------------------------------------"
    Print "Script Name: `"$g_sScriptName`""
    Print "Script Path: `"$g_sScriptPath`""
    Print "Log File: `"$g_sLogFile`""
    Print "Merge: $g_bOption_Merge"
    Print ""

    Return $nReturn
}

#--------------------------------------------------------------------------------------------------
# Shutdown script
#--------------------------------------------------------------------------------------------------
Function Shutdown($ExitCode)
{
    Stop-Transcript -ErrorAction Continue
    Exit $ExitCode
}

#endregion

# Code execution stub
#
$g_nExitCode = Initialise
If ($g_nExitCode -eq $EXIT_SUCCESS) {
    & $Main
}
Shutdown $g_nExitCode

detect-AlwaysRun.ps1

<#
.SYNOPSIS
    Always returns a fail. Used for Intune Proactive Remediations detection to always re-run remediation script
.DESCRIPTION
    No command line arguments
.NOTES
    Blog: colinfordblog.wordpress.com
    Twitter: @colinford    

    Version: 1.0 (Colin Ford)
        - Original release
#> 

#region Code

$Main={
    Exit 1
}

#endregion

#region Functions
#endregion

& $Main

Filter: Windows 10 and later

(device.osVersion -startsWith "10.0.")

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s