Aug 31 2017 09:09 AM
Is Microsoft aware of an issue with various Windows 10 processes causing the date modified timestamp on the NTUSER.DAT file in unused profiles to change? We have specifically noticed that installation of cumulative updates modifies this file in all profiles, but there are other processes (I'm guessing scheduled tasks but I haven't found which ones) that do this too.
This is particularly a problem in Education as we use the "Delete user profiles older than a specified number of days on a system restart" GPO setting to clean up old profiles on Lab and Classroom computers and that policy relies on the timestamp on the NTUSER.DAT file to determine the age of the profile. (The USMT tool also uses that file and timestamp when you specify to only capture profiles used within the last XX days.)
For reference, the date modified timestamp on the profile folder in the Users directory does accurately reflect the last time the profile was used.
Nov 29 2022 09:52 AM - edited Nov 29 2022 10:12 AM
I threw together some code you can use & abuse. (It has not been tested in Production, so use at your own risk. Make improvements and share if you'd like!)
To convert the LocalProfileLoadTimeHigh and LocalProfileLoadTimeLow, you combine them (text strings) and then convert to DateTime. It's convoluted. I'm guessing Microsoft could have used a single QWORD but instead chose 2 DWORDs registry entries.
# Get list of profiles
$profiles = Get-ItemProperty -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\ProfileList\*"
# Loop through each profile
Foreach ($profile in $profiles) {
# Get the SID
$SID = New-Object System.Security.Principal.SecurityIdentifier($profile.PSChildName)
# Convert SID to Friendly name
$FriendlyName = $SID.Translate([System.Security.Principal.NTAccount])
# Trim and store variables
$SidTrimmed = $SID.Value
$FriendlyNameTrimmed = $FriendlyName.Value
# Store the Profile Load time (in Decimal)
# Example: ProfileLoadHighDec = 30997847 / ProfileLoadLowDec = 2259805116
$ProfileLoadHighDec = $profile.LocalProfileLoadTimeHigh
$ProfileLoadLowDec = $profile.LocalProfileLoadTimeLow
# Convert Decimal to Hex string
# Example: ProfileLoadHighHex = 01d8fd57 / ProfileLoadLowHex = 86b1d3bc
$ProfileLoadHighHex = [System.Convert]::ToString($ProfileLoadHighDec,16)
$ProfileLoadLowHex = [System.Convert]::ToString($ProfileLoadLowDec,16)
# Concatenate hex strings
# Example: 01d8fd5786b1d3bc
$ProfileHexJoined = -join ($ProfileLoadHighHex,$ProfileLoadLowHex)
# Convert to DateTime format
# Example: 11/21/2022 03:15:37
$TimestampInt = [Convert]::ToInt64($ProfileHexJoined,16)
$ProfileLoadDate = [DateTime]::FromFileTimeutc($TimestampInt)
# Optional output for example purposes
Write-Output "SID: $SidTrimmed"
Write-Output "Friendly Name: $FriendlyNameTrimmed"
Write-Output "Profile Load Date: $ProfileLoadDate"
Write-Output "`n"
}
Running this script results in the following output:
SID: S-1-5-18
SID: S-1-5-19
SID: S-1-5-20
SID: S-1-5-21-1234567890-1234567890-1234567890-1234 |
Good luck!
-rp
Nov 29 2022 01:33 PM - edited Nov 29 2022 02:11 PM
@Ryan Pertusio
First off, thank you for taking the time to look at this and reply. What exactly is this value supposed to represent? I would assume the last time the profile was loaded?
I tested but all profiles are returning the value of "01/01/1601 00:00:00".
EDIT: After closer review, the workstation I tested on, NONE of the profiles had LocalProfileLoadTime values... strange. I tested on another workstation and it provided useful data. About 20 out of the 180 profiles did have erroneous values though.
So here's the next question: How accurate is this data? If it follows the same patterns as the other data, there can be some inconsistency, I could say, as I've demonstrated by pulling 180 profiles where 20 of them were invalid. Also, what about my other workstation that simply does not have LocalProfileLoadTime values at all?
When should this value be used over other values? If this value returns erroneously or not at all, what's nest? IconCache.db, then user profile directory?
Nov 29 2022 02:04 PM
Yes, it represents the last time the profile was loaded. (It matches my experience at least)
I tried it on 3 different machines, and while I did get some 01/01/1601 profiles (never logged on), the script properly returns dates on other valid logons.
I successfully tried it on 3 PCs:
* Win10 / Domain Joined
* Win11 / Azure AD Joined
* Win11 / Workgroup (home PC)
There could be something wrong with my script. I didn't test it widely beyond my 3 systems. I would check your registry and do the conversion 'manually':
My powershell script should report the same date/time as w32tm.
-Ryan
Nov 29 2022 02:33 PM
Dec 01 2022 06:39 AM
Dec 02 2022 04:24 PM
@MB_99 , @Ryan Pertusio , @Ryan Pertusio
This is an OK idea, but I would not fully rely on it. The best you could hope for is to put this at the top of your IF statement to use IF the file exists. Because inevitably this will run on a workstation that does not have this file and your script will be looking for it and it won't be found--what happens then? A bunch of stuff that you did not want to be deleted is now gone.
Again, my Org does not use the Group Policy to delete profiles. We're using a script on a "on run" basis, or sometimes via Task Scheduler to do the cleanup. This is the operation I landed on. The $UserProfileThreshold is set outside of the scriptblock as a script parameter. The basis for deletion is determined one of two ways. If the User's LocalProfileLoadTime is able to be determined via the registry, this timestamp is used first. If these registry properties don't exist, I take the newest of the IconCache.db and the LastWriteTime of the user profile directory. These are the three values I trust the most, the LocalProfileLoadTime value being the most accurate out of the bunch.
OldUserProfiles {
$DeleteProfile = (Get-Date).AddDays(-$UserProfileThreshold)
$ExcludedUsers = @('Administrator','Public','Default',"$ComputerName")
$UserFolders = "C:\Users" | Get-ChildItem -Directory -Exclude $ExcludedUsers
$CimProfiles = Get-CimInstance -Class Win32_UserProfile
$RegProfiles = Get-ItemProperty -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\ProfileList\*"
$NA = [datetime] "11/11/1111 11:11:11 AM"
$Valid = [datetime] "2/2/2000 22:22:22 PM"
$ProfileData = ForEach ($UserFolder in $UserFolders) {
$UserName = $UserFolder.Name
$UserProfileTime = $UserFolder.LastWriteTime
$CimProfile = $CimProfiles | Where-Object { $_.localpath -eq $UserFolder.FullName }
if ($CimProfile) { $CimProfileTime = $CimProfile.LastUseTime } else { $CimProfileTime = $NA }
#$NTUsr = Get-Item "$UserFolder\NTUSer.dat" -Force
#if ($NTUsr) { $NTUsrTime = $NTUsr.LastWriteTime } else { $NTUsrTime = $NA }
#$UsrClass = Get-Item "$UserFolder\AppData\Local\Microsoft\Windows\UsrClass.dat" -force
#if ($UsrClass) { $UsrClassTime = $UsrClass.LastWriteTime } else { $UsrClassTime = $NA }
$IconCache = Get-Item "$UserFolder\AppData\Local\IconCache.db" -Force
if ($IconCache) { $IconCacheTime = $IconCache.LastWriteTime } else { $IconCacheTime = $NA}
$RegProfile = $RegProfiles | Where-Object { $_.ProfileImagePath -eq $UserFolder.FullName }
$ProfileLoadHighDec = $RegProfile.LocalProfileLoadTimeHigh
$ProfileLoadLowDec = $RegProfile.LocalProfileLoadTimeLow
# Convert Decimal to Hex string, Example: ProfileLoadHighHex = 01d8fd57 / ProfileLoadLowHex = 86b1d3bc
$ProfileLoadHighHex = [System.Convert]::ToString($ProfileLoadHighDec,16)
$ProfileLoadLowHex = [System.Convert]::ToString($ProfileLoadLowDec,16)
# Concatenate hex strings, Example: 01d8fd5786b1d3bc
$ProfileHexJoined = -join ($ProfileLoadHighHex,$ProfileLoadLowHex)
# Convert to DateTime format, Example: 11/21/2022 03:15:37
$TimestampInt = [Convert]::ToInt64($ProfileHexJoined,16)
$ProfileLoadTime = [DateTime]::FromFileTimeutc($TimestampInt)
# If ProfileLoadTime exists, use that as the newest timestamp, if not use the newest of UserProfileTime and IconCacheTime
if ($ProfileLoadTime -gt $Valid) {
$LastUpdated = $ProfileLoadTime
} else { $LastUpdated = $UserProfileTime,$IconCacheTime | Sort-Object | Select-Object -Last 1 }
if ($LastUpdated -lt $DeleteProfile) { $Delete = $True } else { $Delete = $False }
[PSCustomObject]@{
UserName = $UserName
ProfileFullname = $UserFolder.FullName
UserProfileTime = $UserProfileTime
CimProfileTime = $CimProfileTime
# NTUsrTime = $NTUsrTime
# UsrClassTime = $UsrClassTime
IconCacheTime = $IconCacheTime
ProfileLoadTime = $ProfileLoadTime
LastUpdated = $LastUpdated
Delete = $Delete
}
# Remove all iteration data as to not corrupt datastream
'UserName','UserProfileTime','CimProfile','CimProfileTime','NTUsr','NTUsrTime','UsrClass','UsrClassTime','IconCache','IconCacheTime','LastUpdated','Delete' | ForEach-Object { Remove-Variable $_ }
'RegProfile','ProfileLoadHighDec','ProfileLoadLowDec','ProfileLoadHighHex','ProfileLoadLowHex','ProfileHexJoined','TimestampInt','ProfileLoadTime'| ForEach-Object { Remove-Variable $_ }
}
ForEach ($UserProfile in $ProfileData) {
if ($UserProfile.Delete) {
if ($UserProfile.CimProfileTime) {
$CimProfile = $CimProfiles | Where-Object { $_.LocalPath -eq $UserProfile.ProfileFullName }
Remove-CimInstance $CimProfile
Write-Log "User OS profile permanently deleted: $($UserProfile.ProfileFullname)" $LogFile
} else { # If the profile found in C:\Users does not exist in Win32_UserProfiles
#Remove-Item $UserProfile.ProfileFullName -Recurse -Force -ErrorAction Stop
#Write-Log "User OS profile permanently deleted: $($UserProfile.ProfileFullname)" $LogFile
Write-Log "USER OS PROFILE DOES NOT EXIST IN THE REGISTRY: $($UserProfile.ProfileFullname)" $LogFile
}
}
}
Write-Log "END OldUserProfiles cleanup" $LogFile
}
Dec 19 2022 03:27 PM - edited Dec 19 2022 03:37 PM
Your script shows accurately the issues we face in IT to support our business devices. I'm wondering if you would be willing to share a completed script you use to help us periodically run and free up disk space where profiles are not used after 90 days. I only ask because not all of us are fluent in PowerShell to achieve this outcome.
Jan 20 2023 11:00 AM - edited Jan 20 2023 11:01 AM
If anyone is interested I have created a script that will allow for cleanup eventually, it uses a scheduled task that writes to a file in the user profile, and then deletes based on the date contained in that file. It is the best I could come up with, if anyone has any other ideas for capturing the last logon without using the scheduled task please let me know. Thanks.
Oct 13 2023 01:36 AM
Thank you so much for sharing this script.
I'm not the best at PowerShell and coding in general unfortunately, I was just querying the $WorkingFolder variable in the RemoveUserProfiles.ps1 script. I can't seem to find where the value is to change it, I've looked over the script and don't know if I'm just missing it.
I'm also curious as to how this could be deployed via Intune if you have any tips please? I'd assume it would be a Win32 package but I'm not sure on what the command line would be to use it.
Thank you once again, your help is really appreciated.
Oct 13 2023 06:39 AM
Take a look at https://github.com/barrett101/Windows-User-Profile-Remover/tree/main it explains how the working folder works. You define it as an argument when you run the PS1 file.
As for deploying with Intune, a Win32 app to get it on there, but you would need to create a script to create you a scheduled task, so it runs the RemoveUserProfiles.ps1 script on a regular basis. If you have an RMM you could probably push out on a schedule easier and have a place to log activity. I wish Intune had a better solution for running recurring tasks.
Oct 13 2023 07:22 AM
Dec 12 2023 06:31 AM
@midnight51 : I found you solution to most perfect or best resolution to whatever solution or approach mentioned over web.
Great and appreciate
May 18 2024 04:40 AM - edited May 18 2024 04:46 AM
noob alert! Late to the party I know, but I got here searching for a solution and others will too.
I believe there's a logic error in this which results in an incorrect conversion for some values of the input.
For it to work the string representation of the low DWORD value has to represent all 32 bits. In other words, some values have leading zeros. If those zeros are missing this effectively bit shifts right the high DWORD value, resulting in the wrong datetime.
The only non-contemporary date you should get is midnight 1/1/1601, the date represented by int zero. I was seeing year 1627 as an example.
Here's an alternate implementation.
Caveat: I am not a PowerShell expert, had to look up how to make a function . I am, however, confident in the logic having been weaned on K&R.
# https://techcommunity.microsoft.com/t5/windows-deployment/issue-with-date-modified-for-ntuser-dat/m-p/102438/page/3
# made a function. Added UnloadDate
# changed the logic of the function
function Get-ProfileTimestamp {
param (
$ProfileLoadHigh,$ProfileLoadLow
)
$a = ([int64]$ProfileLoadHigh -shl 32) -bor ([int64]$ProfileLoadLow)
return [DateTime]::FromFileTimeutc($a)
}
# Get list of profiles
$profiles = Get-ItemProperty -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\ProfileList\*"
# Loop through each profile
Foreach ($profile in $profiles) {
# Get the SID
$SID = New-Object System.Security.Principal.SecurityIdentifier($profile.PSChildName)
# Convert SID to Friendly name
$FriendlyName = $SID.Translate([System.Security.Principal.NTAccount])
# Trim and store variables
$SidTrimmed = $SID.Value
$FriendlyNameTrimmed = $FriendlyName.Value
$ProfileLoadDate = Get-ProfileTimestamp -ProfileLoadHigh $profile.LocalProfileLoadTimeHigh -ProfileLoadLow $profile.LocalProfileLoadTimeLow
$ProfileUnLoadDate = Get-ProfileTimestamp -ProfileLoadHigh $profile.LocalProfileUnLoadTimeHigh -ProfileLoadLow $profile.LocalProfileUnLoadTimeLow
# Optional output for example purposes
Write-Output "SID: $SidTrimmed"
Write-Output "Friendly Name: $FriendlyNameTrimmed"
Write-Output "Profile Load Date: $ProfileLoadDate"
Write-Output "Profile UnLoad Date: $ProfileUnLoadDate"
Write-Output "`n"
}
For extra credit, how many bits is the data shifted for each missing 0x0?