-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathUninstall ScreenConnect.ps1
More file actions
398 lines (341 loc) · 20.5 KB
/
Copy pathUninstall ScreenConnect.ps1
File metadata and controls
398 lines (341 loc) · 20.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
<#
.SYNOPSIS
Silently removes all installed ScreenConnect (ConnectWise Control) Client instances from a Windows machine.
.DESCRIPTION
ScreenConnect Client installs are uniquely identified by a "thumbprint" in their display name, e.g.
"ScreenConnect Client (8f53c95c9d2e1234)". A machine can have more than one instance installed at once
(for example, one legitimate MSP instance and one dropped by an attacker via phishing/social engineering),
each with its own service, process, and MSI product code.
Before touching anything, this script checks two common reinstall/reinfection vectors so a client that keeps
coming back isn't a mystery:
- Group Policy: scans local and (if domain-joined) SYSVOL Group Policy files - Administrative Templates
(Registry.pol), Group Policy Preferences registry items (Registry.xml), and Group Policy Preferences
Scheduled Tasks (ScheduledTasks.xml) - for any reference to ScreenConnect/ConnectWise Control, and resolves
matches back to the owning GPO's display name.
- Task Scheduler: enumerates every Scheduled Task on the machine (regardless of whether it came from a GPO
or was created locally) whose action executable or arguments reference ScreenConnect/ConnectWise Control,
and disables (does not delete) any it finds unless -SkipScheduledTaskRemediation is specified.
Both checks run every time, even if no ScreenConnect instance is currently installed, since the most useful
moment to catch the source is often right after a previous run already removed it.
For every instance found in the Windows Uninstall registry, this script:
1. Stops the matching Windows service(s) and sets their startup type to Disabled, so a pending start
request or delayed auto-start cannot relaunch the client mid-cleanup.
2. Kills any running ScreenConnect processes so the uninstaller and cleanup are not blocked by files
or registry keys still held open.
3. Reads the instance's MSI product code from its UninstallString and runs msiexec silently
(/qn /norestart) - no dialogs and no reboot, so end users actively on the machine are not interrupted.
4. Cleans up any service, install directory, or registry entry the MSI uninstaller leaves behind.
Safe to run on a machine with zero, one, or several ScreenConnect Client instances - the GPO/Scheduled Task
checks and any resulting task remediation still run even when none are found, and uninstall processes every
matching instance it does find in a single run.
.PARAMETER NameFilter
Wildcard used to match ScreenConnect Client display names, services, and processes.
Defaults to 'ScreenConnect*', which matches every ScreenConnect Client instance on the machine.
Narrow it to a specific instance (e.g. 'ScreenConnect Client (8f53c95c9d2e1234)') to remove only
that one and leave other instances in place.
.PARAMETER SkipScheduledTaskRemediation
If specified, Scheduled Tasks found referencing ScreenConnect/ConnectWise Control are still logged as a
warning but are left enabled. By default (no switch needed), any matching task is disabled - not deleted -
so it stops relaunching the installer without destroying the task definition in case it needs review.
.EXAMPLE
.\Uninstall ScreenConnect.ps1
Checks for GPOs and Scheduled Tasks referencing ScreenConnect (disabling any matching task), then removes
every ScreenConnect Client instance found on the machine.
.EXAMPLE
.\Uninstall ScreenConnect.ps1 -NameFilter 'ScreenConnect Client (8f53c95c9d2e1234)'
Removes only the specified instance, leaving any other ScreenConnect Client installs untouched. The GPO and
Scheduled Task checks still run and are not scoped to the specific instance.
.EXAMPLE
.\Uninstall ScreenConnect.ps1 -SkipScheduledTaskRemediation
Reports any GPO or Scheduled Task referencing ScreenConnect without disabling matching tasks, then proceeds
with uninstalling any instance found.
.NOTES
Does not reboot and does not schedule a restart - safe to run while end users are actively working.
Deploy via NinjaRMM as a scheduled/on-demand script run as SYSTEM.
A GPO match is reported only, never removed/unlinked automatically - Group Policy changes affect every
computer the GPO applies to, so that decision is left to a human reviewing Group Policy Management.
#>
param (
[string]$NameFilter = 'ScreenConnect*',
[switch]$SkipScheduledTaskRemediation
)
# Equivalent to "#Requires -RunAsAdministrator", which PowerShell 2.0/3.0 (the default on
# Server 2008 R2 / Server 2012) fails to parse as a switch, aborting the whole script before
# any code runs. This check works on PS 2.0+.
$CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
$CurrentPrincipal = New-Object Security.Principal.WindowsPrincipal($CurrentIdentity)
if (-not $CurrentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Error 'This script requires administrator privileges.'
exit 1
}
$ProgressPreference = 'SilentlyContinue'
$ErrorActionPreference = 'Continue'
# Writes a timestamped, leveled log line to stdout. NinjaOne captures stdout as the
# script activity log, so everything written here is visible in the device timeline.
function Write-Log {
param (
[string]$Message,
[ValidateSet('Info', 'Warning', 'Error', 'Success')]
[string]$Level = 'Info'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Output "[$timestamp] [$Level] $Message"
}
# Stops and disables every service matching $Filter, then kills any still-running
# ScreenConnect processes. Run before touching the MSI and again after, since the
# uninstaller/registry cleanup will fail to fully release files and keys that a
# live process still has open.
function Stop-ScreenConnect {
param ([string]$Filter)
Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -like $Filter -or $_.DisplayName -like $Filter } | ForEach-Object {
if ($_.Status -ne 'Stopped') {
Write-Log "Stopping service: $($_.Name)"
Stop-Service -InputObject $_ -Force -ErrorAction SilentlyContinue
}
try {
Set-Service -InputObject $_ -StartupType Disabled -ErrorAction Stop
} catch {
Write-Log "Could not set startup type to Disabled for service '$($_.Name)': $($_.Exception.Message)" -Level Warning
}
}
Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -like $Filter } | ForEach-Object {
Write-Log "Stopping process: $($_.ProcessName) (PID $($_.Id))"
Stop-Process -InputObject $_ -Force -ErrorAction SilentlyContinue
}
}
# Runs the MSI uninstaller for one ScreenConnect Client instance silently, with no
# reboot. Returns the msiexec exit code (0 or 3010 both indicate success - 3010 means
# a reboot would finish cleanup but /norestart suppresses it from actually happening).
function Uninstall-ScreenConnectInstance {
param (
[Parameter(Mandatory = $true)] [string]$DisplayName,
[Parameter(Mandatory = $true)] [string]$ProductCode
)
$LogFile = "$env:windir\Temp\ScreenConnectUninstall_$($ProductCode.Trim('{}'))_$Now.log"
$Arguments = @('/x', $ProductCode, '/qn', '/norestart', '/L*V', $LogFile)
Write-Log "Uninstalling '$DisplayName' ($ProductCode)..."
$Process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $Arguments -Wait -NoNewWindow -PassThru
Write-Log "msiexec exited with code $($Process.ExitCode) for '$DisplayName'. Log: $LogFile"
return $Process.ExitCode
}
# Finds every ScreenConnect Client entry in the Uninstall registry matching $Filter.
# Checked both under the native 64-bit view and WOW6432Node, since the ScreenConnect
# Client MSI is a 32-bit package and registers under WOW6432Node on 64-bit Windows.
function Get-ScreenConnectInstances {
param ([string]$Filter)
$UninstallPaths = @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*')
if ([System.Environment]::Is64BitOperatingSystem) {
$UninstallPaths += 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
}
Get-ItemProperty -Path $UninstallPaths -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -like $Filter -and $_.UninstallString -match 'msiexec' }
}
# Searches local and (if domain-joined) SYSVOL Group Policy files - Administrative Templates (Registry.pol), Group
# Policy Preferences registry items (Registry.xml), and Group Policy Preferences Scheduled Tasks (ScheduledTasks.xml)
# - for any reference to ScreenConnect/ConnectWise Control. A Client instance that keeps reinstalling itself on its
# own is often traceable to a GPO (compromised, misconfigured, or a leftover from a prior MSP) rather than a fresh
# phishing/social engineering drop every time, so this surfaces that possibility instead of leaving techs to
# uninstall the same instance over and over. Mirrors the GPO-file-scanning approach used by
# "Check for WSUS Settings and remove.ps1", adapted to search file content for a product name instead of parsing a
# specific registry policy value.
function Get-ScreenConnectGPOSources {
$SearchTerms = @('ScreenConnect', 'ConnectWiseControl', 'ConnectWise Control')
$GPOFolderPaths = @(
"$env:windir\System32\GroupPolicy\"
"$env:windir\System32\GroupPolicyUsers\"
)
$ComputerSystem = $null
try {
$ComputerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
} catch {
Write-Log "Could not determine domain membership: $($_.Exception.Message)" -Level Warning
}
if ($ComputerSystem -and $ComputerSystem.PartOfDomain -and $ComputerSystem.Domain) {
$GPOFolderPaths += "\\$($ComputerSystem.Domain)\SYSVOL\$($ComputerSystem.Domain)\Policies\"
}
$RawMatches = foreach ($FolderPath in $GPOFolderPaths) {
Get-ChildItem -Path $FolderPath -Include 'Registry.pol', 'Registry.xml', 'ScheduledTasks.xml' -Recurse -File -ErrorAction SilentlyContinue |
ForEach-Object {
$PolicyFile = $_
# Registry.pol is UTF-16 with embedded nulls between characters; Registry.xml/ScheduledTasks.xml are plain XML
$Content = (Get-Content -Path $PolicyFile.FullName -Raw -ErrorAction SilentlyContinue) -replace "`0", ''
if ([string]::IsNullOrEmpty($Content)) { return }
$MatchedTerm = $SearchTerms | Where-Object { $Content -match [regex]::Escape($_) } | Select-Object -First 1
if (-not $MatchedTerm) { return }
# Extract the GPO GUID from the file path, e.g. "...\Policies\{GUID}\Machine\Registry.pol" or
# "...\Policies\{GUID}\Machine\Preferences\ScheduledTasks\ScheduledTasks.xml"
$GPOId = $PolicyFile.FullName -replace '.*\\Policies\\(.*?)\\(Machine|User)\\.*', '$1'
if ($GPOId -notmatch '^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$') {
# Not a domain GPO GUID - if it's from the local GroupPolicy folder, it's a true Local GPO (gpedit.msc)
if ($PolicyFile.FullName -like "$env:windir\System32\GroupPolicy\*") {
$GPOId = 'Local Group Policy'
} else {
return
}
}
[PSCustomObject]@{
GPOId = $GPOId
File = $PolicyFile.FullName
MatchedOn = $MatchedTerm
}
}
}
if (-not $RawMatches) { return @() }
# Resolve matched GPO GUIDs to display names, and drop any GUID that isn't actually applied to this computer
# (e.g. a stale SYSVOL copy of a GPO that has since been unlinked).
$GPOHistoryPath = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Group Policy\History\{35378EAC-683F-11D2-A89A-00C04FBBCFA2}'
$ActiveGPOs = @()
try {
$ActiveGPOs = Get-ChildItem -Path $GPOHistoryPath -ErrorAction Stop |
ForEach-Object { Get-ItemProperty -Path $_.PSPath -Name DisplayName, GPOName -ErrorAction SilentlyContinue }
} catch {
Write-Log "Could not read GPO history to resolve display names: $($_.Exception.Message)" -Level Warning
}
foreach ($Match in $RawMatches) {
if ($Match.GPOId -ne 'Local Group Policy' -and $Match.GPOId -notin $ActiveGPOs.GPOName) { continue }
$DisplayName = if ($Match.GPOId -eq 'Local Group Policy') {
'Local Group Policy'
} else {
$ActiveGPOs | Where-Object { $_.GPOName -eq $Match.GPOId } | Select-Object -First 1 -ExpandProperty DisplayName
}
[PSCustomObject]@{
GPODisplayName = $DisplayName
MatchedFile = $Match.File
MatchedOn = $Match.MatchedOn
}
}
}
# Enumerates every Scheduled Task on the machine (regardless of whether it came from a GPO, GPP, or was created
# locally) whose action executable, arguments, or working directory reference ScreenConnect/ConnectWise Control.
# A task silently relaunching the installer is a common reinfection vector on its own, independent of any GPO.
function Get-ScreenConnectScheduledTasks {
$SearchTerms = @('ScreenConnect', 'ConnectWiseControl', 'ConnectWise Control')
Get-ScheduledTask -ErrorAction SilentlyContinue | ForEach-Object {
$Task = $_
$MatchedAction = $Task.Actions | Where-Object {
$ActionText = "$($_.Execute) $($_.Arguments) $($_.WorkingDirectory)"
$SearchTerms | Where-Object { $ActionText -match [regex]::Escape($_) }
} | Select-Object -First 1
if ($MatchedAction) {
[PSCustomObject]@{
TaskName = $Task.TaskName
TaskPath = $Task.TaskPath
State = $Task.State
Author = $Task.Author
Execute = $MatchedAction.Execute
Arguments = $MatchedAction.Arguments
}
}
}
}
$Now = Get-Date -Format 'yyyy-MM-dd_HHmmss'
$LogPath = "$env:windir\Temp\UninstallScreenConnect_$Now.log"
Start-Transcript -Path $LogPath -Force | Out-Null
try {
# --- Reinstall/reinfection source detection ---
# Runs every time, regardless of whether an instance is currently installed - the most useful moment to catch
# a GPO or Scheduled Task that's reinstalling ScreenConnect is often right after a previous run already removed it.
Write-Log 'Checking Group Policy for anything referencing ScreenConnect...'
$GPOSources = @(Get-ScreenConnectGPOSources)
if ($GPOSources.Count -eq 0) {
Write-Log 'No GPOs referencing ScreenConnect were found.'
} else {
foreach ($Source in $GPOSources) {
Write-Log "GPO '$($Source.GPODisplayName)' references ScreenConnect via '$($Source.MatchedFile)' (matched on '$($Source.MatchedOn)'). This GPO may be responsible for reinstalling ScreenConnect - review and, if appropriate, unlink/edit it in Group Policy Management." -Level Warning
}
}
Write-Log 'Checking Windows Task Scheduler for tasks referencing ScreenConnect...'
$SuspectTasks = @(Get-ScreenConnectScheduledTasks)
if ($SuspectTasks.Count -eq 0) {
Write-Log 'No Scheduled Tasks referencing ScreenConnect were found.'
} else {
foreach ($Task in $SuspectTasks) {
$TaskFullPath = "$($Task.TaskPath)$($Task.TaskName)"
Write-Log "Scheduled Task '$TaskFullPath' (State: $($Task.State), Author: $($Task.Author)) references ScreenConnect: '$($Task.Execute) $($Task.Arguments)'" -Level Warning
if ($SkipScheduledTaskRemediation) {
continue
}
try {
Disable-ScheduledTask -TaskName $Task.TaskName -TaskPath $Task.TaskPath -ErrorAction Stop | Out-Null
Write-Log "Disabled Scheduled Task '$TaskFullPath' so it can no longer relaunch the ScreenConnect installer." -Level Warning
} catch {
Write-Log "Failed to disable Scheduled Task '$TaskFullPath': $($_.Exception.Message)" -Level Error
}
}
}
Write-Log "Searching for ScreenConnect Client installs matching '$NameFilter'..."
$Instances = @(Get-ScreenConnectInstances -Filter $NameFilter)
if ($Instances.Count -eq 0) {
Write-Log 'No matching ScreenConnect Client installs found. Nothing to uninstall.' -Level Success
Stop-Transcript | Out-Null
exit 0
}
Write-Log "Found $($Instances.Count) matching instance(s):"
$Instances | ForEach-Object { Write-Log " $($_.DisplayName)" }
# Stop services and kill processes for every matching instance up front, before
# any uninstaller runs, so file/registry locks cannot interfere with removal.
Stop-ScreenConnect -Filter $NameFilter
foreach ($Instance in $Instances) {
if ($Instance.UninstallString -notmatch '(\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\})') {
Write-Log "Could not extract an MSI product code from UninstallString for '$($Instance.DisplayName)': $($Instance.UninstallString)" -Level Warning
continue
}
$ProductCode = $Matches[1]
# Uninstall-ScreenConnectInstance's Write-Log calls also land on the pipeline, so the raw
# call returns [logline, logline, exitcode] - filter to the one [int] to get the real code.
$ExitCode = (Uninstall-ScreenConnectInstance -DisplayName $Instance.DisplayName -ProductCode $ProductCode) |
Where-Object { $_ -is [int] }
if ($ExitCode -ne 0 -and $ExitCode -ne 3010) {
Write-Log "Uninstall of '$($Instance.DisplayName)' returned a non-success exit code ($ExitCode). Continuing with cleanup..." -Level Warning
}
}
# Kill anything the uninstaller may have relaunched or left behind before cleanup.
Stop-ScreenConnect -Filter $NameFilter
Start-Sleep -Seconds 5
# --- Leftover cleanup ---
# The MSI uninstaller does not always fully clean up its own service registration,
# install directory, or registry entries - especially if a file handle was briefly
# held open. Sweep for anything still matching $NameFilter and remove it directly.
Write-Log 'Sweeping for leftover services, folders, and registry entries...'
Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -like $NameFilter } | ForEach-Object {
Write-Log "Removing leftover service: $($_.Name)" -Level Warning
& sc.exe DELETE $_.Name | Out-Null
}
# Filter is "ScreenConnect Client*" (not $NameFilter) so a broad default run never touches an
# on-prem ScreenConnect Server's install folder (named "ScreenConnect", no "Client" suffix) - then
# narrowed further by $NameFilter so a caller-scoped single-instance run does not delete sibling
# instances' folders.
foreach ($Root in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData)) {
if (-not $Root) { continue }
Get-ChildItem -Path $Root -Directory -Filter 'ScreenConnect Client*' -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like $NameFilter } |
ForEach-Object {
Write-Log "Removing leftover install directory: $($_.FullName)" -Level Warning
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
}
$Remaining = @(Get-ScreenConnectInstances -Filter $NameFilter)
foreach ($Leftover in $Remaining) {
Write-Log "Removing leftover registry entry: $($Leftover.PSPath)" -Level Warning
Remove-Item -Path $Leftover.PSPath -Recurse -Force -ErrorAction SilentlyContinue
}
# --- Final verification ---
$StillInstalled = @(Get-ScreenConnectInstances -Filter $NameFilter)
$StillRunning = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -like $NameFilter -or $_.DisplayName -like $NameFilter })
if ($StillInstalled.Count -eq 0 -and $StillRunning.Count -eq 0) {
Write-Log 'All matching ScreenConnect Client instances were removed successfully.' -Level Success
Stop-Transcript | Out-Null
exit 0
} else {
$StillInstalled | ForEach-Object { Write-Log "Still present in Uninstall registry: $($_.DisplayName)" -Level Error }
$StillRunning | ForEach-Object { Write-Log "Still present as a service: $($_.Name)" -Level Error }
Write-Log 'One or more ScreenConnect Client instances could not be fully removed. Manual review required.' -Level Error
Stop-Transcript | Out-Null
exit 1
}
}
catch {
Write-Log "Fatal error: $($_.Exception.Message)" -Level Error
Stop-Transcript | Out-Null
exit 1
}