<#
.SYNOPSIS
Checks every GDAP customer tenant for the compromised ConnectWise vendor
Enterprise Applications (service principals) named in Huntress's October
2026 advisory on unauthorized ConnectWise vendor-credential access.
.DESCRIPTION
Huntress reported that a ConnectWise vendor credential was obtained by an
unauthorized party and used to pull Microsoft Graph tokens for several
ConnectWise multi-tenant applications, with at least one instance observed
self-elevating to Global Administrator in a customer tenant:
https://www.huntress.com/blog/vendor-credential-enables-unauthorized-access
Reads the customer tenant list produced by Get-GDAPCustomerTenants.ps1 and,
for each customer, looks for Enterprise Applications whose AppId matches one
of the known-compromised ConnectWise application IDs. For every match found
it also checks whether that service principal has been added to the Global
Administrator directory role - the specific escalation Huntress observed.
AUTH DESIGN - delegated via GDAP only, no app registration/service principal
of our own, matching how CIPP does this under the hood:
1. ONE interactive sign-in (New-PartnerAccessToken -UseAuthorizationCode),
using the same public multi-tenant client ID Connect-MgGraph itself
uses ("Microsoft Graph Command Line Tools": 14d82eec-204b-4c2f-b7e8-
296a70dab67e). This is delegated - it runs as you, under your own GDAP
role assignments, exactly like the interactive version of this script
did, just once instead of once per tenant.
2. For every customer tenant, the refresh token from step 1 is redeemed
for a Graph-scoped access token FOR THAT TENANT
(New-PartnerAccessToken -RefreshToken ... -Tenant <customerTenantId>).
This is a silent token redemption - no browser, no prompt - because
it's just an OAuth refresh-token grant against a tenant your GDAP
relationship already gives you a role in. This is the same mechanism
Get-PartnerCenterLicensing.ps1 uses for Partner Center tokens, applied
to Microsoft Graph instead.
3. The resulting access token is used directly against the Graph v1.0
REST API (Invoke-RestMethod) - no Microsoft.Graph module needed, which
also sidesteps Connect-MgGraph's own per-tenant interactive re-prompt
behaviour that made the original version of this script re-prompt for
all 214 tenants one at a time.
This only tells you whether the named vendor app is PRESENT (consented) in
a customer's tenant - that's expected for ConnectWise RMM/backup customers
and is not itself proof of compromise. Treat any row this script returns as
"needs vendor-advisory triage", not "confirmed breach": cross-check sign-in/
audit logs for that service principal against the activity windows in
Huntress's writeup, and follow ConnectWise's own remediation guidance.
.PARAMETER CustomerCsv
Path to the customer tenant list from Get-GDAPCustomerTenants.ps1.
.PARAMETER OutputPath
Folder to write the CSV reports to. Defaults to the script's own folder.
.PARAMETER AppIds
Override the built-in list of known-compromised ConnectWise AppIds (e.g. to
add IDs from a later Huntress/ConnectWise update) instead of editing the
script.
.PARAMETER IncludeOtherConnectWiseApps
Also report any OTHER service principal in the tenant owned by ConnectWise's
tenant (AppOwnerOrganizationId) that isn't one of the specific IOC AppIds
above. This is informational only - ConnectWise has many legitimate
multi-tenant apps - but gives a fuller picture of ConnectWise's footprint
in the tenant while this advisory is still developing.
.NOTES
Requires the PartnerCenter PowerShell module (already a dependency of
Get-PartnerCenterLicensing.ps1 in this folder) purely for its
New-PartnerAccessToken helper - no Partner Center API calls are made here,
it's used only as a generic MSAL wrapper to mint Graph tokens per tenant.
Needs Application.Read.All and Directory.Read.All to already be usable via
your GDAP role in each customer tenant - the same requirement the previous,
fully-interactive version of this script had.
#>
[CmdletBinding()]
param(
[string]$CustomerCsv = (Join-Path $PSScriptRoot 'GDAPCustomers.csv'),
[string]$OutputPath = $PSScriptRoot,
[string[]]$AppIds,
[switch]$IncludeOtherConnectWiseApps,
[string]$UserPrincipalName
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $CustomerCsv)) {
throw "Customer list not found at '$CustomerCsv'. Run Get-GDAPCustomerTenants.ps1 first."
}
Import-Module PartnerCenter -RequiredVersion 3.0.10 -ErrorAction Stop
# The well-known Microsoft Graph PowerShell ("Microsoft Graph Command Line Tools")
# public client ID - the exact same one Connect-MgGraph uses internally (confirmed
# by extracting the GUID literal from Microsoft.Graph.Authentication.dll). Reused
# here via New-PartnerAccessToken purely as a generic MSAL wrapper so we can redeem
# one interactive sign-in into a silent per-tenant token, which Connect-MgGraph
# itself doesn't support across repeated -TenantId switches in one session.
$graphClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e'
$graphScope = 'https://graph.microsoft.com/.default'
# Kept deliberately minimal and near-universally pre-consented, to avoid tripping an
# admin-consent prompt (which showed up as "access_denied" when the bootstrap sign-in
# requested .default straight away) - the admin-scoped request only happens per customer
# tenant below, where it was already working in the previous interactive version.
$bootstrapScope = 'https://graph.microsoft.com/User.Read offline_access'
# Source: https://www.huntress.com/blog/vendor-credential-enables-unauthorized-access
# (Huntress advisory, 2026-10). The compromised credential belongs to ConnectWise's
# own tenant and was used against these specific multi-tenant management apps.
$connectWiseTenantId = '3f9cd7a0-127a-4ed4-bf06-2f830d5616b7'
$knownVendorApps = @(
[pscustomobject]@{ AppId = '506e08f9-6e9a-4da4-942b-a099a03644ae'; Label = 'ConnectWise Cloud Backup (formerly SkyKick)'; Note = "Huntress: credential confirmed in hostile hands - used to pull Graph tokens from unauthorized infrastructure (observed 2026-10-15)." }
[pscustomobject]@{ AppId = '24205314-2b89-4e07-a2a4-579e898a5640'; Label = 'ConnectWise Cloud Management - MS Teams'; Note = "Huntress: in one customer tenant this app assigned itself to Global Administrator, then ran a Python script modifying two other applications." }
[pscustomobject]@{ AppId = 'b78f6a77-d4e5-46fb-87e5-d1648bd02519'; Label = 'ConnectWise Cloud Management - Exchange Online'; Note = "Huntress: identified with high confidence as abused for unauthorized access." }
[pscustomobject]@{ AppId = 'b2b55f5b-7758-4719-8d8f-88ae172e6bdb'; Label = 'ConnectWise Cloud Management - SharePoint Online'; Note = "Huntress: one of multiple ConnectWise management apps observed in the exploitation activity." }
[pscustomobject]@{ AppId = '0de14c77-b9b0-4137-9360-34878a929827'; Label = 'ConnectWise Cloud Management - MS Graph'; Note = "Huntress: used to obtain tokens as part of the unauthorized access campaign." }
[pscustomobject]@{ AppId = '412dcf96-176d-4b3a-a1b0-4981ad66971e'; Label = 'ConnectWise Cloud Management - Partner Center'; Note = "Huntress: associated with vendor automation in the incident timeline." }
[pscustomobject]@{ AppId = '2399d300-6fca-4e6a-a686-399fefe75b83'; Label = 'ConnectWise Cloud Management (generic)'; Note = "Huntress: general management app recommended for review and disablement." }
)
if ($AppIds) {
$knownVendorApps = $AppIds | ForEach-Object { [pscustomobject]@{ AppId = $_; Label = '(custom AppId)'; Note = '' } }
}
$globalAdminRoleTemplateId = '62e90394-69f5-4237-9190-012177145e10'
$customers = Import-Csv -Path $CustomerCsv
if (-not $customers -or $customers.Count -eq 0) {
Write-Warning "No customer tenants in '$CustomerCsv'. Nothing to check."
return
}
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$reportPath = Join-Path $OutputPath "ConnectWiseVendorAppCheck_$timestamp.csv"
$errorsPath = Join-Path $OutputPath "ConnectWiseVendorAppCheck_Errors_$timestamp.csv"
$findings = [System.Collections.Generic.List[object]]::new()
$errors = [System.Collections.Generic.List[object]]::new()
function Add-ErrorRow {
param($TenantId, $TenantName, $Message)
$errors.Add([pscustomobject]@{ TenantId = $TenantId; TenantName = $TenantName; Message = $Message })
}
function Invoke-GraphGet {
param([string]$Uri, [string]$AccessToken)
$headers = @{ Authorization = "Bearer $AccessToken"; ConsistencyLevel = 'eventual' }
$items = [System.Collections.Generic.List[object]]::new()
do {
$resp = Invoke-RestMethod -Uri $Uri -Headers $headers -Method Get -ErrorAction Stop
if ($resp.value) { foreach ($v in $resp.value) { $items.Add($v) } }
$Uri = $resp.'@odata.nextLink'
} while ($Uri)
return $items
}
function Test-IsGlobalAdmin {
param([string]$ServicePrincipalObjectId, [string]$AccessToken)
try {
$memberOf = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$ServicePrincipalObjectId/memberOf" -AccessToken $AccessToken
foreach ($m in $memberOf) {
if ($m.'@odata.type' -eq '#microsoft.graph.directoryRole' -and $m.roleTemplateId -eq $globalAdminRoleTemplateId) {
return $true
}
}
return $false
} catch {
# Directory.Read.All missing or role membership not readable - leave unknown rather than assume safe.
return $null
}
}
if (-not $UserPrincipalName -and [Environment]::UserInteractive -and -not [Console]::IsInputRedirected) {
try { $UserPrincipalName = Read-Host "Enter the UPN/email you'll sign in with (optional, just a reminder)" } catch { }
}
Write-Host "`n=== Sign in once (delegated, via your GDAP access) ===" -ForegroundColor Cyan
Write-Host "A browser sign-in window will open - sign in there with your own admin/GDAP account." -ForegroundColor Cyan
if ($UserPrincipalName) {
Write-Host ">>> Sign in as: $UserPrincipalName <<<" -ForegroundColor Magenta
Write-Host "If the browser offers a different/cached account, choose 'Use another account' and enter the above." -ForegroundColor Cyan
}
Write-Host "Use passkey/Windows Hello there if you have one registered. This happens ONCE - every customer tenant after this is a silent token redemption, no further prompts." -ForegroundColor Cyan
$auth = New-PartnerAccessToken -ApplicationId $graphClientId -Scopes $bootstrapScope -UseAuthorizationCode
Write-Host "Signed in." -ForegroundColor Green
Write-Host "Customer tenants to check: $($customers.Count)" -ForegroundColor Green
$refreshToken = $auth.RefreshToken
Write-Host "`n=== ConnectWise vendor app check across $($customers.Count) GDAP customer tenant(s) ===" -ForegroundColor Cyan
Write-Host "Checking for $($knownVendorApps.Count) known-compromised ConnectWise AppId(s)." -ForegroundColor Cyan
$idx = 0
foreach ($customer in $customers) {
$idx++
$tenantLabel = if ($customer.TenantName) { $customer.TenantName } else { $customer.TenantId }
Write-Host "`n[$idx/$($customers.Count)] $tenantLabel" -ForegroundColor Yellow
$customerToken = $null
try {
$customerToken = New-PartnerAccessToken -ApplicationId $graphClientId -RefreshToken $refreshToken -Scopes $graphScope -Tenant $customer.TenantId -ErrorAction Stop
} catch {
Write-Host " Skipped - no Graph access via GDAP in this tenant: $($_.Exception.Message)" -ForegroundColor DarkGray
Add-ErrorRow -TenantId $customer.TenantId -TenantName $tenantLabel -Message "Token redemption failed: $($_.Exception.Message)"
continue
}
# Refresh tokens can rotate on each redemption - always carry the newest one forward.
if ($customerToken.RefreshToken) { $refreshToken = $customerToken.RefreshToken }
$accessToken = $customerToken.AccessToken
foreach ($vendorApp in $knownVendorApps) {
try {
$sp = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$($vendorApp.AppId)'" -AccessToken $accessToken | Select-Object -First 1
} catch {
Add-ErrorRow -TenantId $customer.TenantId -TenantName $tenantLabel -Message "Lookup failed for AppId $($vendorApp.AppId): $($_.Exception.Message)"
continue
}
if (-not $sp) { continue }
$isGA = Test-IsGlobalAdmin -ServicePrincipalObjectId $sp.id -AccessToken $accessToken
$severity = if ($isGA -eq $true) { 'CRITICAL - app has Global Administrator' } elseif ($null -eq $isGA) { 'Present - GA membership unknown (insufficient read access)' } else { 'Present' }
$findings.Add([pscustomobject]@{
TenantId = $customer.TenantId
TenantName = $tenantLabel
AppId = $vendorApp.AppId
VendorAppLabel = $vendorApp.Label
ServicePrincipalName = $sp.displayName
ServicePrincipalObjId = $sp.id
AccountEnabled = $sp.accountEnabled
IsGlobalAdministrator = $isGA
Severity = $severity
HuntressNote = $vendorApp.Note
})
$color = if ($isGA -eq $true) { 'Red' } else { 'Yellow' }
Write-Host " [FOUND] $($vendorApp.Label) - $severity" -ForegroundColor $color
}
if ($IncludeOtherConnectWiseApps) {
try {
$otherSPs = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appOwnerOrganizationId eq $connectWiseTenantId&`$count=true" -AccessToken $accessToken
foreach ($sp in $otherSPs) {
if ($knownVendorApps.AppId -contains $sp.appId) { continue }
$findings.Add([pscustomobject]@{
TenantId = $customer.TenantId
TenantName = $tenantLabel
AppId = $sp.appId
VendorAppLabel = '(other ConnectWise app - not a named IOC)'
ServicePrincipalName = $sp.displayName
ServicePrincipalObjId = $sp.id
AccountEnabled = $sp.accountEnabled
IsGlobalAdministrator = $null
Severity = 'Informational'
HuntressNote = ''
})
}
} catch {
Add-ErrorRow -TenantId $customer.TenantId -TenantName $tenantLabel -Message "Other-ConnectWise-app lookup failed: $($_.Exception.Message)"
}
}
}
$findings | Export-Csv -Path $reportPath -NoTypeInformation -Encoding UTF8
$errors | Export-Csv -Path $errorsPath -NoTypeInformation -Encoding UTF8
Write-Host "`n=== Done ===" -ForegroundColor Cyan
Write-Host "Customer tenants checked : $($customers.Count)" -ForegroundColor Green
Write-Host "Vendor app matches found : $($findings.Count)" -ForegroundColor $(if ($findings.Count -gt 0) { 'Red' } else { 'Green' })
Write-Host "Tenants with a lookup error : $($errors.Count)" -ForegroundColor Yellow
Write-Host "Report : $reportPath" -ForegroundColor Green
Write-Host "Errors log : $errorsPath" -ForegroundColor Yellow
if ($findings | Where-Object { $_.Severity -like 'CRITICAL*' }) {
Write-Host "`nCRITICAL rows found - at least one vendor app holds Global Administrator in a customer tenant. Treat as an active incident, not routine triage." -ForegroundColor Red
}