Disclaimer! Do this at your own risk. I do not take any responsibility in impact this may cause for you or your tenant.
Update: Now that Microsoft has released their own “fix” for this issue, I would advise to test that out first. Though their fix do not support all shortcuts, so this solution can be used to complement with other company specific applications.
Firstly, make sure that your ASR rule for Block Win32 API calls from Office macro has been changed to Audit mode. In the bottom of the article you can see how to verify it is reflected on the computer.
To identify what shortcuts were deleted in your tenant, run the following in Advanced Hunting
DeviceEvents | where ActionType == "AsrOfficeMacroWin32ApiCallsBlocked" and Timestamp >= datetime("2023-01-13 00:00:00Z") | order by Timestamp | where FileName endswith ".lnk" | summarize count() by FileName | sort by count_
The output should be something similar to
Stable method
We have experienced that the most stable method of fixing shortcuts is using a modified version of Microsoft’s script using Proactive Remediations to run the script. This modified script checks if the shortcuts exist AND if they are pointing correctly – this so that it re-runs if there are any broken shortcuts. If one or more shortcuts are not “healthy” the remediation script is run.
You can add your own applications to the Detection and Remediation script by finding the correct names in the registry under “HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\”
$programs = @{ "Adobe Acrobat" = "Acrobat.exe" "[Adobe Photoshop]" = "photoshop.exe" "[Adobe Illustrator]" = "illustrator.exe" "Adobe Creative Cloud" = "Creative Cloud.exe" "Adobe Substance 3D Painter" = "Adobe Substance 3D Painter.exe" "Firefox Private Browsing" = "private_browsing.exe" "Firefox" = "firefox.exe" "Google Chrome" = "chrome.exe" "Microsoft Edge" = "msedge.exe" "Notepad++" = "notepad++.exe" "Parallels Client" = "APPServerClient.exe" "Remote Desktop" = "msrdcw.exe" "TeamViewer" = "TeamViewer.exe" "[Royal TS]" = "royalts.exe" "Elgato StreamDeck" = "StreamDeck.exe" "[Visual Studio]" = "devenv.exe" "Visual Studio Code" = "code.exe" "Camtasia Studio" = "CamtasiaStudio.exe" "Camtasia Recorder" = "CamtasiaRecorder.exe" "Jabra Direct" = "jabra-direct.exe" "7-Zip File Manager" = "7zFM.exe" "Access" = "MSACCESS.EXE" "Excel" = "EXCEL.EXE" "OneDrive" = "onedrive.exe" "OneNote" = "ONENOTE.EXE" "Outlook" = "OUTLOOK.EXE" "PowerPoint" = "POWERPNT.EXE" "Project" = "WINPROJ.EXE" "Publisher" = "MSPUB.EXE" "Visio" = "VISIO.EXE" "Word" = "WINWORD.exe" "[PowerShell 7]" = "pwsh.exe" "SQL Server Management Studio" = "ssms.exe" "Azure Data Studio" = "azuredatastudio.exe" "Zoom" = "zoom.exe" "Skype for Business" = "Skype.exe" "VLC Player" = "vlc.exe" "Cisco Jabber" = "CiscoJabber.exe" "Microsoft Teams" = "msteams.exe" "PuTTY" = "putty.exe" "wordpad" = "WORDPAD.EXE" "[AutoCAD]" = "acad.exe" "[CORSAIR iCUE Software]" = "iCue.exe" "[Steam]" = "steam.exe" "Paint" = "mspaint.exe" } $LogFileName = "ShortcutRepairs.log"; $LogFilePath = "$env:temp\$LogFileName"; Function Log { param($message); $currenttime = Get-Date -format u; $outputstring = "[" + $currenttime + "] " + $message; $outputstring | Out-File $LogFilepath -Append; } Function LogAndConsole($message) { Write-Host $message -ForegroundColor Green Log $message } Function LogErrorAndConsole($message) { Write-Host $message -ForegroundColor Red Log $message } $programs.GetEnumerator() | ForEach-Object { $reg_path = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\$($_.Value)" try { $apppath = $null $target = $null try { $apppath = Get-ItemPropertyValue $reg_path -Name "Path" -ErrorAction SilentlyContinue } catch {} if ($apppath -ne $null) { $target = $apppath + "\" + $_.Value } else { try { $target = Get-ItemPropertyValue $reg_path -Name "(default)" -ErrorAction SilentlyContinue } catch {} } if ($target -ne $null) { $target = $target.Trim("`"") $shortcut_path = "$env:PROGRAMDATA\Microsoft\Windows\Start Menu\Programs\$($_.Key).lnk" $sh = New-Object -ComObject WScript.Shell if(Test-Path($sh.CreateShortcut($shortcut_path).TargetPath)){ LogAndConsole ("Shortcut already present and pointing correctly for {0} in Start Menu" -f $_.Key) } else{ LogErrorAndConsole ("Shortcut not correct - starting for {0} - starting remediation" -f $_.Key) Exit 1 } } } catch { $failures += 1 LogErrorAndConsole "Exception: $_" } }
$programs = @{ "Adobe Acrobat" = "Acrobat.exe" "[Adobe Photoshop]" = "photoshop.exe" "[Adobe Illustrator]" = "illustrator.exe" "Adobe Creative Cloud" = "Creative Cloud.exe" "Adobe Substance 3D Painter" = "Adobe Substance 3D Painter.exe" "Firefox Private Browsing" = "private_browsing.exe" "Firefox" = "firefox.exe" "Google Chrome" = "chrome.exe" "Microsoft Edge" = "msedge.exe" "Notepad++" = "notepad++.exe" "Parallels Client" = "APPServerClient.exe" "Remote Desktop" = "msrdcw.exe" "TeamViewer" = "TeamViewer.exe" "[Royal TS]" = "royalts.exe" "Elgato StreamDeck" = "StreamDeck.exe" "[Visual Studio]" = "devenv.exe" "Visual Studio Code" = "code.exe" "Camtasia Studio" = "CamtasiaStudio.exe" "Camtasia Recorder" = "CamtasiaRecorder.exe" "Jabra Direct" = "jabra-direct.exe" "7-Zip File Manager" = "7zFM.exe" "Access" = "MSACCESS.EXE" "Excel" = "EXCEL.EXE" "OneDrive" = "onedrive.exe" "OneNote" = "ONENOTE.EXE" "Outlook" = "OUTLOOK.EXE" "PowerPoint" = "POWERPNT.EXE" "Project" = "WINPROJ.EXE" "Publisher" = "MSPUB.EXE" "Visio" = "VISIO.EXE" "Word" = "WINWORD.exe" "[PowerShell 7]" = "pwsh.exe" "SQL Server Management Studio" = "ssms.exe" "Azure Data Studio" = "azuredatastudio.exe" "Zoom" = "zoom.exe" "Skype for Business" = "Skype.exe" "VLC Player" = "vlc.exe" "Cisco Jabber" = "CiscoJabber.exe" "Microsoft Teams" = "msteams.exe" "PuTTY" = "putty.exe" "wordpad" = "WORDPAD.EXE" "[AutoCAD]" = "acad.exe" "[CORSAIR iCUE Software]" = "iCue.exe" "[Steam]" = "steam.exe" "Paint" = "mspaint.exe" } $LogFileName = "ShortcutRepairs.log"; $LogFilePath = "$env:temp\$LogFileName"; Function Log { param($message); $currenttime = Get-Date -format u; $outputstring = "[" + $currenttime + "] " + $message; $outputstring | Out-File $LogFilepath -Append; } Function LogAndConsole($message) { Write-Host $message -ForegroundColor Green Log $message } Function LogErrorAndConsole($message) { Write-Host $message -ForegroundColor Red Log $message } $programs.GetEnumerator() | ForEach-Object { $reg_path = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\$($_.Value)" try { $apppath = $null $target = $null try { $apppath = Get-ItemPropertyValue $reg_path -Name "Path" -ErrorAction SilentlyContinue } catch {} if ($apppath -ne $null) { $target = $apppath + "\" + $_.Value } else { try { $target = Get-ItemPropertyValue $reg_path -Name "(default)" -ErrorAction SilentlyContinue } catch {} } if ($target -ne $null) { LogAndConsole ("Recreating shortcut for {0} in Start Menu" -f $_.Key) $target = $target.Trim("`"") $shortcut_path = "$env:PROGRAMDATA\Microsoft\Windows\Start Menu\Programs\$($_.Key).lnk" $description = $_.Key $workingdirectory = (Get-ChildItem $target).DirectoryName $WshShell = New-Object -ComObject WScript.Shell $Shortcut = $WshShell.CreateShortcut($shortcut_path) $Shortcut.TargetPath = $target $Shortcut.Description = $description $shortcut.WorkingDirectory = $workingdirectory $Shortcut.Save() $success += 1 } } catch { $failures += 1 LogErrorAndConsole "Exception: $_" } }
Alternative method
Create a Folder that includes all the shortcuts you want to restore to the startmenu and create a Install.ps1 script – The script will only copy the shortcuts where the application is actually installed – so you can include all shortcuts in the folder even though they might not be installed on all computers.
$StartMenuFolder = "$env:ProgramData\Microsoft\Windows\Start Menu\Programs" $ShortCuts = Get-ChildItem -Filter "*.lnk" $ShortCuts | % { If(test-path("$StartMenuFolder\$($_.name)")){ "$($_.name) already exist in start menu" } else { "$($_.name) not found in start menu - checking if program pointed to by shortcut exist" $sh = New-Object -ComObject WScript.Shell if(Test-Path($sh.CreateShortcut($_.FullName).TargetPath)){ "Program exist - copying $($_.Name) into start menu folder" Copy-Item -Path $_.FullName -Destination $StartMenuFolder -Force } else { "Did not find $($sh.CreateShortcut($_.FullName).TargetPath) - will not copy $($_.name)" } } }
Should look something similar to this.
If you have multiple languages installed on your clients in your tenant it might be wise to change the properties of each shortcut to use system variable instead. Change to %programfiles% or %programfiles(x86)% depending on where it is.
Create a detectionscript – this might need to be edited, depending on what applications you want to verify have shortcuts.
$StartMenuFolder = "$env:ProgramData\Microsoft\Windows\Start Menu\Programs" $Count = (Get-ChildItem $StartMenuFolder | ? Name -match "Word|Outlook|Powerpoint|Edge").count If($count -ge 4){"Installed"}
Create the package and uppload to Intune with install command:
powershell.exe -noprofile -executionpolicy bypass -file .\Install.ps1
Troubleshooting ASR Rules on the client
If the shortcuts keep disappering you can use the following code to check what ASR rules are still in block mode by running this script in PowerShell in Administrator mode. It is the Win32 API calls from Office macros that is the cause of this issue that you need to check for.
White = Off
Red = Block
Yellow = Audit
Orange = Warn
$MPPref = Get-MpPreference -ErrorAction SilentlyContinue $AttackSurfaceIDs = $MPPref | Select-Object -ExpandProperty AttackSurfaceReductionRules_Ids $AttackSurfaceActions = $MPPref | Select-Object -ExpandProperty AttackSurfaceReductionRules_Actions $i = 0 foreach($Rule in $AttackSurfaceIDs){ $Color = Switch($AttackSurfaceActions[$i]) { 0 {"White"} 1 {"Red"} 2 {"Yellow"} 6 {"Orange"} } $RuleName = Switch($Rule) { 56a863a9-875e-4185-98a7-b882c64b5ce5 {"Block abuse of exploited vulnerable signed drivers"} 7674ba52-37eb-4a4f-a9a1-f0f9a1619a2c {"Block Adobe Reader from creating child processes"} d4f940ab-401b-4efc-aadc-ad5f3c50688a {"Block all Office applications from creating child processes"} 9e6c4e1f-7d60-472f-ba1a-a39ef669e4b2 {"Block credential stealing from the Windows local security authority subsystem (lsass.exe)"} be9ba2d9-53ea-4cdc-84e5-9b1eeee46550 {"Block executable content from email client and webmail"} 01443614-cd74-433a-b99e-2ecdc07bfc25 {"Block executable files from running unless they meet a prevalence, age, or trusted list criterion"} 5beb7efe-fd9a-4556-801d-275e5ffc04cc {"Block execution of potentially obfuscated scripts"} d3e037e1-3eb8-44c8-a917-57927947596d {"Block JavaScript or VBScript from launching downloaded executable content"} 3b576869-a4ec-4529-8536-b80a7769e899 {"Block Office applications from creating executable content"} 75668c1f-73b5-4cf0-bb93-3ecf5cb7cc84 {"Block Office applications from injecting code into other processes"} 26190899-1602-49e8-8b27-eb1d0a1ce869 {"Block Office communication application from creating child processes"} e6db77e5-3df2-4cf1-b95a-636979351e5b {"Block persistence through WMI event subscription - File and folder exclusions not supported."} d1e49aac-8f56-4280-b9ba-993a6d77406c {"Block process creations originating from PSExec and WMI commands"} b2b3f03d-6a65-4f7b-a9c7-1c7ef74a9ba4 {"Block untrusted and unsigned processes that run from USB"} 92e97fa1-2edf-4476-bdd6-9dd0b4dddc7b {"Block Win32 API calls from Office macros"} c1db55ab-c21a-4637-bb3f-a12568109d35 {"Use advanced protection against ransomware"} } Write-Host $RuleName -ForegroundColor $Color $i++ }
Output similar to this:
Wrote a little script to remove double licensing from users both having it directly assigned as well as inherited through a group. This is usefull for when transitioning to group based licensing.
$users = Get-MsolUser -All | ? isLicensed $i = 0 foreach($user in $users){ $percentageComplete = [math]::Round($i / $users.count * 100, 2) Write-Progress -Activity "Processing user $i out of $($users.count)" -Status "$percentageComplete% Complete:" -PercentComplete $percentageComplete $Licenses = $user.Licenses foreach($License in $Licenses){ if(($license.GroupsAssigningLicense.count -ge 2) -and ($license.GroupsAssigningLicense.Guid -contains $user.ObjectId)){ "$($License.AccountSkuId) is double licensed for user $($user.DisplayName + "(" + $user.userPrincipalName +")") removing direct assignment" $user | Set-MsolUserLicense -RemoveLicenses $License.AccountSkuId } } $i++ }
There is no easy way to offboard a set of devices from the Microsoft 365 Defender portal. You should be able to mark a set of devices and hit a button called offboard, but no such thing exists. Luckily, we have Powershell and Microsoft API to solve this.
We need to perform a few tasks to set this up:
- Create an application service principal
- Give service principal necessary permissions
- Change values in script
- Run script
Create an application service principal
Go to Azure AD –> App Registrations –> New registration.
Give the application a suitable name that explains what it will do, leave default choices and click on Register to create the application. Now we need to collect some basic information from the application.
In the application overview you will find Application ID and Tenant ID, copy these values, you will need them later.
Go to Certificates & secrets, and create a new client secret with expiration you need. Copy the value from this secret, this will only be visible once!
Give service principal necessary permissions
Go to API permission and add the following permissions, if you’re not an admin you may have to ask an admin to approve these permission after you have added them.
Change values in script
Add you tenant ID, Application ID and Application secret to the variables in the script on line 2, 3 and 4.
In my example I create the query to only get inactive machines where lastseen is prior to 1st February. Change this value if you want to use another date.
Run script
Some considerations
Microsoft’s API have some limitations, these are:
- 100 calls per minute
- 1500 calls per hour
Because of these limitation I’ve added a 3 seconds sleep to keep calls under the limit. One call every 3 seconds will be 20 calls per minute and 1200 per hour.
Timeout for token is 1 hour. To prevent getting unauthorized responses from API in the middle of the script, token will renew every 50 minutes.
I’ve also added a $NextlLink which will get remaining devices over 10000, since that is maximum response from Microsoft’s API.
Some OS platforms are not supported, so the query only includes windows 10 and windows 11.
Client agent version must be greater than 10.2930.0.0, this is included in the query as well.
#Connecting with our service principal $tenantId = '' ### Paste your tenant ID here $appId = '' ### Paste your Application ID here $appSecret = '' ### Paste your Application key here $resourceAppIdUri = 'https://api.securitycenter.microsoft.com' $oAuthUri = "https://login.microsoftonline.com/$TenantId/oauth2/token" $authBody = [Ordered] @{ resource = "$resourceAppIdUri" client_id = "$appId" client_secret = "$appSecret" grant_type = 'client_credentials' } $authResponse = Invoke-RestMethod -Method Post -Uri $oAuthUri -Body $authBody -ErrorAction Stop $token = $authResponse.access_token $headers = @{ 'Content-Type' = 'application/json' 'Accept' = 'application/json' 'Authorization' = "Bearer $token" } $JSON = @' {"Comment":"Offboard machine by powershell automation" } '@ $MachineURI = "https://api.securitycenter.microsoft.com/api/machines" $Response = Invoke-RestMethod -Method Get -Headers $headers -Uri $MachineURI -ErrorAction Stop $Machines = $Response.Value $NextLink = $Response."@odata.nextLink" while ($NextLink -ne $null) { $Response = (Invoke-RestMethod -Uri $NextLink -Headers $headers -Method Get -Verbose) $NextLink = $Response."@odata.nextLink" $Machines += $Response.Value } #Lastseen format is year-month-day $inactiveDevices = $Machines | where-object { ($_.HealthStatus -eq "Inactive") -and ($_.lastSeen -lt '2022-02-01') -and ($_.osPlatform -like "Windows1*") -and ($_.agentVersion -gt "10.2930.0.0") } $timer = [Diagnostics.Stopwatch]::StartNew() Foreach ($inactivedevice in $inactiveDevices) { $DeviceUri = "https://api.securitycenter.microsoft.com/api/machines/$($inactivedevice.id)/offboard" $Response = Invoke-RestMethod -Uri $DeviceUri -Headers $headers -Method Post -Body $JSON -ContentType 'application/json' -Verbose sleep -Seconds 3 #Renew credentials after 50 minutes to avoid disconnecting while running the script if($timer.Elapsed.minutes -eq 50) { $authResponse = Invoke-RestMethod -Method Post -Uri $oAuthUri -Body $authBody -ErrorAction Stop $token = $authResponse.access_token $headers = @{ 'Content-Type' = 'application/json' 'Accept' = 'application/json' 'Authorization' = "Bearer $token" } $timer.Restart() } }
It can take up to 24 hours to remove the devices. If you rerun the script you will receive an error explaining that the action is already in progress.
Tips to take it a step further
The good thing about using a service principal is that the script can be added as a runbook in Azure without changing the script. From there you can run it on interval, daily, weekly, whatever your preference is.
Strangely enough Intune still does not support adding printers that are published through a print server directly, even though it is just a one-liner PowerShell script to execute. Universal Print from our experience has been a little unstable and it’s rare that anyone needs to print outside of the network anyway.
Add-Printer -ConnectionName "\\company.local\printername"
The issue here is that this one-liner needs to be run under the user context and here is where it get’s tricky. There might be more than one way to achieve this, but the script following utilizes a workaround where we create a schedule task to be run under user context from the package deployed. This also helps with the issue that the server hosting the print queue might not be available at the time the script is intially run. The logic here can probably be improved, but it at least works, and uninstallation also work. A second thing that we have to “workaround” is that PowerShell scripts ran from Task Scheduler under user context will show for the end user, which is not desired. We circumvent that annoiance by creating a VBS script that runs from Task Scheduler that runs the actual PowerShell script.
We need two scripts in order for this whole solution to work as well as a custom detection script, unless you figure another way of detecting the printer afterwards easily.
Logging goes to the IntuneManagementExtension folder since we then can run a “Collect diagnostics” on a device directly from Endpoint Manager and fetch the log from folder 42. This so that we don’t need to involve the user if we need to troubleshoot.
The first one is for creating the scheduled task and also creates a VBS script to run.
The script has 2 arguments:
- printerFQDN – Example \\mycompany.local\printqueue1
- uninstall – Add -uninstall to remove the printer
param ( [switch]$uninstall = $false, [Parameter(Mandatory = $true)] [string]$printerFQDN ) $taskScheduleName = "PrintInstall-$($printerFQDN -replace '\W','')" $logfileName = "$taskScheduleName-TaskSchedule.log" $scriptDir = "$env:ProgramData\$taskScheduleName" $scriptName = "InstallPrinter.ps1" Start-Transcript -Path $(Join-Path -Path C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\ -ChildPath $logfileName) -Force if (!(Test-Path $scriptDir)) { New-Item -Path $scriptDir -ItemType Directory } Copy-Item -Path .\$scriptName -Destination $scriptDir\$scriptName -Force if ($uninstall) { # Make the task-scheduler silent $vbs = @" command = "powershell.exe -nologo -executionpolicy Bypass -windowstyle hidden -file $scriptDir\$scriptName -printerFQDN $printerFQDN -uninstall" set shell = CreateObject("WScript.Shell") shell.Run command,0 "@ $vbs | Out-File -FilePath "$scriptDir\$($scriptName.replace(".ps1","-uninstall.vbs"))" -Force Unregister-ScheduledTask -TaskName $taskScheduleName -Confirm:$false -ErrorAction SilentlyContinue $action = New-ScheduledTaskAction -Execute "$scriptDir\$($scriptName.replace(".ps1","-uninstall.vbs"))" $trigger = New-ScheduledTaskTrigger -AtLogOn $principal = New-ScheduledTaskPrincipal -UserId (whoami) $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries $task = New-ScheduledTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings Register-ScheduledTask RemoveThatPrinter -InputObject $task Start-ScheduledTask -TaskName RemoveThatPrinter Start-Sleep -Seconds 1 Unregister-ScheduledTask -TaskName RemoveThatPrinter -Confirm:$false Remove-Item -Path $scriptDir -Recurse -Force } else { # Make the task-scheduler silent $vbs = @" command = "powershell.exe -nologo -executionpolicy Bypass -windowstyle hidden -file $scriptDir\$scriptName -printerFQDN $printerFQDN" set shell = CreateObject("WScript.Shell") shell.Run command,0 "@ $vbs | Out-File -FilePath "$scriptDir\$($scriptName.replace("ps1","vbs"))" -Force Unregister-ScheduledTask -TaskName $taskScheduleName -Confirm:$false -ErrorAction SilentlyContinue $action = New-ScheduledTaskAction -Execute "$scriptDir\$($scriptName.replace("ps1","vbs"))" $trigger = @( $(New-ScheduledTaskTrigger -AtLogOn), $(New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 10)) ) $principal = New-ScheduledTaskPrincipal -UserId (whoami) $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -Hidden $task = New-ScheduledTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings Register-ScheduledTask $taskScheduleName -InputObject $task Start-ScheduledTask -TaskName $taskScheduleName } Stop-Transcript exit 0
param ( [switch]$uninstall = $false, [Parameter(Mandatory = $true)] [string]$printerFQDN ) # Starting our logging. $taskScheduleName = "PrintInstall-$($printerFQDN -replace '\W','')" $logfileName = "$taskScheduleName.log" $scriptDir = "$env:ProgramData\$taskScheduleName" $scriptName = "InstallPrinter.ps1" Start-Transcript -Path $(Join-Path -Path C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\ -ChildPath $logfileName) -Force if (!($uninstall)) { "Installing printer $printerFQDN" Add-Printer -ConnectionName $printerFQDN } else { "Uninstalling printer $printerFQDN" Remove-Printer -Name $printerFQDN } Stop-Transcript # Let's tell IME that all is well :) exit 0
Save both of these files into the same folder and package it as a Win32 file using IntuneWin. Once done you are ready to install printers.
Uppload the package with “Install Behaviour” set to User and assign.
The detectionscript needs to be created based on what the taskname ends up being named.
if(Get-ScheduledTask -TaskName PrintInstall-xxxxx -ErrorAction SilentlyContinue){"Detected"}
Two improvements that should be made to the scripts would be:
- Check for domain line of sight before trying to add printer
- Self remove the scheduled task once printer has been added
- Possibility to add an array of printerFQDN’s for multiple printers
Welcome to our sharing area where we show our tips and tricks, ideas and how to work smart with Microsoft 365, Azure, Intune & Endpoint Manager, Custom Web applications and more.
$welcome = ":)"