Skip to main content

· 2 min read

Sometimes I get a script idea put in my head that's so irritatingly pervasive that the only fix is to write the damned script. David Szpunar from the NinjaOne Users Discord made a somewhat passing comment about time drift causing issues with a remote support tool and that let to me thinking... You could probably monitor for that with a PowerShell one-liner right?

Wrong! Turns out that it's more than one line!

The Script

This Script Requires Input

This script requires user input, whether in the form of variables, parameters or edits to the script itself before you can run it. Areas where you need to provide input will be indicated with:

### Inline Comments

and / or

'<MARKED STRINGS>'

Parameters will be indicated before the script block.

This Script Was Updated

This script was updated after being published, if you're using it please compare the version you have with the version available here.

This script was last updated on 2023/03/17.

# This script will monitor the time drift between the local machine and a reference server.
# The script accepts the following parameters:
## ReferenceServer: The NTP or local domain controller to use as a reference for time drift.
## NumberOfSamples: The number of samples to take.
## AllowedTimeDrift: The allowed time drift in seconds.
# The script will return the following:
## If the time drift is within the allowed time drift, the script will return a message if the -Verbose switch is used.
## If the time drift is greater than the allowed time drift, the script will throw an error.
## If the -Debug switch is used, the script will return various raw data.

# Thanks to David Szpunar from the NinjaOne Users Discord for inspiring this one.
# Thanks to Kevin Holman for the many useful bits of code in his script here: https://kevinholman.com/2017/08/26/monitoring-for-time-drift-in-your-enterprise/
# Thanks to Scott - CO from the One Mand Band MSP Discord for the idea to add a resync option.
# Thanks to Chris Taylor (https://christaylor.codes/) for the suggestion to add `| Where-Object { $_ }` to exclude empty lines from the output.

[CmdletBinding()]
param (
# The NTP or local domain controller to use as a reference for time drift.
[string]$ReferenceServer = 'time.windows.com',
# The number of samples to take.
[int]$NumberOfSamples = 1,
# The allowed time drift in seconds.
[int]$AllowedTimeDrift = 10,
# Force a resync of the time if the time drift is greater than the allowed time drift.
[switch]$ForceResync
)
$Win32TimeExe = Join-Path -Path $ENV:SystemRoot -ChildPath 'System32\w32tm.exe'
$Win32TimeArgs = '/stripchart /computer:{0} /samples:{1} /dataonly' -f $ReferenceServer, $NumberOfSamples
$ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo
$ProcessInfo.FileName = $Win32TimeExe
$ProcessInfo.Arguments = $Win32TimeArgs
$ProcessInfo.RedirectStandardError = $true
$ProcessInfo.RedirectStandardOutput = $true
$ProcessInfo.UseShellExecute = $false
$ProcessInfo.CreateNoWindow = $true
$Process = New-Object System.Diagnostics.Process
$Process.StartInfo = $ProcessInfo
$Process.Start() | Out-Null
$ProcessResult = [PSCustomObject]@{
ExitCode = $Process.ExitCode
StdOut = $Process.StandardOutput.ReadToEnd()
StdErr = $Process.StandardError.ReadToEnd()
}
$Process.WaitForExit()
if ($ProcessResult.StdErr) {
throw "w32tm.exe returned the following error: $($ProcessResult.StdErr)"
} elseif ($ProcessResult.StdOut -contains 'Error') {
throw "w32tm.exe returned the following error: $($ProcessResult.StdOut)"
} else {
Write-Debug ('Raw StdOut: {0}' -f $ProcessResult.StdOut)
$ProcessOutput = $ProcessResult.StdOut.Split("`n") | Where-Object { $_ }
$Skew = $ProcessOutput[-1..($NumberOfSamples * -1)] | ConvertFrom-Csv -Header @('Time', 'Skew') | Select-Object -ExpandProperty Skew
Write-Debug ('Raw Skew: {0}' -f $Skew)
$AverageSkew = $Skew | ForEach-Object { $_ -replace 's', '' } | Measure-Object -Average | Select-Object -ExpandProperty Average
Write-Debug ('Average Skew: {0}' -f $AverageSkew)
if ($AverageSkew -lt 0) { $AverageSkew = $AverageSkew * -1 }
$TimeDriftSeconds = [Math]::Round($AverageSkew, 2)
if ($TimeDriftSeconds -gt $AllowedTimeDrift) {
if ($ForceResync) {
Start-Process -FilePath $Win32TimeExe -ArgumentList '/resync' -Wait
Write-Warning "Time drift was greater than the allowed time drift of $AllowedTimeDrift seconds. Time drift was $TimeDriftSeconds seconds A resync was forced."
} else {
throw "Time drift is greater than the allowed time drift of $AllowedTimeDrift seconds. Time drift is $TimeDriftSeconds seconds."
}
} else {
Write-Verbose "Time drift is within accepted limits. Time drift is $TimeDriftSeconds seconds."
}
}

Using The Script

We tested this as a "Script Result Condition" in NinjaOne set to trigger the monitor if a machine's time drifts by more than 10 seconds from uk.pool.ntp.org (the UK's NTP pool) and it worked like a charm. The script is pretty self-explanatory but here's a quick rundown of what it does:

  1. It uses a configurable NTP or SNTP server to get the "reference" time. (Parameter -ReferenceServer)
  2. It uses the w32tm executable to conduct a number of skew checks against that reference server (Parameter -NumberOfSamples)
  3. It averages the samples and compares the result to the threshold (Parameter -AllowedTimeDrift)
  4. Optionally you can force a resync if the time drift is greater than the threshold (Parameter -ForceResync)

If the average time drift is greater than the threshold, the script returns a non-zero exit code and the monitor triggers. If the w32tm command errors (non existent server, network down etc) the script returns a non-zero exit code and the monitor triggers.

