Skip to content

Commit a758f3e

Browse files
Add Remove-ManageEngineMDM.ps1 — free MDM slot for Intune migration
Generic third-party MDM enrollment removal (ManageEngine/MEMDM by default): backs up + deletes the Enrollments/Status/Tracked/PolicyManager keys and the EnterpriseMgmt scheduled-task folder for the matched enrollment, with optional agent uninstall. Protects the Intune (MS DM Server) enrollment. -DetectOnly, -Force, transcript logging, and registry backups for rollback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 09f92d6 commit a758f3e

1 file changed

Lines changed: 383 additions & 0 deletions

File tree

Intune/Remove-ManageEngineMDM.ps1

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
<#
2+
.SYNOPSIS
3+
Removes an existing third-party MDM enrollment (e.g. ManageEngine Endpoint Central)
4+
so a Windows device can auto-enroll into Microsoft Intune.
5+
6+
.DESCRIPTION
7+
Windows allows only ONE active MDM enrollment per device. When a device is already
8+
enrolled in a third-party MDM (such as ManageEngine / "MEMDM"), Intune auto-enrollment
9+
silently fails ("Auto MDM Enroll: ... Failed", and the device never appears in Intune)
10+
because the single MDM slot is occupied.
11+
12+
This script locates the existing MDM enrollment, backs up every registry key it will
13+
touch, removes the enrollment artifacts (registry + the EnterpriseMgmt scheduled-task
14+
folder), and optionally uninstalls the third-party agent. After it runs, the MDM slot
15+
is free and Intune auto-enrollment (GPO / "Access work or school" / deviceenroller)
16+
can proceed.
17+
18+
It is deliberately GENERIC: by default it targets the well-known ManageEngine provider
19+
id ("MEMDM") and discovery host ("manageengine.com"), but you can point it at any
20+
provider via -ProviderId / -DiscoveryUrlMatch. It will NOT touch a Microsoft Intune
21+
enrollment (ProviderID "MS DM Server") unless you explicitly name it.
22+
23+
Everything is backed up to C:\ProgramData\MDMRemoval\Backup before deletion, and the
24+
run is fully transcript-logged. Re-runnable (idempotent).
25+
26+
.PARAMETER ProviderId
27+
The enrollment ProviderID to remove. Default: "MEMDM" (ManageEngine).
28+
The Intune provider "MS DM Server" is protected and will be skipped unless it is the
29+
value you explicitly pass here.
30+
31+
.PARAMETER DiscoveryUrlMatch
32+
Optional additional safety match. If set, an enrollment is only removed when its
33+
DiscoveryServiceFullURL contains this substring. Default: "manageengine.com".
34+
Pass an empty string ("") to match on ProviderID alone.
35+
36+
.PARAMETER RemoveAgent
37+
Also attempt to uninstall the third-party management agent after removing the
38+
enrollment. Searches the uninstall registry for products matching -AgentNameMatch
39+
and runs their QuietUninstallString / UninstallString.
40+
41+
.PARAMETER AgentNameMatch
42+
Display-name substring used to find the agent to uninstall when -RemoveAgent is set.
43+
Default: "ManageEngine".
44+
45+
.PARAMETER DetectOnly
46+
Report what would be removed without making any changes.
47+
48+
.PARAMETER Force
49+
Skip the confirmation prompt (for unattended GPO / RMM execution).
50+
51+
.NOTES
52+
Version: 1.0
53+
Author: Hal Kurz (The Intune Pros)
54+
Creation Date: 2026-05-29
55+
Requires: Run as SYSTEM or elevated Administrator.
56+
57+
Registry artifacts removed (for the matched {EnrollmentGUID}):
58+
HKLM\SOFTWARE\Microsoft\Enrollments\{GUID}
59+
HKLM\SOFTWARE\Microsoft\Enrollments\Status\{GUID}
60+
HKLM\SOFTWARE\Microsoft\EnterpriseResourceManager\Tracked\{GUID}
61+
HKLM\SOFTWARE\Microsoft\PolicyManager\AdmxInstalled\{GUID} (if present)
62+
HKLM\SOFTWARE\Microsoft\PolicyManager\Providers\{GUID} (if present)
63+
Scheduled tasks removed:
64+
\Microsoft\Windows\EnterpriseMgmt\{GUID}\* (and the {GUID} task folder)
65+
66+
Rollback: import the .reg files from C:\ProgramData\MDMRemoval\Backup.
67+
68+
.EXAMPLE
69+
.\Remove-ManageEngineMDM.ps1 -DetectOnly
70+
Show the matched ManageEngine enrollment and what would be removed. No changes.
71+
72+
.EXAMPLE
73+
.\Remove-ManageEngineMDM.ps1 -Force
74+
Back up and remove the ManageEngine MDM enrollment, unattended.
75+
76+
.EXAMPLE
77+
.\Remove-ManageEngineMDM.ps1 -Force -RemoveAgent
78+
Remove the enrollment and uninstall the ManageEngine agent.
79+
80+
.EXAMPLE
81+
.\Remove-ManageEngineMDM.ps1 -ProviderId "SomeOtherMDM" -DiscoveryUrlMatch "vendor.com" -Force
82+
Target a different third-party MDM by provider id + discovery host.
83+
#>
84+
85+
[CmdletBinding()]
86+
param(
87+
[string]$ProviderId = "MEMDM",
88+
[string]$DiscoveryUrlMatch = "manageengine.com",
89+
[switch]$RemoveAgent,
90+
[string]$AgentNameMatch = "ManageEngine",
91+
[switch]$DetectOnly,
92+
[switch]$Force
93+
)
94+
95+
#Requires -RunAsAdministrator
96+
97+
# ============================================================================
98+
# CONFIGURATION
99+
# ============================================================================
100+
$Script:Config = @{
101+
LogPath = "C:\ProgramData\MDMRemoval"
102+
BackupPath = "C:\ProgramData\MDMRemoval\Backup"
103+
LogFile = "MDMRemoval_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
104+
}
105+
106+
# Provider we must never remove unless explicitly named (Intune).
107+
$Script:ProtectedProvider = "MS DM Server"
108+
109+
$Script:EnrollmentsRoot = "HKLM:\SOFTWARE\Microsoft\Enrollments"
110+
$Script:TaskFolderRoot = "\Microsoft\Windows\EnterpriseMgmt"
111+
112+
# ============================================================================
113+
# LOGGING
114+
# ============================================================================
115+
function Initialize-Logging {
116+
foreach ($p in @($Script:Config.LogPath, $Script:Config.BackupPath)) {
117+
if (-not (Test-Path $p)) { New-Item -Path $p -ItemType Directory -Force | Out-Null }
118+
}
119+
$Script:LogFilePath = Join-Path $Script:Config.LogPath $Script:Config.LogFile
120+
Start-Transcript -Path $Script:LogFilePath -Append | Out-Null
121+
}
122+
123+
function Write-Log {
124+
param(
125+
[string]$Message,
126+
[ValidateSet("INFO","WARN","ERROR","SUCCESS","DETECT")]
127+
[string]$Level = "INFO"
128+
)
129+
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
130+
$line = "[$ts] [$Level] $Message"
131+
switch ($Level) {
132+
"ERROR" { Write-Host $line -ForegroundColor Red }
133+
"WARN" { Write-Host $line -ForegroundColor Yellow }
134+
"SUCCESS" { Write-Host $line -ForegroundColor Green }
135+
"DETECT" { Write-Host $line -ForegroundColor Cyan }
136+
default { Write-Host $line }
137+
}
138+
}
139+
140+
# ============================================================================
141+
# DISCOVERY
142+
# ============================================================================
143+
function Get-TargetEnrollments {
144+
# NOTE: do not use $matches here — it is the automatic variable set by -match.
145+
$results = @()
146+
if (-not (Test-Path $Script:EnrollmentsRoot)) {
147+
Write-Log "No Enrollments key present — device has no MDM enrollment." -Level INFO
148+
return $results
149+
}
150+
151+
# foreach STATEMENT (not ForEach-Object) so += persists in this scope.
152+
$subKeys = Get-ChildItem $Script:EnrollmentsRoot -ErrorAction SilentlyContinue |
153+
Where-Object { $_.PSChildName -match '^\{?[0-9A-Fa-f-]{36}\}?$' }
154+
155+
foreach ($key in $subKeys) {
156+
$guid = $key.PSChildName
157+
$props = Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue
158+
$prov = $props.ProviderID
159+
$disc = $props.DiscoveryServiceFullURL
160+
$upn = $props.UPN
161+
162+
if (-not $prov) { continue }
163+
164+
# Never remove Intune unless the caller explicitly asked for that provider id.
165+
if ($prov -eq $Script:ProtectedProvider -and $ProviderId -ne $Script:ProtectedProvider) {
166+
Write-Log "Skipping protected Intune enrollment {$guid} (ProviderID '$prov')." -Level INFO
167+
continue
168+
}
169+
170+
$provMatch = ($prov -eq $ProviderId)
171+
$urlMatch = [string]::IsNullOrEmpty($DiscoveryUrlMatch) -or
172+
($disc -and $disc -match [regex]::Escape($DiscoveryUrlMatch))
173+
174+
if ($provMatch -and $urlMatch) {
175+
$results += [PSCustomObject]@{
176+
Guid = $guid
177+
ProviderID = $prov
178+
UPN = $upn
179+
DiscoveryUrl = $disc
180+
}
181+
Write-Log "MATCH: enrollment {$guid} ProviderID='$prov' UPN='$upn'" -Level DETECT
182+
} else {
183+
Write-Log "No match: {$guid} ProviderID='$prov'" -Level INFO
184+
}
185+
}
186+
return ,$results
187+
}
188+
189+
# ============================================================================
190+
# BACKUP
191+
# ============================================================================
192+
function Backup-Key {
193+
param([string]$RegPath) # PowerShell-style HKLM:\...
194+
if (-not (Test-Path $RegPath)) { return }
195+
$name = ($RegPath -replace 'HKLM:\\','' -replace '[\\:]','_')
196+
$file = Join-Path $Script:Config.BackupPath "$name`_$(Get-Date -Format 'yyyyMMdd_HHmmss').reg"
197+
$regExe = $RegPath -replace 'HKLM:\\','HKLM\'
198+
$null = reg export $regExe $file /y 2>&1
199+
if ($LASTEXITCODE -eq 0) { Write-Log "Backed up $RegPath -> $file" -Level SUCCESS }
200+
else { Write-Log "Backup warning for $RegPath (exit $LASTEXITCODE)" -Level WARN }
201+
}
202+
203+
# ============================================================================
204+
# REMOVAL
205+
# ============================================================================
206+
function Remove-EnrollmentArtifacts {
207+
param([Parameter(Mandatory)][string]$Guid)
208+
209+
$regTargets = @(
210+
"HKLM:\SOFTWARE\Microsoft\Enrollments\$Guid",
211+
"HKLM:\SOFTWARE\Microsoft\Enrollments\Status\$Guid",
212+
"HKLM:\SOFTWARE\Microsoft\EnterpriseResourceManager\Tracked\$Guid",
213+
"HKLM:\SOFTWARE\Microsoft\PolicyManager\AdmxInstalled\$Guid",
214+
"HKLM:\SOFTWARE\Microsoft\PolicyManager\Providers\$Guid"
215+
)
216+
217+
# Backup first (always, even in DetectOnly we skip — backup only when changing).
218+
foreach ($t in $regTargets) { Backup-Key -RegPath $t }
219+
220+
foreach ($t in $regTargets) {
221+
if (Test-Path $t) {
222+
if ($DetectOnly) {
223+
Write-Log "DETECT-ONLY: would remove $t" -Level DETECT
224+
} else {
225+
try {
226+
Remove-Item -Path $t -Recurse -Force -ErrorAction Stop
227+
Write-Log "Removed $t" -Level SUCCESS
228+
} catch {
229+
Write-Log "FAILED to remove $t : $_" -Level ERROR
230+
}
231+
}
232+
}
233+
}
234+
235+
# Scheduled task folder \Microsoft\Windows\EnterpriseMgmt\{GUID}\
236+
$taskPath = "$Script:TaskFolderRoot\$Guid\"
237+
$tasks = Get-ScheduledTask -TaskPath $taskPath -ErrorAction SilentlyContinue
238+
if ($tasks) {
239+
if ($DetectOnly) {
240+
Write-Log "DETECT-ONLY: would remove $($tasks.Count) scheduled task(s) under $taskPath" -Level DETECT
241+
} else {
242+
foreach ($tk in $tasks) {
243+
try {
244+
Unregister-ScheduledTask -TaskName $tk.TaskName -TaskPath $taskPath -Confirm:$false -ErrorAction Stop
245+
Write-Log "Removed scheduled task $taskPath$($tk.TaskName)" -Level SUCCESS
246+
} catch {
247+
Write-Log "FAILED to remove task $($tk.TaskName): $_" -Level ERROR
248+
}
249+
}
250+
# Remove the now-empty task folder via the Schedule.Service COM object.
251+
try {
252+
$svc = New-Object -ComObject "Schedule.Service"
253+
$svc.Connect()
254+
$root = $svc.GetFolder($Script:TaskFolderRoot)
255+
$root.DeleteFolder($Guid, 0)
256+
Write-Log "Removed task folder $taskPath" -Level SUCCESS
257+
} catch {
258+
Write-Log "Task folder $taskPath not removed (may be non-empty or absent): $_" -Level WARN
259+
}
260+
}
261+
}
262+
}
263+
264+
# ============================================================================
265+
# AGENT UNINSTALL (optional)
266+
# ============================================================================
267+
function Remove-Agent {
268+
param([string]$NameMatch)
269+
270+
$uninstallRoots = @(
271+
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
272+
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
273+
)
274+
275+
$found = foreach ($r in $uninstallRoots) {
276+
Get-ItemProperty $r -ErrorAction SilentlyContinue |
277+
Where-Object { $_.DisplayName -and $_.DisplayName -match [regex]::Escape($NameMatch) }
278+
}
279+
280+
if (-not $found) {
281+
Write-Log "No installed product matching '$NameMatch' found to uninstall." -Level INFO
282+
return
283+
}
284+
285+
foreach ($app in $found) {
286+
$cmd = $app.QuietUninstallString
287+
if (-not $cmd) { $cmd = $app.UninstallString }
288+
if (-not $cmd) {
289+
Write-Log "No uninstall string for '$($app.DisplayName)' — skipping." -Level WARN
290+
continue
291+
}
292+
293+
if ($DetectOnly) {
294+
Write-Log "DETECT-ONLY: would uninstall '$($app.DisplayName)' via: $cmd" -Level DETECT
295+
continue
296+
}
297+
298+
Write-Log "Uninstalling '$($app.DisplayName)'..." -Level INFO
299+
try {
300+
if ($cmd -match 'msiexec') {
301+
# Normalise MSI uninstall to quiet/no-restart.
302+
$guid = if ($cmd -match '\{[0-9A-Fa-f-]{36}\}') { $matches[0] } else { $null }
303+
if ($guid) {
304+
Start-Process msiexec.exe -ArgumentList "/x $guid /qn /norestart" -Wait -NoNewWindow
305+
} else {
306+
Start-Process cmd.exe -ArgumentList "/c $cmd /qn /norestart" -Wait -NoNewWindow
307+
}
308+
} else {
309+
Start-Process cmd.exe -ArgumentList "/c $cmd" -Wait -NoNewWindow
310+
}
311+
Write-Log "Uninstall command completed for '$($app.DisplayName)'." -Level SUCCESS
312+
} catch {
313+
Write-Log "FAILED to uninstall '$($app.DisplayName)': $_" -Level ERROR
314+
}
315+
}
316+
}
317+
318+
# ============================================================================
319+
# MAIN
320+
# ============================================================================
321+
function Invoke-Removal {
322+
Write-Log "============================================================"
323+
Write-Log "ManageEngine / third-party MDM removal"
324+
Write-Log "Mode: $(if ($DetectOnly) {'DETECT-ONLY'} else {'REMOVE'}) | ProviderId: '$ProviderId' | UrlMatch: '$DiscoveryUrlMatch'"
325+
Write-Log "Computer: $env:COMPUTERNAME"
326+
Write-Log "============================================================"
327+
328+
$targets = Get-TargetEnrollments
329+
if (-not $targets -or $targets.Count -eq 0) {
330+
Write-Log "No matching third-party MDM enrollment found. Nothing to do." -Level SUCCESS
331+
return 0
332+
}
333+
334+
Write-Log "Found $($targets.Count) matching enrollment(s)." -Level $(if ($targets.Count) {'WARN'} else {'INFO'})
335+
336+
if (-not $DetectOnly -and -not $Force) {
337+
Write-Host ""
338+
$ans = Read-Host "Remove the above enrollment(s) and free the MDM slot? [y/N]"
339+
if ($ans -notmatch '^[Yy]') { Write-Log "Cancelled by operator." -Level WARN; return 2 }
340+
}
341+
342+
foreach ($t in $targets) {
343+
Write-Log "--- Processing enrollment {$($t.Guid)} (ProviderID '$($t.ProviderID)') ---"
344+
Remove-EnrollmentArtifacts -Guid $t.Guid
345+
}
346+
347+
if ($RemoveAgent) {
348+
Write-Log "--- Agent uninstall phase (match '$AgentNameMatch') ---"
349+
Remove-Agent -NameMatch $AgentNameMatch
350+
}
351+
352+
# Validation
353+
Write-Log "--- Validation ---"
354+
$remaining = Get-TargetEnrollments
355+
if ($DetectOnly) {
356+
Write-Log "DETECT-ONLY complete. $($targets.Count) enrollment(s) would be removed." -Level DETECT
357+
return 0
358+
}
359+
if ($remaining.Count -gt 0) {
360+
Write-Log "WARNING: $($remaining.Count) matching enrollment(s) still present." -Level ERROR
361+
return 1
362+
}
363+
364+
Write-Log "MDM slot is now free. Trigger Intune enrollment with:" -Level SUCCESS
365+
Write-Log " deviceenroller.exe /c /AutoEnrollMDM (in the logged-on user's context, valid PRT required)" -Level INFO
366+
return 0
367+
}
368+
369+
# ============================================================================
370+
# ENTRY POINT
371+
# ============================================================================
372+
try {
373+
Initialize-Logging
374+
$code = Invoke-Removal
375+
Write-Log "Done. Exit code: $code | Log: $Script:LogFilePath"
376+
Stop-Transcript | Out-Null
377+
exit $code
378+
} catch {
379+
Write-Log "FATAL: $_" -Level ERROR
380+
Write-Log $_.ScriptStackTrace -Level ERROR
381+
try { Stop-Transcript | Out-Null } catch {}
382+
exit 99
383+
}

0 commit comments

Comments
 (0)