Unlock User Age Insights: PowerShell Scripts for Windows Server Profile Analysis

Table of Contents

Unlock User Age Insights

Managing user profiles effectively is a crucial aspect of Windows Server administration, especially in environments where user profiles consume significant storage space over time. To address this, administrators often implement group policies to automatically delete user profiles that have not been used for a specified period. A common policy is “Delete user profiles older than a specified number of days on system restart”, which helps to maintain system performance and storage efficiency by removing stale profiles upon system startup.

This article delves into how profile age is determined in modern Windows environments and provides a valuable PowerShell script to gain insights into user profile ages. This script is particularly useful for understanding which profiles are candidates for deletion based on the configured group policy or other profile management mechanisms.

It’s important to note that while Group Policy is a primary method for controlling profile handling, Windows Management Instrumentation (WMI) providers can also be employed. WMI can sometimes override Group Policy settings, offering more granular control or integration with systems management tools like Microsoft System Center Configuration Manager (SCCM). Therefore, understanding how profile age is calculated and having tools to analyze it becomes essential regardless of the management method in place.

Understanding Profile Age Determination in Modern Windows

Recent versions of Windows, starting with Windows 10 version 1809 and Windows Server 2019 (version 1809), have significantly changed how profile age is calculated. Older systems relied heavily on the modified timestamp of the ntuser.dat file to determine profile age. However, modern Windows versions have moved away from this single file timestamp approach for a more robust and accurate method.

The current method utilizes timestamped registry values to track key profile events:

  • Profile Load Time: Records when a user profile is loaded by the profile service.
  • Profile Unload Time: Tracks when a user profile is unloaded.
  • Profile Cleanup Time: Indicates when a profile cleanup process might have occurred.

These registry values offer a more precise understanding of profile activity compared to solely relying on the ntuser.dat timestamp. While the ntuser.dat timestamp is still considered, it now serves as a fallback mechanism. If the registry values are invalid or missing, and the ntuser.dat timestamp is not old enough to trigger deletion based on the configured policy, then the age calculation may fall back to this timestamp.

The specific registry values used for tracking are located under the user’s profile key in the registry, typically found at HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\<UserProfileSID>. The relevant values are:

  • LocalProfileLoadTimeLow & LocalProfileLoadTimeHigh: These two values, when combined, represent the last profile load time. They are crucial for validating the Unload and Cleanup times.
  • LocalProfileUnLoadTimeLow & LocalProfileUnLoadTimeHigh: These values track the last time the profile was unloaded by the profile service.
  • LocalProfileCleanupCheckTimeLow & LocalProfileCleanupCheckTimeHigh: These values record the cleanup time. As mentioned, if the load or unload times are invalid or missing, and the ntuser.dat timestamp is not old enough for deletion, the age calculation may be derived from this timestamp.

It’s important to note that if a user logs in while these registry values are set, the values are typically removed. This action effectively resets the tracking mechanism back to the primary load/unload/cleanup times. This behavior ensures that the system continuously monitors profile usage and age based on the most recent activity.

PowerShell Script for Retrieving Profile Age Information

To help administrators effectively analyze profile ages and understand the impact of profile cleanup policies, the following PowerShell script is provided. This script retrieves profile information, calculates profile age based on the methods described above, and presents the data in an easily digestible format.

Before using the script, it’s crucial to understand its limitations. This script is provided as a sample and is not officially supported by Microsoft. It is offered “AS IS” without any warranties. Users should thoroughly test and understand the script in a non-production environment before deploying it in a live production setting. Microsoft disclaims any liability for issues arising from the use of this script.

Here is the PowerShell script:

Function TranslateSID2Name
{
    param(
        [string]$sid
    )

    try
    {
        $osid = New-Object System.Security.Principal.SecurityIdentifier( $sid )
        $osid.Translate([System.Security.Principal.NTAccount])
    }
    catch
    {
        "<Unresolved>"
    }
}

Function ConvertToDate
{
    param(
        [uint32]$lowpart,
        [uint32]$highpart
    )

    $ft64 = ( [UInt64]$highpart -shl 32) -bor $lowpart
    [datetime]::FromFileTime( $ft64 )
}