Credits

This script borrows ideas and the approach and a little code from the excellent blog of Kevin Holman.

The formidable Chris Taylor helped with a cool suggestion to suppress empty lines in the output and his site is well worth a visit.

· 6 min read

This post will hold detection scripts for any serious CVE vulnerability that we write detection scripts for in the future. It will be updated and added to as new vulnerability detection scripts are written.

CVE-2022-41099

This script has been compiled using information from the following Microsoft sources:

security

This article relates to CVE-2022-41099 which is a vulnerability in the Windows Recovery Environment (WinRE) which could allow a successful attacker to bypass the BitLocker Device Encryption feature on the system storage device. An attacker with physical access to the target could exploit this vulnerability to gain access to encrypted data.

Fixed a Bug

Thanks to DTGBilly from the NinjaOne Users Discord for pointing out that in altogether far too many places I had typo'd the CVE as CVE-4022-41099 instead of CVE-2022-41099 🤦‍♂️ this included field names and labels so please check yours are correct as now shown in the post.

Parameters!

Since version 1.2.0 (2023-03-21) this script now requires one of two mandatory parameters.

  • If you are checking for the presence of the small "Safe OS Dynamic Update (SODU)" which is the minimum required change to mitigate the vulnerability use the -CheckPackage parameter and if required alter the -MountDirectory and -LogDirectory parameters (defaults to C:\RMM\WinRE).

  • If you are checking for the presence of the larger "Servicing Stack Update (SSU)" or "Dynamic Cumulative Update" which updates more than is required to mitigate the vulnerability, but may offer other benefits including new WinRE functionality or more reliable reset/restore behaviours use the -CheckImage parameter which checks the image build version.

If you were passing these in NinjaOne your parameter preset might look like this:

-CheckPackage -MountDirectory C:\RMM\WinRE -LogDirectory C:\RMM\WinRE

or this:

-CheckImage

Windows Recovery Environment (WinRE) Not Enabled

Before version 1.3.0 the script did not check if WinRE was enabled which could lead to confusing error output in the event WinRE was disabled. Now if you get the WinRE not enabled warning you are clear on why the script isn't executing.

A simple reagentc /enable should enable WinRE or at least provide some useful troubleshooting output.

Creating Fields

Creating custom fields in NinjaOne

To create a custom field at the device level in NinjaOne go to Administration > Devices and select either Role Custom Fields or Global Custom Fields then select Add.

  • Role Custom Fields are custom fields that are specific to a device role.
  • Global Custom Fields are custom fields that are applicable to all devices.

Make sure you add the fields to the roles you want to use them in at Administration > Devices > Roles.

To create a custom field at the organisation level in NinjaOne go to Administration > Apps > Documentation, enable the Documentation feature if you haven't already. If you have then select Add.

When you create your custom field you need to make sure that you set the "Scripts" permission to ensure that you can read or write to the field from your scripts - as appropriate for the script you're using.

We're adding one role custom field for devices with the Windows Desktop or Laptop role, note that we've customised slightly the autogenerated machine name here, if you use the default adjust the field name in the script appropriately.

Field LabelField NameField TypeDescription
CVE-2022-41099CVE202241099CheckboxWhether the device has a WinRE image vulnerable to CVE-2022-41099

The Script

This Script Was Updated

This script was updated after being published, if you're using it please compare the version you have with the version available here.

This script was last updated on 2023/03/24.

# This script tests whether the Windows Recovery Environment is vulnerable to CVE-2022-41099.
# Thanks to Wisecompany on the One Man Band MSP Discord and NinjaOne Users Discord for prompting me to update this to check for the Safe OS Dynamic Update package.

# Version 1.3.0 - Checks whether WinRE is even enabled before attempting to check for the update/vulnerability. - 2023/03/24
# Version 1.2.0 - Major refactor, now supports checking for the SafeOS Dynamic Update package presence. - 2023/03/21
# Version 1.1.0 - Better logic, more versions supported - 2023-01-17
# Version 1.0.0 - Initial Release - 2023-01-13
[CmdletBinding()]
param (
[Parameter(ParameterSetName = 'Package', Mandatory = $true)]
[Switch]$CheckPackage,
[Parameter(ParameterSetName = 'Image', Mandatory = $true)]
[Switch]$CheckImage,
[Parameter(ParameterSetName = 'Package')]
[System.IO.DirectoryInfo]$MountDirectory = 'C:\RMM\WinRE\Mount',
[Parameter(ParameterSetName = 'Package')]
[System.IO.DirectoryInfo]$LogDirectory = 'C:\RMM\WinRE\Logs'
)
$WinREEnabled = (reagentc /info | findstr 'Enabled').Replace('Windows RE status: ', '').Trim()
if (-not ($WinREEnabled)) {
Write-Warning 'Windows RE is disabled - exiting...'
return $false
}
$WinREImagePath = (reagentc /info | findstr '\\?\GLOBALROOT\device').Replace('Windows RE location: ', '').Trim() + '\winre.wim'
$WinREBuild = (Get-WindowsImage -ImagePath $WinREImagePath -Index 1).SPBuild
# $WinREModified = (Get-WindowsImage -ImagePath $WinREImagePath -Index 1).ModifiedTime
$WinOSBuild = [System.Environment]::OSVersion.Version.Build
$BuildtoKBMap = @{
22623 = 5023527
22621 = 5023527
22000 = 5021040
19045 = 5021043
19044 = 5021043
19043 = 5021043
19042 = 5021043
}
function Mount-WinRE {
if (-not(Test-Path $MountDirectory)) {
New-Item $MountDirectory -ItemType Directory
} else {
$MountDirectoryContents = Get-ChildItem $MountDirectory
if ($MountDirectoryContents) {
Write-Warning "Mount directory isn't empty - exiting..."
return $false
}
}
if ((Get-WindowsImage -Mounted).count -ge 1) {
Write-Warning 'There is at least one other image mounted already = exiting...'
return $false
}
$Mount = ReAgentC.exe /mountre /path $MountDirectory
if ($Mount) {
if ($Mount[0] -notmatch '.*\d+.*' -and (Get-WindowsImage -Mounted).count -ge 1 -and $LASTEXITCODE -eq 0) {
return $true
}
} else {
Write-Warning 'Could not mount WinRE image.'
Write-Warning "$Mount"
return $false
}
}

