Unlock User Age Insights: PowerShell Scripts for Windows Server Profile Analysis
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
UnloadandCleanuptimes. - 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.dattimestamp 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:
-
TranslateSID2NameFunction: This function takes a Security Identifier (SID) as input and attempts to resolve it to a user account name. It uses theSystem.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. -
ConvertToDateFunction: 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 readableDateTimeobject in PowerShell. This conversion is essential for working with the timestamps and calculating profile ages. -
Get-ProfileLoadUnloadInfoFunction: 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, andLocalProfileCleanupCheckTimeHighregistry values from the profile’s registry key. It uses theConvertToDatefunction to convert these values intoDateTimeobjects. - Retrieves the
ProfileImagePathfrom the registry to determine the profile path. - Checks for the existence of
NTUSER.MAN(for mandatory profiles) orNTUSER.DAT(for standard profiles) within the profile path and retrieves theLastWriteTimeof the existing file. This timestamp is used as a fallback. - Determines the
basedatefor age calculation. It prioritizesUnloadDateandCleanupCheckDateif they are valid (i.e., not the default zero date). If neither is valid, it falls back to theNtUserDate(last write time ofNTUSER.DATorNTUSER.MAN). - Calculates
ProfileAgeDaysby calculating the time difference between thebasedateand the current date. - If
$CleanUpDaysis provided andbasedateis valid, it calculates theCleanupDateby adding$CleanUpDaysto thebasedate. - Outputs a
PSCustomObjectcontaining the SID, ProfilePath, LoadDate, UnloadDate, CleanupCheckDate, NTUserDate, the source of the determined date (DeterminedFrom), ProfileAgeDays, and CleanupDate.
- Retrieves the
-
Main Execution Block: The script’s main section handles the following:
- Error Handling: Sets
$ErrorActionPreferenceto “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
CleanupProfilesvalue 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
$cleanupvalueis 0, indicating that profile cleanup is effectively disabled. - Profile Information Retrieval: Uses
Get-WmiObject(orgwmi) to retrieve unloaded user profiles (excluding special profiles like default profiles). It pipes the SIDs of these profiles to theGet-ProfileLoadUnloadInfofunction along with the$cleanupvalue. - Output Formatting: Formats the output using
Select-Objectto display specific properties and calculated values. It uses calculated properties (@{L=...; E={...}}) to display user names usingTranslateSID2Nameand 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 likeFormat-List,Out-GridView, orExport-Csvare commented out but provided as alternatives.
- Error Handling: Sets
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