Function Get-ProfileLoadUnloadInfo
{
    param(
        [Parameter(Mandatory=$true, ValueFromPipeline=$true)][String] $Name,
        [int] $CleanUpDays
    )

    BEGIN {}

    PROCESS{
        $SaveErrorActionPreference = $ErrorActionPreference
        $ErrorActionPreference = "SilentlyContinue"
        $date0 = [datetime]::FromFileTime(0)

        $profilelistsidkey = "HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProfileList\\$Name"
        $localprofileloadtimelow = 0
        $localprofileloadtimelow = Get-ItemPropertyValue -Path $profilelistsidkey -Name LocalProfileLoadTimeLow
        $localprofileloadtimehigh = 0
        $localprofileloadtimehigh = Get-ItemPropertyValue -Path $profilelistsidkey -Name LocalProfileLoadTimeHigh
        $localprofileloadtime = ConvertToDate $localprofileloadtimelow $localprofileloadtimehigh

        $localprofileunloadtimelow = 0
        $localprofileunloadtimelow = Get-ItemPropertyValue -Path $profilelistsidkey -Name LocalProfileUnLoadTimeLow
        $localprofileunloadtimehigh = 0
        $localprofileunloadtimehigh = Get-ItemPropertyValue -Path $profilelistsidkey -Name LocalProfileUnLoadTimeHigh
        $localprofileunloadtime = ConvertToDate $localprofileunloadtimelow $localprofileunloadtimehigh

        $localprofilecleanupchecktimelow = 0
        $localprofilecleanupchecktimelow = Get-ItemPropertyValue -Path $profilelistsidkey -Name LocalProfileCleanupCheckTimeLow
        $localprofilecleanupchecktimehigh = 0
        $localprofilecleanupchecktimehigh = Get-ItemPropertyValue -Path $profilelistsidkey -Name LocalProfileCleanupCheckTimeHigh
        $localprofilecleanupchecktime = ConvertToDate $localprofilecleanupchecktimelow $localprofilecleanupchecktimehigh

        $profilepath = "-"
        $profilepath = Get-ItemPropertyValue -Path $profilelistsidkey -Name ProfileImagePath
        $manprofilepath = $profilepath + "\\NTUSER.MAN"
        $datprofilepath = $profilepath + "\\NTUSER.DAT"
        $ntusermodified = $date0
        if( Test-Path $manprofilepath ){
            $ntusermodified = Get-ItemPropertyValue $manprofilepath -Name LastWriteTime
        }
        elseif( Test-Path $datprofilepath ){
            $ntusermodified = Get-ItemPropertyValue $datprofilepath -Name LastWriteTime
        }

        $ErrorActionPreference = $SaveErrorActionPreference

        $determinedfrom = "NtUserDate"
        $basedate = $ntusermodified
        switch( ($localprofileloadtime, $localprofileunloadtime, $localprofilecleanupchecktime | Measure-Object -Maximum).Maximum )
        {
            $date0
                {
                    break
                }
            #$localprofileloadtime
            # {
            #    $determinedfrom = "-" # shouldn't happen since would most likely indicate profile currently loaded or machine crashed
            #    $basedate = $date0
            # }
            $localprofileunloadtime
                {
                    $determinedfrom = "UnloadDate"
                    $basedate = $localprofileunloadtime
                }
            $localprofilecleanupchecktime
                {
                    $determinedfrom = "CleanupCheckDate"
                    $basedate = $localprofilecleanupchecktime
                }
        }

        $profileagedays = "?"
        if( ($basedate -gt $date0) )
        {
            $profileagedays = [Math]::Truncate( (New-TimeSpan -Start $basedate -End (Get-Date)).TotalDays )
        }

        if( ($cleanupdays -gt 0) -and ($basedate -gt $date0) )
        {
            $cleanupdate = $basedate.AddDays($cleanupdays)
        }
        else
        {
            $cleanupdate = $date0
        }

        [PSCustomObject] @{
            Sid = $Name
            ProfilePath = $profilepath
            LoadDate = $localprofileloadtime
            UnLoadDate = $localprofileunloadtime
            CleanupCheckDate = $localprofilecleanupchecktime
            NTUserDate = $ntusermodified
            DeterminedFrom = $determinedfrom
            ProfileAgeDays = $profileagedays
            CleanupDate = $cleanupdate
        }
    }

    END{}
}


$SaveErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"

# Begin: Check GPO or WMI controlled and get CleanupProfiles value
$WMIcontrolled = $false
$cleanupvalue = 0
$UserStateRoaming = 0
$UserStateRoaming = Get-ItemPropertyValue -Path "HKLM:SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\UserState\\UserStateTechnologies\\ConfigurationControls" -Name RoamingUserProfile
if( ($UserStateRoaming -eq $null) -or ($UserStateRoaming -eq 0) )
{
    # Profiles controlled by GPO, get GPO configured setting if available
    $cleanupvalue = Get-ItemPropertyValue -Path "HKLM:Software\\Policies\\Microsoft\\Windows\\System" -Name CleanupProfiles
}
else
{
    # Profiles controlled by WMI, get setting if configured
    $cleanupvalue = Get-ItemPropertyValue -Path "HKLM:Software\\Microsoft\\Windows\\CurrentVersion\\UserState\\RoamingUserProfile" -Name CleanupProfiles
    $WMIcontrolled = $true
}
if( $cleanupvalue -eq $null )
{
    $cleanupvalue = 0
}
$cleanupwarning = ""
if( $cleanupvalue -eq 0)
{
    $cleanupwarning = " - Value needs to be 1 or larger for profiles to age and be deleted.  Check setting and GP application."
}
# End: Check GPO or  WMI controlled and get CleanupProfiles value