function Dismount-WinRE {
$DismountImageLogFile = Join-Path -Path $LogDirectory -ChildPath ('Dismount-WindowsImage_{0}.log' -f $DateTime)
$DismountWinRECommonParameters = @{
Path = $MountDirectory
LogLevel = 'WarningsInfo'
}
$UnmountDiscard = ReAgentC.exe /unmountre /path $($MountDirectory) /discard
if (($UnmountDiscard[0] -match '.*\d+.*') -or $LASTEXITCODE -ne 0) {
Write-Warning 'Attempting to unmount and discard failed - trying alternative method'
Dismount-WindowsImage @DismountWinRECommonParameters -LogPath $DismountImageLogFile -Discard
if ($(Get-WindowsImage -Mounted).count -ge 1) {
Write-Warning 'Unmounting failed, including alternative methods.'
return $false
} else {
return $true
}
} else {
return $true
}
}

if ($CheckPackage) {
if (-not (Mount-WinRE)) {
Write-Warning 'Could not mount WinRE image - exiting...'
exit 1
}
$KB = ('KB{0}' -f $BuildtoKBMap[$WinOSBuild])
$PackageApplied = (Get-WindowsPackage -Path $MountDirectory | Where-Object { $_.PackageName -like "*$KB*" }).PackageState -eq 'Installed'
if (-not (Dismount-WinRE)) {
Write-Warning 'Could not dismount WinRE image - exiting...'
exit 1
}
if (-not ($PackageApplied)) {
Write-Warning 'SafeOS Dynamic Update Package not present in WinRE image.'
$Vulnerable = $true
} else {
Write-Output 'SafeOS Dynamic Update Package present in WinRE image.'
$Vulnerable = $false
}
}

if ($CheckImage) {
if (($WinOSBuild -in @(22623, 22621)) -and ($WinREBuild -lt 1105)) {
$Vulnerable = $true
} elseif (($WinOSBuild -eq 22000) -and ($WinREBuild -lt 1455)) {
$Vulnerable = $true
} elseif (($WinOSBuild -in @(19045, 19044, 19042)) -and ($WinREBuild -lt 2486)) {
$Vulnerable = $true
} elseif (($WinOSBuild -eq 19043) -and ($WinREBuild -lt 2364)) {
$Vulnerable = $true
} else {
$Vulnerable = $false
}
}
if ($Vulnerable) {
Write-Warning 'Vulnerable to CVE-2022-41099'
Ninja-Property-Set CVE202241099 1
} else {
Write-Output 'Not vulnerable to CVE-2022-41099'
Ninja-Property-Set CVE202241099 0
}

The Results

CVE-2022-41099 Related Custom Fields

We run this script daily and have a corresponding monitor setup to check CVE fields with a value of "Yes" and alert us if any are found. You'll find information on remediating this vulnerability in this followup post.

CVE-2023-23397

This script has been compiled using information from the following Microsoft sources:

Thanks to:
  • Concentus on the NinjaOne Users Discord for helping me run down and test different versions of Office to ensure this script was as accurate as possible.
  • Wisecompany on the One Man Band MSP Discord for reminding me to add an exit code and not overuse Write-Warning!
  • Thanks to KennyW on the MSPGeek Discord for helping find an error where certain versions were incorrectly detected as not vulnerable!
  • Thanks to Alkerayn on the NinjaOne Users Discord for helping find an error where certain channels were incorrectly detected as not vulnerable and identifying that we needed to first check the GPO-configured update channel!
  • Thanks to Tanner - MO on the MSPs R Us Discord for pointing out that version comparisons should all use -lt instead of -ne to ensure future compatibility / accuracy.
  • Thanks to DarrenWhite99 on the MSPGeek Discord for pointing out that the check for the GPO UpdateChannel was completely nonsensical and incompletely written.
  • Thanks to JSanz on the NinjaOne Users Discord for pointing out the GUID matching issue/bug.
  • Thanks to Jhn - TS on the NinjaOne Users Discord for discovering the issue with empty registry props causing the script to error.

This has only been tested against M365 Apps and Office 2021 VL versions "en masse" and only 64-bit office - if it doesn't work for you let me know on the NinjaOne Users Discord and I'll see what I can do to fix it!

security

This article relates to CVE-2023-23397 which is a vulnerability in Microsoft Outlook whereby an attacker could access a user's Net-NTLMv2 hash which could be used as a basis of an NTLM Relay attack against another service to authenticate as the user.

Creating Fields

Creating custom fields in NinjaOne

To create a custom field at the device level in NinjaOne go to Administration > Devices and select either Role Custom Fields or Global Custom Fields then select Add.

  • Role Custom Fields are custom fields that are specific to a device role.
  • Global Custom Fields are custom fields that are applicable to all devices.

Make sure you add the fields to the roles you want to use them in at Administration > Devices > Roles.

To create a custom field at the organisation level in NinjaOne go to Administration > Apps > Documentation, enable the Documentation feature if you haven't already. If you have then select Add.

When you create your custom field you need to make sure that you set the "Scripts" permission to ensure that you can read or write to the field from your scripts - as appropriate for the script you're using.

We're adding one role custom field for devices with the Windows Desktop or Laptop role, note that we've customised slightly the autogenerated machine name here, if you use the default adjust the field name in the script appropriately.

