How to display this information on pre-Vista OSs
| Executive Summary:|
You can configure Windows Vista to display a pop-up window that contains the date of the last successful logon, the date of the last unsuccessful logon, and the number of unsuccessful logon attempts. However, this feature works only when the Vista workstation is in a workgroup or a member of an Active Directory (AD) domain running at a Windows Server 2008 functional level. If you have a Windows Server 2003 domain, though, you can display a similar pop-up window by incorporating a VBScript script named DisplayLastLogonInfo.vbs in a logon script.
Windows Vista and Windows Server 2008 include an interesting feature that security-minded administrators might find useful. Through Group Policy or registry settings, you can configure these OSs to display previous logon information when a user logs on. As Figure 1 shows, a pop-up window displays the last successful logon, the last unsuccessful logon, and the number of unsuccessful logon attempts based on the value of the account lockout counter. The account lockout counter stores the number of unsuccessful logon attempts and is reset periodically based on the Reset account lockout counter after policy setting. If the account lockout threshold is exceeded before the account lockout counter is reset, the corresponding account is locked.
This feature requires the Vista or Server 2008 host to be in a workgroup or a member of an Active Directory (AD) domain running at a Server 2008 functional level. When in a workgroup, the local SAM database reports interactive logon information. Domain members retrieve this information from the AD database. The Server 2008 functional level adds attributes used to track interactive logon information to the schema. In addition, both the Vista clients and the Server 2008 domain controllers (DCs) must have the following Group Policy option or registry setting enabled for the feature to work properly:
- In Group Policy, the Display information about previous logons during user logon option needs to be enabled. You can access this option by navigating to Computer Configuration, Administrative Templates, Windows Components, Windows Logon Options.
- In the registry, the DWORD value for the DisplayLastLogonInfo entry needs to be set to 1. You can find this entry under the HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System registry subkey.
I wanted to support the same functionality for Windows Server 2003, Windows 2000, and XP systems in a Windows 2003 domain. In a Windows 2003 domain, running at a Windows 2003 functional level, there are two user attributes that are used to track user logon information. The first is lastLogon. This attribute is present in all AD implementations. It is local to each DC, is updated whenever a DC authenticates a user, and is not replicated. To provide improved reporting capabilities, the Server 2003 functional level added the lastLogonTimestamp attribute. This attribute also stores date/time information for user logons, but it is replicated to all other DCs in the domain. However, to reduce the amount of replication traffic, this value is only replicated every 14 days. Therefore, to get a truly accurate report of a user’s last logon, you still have to query each DC in the domain for the user’s lastLogon attribute and use the most recent entry.
Because I had been previously asked to find out when certain domain users last logged on, I already had a VBScript script that would query each DC for a user’s lastLogon attribute. I thought this script would work well for this scenario, as long as I modified it to run during logon. I made some changes to the script and began testing.
As you might have guessed, the existing script didn't work well at all. Because it executed after a user authenticated to the domain, the script always reported the current logon as the last successful logon. I discovered that AD updates the lastLogon attribute during the authentication process and before logon scripts run. Thus, that attribute can't be used to report previous logon data as part of the logon process. After giving it some thought, I determined that I could still use a logon script to obtain a user's last logon, but I needed a way to obtain an accurate timestamp and store that timestamp in a different User object attribute.
Determining How to Obtain an Accurate Timestamp
The first item I had to address was how to obtain a timestamp that takes time zones and daylight saving time into consideration. There are a number of methods that can be used to obtain and adjust timestamps. One of the easiest methods is to use Windows Management Instrumentation's (WMI's) WbemScripting.SWbemDateTime object. You can use this object to easily convert date and time values between time zones because WMI uses Coordinated Universal Time (UTC).
WMI follows the UTC format of yyyymmddhhmmss.000000-uuu, which includes the offset of the local time zone. For example, in
2001 is the year, 12 is the month, 24 is the day of the month, 11 is the hour (using a 24-hour clock), 30 is the number of minutes, 47 is the number of seconds, 000000 is the number of milliseconds, and 480 is the time-zone offset in minutes. So, it refers to 11:30 a.m. on December 24, 2001; given the offset, this would display locally in Pacific Time as 3:30 a.m. on December 24, 2001. Following the UTC format makes scripting date and time differences significantly less complicated.
Unfortunately, the SWbemDateTime object isn't a viable option in environments running Windows versions earlier than Windows 2003 and XP. My network still supports legacy Windows 2000 servers and workstations, so I had to look for alternatives.
The first alternative I considered was WMI's Win32_TimeZone class. This class defines two properties generally used to calculate time differences:
- Bias, which defines the difference between UTC and the local time zone in minutes
- DaylightBias, which identifies the offset for daylight saving time (typically -60 minutes if you’re west of the prime meridian)
However, the Win32_TimeZone class doesn't include an easy method of determining whether daylight saving time is in effect. For that reason, I decided to use another alternative.
Time-zone information is stored in the Windows registry. The two registry entries of interest are:
The ActiveTimeBias entry's DWORD value is the difference between the local time and UTC in minutes, including any applicable offset for daylight saving time. The Bias entry's DWORD value is the difference between the local time and UTC in minutes, excluding daylight saving time. For the purposes of obtaining an accurate timestamp and conversion, all that is needed is the ActiveTimeBias entry. During testing, I stumbled across an easy method of determining whether daylight saving time is in effect: Simply compare the ActiveTimeBias and Bias entries. If the values are equal, then daylight saving time isn't in effect and doesn't apply.
Note that the registry's ActiveTimeBias and Bias entries differ from WMI's Bias property in one important respect. WMI's Bias property uses
UTC – x-minutes = local time
to calculate the difference. For example, in the mountain time zone (standard time), the difference is reported as -420 minutes, or 7 hours behind UTC. The registry's ActiveTimeBias and Bias entries use
local time + x-minutes = UTC
to calculate the difference. For example, in the mountain time zone (standard time), the difference is reported as 420 minutes, or 7 hours that must be added to calculate UTC.
Determining the Attribute
Once the method of obtaining accurate timestamps was established, I needed to determine where I could store them in the AD database. For a number of reasons, I decided the Notes field under the Telephone tab of a user account would serve this purpose well. First, it seemed to be a logical choice because it wasn’t being used for anything else in my environment. Second, this attribute is part of the Personal Information property set, so if the default AD permissions are intact, users already have write access to it. Write access is necessary so that the attribute can be updated when the logon script runs. The attribute is also readily accessible to administrators using the Microsoft Management Console (MMC) Active Directory Users and Computer (ADUC) snap-in, but users have no easy means of directly modifying it.
If you already store data in the Notes field, you can use any attribute capable of storing a Unicode String, as long as the necessary permissions are present. You can always grant users write access to specific object attributes in bulk by assigning the necessary permissions to SELF on the parent organizational unit (OU).
Note that the display name of an attribute isn't always the same as the attribute name itself. For example, the ADUC snap-in identifies the field I intended to use as the Notes field, but the attribute name is actually info. One easy method of identifying the underlying attribute name is to assign a value using the ADUC snap-in's user interface (see Figure 2), then use the ADSI Edit utility (see Figure 3) to identify the attribute name associated with the assigned value. Alternatively, you can the LDIFDE utility instead of ADSI Edit.
Putting It All Together
After I knew how I wanted to obtain the timestamps and where I wanted to store them, I wrote a logon script named DisplayLastLogonInfo.vbs. As Listing 1 shows, this script begins by connecting to the local computer and querying the registry to determine the difference between the local time and UTC. It does this by setting constants for the local computer and HKEY_LOCAL_MACHINE registry hive, binding to the WMI registry provider, and retrieving the ActiveTimeBias value. This value is assigned to the intToUTC variable, which is used with VBScript's DateAdd function to convert the local time to UTC. The ActiveTimeBias value is also multiplied by -1, the result of which is assigned to the intFromUTC variable. This variable is used with the DateAdd function to convert UTC to the local time.
Next, the script creates a Windows Script Host (WSH) WScript.Network object and retrieves the user’s logon name. The name is assigned to the strUserID variable, which identifies the target user account in the rest of the script. The script then connects to the AD domain. It uses RootDSE to set the default configuration and naming contexts, and searches the configuration container for nTDSDSA (directory service) objects to identify DCs.
The early version of DisplayLastLogonInfo.vbs simply dumped all the DCs into an array. This didn't work well in my environment. For one, I support an AD forest with multiple domains and several AD sites. In addition, a number of my AD sites contained DCs from multiple domains. This caused two problems:
- The script generated errors when it tried to query DCs that weren't members of the domain to which the target account belonged.
- The script took a long time to run, because it was querying different DCs in AD sites located across the country, from Hawaii to Virginia.
To work around these challenges, I modified my script to be more selective when adding DCs to the array. Only the DCs from the account’s logon domain, which are also members of the local AD site, are added to the array. As you can see in Listing 2, the ADSystemInfo object is used to determine the site membership of the local computer. The site name is stored in the strSite variable. The defaultNamingContext is an attribute of the rootDSE (which identifies the current domain) and is stored in the strDefaultNameContext variable. The DC's serverReference attribute, which contains the name of the DC in the domain naming context, is stored in the strSvrRef variable. As callout A in Listing 2 shows, an If…Then…Else statement and the AND operator are used to ensure the site name is present in the DC's distinguishedName attribute and the domain naming context is present in the serverReference attribute before the DC is added to the array.
Each DC in the array is queried for the target User object using a For Each…Next statement, which is shown in Listing 3. When found, the script uses the object's ADsPath to retrieve the User object and its info attribute's value, assigning that value to the dtmLogon variable. As callout A in Listing 3 shows, the On Error Resume Next statement is used to suppress errors during this process. Without this statement, the script would generate an error and stop running whenever the info attribute didn’t have an assigned value (which is the case the first time the script is run). After the attribute is retrieved, the On Error GoTo 0 statement disables the error suppression.
In the first line in callout B in Listing 3, the script converts the dtmLogon variable's value to the local time using the DateAdd function with the intFromUTC variable. The code in callout B also retrieves the User object's badPwdCount and badPasswordTime attributes. The badPwdCount attribute's value specifies how many times a user has tried to log on with an incorrect password. As I noted earlier, this number is reset periodically, based on the Account Lockout Policy setting Reset account lockout counter after in AD. The badPwdCount value is stored in the objBadCount variable.
The badPasswordTime attribute's value specifies the date and time of the last unsuccessful logon attempt due to an incorrectly entered password. Unfortunately, the badPasswordTime attribute value is an ANSI-decimal date, which is a 64-bit integer consisting of the number of nanoseconds that have passed since January 1, 1601. VBScript doesn't support 64-bit integers, so the code in callout C in Listing 3 splits it into two 32-bit integers and converts this value into one that VBScript does support and stores it in the dtmBadPwd variable. For information about how this conversion code works, see the Microsoft TechNet article "Dandelions, VCR Clocks, and Last Logon Times: These are a Few of Our Least Favorite Things" (http://www.microsoft.com/technet/scriptcenter/topics/win2003/lastlogon.mspx).
The script runs the dtmLogon, dtmBadPwd, and objBadCount values through If…Then…Else statements to identify the most current value returned, as callout D shows. For example, the statement
If dtmLogon > dtmLatestLogon Then
dtmLatestLogon = dtmLogon
identifies the most recent logon. As the script queries each DC for the logon timestamp stored in the info attribute, it stores this value in the dtmLogon variable. It then compares this value to the dtmLatestLogon variable. If the current dtmLogon value is greater, or more recent, than the value stored in the dtmLatestLogon variable, the script will overwrite it with dtmLogon value. The first time the dtmLogon and dtmLatestLogon values are compared, the dtmLatestLogon is zero, so the first DC polled will always overwrite the dtmLatestLogon value. This comparison ensures the dtmLatestLogon variable stores the most current value out of all the domain controller responses. The same comparisons are applied to the variables that store the last bad password timestamp, and the bad password count.
After the date of last successful logon, the date of the last unsuccessful logon, and the number of unsuccessful logon attempts have been retrieved, the script focuses its efforts on obtaining and storing a new logon timestamp in the info attribute. As Listing 4 shows, it first uses VBScript's Now and FormatDateTime functions to obtain and format the current date and time. The script then uses the DateAdd function with the intToUTC variable to convert the timestamp from local time to UTC. This value is then written to the user’s info attribute.
At first, I had trouble writing this value to the info attribute. Whenever I tried, the script would consistently generate an 800500C error. When I turned on error handling, I was able to retrieve the error code -2147463156. The only descriptions for this error code I could find indicated that a provider object was incorrectly accessed or that the value's data type couldn't be converted (ADS_TYPE_CANNOT_BE_CONVERTED). Looking into the latter, I quickly discovered that although the info attribute will store any Unicode String, you can't write a future date and time value to it. Because of the UTC time conversion, this value was always greater than the current, local date and time. Therefore, I had to use VBScript's CStr function to convert the value type to a string value. This is referred to as type coercion. Once the value type was specified, the script was able to update the info attribute without any problems.
DisplayLastLogonInfo.vbs ends by presenting the end user with a message box that contains the user account display name, last successful logon, last unsuccessful logon, and bad password count (see Figure 4). Administrators can also see the values written to the info attribute using the ADUC snap-in and looking at the Notes field in the account properties. The value will be in UTC.
You can download DisplayLastLogonInfo.vbs by clicking the Download the Code Here button at the top of the page. This script is fairly easy to use. First, as a standalone script, test DisplayLastLogonInfo.vbs on a standard user account to verify it works as expected. If your environment spans several time zones and/or AD sites, you should also verify that the timestamps in the info attribute are being replicated and displayed correctly.
After your tests show that the script is working correctly, you can incorporate the code into your existing logon scripts. If VBScript logon scripts are already in use, you can insert this code as a procedure or subroutine into them. However, make sure there aren’t any existing global constants, variables, or error settings that would prevent the code from running normally. Alternatively, you can run DisplayLastLogonInfo.vbs as a separate process by using Windows Script Host's (WSH's) WScript.Shell Run or Exec methods in the existing VBScript logon scripts.
If Windows shell logon scripts are already in use, you can run DisplayLastLogonInfo.vbs by invoking the CScript or WScript engine. The entry should look like
cscript.exe //e:vbscript path
where path is the path to DisplayLastLogonInfo.vbs.
You need to be aware of two things before you incorporate DisplayLastLogonInfo.vbs into your logon scripts. First, you need to consider how using the info attribute to store the last logon timestamp will impact your AD topology. Each user logon updates the info attribute, which is then replicated to the other DCs in the domain. This can result in a significant increase in replication traffic, especially if you have a large number of users that logon several times a day.
Second, you shouldn't consider this script as a replacement for audit logging or existing security practices. It's simply a tool you can use to obtain and display logon information for those user accounts that warrant additional monitoring. For example, I’ve added DisplayLastLogonInfo.vbs to the logon scripts for administrative and high-profile user accounts. DisplayLastLogonInfo.vbs has worked well for me in this scenario. I would recommend thorough testing prior to a large-scale deployment.