$ErrorActionPreference = $SaveErrorActionPreference

# Get unloaded profiles information on machine
$profinfo = (gwmi win32_userprofile -Filter "Loaded = 'False' and Special = 'False'").sid | Get-ProfileLoadUnloadInfo -CleanUpDays $cleanupvalue | Sort-Object -Property @{E="CleanupDate"; Descending = $false}, @{E="ProfileAgeDays"; Descending = $true}

$Date0 = [datetime]::FromFileTime(0)

"Current Date........: $((Get-Date).ToString())"
"Run By..............: $($env:USERDOMAIN)\\$($env:USERNAME)"
$OSInfo = Get-ComputerInfo CsName,OsName,WindowsVersion,OsVersion,OsLastBootUpTime,OsUptime
"Machine Name........: " + $OSInfo.CsName
"Operating System....: " + $OSInfo.OsName
"Windows Version.....: " + $OSInfo.WindowsVersion + " (" + $OSInfo.OsVersion + ")"
$ProfsvcdllVersionInfo = (Get-Item $env:windir\\system32\\profsvc.dll).VersionInfo
"Profsvc.dll Version.: $($ProfsvcdllVersionInfo.FileMajorPart).$($ProfsvcdllVersionInfo.FileMinorPart).$($ProfsvcdllVersionInfo.FileBuildPart).$($ProfsvcdllVersionInfo.FilePrivatePart)"
"Last boot time......: " + $OSInfo.OsLastBootUpTime.ToString() + " (Up time: " + $OSInfo.OsUptime + ")"
"-" * 21
"Profile Cleanup Days: $cleanupvalue$cleanupwarning"
"Controlled by WMI...: $WMIcontrolled"
"Profile count.......: $($profinfo.count)"

$profoutput = $profinfo | Select-Object `
    Sid, `
    @{L="User"; E={TranslateSID2Name $_.Sid }}, `
    ProfilePath, `
    @{L="LoadDate"; E={if($_.LoadDate -gt $Date0){ $_.LoadDate } else {"-"}}}, `
    @{L="UnLoadDate"; E={if( $_.UnLoadDate -gt $Date0 ){ $_.UnLoadDate } else {"-"}}}, `
    @{L="CleanupCheckDate"; E={if( $_.CleanupCheckDate -gt $Date0 ){ $_.CleanupCheckDate } else {"-"}}}, `
    @{L="NtUserDate"; E={if( $_.NtUserDate -gt $Date0 ) { $_.NtUserDate } else { "-" }}}, `
    DeterminedFrom, `
    ProfileAgeDays, `
    @{L="CleanupDate"; E={if( $_.CleanupDate -gt $Date0 ) { $_.CleanupDate } else { "-" }}}


# Different output options
#$profoutput | fl *
$profoutput | ft *       # NOTE: may not give all columns depending on window width.
#$profoutput | Out-GridView
#$profoutput | Export-Csv -Path ProfileAge.csv -NoTypeInformation

Script Functionality Breakdown