Field LabelField NameField TypeDescription
CVE-2023-23397CVE202323397CheckboxWhether the device has an Office or Microsoft 365 Apps version vulnerable to CVE-2023-23397.

The Script

This Script Was Updated

This script was updated after being published, if you're using it please compare the version you have with the version available here.

This script was last updated on 2023/03/17.

# This script tests whether the Microsoft Office (C2R) installation is vulnerable to CVE-2020-23397.
# Thanks to Concentus on the NinjaOne Users Discord for help running down Office versions to check the logic!
# Thanks to Wisecompany on the One Man Band MSP Discord for reminding me to add an exit code and not overuse `Write-Warning`!
# Thanks to KennyW on the MSPGeek Discord for helping find an error where certain versions were incorrectly detected as not vulnerable!
# Thanks to Alkerayn on the NinjaOne Users Discord for helping find an error where certain channels were incorrectly detected as not vulnerable and identifying that we needed to first check the GPO-configured update channel!
# Thanks to Tanner - MO from MSPs R Us Discord for pointing out that comparisons should all be `-lt` for future proofing.
# Thanks to DarrenWhite99 on the MSPGeek Discord for pointing out that the check for the GPO UpdateChannel was completely nonsensical and incompletely written.
# Thanks to Nullzilla on all the Discords for pointing out that we need to silently continue on missing registry props.
# Thanks to JSanz on the NinjaOne Users Discord for pointing out the GUID matching issue/bug.
# Thanks to Jhn - TS on the NinjaOne Users Discord for discovering the issue with empty registry props causing the script to error.

# Version 1.7.0 - Fix URL handling to avoid errors then the key exists with a null value. - 2023/03/17
# Version 1.6.0 - Improved output, fixed a bug where the update channel GUID was failing to match despite actually matching! - 2023/03/17
# Version 1.5.0 - Silently continue if missing registry properties. - 2023/03/17
# Version 1.4.0 - Handle more Office update channel configuration locations. Fix incorrect channel detection logic when using the GPO UpdateChannel. - 2023/03/17
# Version 1.3.0 - Check versions using a "less than" comparison for vulnerability to allow future proof usage. - 2023/03/16
# Version 1.2.0 - Check GPO channel config, adjust target version for Semi-Annual Enterprise (Preview) channel, fix Write-Warning/Write-Output mixup, more output info. - 2023/03/16
# Version 1.1.0 - Fix O365 app misdetection, better error handling, don't omit warning on success. - 2023/03/16
# Version 1.0.0 - Initial Release - 2023/03/15

$IsC2R = Test-Path 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun'

