I found a handy module by Michael Niehaus some time ago called LocalMDM. I’ve since converted it to be usable as an ImmyBot module. This allows me to create a task that runs MDM commands just like Intune, without the need to be enrolled.
An unfortunate side-effect is the requirement to have Windows in embedded mode. The script will handle enabling it, however it means that there will be no Microsoft support if there are any issues. This is because embedded mode is technically only supported by IoT Core and IoT Enterprise editions of Windows. Though it has been tested and works on Pro, Education, and Enterprise as well.
Here is the module itself, called LocalMDM:
function Unregister-LocalMDM {
<#
.SYNOPSIS
Unregisters the local MDM server.
.DESCRIPTION
Unregisters the local MDM server. This will in some cases revert any policies configured via the local MDM server back to their default values.
.EXAMPLE
Unregister-LocalMDM
.OUTPUTS
A message confirming the unregister operation.
#>
[cmdletbinding()]
Param(
)
PROCESS {
Invoke-ImmyCommand -Context System -ScriptBlock {
$rc = [MDMLocal.Interface]::UnregisterDeviceWithLocalManagement()
Write-Host "Unregisterd, rc = $rc"
}
Write-Progress "The device has been unregistered from LocalMDM. Policy enforcement is removed."
}
}
Function Send-LocalMDMRequest {
<#
.SYNOPSIS
Sends a SyncML request to the local MDM server.
.DESCRIPTION
This module leverages the Windows MDM local management APIs to directly
process MDM policy requests (SyncML) on Windows devices. Windows Embedded mode
will be enabled when this is run. IoT Core and IoT Enterprise are the only
operating systems with official support for Embedded mode. It can be enabled on
Windows 10/11 Pro, Education, and Enterprise SKUs, but is not officially supported.
.PARAMETER SyncML
Specifies the explicit SyncML XML string that should be sent to the local MDM service.
.PARAMETER OmaUri
Specifies the OMA-URI path that should be used to construct a SyncML request.
.PARAMETER Cmd
Specifies the MDM command that should be used to construct a SyncML request. Valid values are "Get", "Add", "Atomic", "Delete", "Exec", "Replace", and "Result". The default is "Get".
.PARAMETER Format
Specifies the format of the data value to be included in the SyncML request. The default value is "int".
.PARAMETER Type
Specifies the type of the data value to be included in the SyncML request. The default value is "text/plain".
.PARAMETER Data
Specifies the data to be included in the SyncML request. This is optional for some requests (e.g. "Get").
.PARAMETER Raw
Specifies that the result should be returned as a raw string (exactly as returned by the local MDM service) rather than as a PowerShell object.
.EXAMPLE
Send-LocalMDMRequest -OmaUri "./DevDetail/Ext/Microsoft/ProcessorArchitecture"
.EXAMPLE
Send-LocalMDMRequest -OmaUri "./DevDetail/Ext/Microsoft/ProcessorArchitecture"
.OUTPUTS
The result of the SyncML request. If -Raw is specified, this will be an XML string. Otherwise, it will be a PowerShell object.
.LINK
https://oofhours.com/2022/08/26/send-mdm-commands-without-an-mdm-service-using-powershell/
https://github.com/ms-iot/iot-core-azure-dm-client/blob/master/src/SystemConfigurator/CSPs/MdmProvision.cpp
https://docs.microsoft.com/en-us/windows/iot-core/develop-your-app/embeddedmode
#>
[cmdletbinding()]
Param(
[Parameter(ParameterSetName='Raw', Mandatory = $true)]
[String]$SyncML,
[Parameter(ParameterSetName='Assisted', Mandatory = $true)]
[String]$OmaUri,
[Parameter(ParameterSetName='Assisted', Mandatory = $false)]
[ValidateSet("Get", "Add", "Atomic", "Delete", "Exec", "Replace", "Result")]
[String]$Cmd = "Get",
[Parameter(ParameterSetName='Assisted', Mandatory = $false)]
[String]$Format = "int",
[Parameter(ParameterSetName='Assisted', Mandatory = $false)]
[String]$Type = "text/plain",
[Parameter(ParameterSetName='Assisted', Mandatory = $false)]
[String]$Data = "",
[Parameter()]
[Switch]$Raw = $false
)
BEGIN {
$source = @"
using System;
using System.Runtime.InteropServices;
namespace MDMLocal
{
public class Interface
{
[DllImport("mdmlocalmanagement.dll", CharSet = CharSet.Unicode, SetLastError = true)]
internal static extern uint RegisterDeviceWithLocalManagement(out uint alreadyRegistered);
[DllImport("mdmlocalmanagement.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern uint UnregisterDeviceWithLocalManagement();
[DllImport("mdmlocalmanagement.dll", CharSet = CharSet.Unicode, SetLastError = true)]
internal static extern uint ApplyLocalManagementSyncML(string syncMLRequest, out IntPtr syncMLResult);
[DllImport("kernel32.dll")]
internal static extern uint LocalFree(IntPtr hMem);
public static uint Apply(string syncML, out string syncMLResult)
{
uint rc;
uint alreadyRegistered;
IntPtr resultPtr;
rc = RegisterDeviceWithLocalManagement(out alreadyRegistered);
rc = ApplyLocalManagementSyncML(syncML, out resultPtr);
syncMLResult = "";
if (resultPtr != null)
{
syncMLResult = Marshal.PtrToStringUni(resultPtr);
LocalFree(resultPtr);
}
return rc;
}
}
}
"@
Write-Progress "Enabling Windows embedded mode..."
$uuidBytes = Invoke-ImmyCommand -Context System -ScriptBlock { ([GUID](Get-WMIObject -Class win32_computersystemproduct).UUID).ToByteArray() }
$hash = Invoke-ImmyCommand -Context System -ScriptBlock {
$hasher = [System.Security.Cryptography.HashAlgorithm]::Create('sha256')
$hash = $hasher.ComputeHash($($using:uuidBytes))
return $hash
}
Get-WindowsRegistryValue -Path "HKLM:\SYSTEM\CurrentControlSet\Services\embeddedmode\Parameters" -Name "Flags" | RegistryShould-Be -Value $hash
# THIS IS THE SAME AS LINES 136-142 WITHOUT UTILIZING IMMYBOT FUNCTIONS
# Invoke-ImmyCommand -Context System -ScriptBlock {
# $uuidBytes = ([GUID](Get-WMIObject -Class win32_computersystemproduct).UUID).ToByteArray()
# $hasher = [System.Security.Cryptography.HashAlgorithm]::Create('sha256')
# $hash = $hasher.ComputeHash($uuidBytes)
# Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\embeddedmode\Parameters" -Name "Flags" -Value $hash
# }
}
PROCESS {
Write-Progress "Targetting $OmaUri"
if ($Cmd -eq "Get"){
Write-Progress "Running as a $Cmd command."
} else {
Write-Progress "Running as a $Cmd command with the following data: $Data"
}
Invoke-ImmyCommand -Context System -ScriptBlock {
Add-Type -TypeDefinition $($using:source) -Language CSharp
$global:localMDMCmdCounter = 0
# Depending on the parameter set, build or use SyncML
if ($PSCmdlet.ParameterSetName -eq "Raw") {
$useSyncML = $using:SyncML
} else {
$useSyncML = @"
<SyncBody>
<$($using:Cmd)>
<CmdID>1</CmdID>
<Item>
<Target>
<LocURI>$($using:OmaUri)</LocURI>
</Target>
<Meta>
<Format xmlns="syncml:metinf">$($using:Format)</Format>
<Type xmlns="syncml:metinf">$($using:Type)</Type>
</Meta>
<Data>$($using:Data)</Data>
</Item>
</$($using:Cmd)>
</SyncBody>
"@
}
# Make sure we have a unique command ID
[xml] $xml = $useSyncML
$global:localMDMCmdCounter++
$xml.SyncBody.FirstChild.CmdID = $global:localMDMCmdCounter.ToString()
$cmdId, $locURI, $updatedSyncML = $global:localMDMCmdCounter, $xml.SyncBody.FirstChild.Item.Target.LocURI, $xml.OuterXml
# Make a request and check for fatal errors
$syncMLResultString = ""
$rc = [MDMLocal.Interface]::Apply($updatedSyncML, [ref]$syncMLResultString)
if ($rc -eq 2147549446) {
throw "MDM local management requires running powershell.exe with -MTA."
} elseif ($rc -eq 2147746132) {
throw "MDM local management requires a 64-bit process."
} elseif ($syncMLResultString -like "Error*") {
throw $syncMLResultString
} elseif ($rc -ne 0) {
Write-Host -ForegroundColor Yellow "$syncMLResultString"
throw "Unexpected return code from MDM local management: $rc"
}
# Return the response details (Status of 200 is success)
if ($($using:Raw)) {
$syncMLResultString
} else {
[xml] $syncMLResult = $syncMLResultString
$status = $syncMLResult.SyncML.SyncBody.Status[1]
$Result = New-Object PSObject -Property ([ordered] @{
CmdId = $cmdId
Cmd = $status.Cmd
Status = $status.Data
OmaUri = $locURI
Data = $syncMLResult.SyncML.SyncBody.Results.Item.Data
})
Write-Host -Fore Green "The policy has been applied. Result:"
return $Result
}
}
}
}
Export-ModuleMember -Function Unregister-LocalMDM,Send-LocalMDMRequest
And the combined script for a task is:
param(
[Parameter(Position=0,Mandatory=$False,HelpMessage=@'
If set to $true, other parameters will be unused.
'@)]
[Boolean]$Unregister=$false,
[Parameter(Position=1,Mandatory=$True)]
[String]$OMAURI,
[Parameter(Position=2,Mandatory=$True)]
[ValidateSet('Add','Atomic','Delete','Exec','Replace','Result')]
[String]$SetCmd,
[Parameter(Position=3)]
[String]$DataValue
)
Import-Module LocalMDM
switch($method){
test{
$TestResult = Send-LocalMDMRequest -OMAURI $OMAURI -Cmd Get
Write-Host -Fore DarkGreen $TestResult
if ($null -eq $TestResult.Data -or $TestResult.Data -ne $DataValue -and $TestResult.Status -eq "200"){
return $false
}else{
return $true
}
}
get{
return $TestResult.Data
}
set{
if ($Unregister){
Unregister-LocalMDM
return
}
if ($null -ne $DataValue -and $SetCmd -eq "Add"){
$SetCmd = "Replace"
}
Send-LocalMDMRequest -OMAURI $OMAURI -Cmd $SetCmd -Data $DataValue
}
}
It’s a bit tricky, so there’s some things I’m still working on to smooth it out. It’s great for different types of remote wipes, though. For example, You could add a line in the task to remove the ppkg from C:\Recovery\Customizations
and run a protected wipe. Alternatively, you can run a wipe and persist user data if needed. Wipes are a bit weird, so don’t expect the session to end in a compliant state. It’s difficult to determine if the wipe happened or not programmatically.
If you’re interested in this module and how it works, this is where I found it: