Backup with PowerShell

I’ve migrated recently a Domain Controller based on Windows 2008 R2 to Windows 2012 and I took the opportunity to dig into the Backup cmdlets.

Before trying to upgrade it, I wanted to take a full backup of the system state plus the critical partition where the operating system has been installed so that I could restore the server just in case things go wrong.

The following good blog post from Richard Siddaway, http://richardspowershellblog.wordpress.com/2008/01/11/powershell-for-windows-server-backup/, had almost everything to get started.

As a prerequisite, you may need to define a backup strategy. Even if it’s not 100% suitable for corporate environments, you can also find a good example on this page: Scott Hanselman’s basic non-cloud-based personal backup strategy

Hands on! First, we need some server features to be added:

Import-Module ServerManager            
Get-WindowsFeature | ? { $_.DisplayName -match "Backup" }            
Add-WindowsFeature -Name Backup-Features -IncludeAllSubFeature:$true -Restart:$false            

The above commands will install the following 3 features:
2008 R2 Windows Backup Feature

Add-WindowsFeature -Name Windows-Server-Backup -Restart:$false

Now, we can load the module that contains the cmdlets for backup operations:

Add-Pssnapin windows.serverbackup

Everything has been simplified on Windows 2012, you just need to add a single backup feature and the module preloading feature makes it available automatically

Add-WindowsFeature -Name Windows-Server-Backup -Restart:$false

Windows 2012 Backup Feature

There are 30 cmdlets on Windows 2008 R2 vs. 49 in Windows Server 2012. Here’s the list of the 19 new cmdlets:

  • Add-WBVirtualMachine
  • Get-WBBackupVolumeBrowsePath
  • Get-WBPerformanceConfiguration
  • Get-WBVirtualMachine
  • Get-WBVssBackupOption
  • Remove-WBBackupSet
  • Remove-WBCatalog
  • Remove-WBVirtualMachine
  • Restore-WBCatalog
  • Resume-WBBackup
  • Resume-WBVolumeRecovery
  • Set-WBPerformanceConfiguration
  • Set-WBVssBackupOption
  • Start-WBApplicationRecovery
  • Start-WBFileRecovery
  • Start-WBHyperVRecovery
  • Start-WBSystemStateRecovery
  • Start-WBVolumeRecovery
  • Stop-WBJob

I digress, let’s stick to the backup of my 2008 R2 Domain Controller:

  • Get inventory data
    # Check if there's a policy in place            
    Get-WBPolicy            
    # List disks and their properties            
    Get-WBDisk

    Get-WBdisk
    NB: In my case, HP disk 0 is a RAID 1 with 2 volumes (C: & D:) + I’ve got a spare disk as volume E:. Only C: is actually critical.

  • Create a new Windows Backup policy object
    # Create an empty policy object            
    $pol = New-WBPolicy

    Empty default WB-policy object

  • Configure desired backup settings
    # Set the 'BareMetalRecovery (BMR)' checkbox to true            
    $pol | Add-WBBareMetalRecovery            
                
    # Set the System state checkbox to true            
    $pol | Add-WBSystemState            
                
    # Backup all critical volumes            
    Add-WBVolume -Policy $pol -Volume (Get-WBVolume -CriticalVolumes)            
                
    # Add a target volume where the backup files will be written            
    $targetvol = Get-WBDisk | Where { $_.Properties -match "ValidTarget" } | Get-WBVolume            
    Add-WBBackupTarget -Policy $pol -Target (New-WBBackupTarget -Volume $targetvol)            
                
    # Set a schedule             
    Set-WBSchedule -Policy $pol -Schedule ([datetime]::Now.AddMinutes(10))

    WB-policy settings

  • Launch it
    Start-WBBackup -Policy $pol
  • Review the result
    # Get a summary of previously run backup operations.            
    Get-WBSummary            
                
    # Get info from the backup target files            
    Get-WBBackupSet -BackupTarget (Get-WBBackupTarget $pol)            
                
    # Gets the status of the last backup job from the backup events in the event manager.            
    Get-WBJob -Previous 1

    Backup result
    I took 3 minutes to backup the C: drive of my DC and to create a 30GB Vhd file using a VSS snapshot.

Without PowerShell, I could have also simply used this one-liner to launch and backup the C: drive:

wbadmin.exe start backup -backupTarget:E: -include:C:\

http://technet.microsoft.com/en-us/library/cc742083%28v=ws.10%29.aspx

Before upgrading a domain controller, the schema of the forest needs to be extended otherwise the setup wizard ends with the following warning:
upgrade DC forestprep
Active Directory on this domain controller does not contain Windows Server 2012 ADPREP /FORESTPREP updates. See http://go.microsoft.com/fwlink/?LinkId=113955.
schema upgrade prereq
Here is the best fully detailed technet page about the upgrading Domain Controllers to Windows Server 2012

In my case I quickly typed the following command to confirm that the account I was using is a member of the abouve groups.

net user /dom $env:USERNAME

Finally, before completing the setup wizard, I extended the schema with these two commands:

F:\support\adprep\adprep.exe /forestprep            
F:\support\adprep\adprep.exe /domainprep /gpprep

Bonus: On the following page we can also find out what is a system image and how it works: http://blogs.technet.com/b/filecab/archive/2009/10/31/learn-more-about-system-image-backup.aspx
WB image

Advertisements

Extending the new Invoke-GPUpdate cmdlet