if ($IsC2R) {
# Get the installed Office Version
$OfficeVersion = [version]( Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration' | Select-Object -ExpandProperty VersionToReport )
# Get the installed Office Product IDs
$OfficeProductIds = ( Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration' | Select-Object -ExpandProperty ProductReleaseIds )
} else {
Write-Error 'No Click-to-Run Office installation detected. This script only works with Click-to-Run Office installations.'
Exit 1
}

$IsO365 = $OfficeProductIds -like '*O365*'

if ($IsO365) {
# Check the Office GPO settings for the update channel.
$OfficeUpdateChannelGPO = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Office\16.0\Common\OfficeUpdate' -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty UpdateBranch -ErrorAction 'SilentlyContinue')
if ($OfficeUpdateChannelGPO) {
Write-Output 'Office is configured to use a GPO update channel.'
# Define the Office Update Channels
$Channels = @(
@{
ID = 'Current'
Name = 'Current'
PatchedVersion = [version]'16.0.16130.20306'
},
@{
ID = 'FirstReleaseCurrent'
Name = 'Current (Preview)'
PatchedVersion = [version]'16.0.16227.20094'
},
@{
ID = 'MonthlyEnterprise'
Name = 'Monthly Enterprise'
PatchedVersion = [version]'16.0.16026.20238'
},
@{
ID = 'Deferred'
Name = 'Semi-Annual Enterprise'
PatchedVersion = [version]'16.0.15601.20578'
},
@{
# This does not match Microsoft's documented version but is the latest available update on tested SAE-Preview channel installations.
ID = 'FirstReleaseDeferred'
Name = 'Semi-Annual Enterprise (Preview)'
PatchedVersion = [version]'16.0.16026.20238'
},
@{
ID = 'InsiderFast'
Name = 'Beta'
PatchedVersion = [version]'16.0.16310.20000'
}
)
foreach ($Channel in $Channels) {
if ($OfficeUpdateChannelGPO -eq $Channel.ID) {
$OfficeChannel = $Channel
}
}
} else {
$C2RConfigurationPath = 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration'
Write-Output 'Office is not configured to use a GPO update channel.'
# Get the UpdateUrl if set
$OfficeUpdateURL = [System.Uri](Get-ItemProperty -Path $C2RConfigurationPath -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty UpdateURL -ErrorAction 'SilentlyContinue')
# Get the UnmanagedUpdateUrl if set
$OfficeUnmanagedUpdateURL = [System.Uri](Get-ItemProperty -Path $C2RConfigurationPath -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty UnmanagedUpdateURL -ErrorAction 'SilentlyContinue')
# Get the Office Update CDN URL
$OfficeUpdateChannelCDNURL = [System.Uri](Get-ItemProperty -Path $C2RConfigurationPath -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty CDNBaseUrl -ErrorAction 'SilentlyContinue')
# Get just the channel GUID
if ($OfficeUpdateURL.IsAbsoluteUri) {
$OfficeUpdateGUID = $OfficeUpdateURL.Segments[2]
} elseif ($OfficeUnmanagedUpdateURL.IsAbsoluteUri) {
$OfficeUpdateGUID = $OfficeUnmanagedUpdateURL.Segments[2]
} elseif ($OfficeUpdateChannelCDNURL.IsAbsoluteUri) {
$OfficeUpdateGUID = $OfficeUpdateChannelCDNURL.Segments[2]
} else {
Write-Error 'Unable to determine Office update channel URL.'
Exit 1
}
# Define the Office Update Channels
$Channels = @(
@{
ID = '492350f6-3a01-4f97-b9c0-c7c6ddf67d60'
Name = 'Current'
PatchedVersion = [version]'16.0.16130.20306'
},
@{
ID = '64256afe-f5d9-4f86-8936-8840a6a4f5be'
Name = 'Current (Preview)'
PatchedVersion = [version]'16.0.16227.20094'
},
@{
ID = '55336b82-a18d-4dd6-b5f6-9e5095c314a6'
Name = 'Monthly Enterprise'
PatchedVersion = [version]'16.0.16026.20238'
},
@{
ID = '7ffbc6bf-bc32-4f92-8982-f9dd17fd3114'
Name = 'Semi-Annual Enterprise'
PatchedVersion = [version]'16.0.15601.20578'
},
@{
# This does not match Microsoft's documented version but is the latest available update on tested SAE-Preview channel installations.
ID = 'b8f9b850-328d-4355-9145-c59439a0c4cf'
Name = 'Semi-Annual Enterprise (Preview)'
PatchedVersion = [version]'16.0.16026.20238'
},
@{
ID = '5440fd1f-7ecb-4221-8110-145efaa6372f'
Name = 'Beta'
PatchedVersion = [version]'16.0.16310.20000'
}
)
foreach ($Channel in $Channels) {
if ($OfficeUpdateGUID -eq $Channel.ID) {
$OfficeChannel = $Channel
}
}
}
if (-not $OfficeChannel) {
Write-Error 'Unable to determine Office update channel.'
Exit 1
} else {
Write-Output ("{0} found using the {1} update channel. `r`nChannel ID: {2}. `r`nTarget Version: {3}. `r`nDetected Version: {4}" -f 'Microsoft 365 Apps', $OfficeChannel.Name, $OfficeChannel.ID, $OfficeChannel.PatchedVersion, $OfficeVersion)
}
}

if ( $OfficeVersion.Major -eq '16' ) {
if ( ( $OfficeVersion.Build -ge 7571 ) -and ( $OfficeVersion.Build -le 16130 ) -and $IsO365 ) {

# Handle Microsoft 365 Apps
if ($OfficeVersion -lt $OfficeChannel.PatchedVersion) {
$Vulnerable = $true
}
} elseif ( ( $OfficeVersion.Build -ge 10356) -and ( $OfficeVersion.Build -le 10396 ) -and ( $OfficeProductIds -like '*2019Volume*' ) -and ( $OfficeProductIds -like '*2019Volume*' ) ) {
# Handle VL Office 2019
if ( ( $OfficeVersion.Build -lt 10396 ) -and ( $OfficeVersion.Revision -lt 20023 ) ) {
Write-Output ("{0} found. `r`nTarget Version: {1}. `r`nDetected Version: {2}" -f 'Office 2019 VL', [Version]'16.0.10396.20023', $OfficeVersion)
$Vulnerable = $true
}
} elseif ( ( $OfficeVersion.Build -ge 12527 ) -and ( $OfficeVersion.Build -le 16130 ) -and ( $OfficeProductIds -like '*Retail*' ) ) {
# Handle Office 2021 Retail, Office 2019 Retail and Office 2016 Retail
if ( ( $OfficeVersion.Build -lt 16130 ) -and ( $OfficeVersion.Revision -lt 20306 ) ) {
Write-Output ("{0} found. `r`nTarget Version: {1}. `r`nDetected Version: {2}" -f 'Office 2021, 2019 or 2016 Retail', [Version]'16.0.16130.20306', $OfficeVersion)
$Vulnerable = $true
}
} elseif ( ( $OfficeVersion.Build -eq 14332 ) -and ( $OfficeProductIds -like '*2021Volume*' ) ) {
# Handle VL Office LTSC 2021
if ( ( $OfficeVersion.Build -ne 14332 ) -and ( $OfficeVersion.Revision -lt 20481 ) ) {
Write-Output ("{0} found. `r`nTarget Version: {1}. `r`nDetected Version: {2}" -f 'Office LTSC 2021', [Version]'16.0.14332.20481', $OfficeVersion)
$Vulnerable = $true
}
}
} elseif ( $OfficeVersion.Major -eq '15' ) {
if ( [version]'15.0.5537.1000' -gt $OfficeVersion ) {
Write-Output ("{0} found. `r`nTarget Version: {1}. `r`nDetected Version: {2}" -f 'Office 2013', [Version]'15.0.5537.1000', $OfficeVersion)
$Vulnerable = $true
}
}

if ($Vulnerable) {
Write-Warning 'This version of Office is vulnerable to CVE-2023-23397.'
Ninja-Property-Set CVE202323397 1
} else {
Write-Output 'This version of Office is not vulnerable to CVE-2023-23397.'
Ninja-Property-Set CVE202323397 0
}

The Results

CVE-2023-23397 Related Custom Fields

We run this script daily and have a corresponding monitor setup to check CVE fields with a value of "Yes" and alert us if any are found. To remediate this vulnerability update Microsoft Office by running something like this:

This Script Was Updated

This script was updated after being published, if you're using it please compare the version you have with the version available here.

This script was last updated on 2023/03/16.

# Version 1.1.0 - Exit if no C2R.
# Version 1.0.0 - Initial Release - 2023/03/15
[CmdletBinding()]
param ()

if ([System.Environment]::Is64BitOperatingSystem) {
$C2RPaths = @(
(Join-Path -Path $ENV:SystemDrive -ChildPath 'Program Files (x86)\Common Files\Microsoft Shared\ClickToRun\OfficeC2RClient.exe'),
(Join-Path -Path $ENV:SystemDrive -ChildPath 'Program Files\Common Files\Microsoft Shared\ClickToRun\OfficeC2RClient.exe')
)
} else {
$C2RPaths = (Join-Path -Path $ENV:SystemDrive -ChildPath 'Program Files\Common Files\Microsoft Shared\ClickToRun\OfficeC2RClient.exe')
}
$C2RPaths | ForEach-Object {
if (Test-Path -Path $_) {
$C2RPath = $_
}
}
if ($C2RPath) {
Write-Verbose "C2RPath: $C2RPath"
Start-Process -FilePath $C2RPath -ArgumentList '/update user displaylevel=false forceappshutdown=true' -Wait
} else {
Write-Error 'No Click-to-Run Office installation detected. This script only works with Click-to-Run Office installations.'
Exit 1
}

This update script will force restart Office apps - it should restore open files automatically but if you want a softer approach replace the Start-Process line with:

Start-Process -FilePath $C2RPath -ArgumentList '/update user forceappshutdown=true updatepromptuser=true' -Wait

Prejay on the MSPGeek Discord has helpfully suggested the following to update C2R Office builds without a user logged in or as system:

Start-Process -FilePath $C2RPath -ArgumentList '/frequentupdate SCHEDULEDTASK displaylevel=false' -Wait

Mark Hodges (also on the MSPGeek Discord) has also helpfully suggested this more comprehensive update script which will update Office 2016 and 2019 as well as C2R Office.

· One min read

This post will show you how to use registry keys to test, set and remove target versions for Windows Feature Updates. This allows you to prevent Windows 10 or 11 from updating past your configured limit.

The Script

<#
.DESCRIPTION:
Script to disable the Windows 11 upgrade
.SYNOPSIS:
This script can check the Windows 11 upgrade settings or be used to set/unset those settings using. `-Test` for test-only mode, `-Unset` to remove the block on Windows 11 upgrades and no parameters to set the block.
.AUTHOR:
Mikey O'Toole
www.homotechsual.dev
.REVISION HISTORY:
2023-02-16: Parameterise the script to allow more control over the target versions
2022-01-25: Initial version
#>
[CmdletBinding()]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'RMM script - not useful to implement ShouldProcess')]
param (
[Switch]$Test,
[Switch]$Unset,
[String]$TargetProductVersion = '22H2',
[String]$TargetProduct = 'Windows 11'
)
function Test-UpdateSettings {
$UpdateSettings = Get-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\'
$Message = [System.Collections.Generic.List[String]]::New()
if ($UpdateSettings.TargetReleaseVersion -and $UpdateSettings.TargetReleaseVersion -ne 0) {
$Message.Add('Windows Update is currently set to target a specific release version.')
if ($UpdateSettings.TargetReleaseVersionInfo) {
$Message.Add("Target release version: $($UpdateSettings.TargetReleaseVersionInfo.ToString())")
} else {
$Message.Add('Target release version is not set.')
}
if ($UpdateSettings.ProductVersion) {
$Message.Add("Product version: $($UpdateSettings.ProductVersion.ToString())")
} else {
$Message.Add('Product version is not set.')
}
} else {
$Message.Add('Windows Update is currently set to target all versions.')
}
if ($String -is [array] -or $String.Count -gt 0) {
return $Message.Join(' ')
} else {
return $Message
}

}

