SOLVED

Issue with date modified for NTUSER.DAT

Brass Contributor

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.

52 Replies

@midnight51

 

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
Friendly Name: NT AUTHORITY\SYSTEM
Profile Load Date: 01/01/1601 00:00:00

 

SID: S-1-5-19
Friendly Name: NT AUTHORITY\LOCAL SERVICE
Profile Load Date: 01/01/1601 00:00:00

 

SID: S-1-5-20
Friendly Name: NT AUTHORITY\NETWORK SERVICE
Profile Load Date: 01/01/1601 00:00:00

 

SID: S-1-5-21-1234567890-1234567890-1234567890-1234
Friendly Name: LAPTOP-12345\username123
Profile Load Date: 11/21/2022 03:15:37

 

 

Good luck!

-rp

 

 

@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?

@midnight51

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':

  1. Grab the "LocalProfileLoadTimeLow" and "LocalProfileLoadTimeHigh" from 1 of the profiles:
    HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\
  2. Concatenate the Low/High hex into a single hex string
  3. Convert the single hex string to decimal (Google a hex2dec converter)
  4. Take decimal (NT epoch time) and convert to DateTime at command prompt:
    w32tm /ntte <nt epoch time>

 

My powershell script should report the same date/time as w32tm.

 

-Ryan

@midnight51
Unfortunately, all I can say is that MS told me it's the value they use for the Group Policy to cleanup old profiles. Maybe there's more to their secret sauce that they're not sharing, OR, they're using inconsistent data.
Had a thought, why not create a logon script that runs each time a user logs in, that appends their username and logon date to a CSV file. Then you can use a PowerShell script to go through the each record to determine which profiles can be removed or not based on the date. This would take any of the guess work out, and would take the reliance of modify dates to determine last logon. I don't have time right now but if I do in the near future I will write something and post back.

@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
        }

 

 

 

@midnight51 

 

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.

@cameron1340 @Ryan Pertusio 

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.

 

https://github.com/barrett101/Windows-User-Profile-Remover

@MB_99 

 

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.

@kzapater1981 

 

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.

@kzapater1981 (and @MB_99),
Intune has what you're looking for already.

Intune -> Devices -> Remediations

You can run a PowerShell script on a schedule (every X hours for example).

@midnight51 : I found you solution to most perfect or best resolution to whatever solution or approach mentioned over web.

 

Great and appreciate

@Ryan Pertusio 

 

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 :smile:. 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?