One of the most wanted feature has been added to Windows 8 / Windows 2012: a way to force a remote group policy update.

Forget everything you find on this page: http://www.windowsecurity.com/articles/How-Force-Remote-Group-Policy-Processing.html

The Microsoft GPTeam already introducted this feature.
http://blogs.technet.com/b/grouppolicy/archive/2012/11/27/group-policy-in-windows-server-2012-using-remote-gpupdate.aspx

Cool, let’s give it a try: Force a Remote Group Policy Refresh (GPUpdate)

As mentioned in the help of the cmdlet, it works perfectly if you run in on Windows 2012 server, you can target Windows 7 and Windows 8 computers and if the following Firewall exception have been added to client computers

  • Remote Scheduled Tasks Management (RPC)
  • Remote Scheduled Tasks Management (RPC-ERMAP)
  • Windows Management Instrumentation (WMI-IN)

Source: Invoke-GPUpdate http://technet.microsoft.com/en-us/library/hh967455.aspx
Note: to use this cmdlet, you need to import the ‘GroupPolicy’ module that is available only if the ‘GPMC’ feature has been enabled on the server.

# Step 1: Add first the 'Group Policy Management' feature            
Add-WindowsFeature -Name  GPMC -Restart:$false            
# Step 2: Load the GPO module            
Import-module GroupPolicy

Wait, there are other limitations and gotchas.

  • When the gpupdate command runs, it’s not necessarily hidden
  • There are no ‘Credential’ parameter associated with the Invoke-GPUpdate cmdlet to pass alternate credentials. You need to run it with an account that has admin privileges on the target remote computers
  • This cmdlet doesn’t exist on a Windows 7 computer

Let’s have a look at what the original Invoke-GPUpdate cmdlet does behind the scene.
The Invoke-GPupdate cmdlet actually creates a scheduled task on the target remote computer that runs only once the gpupdate executable and deletes itself as it isn’t rescheduled.
GPUpdate task

As a proof of concept I’ve tried to achieve the same behavior as the original Invoke-GPUpdate cmdlet but only over PS remoting.
Here’s how:

#Requires -Version 2.0