function Set-UpdateSettings ([switch]$Unset) {
if ($Unset) {
try {
Set-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'TargetReleaseVersion' -Value 0 -Type DWord
if (Test-Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\TargetReleaseVersionInfo') {
Remove-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'TargetReleaseVersionInfo'
}
if (Test-Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\ProductVersion') {
Remove-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'ProductVersion'
}
} catch {
Throw $_
}
$Message = 'Windows Update is now set to target all versions.'
} else {
try {
Set-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'TargetReleaseVersion' -Value 1 -Type DWord
Set-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'TargetReleaseVersionInfo' -Value $TargetProductVersion
Set-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'ProductVersion' -Value $TargetProduct
$Message = 'Windows Update is now set to target Windows 10, 21H2.'
} catch {
Throw $_
}
}
return $Message
}

if ($Test) {
$Message = Test-UpdateSettings
Write-Output $Message
} elseif ($Unset) {
$Message = Set-UpdateSettings -Unset
Write-Output $Message
} else {
$Message = Set-UpdateSettings
Write-Output $Message
}
Parameters

When you run this script you might want to pass some parameters - here's what they do:

  • -Test - This will test the current target version settings and show you the results.
  • -Unset - This will remove the target version settings.
  • -TargetProductVersion - Specify the target version to aim for, examples would be 21H2 or 22H2.
  • -TargetProduct - Specify the target product to aim for, examples would be Windows 10 or Windows 11.

· 2 min read

This post will show you how to deploy the Printix client using NinjaOne Documentation fields and a PowerShell script.

Creating Fields

Creating custom fields in NinjaOne

To create a custom field at the device level in NinjaOne go to Administration > Devices and select either Role Custom Fields or Global Custom Fields then select Add.

  • Role Custom Fields are custom fields that are specific to a device role.
  • Global Custom Fields are custom fields that are applicable to all devices.

Make sure you add the fields to the roles you want to use them in at Administration > Devices > Roles.

To create a custom field at the organisation level in NinjaOne go to Administration > Apps > Documentation, enable the Documentation feature if you haven't already. If you have then select Add.

When you create your custom field you need to make sure that you set the "Scripts" permission to ensure that you can read or write to the field from your scripts - as appropriate for the script you're using.

We're adding two documentation fields to facilitate this script. You'll need to note your document template id, in the screenshots / our internal use we have a template called "Integration Identifiers" which we use to store any integration identifiers we need to reference in our scripts.