The PowerShell script is composed of three primary functions and a main execution block:

  1. TranslateSID2Name Function: This function takes a Security Identifier (SID) as input and attempts to resolve it to a user account name. It uses the System.Security.Principal.SecurityIdentifier .NET class to perform the translation. If the SID cannot be resolved (e.g., the account no longer exists), it returns ““. This function is crucial for making the script output more user-friendly by displaying user names instead of raw SIDs.

  2. ConvertToDate Function: Windows stores timestamps in a 64-bit file time format. This function converts these 64-bit file times, which are split into low and high 32-bit parts in the registry, into a readable DateTime object in PowerShell. This conversion is essential for working with the timestamps and calculating profile ages.

  3. Get-ProfileLoadUnloadInfo Function: This is the core function of the script. It takes a user profile SID ($Name) and the configured profile cleanup days ($CleanUpDays) as input. For each profile, it performs the following steps:

    • Retrieves the LocalProfileLoadTimeLow, LocalProfileLoadTimeHigh, LocalProfileUnLoadTimeLow, LocalProfileUnLoadTimeHigh, LocalProfileCleanupCheckTimeLow, and LocalProfileCleanupCheckTimeHigh registry values from the profile’s registry key. It uses the ConvertToDate function to convert these values into DateTime objects.
    • Retrieves the ProfileImagePath from the registry to determine the profile path.
    • Checks for the existence of NTUSER.MAN (for mandatory profiles) or NTUSER.DAT (for standard profiles) within the profile path and retrieves the LastWriteTime of the existing file. This timestamp is used as a fallback.
    • Determines the basedate for age calculation. It prioritizes UnloadDate and CleanupCheckDate if they are valid (i.e., not the default zero date). If neither is valid, it falls back to the NtUserDate (last write time of NTUSER.DAT or NTUSER.MAN).
    • Calculates ProfileAgeDays by calculating the time difference between the basedate and the current date.
    • If $CleanUpDays is provided and basedate is valid, it calculates the CleanupDate by adding $CleanUpDays to the basedate.
    • Outputs a PSCustomObject containing the SID, ProfilePath, LoadDate, UnloadDate, CleanupCheckDate, NTUserDate, the source of the determined date (DeterminedFrom), ProfileAgeDays, and CleanupDate.
  4. Main Execution Block: The script’s main section handles the following:

    • Error Handling: Sets $ErrorActionPreference to “SilentlyContinue” to suppress non-critical errors during registry access.
    • Cleanup Policy Detection: Determines if profile cleanup is controlled by Group Policy or WMI. It checks the registry for the CleanupProfiles value under both GPO and WMI control paths.
    • Cleanup Value Retrieval: Retrieves the configured number of cleanup days ($cleanupvalue) from the registry based on whether GPO or WMI is in control.
    • Warning Message: Generates a warning message if $cleanupvalue is 0, indicating that profile cleanup is effectively disabled.
    • Profile Information Retrieval: Uses Get-WmiObject (or gwmi) to retrieve unloaded user profiles (excluding special profiles like default profiles). It pipes the SIDs of these profiles to the Get-ProfileLoadUnloadInfo function along with the $cleanupvalue.
    • Output Formatting: Formats the output using Select-Object to display specific properties and calculated values. It uses calculated properties (@{L=...; E={...}}) to display user names using TranslateSID2Name and to format dates, displaying “-” if a date is the default zero date.
    • Output Display: Finally, it displays the formatted profile information in a table format using Format-Table (ft *). Other output options like Format-List, Out-GridView, or Export-Csv are commented out but provided as alternatives.

Sample Script Output

Below is an example of the script’s output. Note that the tabular output might be truncated depending on the console window width. You can adjust the output format using the alternative options provided in the script (e.g., Format-List, Out-GridView) to see all columns if needed.

Current Date........: 10/26/2023 10:30:00 AM
Run By..............: DOMAIN\Administrator
Machine Name........: SERVER01
Operating System....: Microsoft Windows Server 2022 Datacenter
Windows Version.....: 10.0 (Version 20348)
Profsvc.dll Version.: 10.0.20348.1
Last boot time......: 10/25/2023 6:00:00 AM (Up time: 1.16:30:00)
---------------------
Profile Cleanup Days: 30
Controlled by WMI...: False
Profile count.......: 5

Sid              User             ProfilePath                                         LoadDate              UnLoadDate            CleanupCheckDate      NtUserDate            DeterminedFrom   ProfileAgeDays CleanupDate
---              ----             -----------                                         --------              ----------            ----------------      ----------            --------------   -------------- -----------
S-1-5-21-...     User1            C:\Users\User1                                      -                     09/20/2023 14:00:00   -                     -                     UnloadDate                 36 10/20/2023 14:00:00
S-1-5-21-...     User2            C:\Users\User2                                      -                     08/15/2023 10:00:00   -                     -                     UnloadDate                 72 09/14/2023 10:00:00
S-1-5-21-...     User3            C:\Users\User3                                      -                     07/01/2023 08:00:00   -                     -                     UnloadDate                117 07/31/2023 08:00:00
S-1-5-21-...     User4            C:\Users\User4                                      -                     06/10/2023 16:00:00   -                     -                     UnloadDate                138 07/10/2023 16:00:00
S-1-5-21-...     Unresolved       C:\Users\TempProfile                                -                     -                     10/20/2023 20:00:00   -                     CleanupCheckDate            6 11/19/2023 20:00:00

This sample output demonstrates how the script provides a clear overview of user profiles, their last activity dates (LoadDate, UnloadDate, CleanupCheckDate, NtUserDate), the determined source for age calculation (DeterminedFrom), the profile age in days (ProfileAgeDays), and the calculated cleanup date (CleanupDate) based on the configured cleanup policy. This information empowers administrators to proactively manage user profiles and ensure efficient storage utilization within their Windows Server environments.

Feel free to experiment with this script and adapt it to your specific needs. What are your experiences with managing user profile ages and cleanup in Windows Server? Share your thoughts and insights in the comments below!

Post a Comment