Function Invoke-GPUpdateTask {
[cmdletbinding()]
Param(
           
    [parameter(mandatory=$false)]            
    [Switch]$Boot=$false,

     [Parameter(Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]            
     [Alias("CN","__SERVER","IPAddress")]            
     [string[]]$ComputerName=$Env:Computername,            

    [parameter()]            
    [Alias('RunAs')]            
    [System.Management.Automation.Credential()]$Credential = [System.Management.Automation.PSCredential]::Empty,   
    
    [parameter(mandatory=$false)]            
    [ValidateSet('Sync','Force')]
    [String]$RefreshType=$null,

    [parameter(mandatory=$false)]            
    [Switch]$LogOff=$false,

    [parameter(mandatory=$false)]            
    [ValidateRange(0,44640)]            
    [int32]$RandomDelayInMinutes = 10,

    [parameter(mandatory=$false)]            
    [ValidateSet('User','Computer')]
    [System.String]$Target=$null
)
Begin {
    $HT = @{}
    if ($Credential) {
        $HT += @{Credential = $Credential}
    }
$str = @'
#Requires -Version 2.0

Function Add-GPUpdateTask {
Param(
    [parameter(mandatory=$false)]            
    [Switch]$LogOff,

    [parameter(mandatory=$false)]            
    [Switch]$Boot,

    [parameter(mandatory=$false)]            
    [ValidateSet('Sync','Force')]
    [String]$RefreshType=$null,

    [parameter(mandatory=$false)]            
    [ValidateRange(0,44640)]            
    [int]$RandomDelayInMinutes=10,
    
    [parameter(mandatory=$false)]            
    [ValidateSet('User','Computer')]
    [System.String]$Target
)

Begin {
    # Make sure we run as admin     
    # Creating tasks require admin privileges                               
    $usercontext = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()                                    
    $IsAdmin = $usercontext.IsInRole(544)                                                       
    if (-not($IsAdmin)) {                                    
        Write-Warning "Must run powerShell as Administrator to perform these actions"                                    
        break                        
    }              
    if ([System.Environment]::OSVersion.Version -lt [system.version]'6.0') {
        Write-Warning 'Can only run this on Vista or later based computers'
        break
    }
    # Make sure the computer is a member of a domain
    if (-not(Get-WmiObject -Query 'Select * from Win32_ComputerSystem' -ErrorAction SilentlyContinue).PartOfDomain) {
        Write-Warning 'Can only run this on a computer member of ActiveDirectory domain'
        break
    }

    $now = (Get-Date).ToUniversalTime()
    # http://msdn.microsoft.com/en-us/library/az4se3k1.aspx
    # $StartDate = $now.GetDateTimeFormats('u') -replace "\s","T"
    if ($RandomDelayInMinutes -eq 0) {
        $TrigType = "RegistrationTrigger"
    } else {
        $TrigType = "TimeTrigger"
        $StartDate = @"
      <StartBoundary>$($now.ToString("yyyy-MM-ddTHH:mm:ssZ"))</StartBoundary>    
"@    
    }

    # Differ the end date by 90 seconds what they call the random offset in the cmdlet help documentation
    $EndDate = $now.AddSeconds((($RandomDelayInMinutes*60)+90)).ToString("yyyy-MM-ddTHH:mm:ssZ")

    # The modulus operator only works on numbers as well and returns the remainder from a division operation of the right to the left operand.
    if ($RandomDelayInMinutes -eq 0) {
        $RandomDelay = @"
      <Delay>PT0S</Delay>
"@        
    } else {
        Switch ($RandomDelayInMinutes) {
            {($_ % 1440) -eq 0} { $Delay1 = "P" + ($_/1440) + "D" ; break }
            {($_ % 60) -eq 0  } { $Delay1 = "PT" + ($_/60) + "H" ; break   }
            default { $Delay1 = "PT" + ($_*60) + 'S'}
        }
      $RandomDelay = @"
      <RandomDelay>$Delay1</RandomDelay>        
"@      
    }
    # Define a helper function that will fill-in the blanks in our here-string XML template
    Function Get-xmlTemplate {
    param(
    $StartDate,
    $EndDate,
    $Delay,
    $UserID,
    $IdleSettings,
    $Arguments,
    $TriggerType
    )
    # Define an XML template
    $xml= @"
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <RegistrationInfo>
    <Author>SYSTEM</Author>
  </RegistrationInfo>
  <Triggers>
    <$TriggerType id="GPUpdate Trigger">
      $StartDate
      <EndBoundary>$EndDate</EndBoundary>
      <Enabled>true</Enabled>
      $Delay
    </$TriggerType>
  </Triggers>
  <Principals>
    <Principal id="Author">
      $UserIDxmlpart
      <RunLevel>LeastPrivilege</RunLevel>
    </Principal>
  </Principals>
  <Settings>
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
    <AllowHardTerminate>true</AllowHardTerminate>
    <StartWhenAvailable>true</StartWhenAvailable>
    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
    <IdleSettings>
      $ComputerIdleSettings
      <StopOnIdleEnd>false</StopOnIdleEnd>
      <RestartOnIdle>false</RestartOnIdle>
    </IdleSettings>
    <AllowStartOnDemand>true</AllowStartOnDemand>
    <Enabled>true</Enabled>
    <Hidden>false</Hidden>
    <RunOnlyIfIdle>false</RunOnlyIfIdle>
    <WakeToRun>false</WakeToRun>
    <ExecutionTimeLimit>P3D</ExecutionTimeLimit>
    <DeleteExpiredTaskAfter>PT0S</DeleteExpiredTaskAfter>
    <Priority>6</Priority>
  </Settings>
  <Actions Context="Author">
    <Exec id="GPUpdate">
      <Command>gpupdate.exe</Command>
      <Arguments>$gpupdatearguments</Arguments>
    </Exec>
  </Actions>
</Task>
"@
    return $xml
    } # End of Function

    # Define a second helper function that will be responsible for creating a task
    Function Register-GPUpdateTask {
    Param(
        [string]$XmlTaskDefinition,
        $TaskName = 'GPUpdate-V2',
        $LogonType    
    )
    Begin {
        $TASK_CREATE_OR_UPDATE_FLAG = 0x6            
        $TaskService = New-Object -com schedule.service
        try {
            $TaskService.Connect() | Out-Null
        } catch {
            Write-Warning "Failed to initialize the schedule service COM object"
            return
        }
    } 
    Process {
        # http://msdn.microsoft.com/en-us/magazine/cc163350.aspx
       try {
            $folder = $TaskService.GetFolder('\Microsoft\Windows\GroupPolicy')
        } catch {
            try {
                $folder = $TaskService.GetFolder('\').CreateFolder('\Microsoft\Windows\GroupPolicy',"D:(A;;FA;;;BA)(A;;FA;;;SY)")
            } catch {
                Write-Warning "Failed to create task folder \Microsoft\Windows\GroupPolicy because $($_.Exception.Message)"
            }
        }
        if ($folder) {
            try {
                $taskdefinition = (New-Object -ComObject schedule.service).NewTask($null)
                $taskdefinition.xmlText = $XmlTaskDefinition
                # Create the task
                $folder.RegisterTaskDefinition($TaskName,$taskdefinition,$TASK_CREATE_OR_UPDATE_FLAG,$null,$null,$LogonType) |Out-Null
            } catch {
                Write-Warning "Failed to register task because $($_.Exception.Message)"
            }
        }
    } 
    End {}
    } # End of function
   
}
Process {
    # Define an array to add strings based on the main function parameters
    $commandargs = @()
    if ($Boot)   { $commandargs += '/Boot'   }
    if ($LogOff) { $commandargs += '/LogOff' }
    if ($RefreshType) { $commandargs += "/$($RefreshType)"}
    Switch ($Target) {
        User {
            $commandargs += '/Target:User'
            # To get the currenly logged on users accounts we lock at the owner of the explorer.exe
            $explorerprocesses = @(Get-WmiObject -Query "Select * FROM Win32_Process WHERE Name='explorer.exe'" -ErrorAction SilentlyContinue)
            # http://msdn.microsoft.com/en-us/library/windows/desktop/aa394102%28v=vs.85%29.aspx
            # The Domain property can be a FQDN
            $Domain = ((Get-WmiObject -Query 'Select * from Win32_ComputerSystem' -ErrorAction SilentlyContinue).Domain -split "\.")[0]
            $loggedonUsers = @()
            if ($explorerprocesses.Count -eq 0)
            {
               # No user logged on
            } else {
                $explorerprocesses | ForEach-Object -Process {
                    # Avoid invoke-GPUpdate on local accounts
                    if ($_.GetOwner().Domain -imatch $Domain) {
                        $loggedonUsers += $_.GetOwner().Domain + "\" + $_.GetOwner().User
                    }
                }
            }
            
            # Concatenate
            $gpupdatearguments = [string]::Join(" ",$commandargs)
          
            # Run a refresh for all domain users
            $loggedonUsers | ForEach-Object -Process {
                $UserIDxmlpart = @"
                      <UserId>$_</UserId>
                      <LogonType>InteractiveToken</LogonType>
"@

                # Fill-in the XML template
                if ($StartDate -ne  $null) {                
                    $xmldef = Get-xmlTemplate -StartDate $StartDate -EndDate $EndDate -Delay $RandomDelay -UserID $UserIDxmlpart  -Arguments $gpupdatearguments -TriggerType $TrigType
                } else {
                    $xmldef = Get-xmlTemplate -EndDate $EndDate -Delay $RandomDelay -UserID $UserIDxmlpart  -Arguments $gpupdatearguments -TriggerType $TrigType
                }
                $Name =  ($_ -split "\\")[1] + '@' + ($_ -split "\\")[0]
                Register-GPUpdateTask -XmlTaskDefinition $xmldef.ToString() -TaskName "GPUpdate-V2 ($Name)" -LogonType 3
            } # End of foreach
        }
        Computer {
            $commandargs += '/Target:Computer'
            $ComputerIdleSettings = @"
          <Duration>PT10M</Duration>
          <WaitTimeout>PT1H</WaitTimeout>
"@
            $UserIDxmlpart = @"
          <UserId>S-1-5-20</UserId>
"@
        # Concatenate
        $gpupdatearguments = [string]::Join(" ",$commandargs)
        
        # Fill-in the XML template
        if ($StartDate -ne $null) {                        
            $xmldef = Get-xmlTemplate -StartDate $StartDate -EndDate $EndDate -Delay $RandomDelay -UserID $UserIDxmlpart -IdleSettings $ComputerIdleSettings -Arguments $gpupdatearguments -TriggerType $TrigType
        } else {
            $xmldef = Get-xmlTemplate -EndDate $EndDate -Delay $RandomDelay -UserID $UserIDxmlpart -IdleSettings $ComputerIdleSettings -Arguments $gpupdatearguments -TriggerType $TrigType
        }
        # Register the task
        Register-GPUpdateTask -XmlTaskDefinition $xmldef.ToString() -LogonType 5
        }
        Default {
            # Target wasn't specified -> do both Computer and User with a recall of the function
            Add-GPUpdateTask -Target Computer @PSBoundParameters
            Add-GPUpdateTask -Target User @PSBoundParameters
        }
    }


}
End {}
}

'@

} 
Process {
        # Loop for each computername object
        $ComputerName | ForEach-Object -Process {
            $Computer  = $_
            # Based on function parameters, add strings to the array
            $commandargs = @()
            if ($Boot)   { $commandargs += '-Boot'    }
            if ($LogOff) { $commandargs += '-LogOff'  }
            if ($RefreshType) { $commandargs += "-RefreshType $($RefreshType)"}
            if ($Target) { $commandargs += "-Target $($Target)"}
            $commandargs += "-RandomDelayInMinutes $($RandomDelayInMinutes)"
            # Create a second string that contains our one-liner
            $newstr = "Add-GPUpdateTask " + [string]::Join(" ",$commandargs)  
            $sb = [scriptblock]::Create(($str + $newstr))
            try {
                Invoke-Command -ComputerName $Computer -ScriptBlock $sb -ErrorAction Stop @HT
            } catch {
                Write-Warning -Message "Failed to invoke command on remote computer $Computer because $($_.Exception.Message)"
            }
        }
}
End {}

<#
    .SYNOPSIS   
        Refresh Group Policies of remote computers and users
             
    .DESCRIPTION   
        Schedule a remote Group Policy refresh (gpupdate) on the specified computer.
        
    .PARAMETER Computername
            Specifies the name of the computer for which to schedule a Group Policy refresh.
            If the computer name is not specified the computer, on which the Invoke-GPUpdate cmdlet was run, will have the Group Policy settings refreshed.

    .PARAMETER Credential
        Alternate credentials to use to access remote computers
        
    .PARAMETER Boot
        Causes a computer restart after the Group Policy settings are applied. This is required for those Group Policy client side extensions (CSEs) that do not process Group Policy on a background update cycle, but do process Group Policy at computer startup, for example, per-computer Software Installation policy settings.
        This parameter has no effect if there are no CSEs called that require a restart.        
    
    .PARAMETER RefreshType
        Can only be set to 'Force','Sync' or null.
        'Force' reapplies all policy settings. By default, Group Policy is only refreshed when policy settings have changed.       
        'Sync' causes the next foreground Group Policy application to be done synchronously. Foreground Group Policy applications occur at computer startup and user logon. You can specify this for the user, computer or both using the Target parameter.
        On a client computer, by default, Group Policy processes synchronously at computer startup and asynchronously at user logon.
        On a server, by default Group Policy processes synchronously at computer startup and at user logon.

    .PARAMETER Target
        Specifies that only user or computer policy settings are refreshed. 
        By default, both user and computer policy settings are refreshed. You can specify one of two allowable values for this parameter:
        -- User
        -- Computer
        If the target parameter is not specified both user and computer policy settings will be refreshed.

    .PARAMETER LogOff
        Causes a logoff after the policy settings have been updated. 
        This is required for those Group Policy client-side extensions (CSEs) that do not process Group Policy on a background update cycle but do process Group Policy when a user logs on. Examples include per-user Software Installation policy settings and the Folder Redirection extension.
        This parameter has no effect if there are no CSEs called that require a logoff.

    .PARAMETER RandomDelayInMinutes
        Specifies the delay, in minutes that Task Scheduler will wait, with a random factor added to lower the network load, before running a scheduled Group Policy refresh.
        You can specify a delay in from 0 minutes to a maximum of 44640 minutes (31 days):
        -- A value of 0 will cause the Group Policy refresh to run as soon as the gpupdate task has been scheduled.
        —A value in the range of 1 to the maximum value of 44640 minutes cause the Group Policy refresh to delay the specified number of minutes plus a random offset before starting the Group Policy refresh. 

    .EXAMPLE 
        Invoke-GPUpdateTask -RefreshType Force
                     
        Description 
        ----------- 
        Forces a refresh of the computer policies as well as user policies for any logged on domain user

    .EXAMPLE
        $Workstations = Get-Content Workstations.txt
        $Workstations | Invoke-GPUpdateTask -RefreshType Force -Target Computer
            
        Description
        -----------
        Forces a refresh of the computer policies on a collection of workstations
            
    .EXAMPLE
        (Get-Content Workstations.txt) | Invoke-GPUpdateTask -RefreshType Force -Target User -LogOff -Credential (Get-Credential)
            
        Description
        -----------
        Forces a refresh of the user policies on a collection of workstations. Also uses alternate administrator credentials provided.                                            
        Logs off the users only if a client-side extensions (CSEs) require it

#>

}

I’ve seen some issues with the task scheduler engine:

  • The XML property DeleteExpiredTaskAfter set to 30 seconds not being honored for example.
  • The task engine reaches the end date, says “Task Scheduler launched “{00000000-0000-0000-0000-000000000000}” instance of task “\Microsoft\Windows\GroupPolicy\GPUpdate-V2″ due to a time trigger condition.” and then just deletes the task w/o launching the gpupdate process. Weird!
  • This MSDN page says

    The default value is 7. The minimum and maximum values are set by the priorityType simple type. Priority levels 7 and 8 are used for background tasks, and priority levels 4, 5, and 6 are used for interactive tasks.

    For both a computer and a user GPUpdate task, the priority is always 6.

Currently the above PoC code runs w/o any issue on a Windows 7 box when I do:

# Do both a computer and user refresh            
Invoke-GPUpdateTask -Computer RemotePC -RefreshType Force -RandomDelayInMinutes 0            
            
# Do only a computer refresh            
Invoke-GPUpdateTask -Computer RemotePC -Target Computer -RefreshType Force -RandomDelayInMinutes 0

Voilà 😎 Far from being perfect … for version 1.0

MSERT (Microsoft Safety Scanner) and PowerShell

MSERT promotion on twitter
The twitter tinyurl redirected to http://blogs.technet.com/b/security/archive/2012/11/15/microsoft-s-free-security-tools-microsoft-safety-scanner.aspx

The original locations of the MSERT site are:

That said, let me also share my recent experience about it 🙂

While investigating an APT (Advanced Persistent Threat) in September, the CSO in my organisation asked me to run the free MSERT tool in ‘detect-only’ mode on both Windows XP (32bit) and Windows 7 (64bit) workstations.

I won’t have been able to achieve this task without PowerShell 😎

Here’s what I did (I was using powershell V2 at that time):

  • Download the tool
  • The 32bit and 64bit file can be dowloaded from the following locations:
    http://definitionupdates.microsoft.com/download/definitionupdates/safetyscanner/amd64/msert.exe
    http://definitionupdates.microsoft.com/download/definitionupdates/safetyscanner/x86/msert.exe

    I’ve automated this task with a small PowerShell script that runs as a scheduled task under a specific domain user account who has his proxy settings configured:
    On PowerShell version 3.0 you can do:

    #Requires -Version 3.0            
    $scriptrootpath = Split-Path -parent $MyInvocation.MyCommand.Definition            
    $urlrootpath = "http://definitionupdates.microsoft.com/download/definitionupdates/safetyscanner"            
    "amd64","x86" | ForEach-Object -Process {            
        $version = $_            
        try {            
            Invoke-WebRequest -Uri "$urlrootpath/$_/msert.exe" -ErrorAction Stop -OutFile (            
                Join-Path -Path $scriptrootpath -ChildPath "\$_\msert.exe")            
        } catch {            
            Write-Warning -Message "Failed to download the $version because $($_.Exception.Message)"            
        }            
    }

    On PowerShell version 2.0 you can do:

    #Requires -Version 2.0            
    # Set the web client            
    $wc = New-Object System.Net.WebClient            
                
    $scriptrootpath = Split-Path -parent $MyInvocation.MyCommand.Definition            
                
    # Get the x64 version            
    $wc.DownloadFile(            
    'http://definitionupdates.microsoft.com/download/definitionupdates/safetyscanner/amd64/msert.exe',            
    (Join-Path -Path $scriptrootpath -ChildPath "\x64\msert.exe")            
    )            
                
    # Get the x86 version            
    $wc.DownloadFile(            
    'http://definitionupdates.microsoft.com/download/definitionupdates/safetyscanner/x86/msert.exe',            
    (Join-Path -Path $scriptrootpath -ChildPath "\x86\msert.exe")            
    )

    Note that if you need a to specify a proxy and use credentials with the Invoke-Webrequest cmdlet, you could use splatting and do:

    $securePW = ConvertTo-SecureString -AsPlainText -String $cleartxtPW -Force            
    $cred = new-object System.Management.Automation.PSCredential("$env:USERDOMAIN\$env:username",$securePW)            
    $HT = @{            
        Proxy = [system.uri]"http://my.corp.proxy.fqdn.url:8080"            
        ProxyCredential = $cred            
    }
  • Run the tool
  • I’ve stored both 32 and 64 bit versions in a central location. I’ve planned a scheduled task on all the target workstations that runs once at midnight, pulls locally the correct file (32bit for XP and 64bit for Windows 7 in my case) and launches the msert.exe with the following arguments: /F /N /Q
    MSERT commandline switches

  • Copy results to a central location
  • On the next morning, I’ve configured my daily maintenance script to copy the log file found in to a central location:

    "$env:SystemRoot\debug\msert.log"

    Each msert.log has been copied in a central share into a folder based on the computername and its location.

  • Analyse results
  • I’m not really very proud of this quick and dirty approach but to be able to quickly analyse results over thousands of files, I wrote the following function that:

    • parses the file log and splits results being appended to the log by each msert.exe invocation
    • extracts the main result code of each scan, the version of msert definition used, …
    • extracts an array of threats that contains the threat name and the raw details associated with it
    • only returns custom objects

    I’ve been using two helpers function provided by two Powershell MVPs:

    #Requires -Version 2.0
    Function Get-MsertReport {
        [cmdletBinding()]
        param(
            [parameter(mandatory=$true)]
            [string]$filepath=$null
        )
        Begin {
           
            $reports = @()
            $mainstart = 0
            $allcontent = (Get-content -Path $filepath -ReadCount 1 -TotalCount -1 -Encoding Unicode)
            $mainend = $allcontent.Length
            $allcontentatonce = Get-content  -ReadCount 0 -Path $filepath -Encoding Unicode
            $reportscount = (($allcontentatonce | Get-Matches '\-{87}') | Measure-Object).Count
            $reportsObj = $allcontent | Select-String -Pattern ([regex]'(\-){87}')
    
            if ($reportscount -eq 1)
            {
                $reports += New-Object -TypeName PSObject -Property @{
                    Index = 0
                    Start = $mainstart
                    End = $mainend
                    Value = $allcontentatonce
                }
            } else {
                for ($i=0 ; $i -le ($reportscount-1) ; $i++)
                {
                    switch($i)
                    {
                        {$i -eq 0} {
                            $first = $reportsObj[0].LineNumber
                            $second = $reportsObj[1].LineNumber
                            $reports += New-Object -TypeName PSObject -Property @{
                                Index = $_
                                Start = $first
                                End = $second
                                Value = ((Get-Content -Path $filepath -TotalCount $second)[$first..$second])
                            }
                            break
                        }
                        {$i -eq ($reportscount-1)} {
                            $prev2last = $reportsObj[$reportscount-1].LineNumber
                            $reports += New-Object -TypeName PSObject -Property @{
                                Index = $_
                                Start = $prev2last
                                End = $mainend
                                Value = (($allcontent)[$prev2last..$mainend])
                            }
                            break
                        }
                        default {
                            $next = $reportsObj[$i+1].LineNumber
                            $prev = $reportsObj[$i].LineNumber
                            $reports += New-Object -TypeName PSObject -Property @{
                                Index = $_
                                Start = $prev
                                End = $next
                                Value = ((Get-Content -Path $filepath -TotalCount $next)[($prev+1)..($next-1)])
                           }
                        }
                    }
                }
            }
        }
        Process 
        {    
            $reports | ForEach-Object -Process {
    
                # Build an empty treats array
                $Threats = @()
                
                $report = $_
                $reportvalue = $report.Value
                
                # Get the build
                $build = [version]($reportvalue | Get-Matches '^Microsoft\sSafety\sScanner\sv\d{1}\.\d{1},\s\(build(?<build>\s\d{1}\.\d{3}\.\d{3}\.\d{1})\)').build
                
                # Get the return code
                $ReturnCode = ($reportvalue | Get-Matches '^Return\scode:\s\d{1,2}\s\((?<hexcode>.*)\)' ).hexcode
    
                # Ignore partial logs or old formatted logs w/o return code
                if ($ReturnCode){
    
                # Get the date of the scan
                $startdate = $reportvalue | Get-Matches '^Started\sOn\s(?<date>.*)'
                
                # http://msdn.microsoft.com/en-us/library/8kb3ddd4.aspx
                $date = ConvertFrom-DateString -Value $startdate.date -FormatString  'ddd MMM dd HH:mm:ss yyyy'
                
                $start = ($reportvalue | Select-String -Pattern "^----------------$")[0].LineNumber
                $end   = ($reportvalue | Select-String -Pattern "^----------------$")[1].LineNumber-3
    
                $count = (($reportvalue | Get-Matches '^Threat\sdetected:\s(?<ThreatName>.*)') | Measure-Object).Count
    
                $ThreatsObj = ($reportvalue | Select-String -Pattern "^Threat\sdetected:\s(?<ThreatName>.*)") 
    
                if ($count -eq 1)
                {
                    $ThreatName = ($ThreatsObj | Get-Matches '^Threat\sdetected:\s(?<ThreatName>.*)').ThreatName
                    $ThreatDetails = (Get-Content -Path $filepath -TotalCount $report.end)[($report.start+$ThreatsObj.LineNumber)..($report.start+$end)]
                    $Threats += New-Object -TypeName PSObject -Property @{
                            ThreatName = $ThreatName
                            RawDetails = $ThreatDetails
                    }
                } else {
                    # If there's more than 1 match returned by select-string...
                    for ($i=0 ; $i -le ($count-1) ; $i++)
                    {
                        $ThreatName = ($ThreatsObj[$i]).Matches | ForEach-Object -Process {$_.Groups} | Select-Object -Last 1 -ExpandProperty Value
                        switch ($i)
                        {
                            # First
                            {$i -eq 0} {
                                $second = $report.start + ($ThreatsObj[1]).LineNumber
                                $first =  $report.start + ($ThreatsObj[0]).LineNumber
                                $ThreatDetails = (Get-Content -Path $filepath -TotalCount $second)[$first..($second-2)] #| Select-string -Pattern "^->Scan\sERROR:" -NotMatch
                                break
                            }
                            # Last
                            {$i -eq ($count-1)} {
                                $prev2last = $report.start + ($ThreatsObj[$count-1]).LineNumber
                                $ThreatDetails = (Get-Content -Path $filepath -TotalCount $report.end)[$prev2last..($report.start+$end+1)]
                                break
                            }
                            default {
                                $prev = $report.start + ($ThreatsObj[$i]).LineNumber
                                $next = $report.start + ($ThreatsObj[$i+1]).LineNumber
                                $ThreatDetails = (Get-Content -Path $filepath -TotalCount $report.end)[($prev+1)..($next-1)]
                            }
                        }
                        $Threats += New-Object -TypeName PSObject -Property @{
                            ThreatName = $ThreatName
                            RawDetails = $ThreatDetails
                        }
                    }
                }
                New-Object -TypeName PSobject -Property @{
                    Build = $build
                    Date = $date
                    ReturnCode = $ReturnCode
                    Threats = $Threats
                }
                }
            }
        } 
        End {}
    }
    

    For the statistics part, I did:

    • Grab all the results
    • $results = @()            
      $results = Get-ChildItem -Path "\\ServerName\CentralShareName" -Exclude "FolderName1","Folder2" | Where-Object -FilterScript {$_ -is [System.IO.DirectoryInfo]} | ForEach-Object -Process {            
          $Directory = $_.FullName            
          # There's an underscore between the location and the computername            
          $ComputerName = ($_.Name -split '_')[-1]            
          $hasMSERT = $isW7 = $false            
          $ComputerReport = $null            
          # if the W7.dat file is present in the folder, it's a Windows 7 based computer            
          Get-Item "$Directory\*" -Include W7.dat,msert.log | ForEach-Object -Process {            
              if ($_.Name -eq 'W7.dat') {$isW7 = $true}            
              if ($_.Name -eq 'msert.log') { $hasMSERT = $true ; $ComputerReport = Get-MsertReport -filepath $_.FullName}            
          }            
          New-Object -TypeName PSObject -Property @{            
              ComputerName = $ComputerName            
              HasMSERTlog = $hasMSERT            
              isWindows7 = $isW7            
              Report = $ComputerReport            
          }            
      }
    • Get some general figures
    • # Count the total # of folders            
      $results.Count            
                  
      # Count those that have a msertlog file            
      ($results | Where {$_.HasMSERTlog}).Count            
                  
      # Count the total # of W7 computers            
      ($results | Where { $_.isWindows7}).Count            
                  
      # Count the total # of Windows 7 that have a msert log file            
      ($results | Where { $_.isWindows7 -and $_.HasMSERTlog}).Count
    • Extract the results for a single computer
    • # Get the specific results from our test computer for a specific Msert version            
      $results | Where { $_.ComputerName -eq 'test-ComputerName'} | ForEach-Object -Process {            
          New-Object -TypeName PSObject -Property @{            
              ComputerName = $_.ComputerName            
              IsW7 = $_.isWindows7            
              ThreatsName = ($_.Report | Where {$_.Build -eq '1.135.850.0'} | Select-Object -ExpandProperty Threats | Select-Object -Property ThreatName)            
          }            
      }
    • Extract other lists
    • # Sort the unique names of malware found            
      ($results | Where {$_.Report } | Select-Object -ExpandProperty Report | Where { $_.ReturnCode -ne '0x0'} | Select-Object -ExpandProperty Threats | Select-Object -Property ThreatName | Sort-Object -Unique -Property ThreatName)            
                  
      # Total count of unique malware names found            
      ($results | Where {$_.Report } | Select-Object -ExpandProperty Report | Where { $_.ReturnCode -ne '0x0'} | Select-Object -ExpandProperty Threats | Select-Object -Property ThreatName | Sort-Object -Unique -Property ThreatName).Count            
                  
      # Unique malware name with their occurrence            
      $results | Where {$_.Report } | Select-Object -ExpandProperty Report | Where { $_.ReturnCode -ne '0x0'} | Select-Object -ExpandProperty Threats | Select-Object -Property ThreatName | Group-Object -Property ThreatName | Sort-Object -Property Count            
                  
      # Total unique computers infected after our scan             
      ($results | Where {$_.Report } | ForEach-Object -Process {            
          New-Object -TypeName PSObject -Property @{            
              ComputerName = $_.ComputerName            
              IsW7 = $_.isWindows7            
              ThreatsName = ($_.Report | Where {$_.Build -eq '1.135.850.0'} | Select-Object -ExpandProperty Threats | Select-Object -Property ThreatName)            
          }            
      }  | Where {$_.ThreatsName} | Sort-Object -Property ThreatsName).count | Format-Table -AutoSize -Wrap -Property ComputerName,isW7,@{l='Threats';e={$_.ThreatsName}}            
      
    • More and more…
    •          
                  
      # Display results with computer names and their list of threats found            
      $results | Where {$_.Report } | ForEach-Object -Process {            
          New-Object -TypeName PSObject -Property @{            
              ComputerName = $_.ComputerName            
              IsW7 = $_.isWindows7            
              ThreatsName = ($_.Report | Where {$_.Build -eq '1.135.850.0'} | Select-Object -ExpandProperty Threats | Select-Object -Property ThreatName)            
          }            
      }  | Where {$_.ThreatsName} | Sort-Object -Property ThreatsName | Format-Table -AutoSize -Wrap -Property ComputerName,isW7,@{l='Threats';e={$_.ThreatsName}}            
                  
                  
      # Display results for W7 computers            
      $results | Where {$_.Report } | ForEach-Object -Process {            
          New-Object -TypeName PSObject -Property @{            
              ComputerName = $_.ComputerName            
              IsW7 = $_.isWindows7            
              ThreatsName = ($_.Report | Where {$_.Build -eq '1.135.850.0'} | Select-Object -ExpandProperty Threats | Select-Object -Property ThreatName)            
          }            
      }  | Where {$_.ThreatsName -and $_.isW7} | Sort-Object -Property ThreatsName | Format-Table -AutoSize -Wrap -Property ComputerName,isW7,@{l='Threats';e={$_.ThreatsName}}            
                  
                  
      # Display results for XP computers            
      $results | Where {$_.Report } | ForEach-Object -Process {            
          New-Object -TypeName PSObject -Property @{            
              ComputerName = $_.ComputerName            
              IsW7 = $_.isWindows7            
              ThreatsName = ($_.Report | Where {$_.Build -eq '1.135.850.0'} | Select-Object -ExpandProperty Threats | Select-Object -Property ThreatName)            
          }            
      }  | Where {$_.ThreatsName -and -not($_.isW7)} | Sort-Object -Property ThreatsName | Format-Table -AutoSize -Wrap -Property ComputerName,isW7,@{l='Threats';e={$_.ThreatsName}}            
                  
                  
      # Count of W7 computers compromised            
      ($results | Where {$_.Report } | ForEach-Object -Process {            
          New-Object -TypeName PSObject -Property @{            
              ComputerName = $_.ComputerName            
              IsW7 = $_.isWindows7            
              ThreatsName = ($_.Report | Where {$_.Build -eq '1.135.850.0'} | Select-Object -ExpandProperty Threats | Select-Object -Property ThreatName)            
          }            
      }  | Where {$_.ThreatsName -and $_.isW7} ).Count            
                  
      # Count of XP computers            
      ($results | Where {$_.Report } | ForEach-Object -Process {            
          New-Object -TypeName PSObject -Property @{            
              ComputerName = $_.ComputerName            
              IsW7 = $_.isWindows7            
              ThreatsName = ($_.Report | Where {$_.Build -eq '1.135.850.0'} | Select-Object -ExpandProperty Threats | Select-Object -Property ThreatName)            
          }            
      }  | Where {$_.ThreatsName -and -not($_.isW7)} ).Count            
      
    • Look for a specific string in the results
    • $results | Where {$_.Report } | ForEach-Object -Process {            
          New-Object -TypeName PSObject -Property @{            
              ComputerName = $_.ComputerName            
              IsW7 = $_.isWindows7            
              Threats = ($_.Report | Where {$_.Build -eq '1.135.850.0'} | Select-Object -ExpandProperty Threats | Select-Object -ExpandProperty RawDetails | Where {$_ -match "Obfuscator"})            
          }            
      }  | Where {$_.Threats }
    • Review all the results for Windows 7 computers
    • # Display results with computer names and their list of threats found            
      $results | Where {$_.Report } | ForEach-Object -Process {            
          New-Object -TypeName PSObject -Property @{            
              ComputerName = $_.ComputerName            
              IsW7 = $_.isWindows7            
              ThreatsName = ($_.Report | Where {$_.Build -eq '1.135.850.0'} | Select-Object -ExpandProperty Threats | Select-Object -Property ThreatName)            
              Report = $_.Report            
          }            
      }  | Where {$_.ThreatsName} | % {             
          if ($_.IsW7) {            
              "$($_.ComputerName)" ;             
                          
              $_.Report| Where {$_.Build -eq '1.135.850.0'} | Select-Object -ExpandProperty Threats | % {            
                  $_.ThreatName            
                  $_.RawDetails            
              }            
                  
          }                
      }

    Last word, don’t panic when you review results. You may find many malware that are actually inactive and harmless. I recommend that you contact your antivirus editor. You should ask them how their real time protection works, they should help you analyse results as well as review your antivirus configuration and check if you’ve followed best practices.