Field LabelField NameField TypeDescription
Printix Tenant IdprintixTenantIdTextHolds the customer's Printix tenant id.
Printix Tenant DomainprintixTenantDomainTextHolds the customer's Printix domain.

The Script

[Cmdletbinding()]
param (
[Parameter(Mandatory = $true)]
[String]$DocumentTemplate
)
try {
$PrintixTenantId = Ninja-Property-Docs-Get-Single $DocumentTemplate printixTenantId
$PrintixTenantDomain = Ninja-Property-Docs-Get-Single $DocumentTemplate printixTenantDomain
Write-Verbose ('Found Printix Tenant: {0} ({1})' -f $PrintixTenantId, $PrintixTenantDomain)
if (-not ([String]::IsNullOrEmpty($PrintixTenantId) -and ([String]::IsNullOrEmpty($PrintixTenantDomain)))) {
$PrintixInstallerURL = ('https://api.printix.net/v1/software/tenants/{0}/appl/CLIENT/os/WIN/type/MSI' -f $PrintixTenantId)
Write-Verbose ('Built Printix Installer URL: {0}' -f $PrintixInstallerURL)
$PrintixFileName = "CLIENT_{$PrintixTenantDomain}_{$PrintixTenantId}.msi"
$PrintixSavePath = 'C:\RMM\Installers'
if (-not (Test-Path $PrintixSavePath)) {
New-Item -Path $PrintixSavePath -ItemType Directory | Out-Null
}
$PrintixInstallerPath = ('{0}\{1}' -f $PrintixSavePath, $PrintixFileName)
Invoke-WebRequest -Uri $PrintixInstallerURL -OutFile $PrintixInstallerPath -Headers @{ 'Accept' = 'application/octet-stream' }
if (Test-Path $PrintixInstallerPath) {
Start-Process -FilePath 'msiexec.exe' -ArgumentList @(
'/i',
('"{0}"' -f $PrintixInstallerPath),
'/quiet',
('WRAPPED_ARGUMENTS=/id:{0}' -f $PrintixTenantId)
) -Wait
} else {
Write-Error ('Printix installer not found in {0}' -f $PrintixInstallerPath)
}
}
} catch {
Write-Error ('Failed to install Printix Client: `r`n {0}' -f $_)
}
Parameters

When you run this script you need to pass your document template id. For example, sticking with our example above, you'd run the script with the parameter: -DocumentTemplate Integration Identifiers

The Results

Printix Documentation Fields

Printix Installation Activity

We run this script on a group of devices which don't have the Printix client installed.

· 5 min read

Collaboration with Martin Himken

This post and the WinRE patching script on Martin's blog at https://manima.de are the result of a collaboration between Martin and I to help mutually improve our various efforts towards patching CVE-2022-41099.

security

This article relates to CVE-2022-41099 which is a vulnerability in the Windows Recovery Environment (WinRE) which could allow a successful attacker to bypass the BitLocker Device Encryption feature on the system storage device. An attacker with physical access to the target could exploit this vulnerability to gain access to encrypted data.

If you're running Windows 10 or 11 you might have come across CVE-2022-41099 which is a vulnerability in the Windows Recovery Environment (WinRE) which could allow a successful attacker to bypass BitLocker if they can boot the device to WinRE. This is a pretty serious vulnerability and Microsoft have released a patch for it. However, the patch is not applied automatically and you need to take action to apply it.

Martin Himken has written a script to patch the WinRE drivers and I've written a script to download and stage the patch and servicing stack update files. The link to Martin's blog is at the top of this post and will be repeated at the end.

· 9 min read

This post uses code in part from the SMSAgent Blog.

This post takes a snippet from the SMSAgent Blog and adds some additional magic along with two new custom functions.

If you're a Windows 10 or 11 user you'll be familiar with the toast notifications that appear in the bottom right of your screen. These are a great way to get a quick message to the user without interrupting what they're doing. In this article we'll look at how to send a toast notification from PowerShell.

We could use the excellent BurntToast PowerShell module to send a toast notification, but in the interests of reducing the number of third-party modules installed on client machines we'll be using the underlying .NET APIs directly as our needs are fairly simple.

Sending toast notifications is fairly simple once you get to grips with the underlying XML schema but we want our Toasts to be next-level so we're going to make them beautiful by registering a custom Notification App with Windows so we can customise the App Name and Icon which appear.

· One min read

Creating Fields

Creating custom fields in NinjaOne

To create a custom field at the device level in NinjaOne go to Administration > Devices and select either Role Custom Fields or Global Custom Fields then select Add.

  • Role Custom Fields are custom fields that are specific to a device role.
  • Global Custom Fields are custom fields that are applicable to all devices.

Make sure you add the fields to the roles you want to use them in at Administration > Devices > Roles.

To create a custom field at the organisation level in NinjaOne go to Administration > Apps > Documentation, enable the Documentation feature if you haven't already. If you have then select Add.

When you create your custom field you need to make sure that you set the "Scripts" permission to ensure that you can read or write to the field from your scripts - as appropriate for the script you're using.

We're adding three role custom fields for devices with the Windows Laptop role:

Field NameField TypeDescription
Driver Update: Reboot RequiredCheckboxWhether the latest driver update run requires a reboot to finalise.
Driver Update: Last RunDate/TimeThe date and time the driver update script last ran successfully.
Driver Update: Number Installed on Last RunIntegerThe number of driver updates installed on last script run.

The Script

[CmdletBinding()]
param ()
try {
# Create a new update service manager COM object.
$UpdateService = New-Object -ComObject Microsoft.Update.ServiceManager
# If the Microsoft Update service is not enabled, enable it.
$MicrosoftUpdateService = $UpdateService.Services | Where-Object { $_.ServiceId -eq '7971f918-a847-4430-9279-4a52d1efe18d' }
if (!$MicrosoftUpdateService) {
$UpdateService.AddService2('7971f918-a847-4430-9279-4a52d1efe18d', 7, '')
}
# Create a new update session COM object.
$UpdateSession = New-Object -ComObject Microsoft.Update.Session
# Create a new update searcher in the update session.
$UpdateSearcher = $UpdateSession.CreateUpdateSearcher()
# Configure the update searcher to search for driver updates from Microsoft Update.
## Set the update searcher
$UpdateSearcher.ServiceID = '7971f918-a847-4430-9279-4a52d1efe18d'
## Set the update searcher to search for per-machine updates only.
$UpdateSearcher.SearchScope = 1
## Set the update searcher to search non-Microsoft sources only (no WSUS, no Windows Update) so Microsoft Update and Manufacturers only.
$UpdateSearcher.ServerSelection = 3
# Set our search criteria to only search for driver updates.
$SearchCriteria = "IsInstalled=0 and Type='Driver'"
# Search for driver updates.
Write-Verbose 'Searching for driver updates...'
$UpdateSearchResult = $UpdateSearcher.Search($SearchCriteria)
$UpdatesAvailable = $UpdateSearchResult.Updates
# If no updates are available, output a message and exit.
if (($UpdatesAvailable.Count -eq 0) -or ([string]::IsNullOrEmpty($UpdatesAvailable))) {
Write-Warning 'No driver updates are available.'
Ninja-Property-Set driverUpdateRebootRequired 0 # Adjust for RMM
Ninja-Property-Set driverUpdateLastRun (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') # Adjust for RMM
Ninja-Property-Set driverUpdateNumberInstalledOnLastRun 0 # Adjust for RMM
exit 0
} else {
Write-Verbose "Found $($UpdatesAvailable.Count) driver updates."
# Output available updates.
$UpdatesAvailable | Select-Object -Property Title, DriverModel, DriverVerDate, DriverClass, DriverManufacturer | Format-Table
# Create a new update collection to hold the updates we want to download.
$UpdatesToDownload = New-Object -ComObject Microsoft.Update.UpdateColl
$UpdatesAvailable | ForEach-Object {
# Add the update to the update collection.
$UpdatesToDownload.Add($_) | Out-Null
}
# If there are updates to download, download them.
if (($UpdatesToDownload.count -gt 0) -or (![string]::IsNullOrEmpty($UpdatesToDownload))) {
# Create a fresh session to download and install updates.
$UpdaterSession = New-Object -ComObject Microsoft.Update.Session
$UpdateDownloader = $UpdaterSession.CreateUpdateDownloader()
# Add the updates to the downloader.
$UpdateDownloader.Updates = $UpdatesToDownload
# Download the updates.
Write-Verbose 'Downloading driver updates...'
$UpdateDownloader.Download()
}
# Create a new update collection to hold the updates we want to install.
$UpdatesToInstall = New-Object -ComObject Microsoft.Update.UpdateColl
# Add downloaded updates to the update collection.
$UpdatesToDownload | ForEach-Object {
if ($_.IsDownloaded) {
# Add the update to the update collection if it has been downloaded.
$UpdatesToInstall.Add($_) | Out-Null
}
}
# If there are updates to install, install them.
if (($UpdatesToInstall.count -gt 0) -or (![string]::IsNullOrEmpty($UpdatesToInstall))) {
# Create an update installer.
$UpdateInstaller = $UpdaterSession.CreateUpdateInstaller()
# Add the updates to the installer.
$UpdateInstaller.Updates = $UpdatesToInstall
# Install the updates.
Write-Verbose 'Installing driver updates...'
$InstallationResult = $UpdateInstaller.Install()
# If we need to reboot flag that information.
if ($InstallationResult.RebootRequired) {
Write-Warning 'Reboot required to complete driver updates.'
Ninja-Property-Set driverUpdateRebootRequired 1 # Adjust for RMM
}

# Output the results of the installation.
## Result codes: 0 = Not Started, 1 = In Progress, 2 = Succeeded, 3 = Succeeded with Errors, 4 = Failed, 5 = Aborted
## We consider 1, 2, and 3 to be successful here.
if (($InstallationResult.ResultCode -eq 1) -or ($InstallationResult.ResultCode -eq 2) -or ($InstallationResult.ResultCode -eq 3)) {
Write-Verbose 'Driver updates installed successfully.'
Ninja-Property-Set driverUpdateRebootRequired 0 # Adjust for RMM
Ninja-Property-Set driverUpdateLastRun (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') # Adjust for RMM
Ninja-Property-Set driverUpdateNumberInstalledOnLastRun $UpdatesToInstall.Count # Adjust for RMM
} else {
Write-Warning "Driver updates failed to install. Result code: $($InstallationResult.ResultCode.ToString())"
exit 1
}
}
}
} catch {
Write-Error $_.Exception.Message
exit 1
}

The Results

Driver Update Related Custom Fields

You can set this up to run on a schedule - we run this script immediately on machine onboarding and then every 7 days on a Tuesday. This doesn't always have anything to do as our Windows Update run usually handles these updates, but it's a good way to ensure that we're always up to date with the latest drivers from Microsoft Update.

· 10 min read

This post uses code from CyberDrain

This post draws on multiple posts, click the link below to check out CyberDrain.com and support Kelvin's fantastic work for the MSP community.

You will find more excellent uses for NinjaOne custom fields on the Dojo, on Stephen Murphy's blog and on Luke Whitelock's blog.

Custom fields are a great way to store arbitrary data from your devices in NinjaOne. In this post I will explore a few examples, some using code from CyberDrain, which store data in NinjaOne custom fields.

This post was updated on 2022/12/22 to add a new script to run a speedtest on a device and store the results in NinjaOne.