Automating with PowerShell: making stolen tokens useless with Global Secure Access

Last week I watched Merill Fernando talk to Alex Pavlovsky and Marilee Tuscak from the Entra team about something I’d kinda put under the “needs a GSA licence” branch and i’d check it out later, turns out that like most people, I half-assed the documentation and it turns out it doesn’t. There’s two features that come with any P1 license; Compliant network check and Source IP Restoration. So BP, P1, or even frontline worker licenses already have this.

If you want to check out Merill being his awesome self, and educate yourself on this go check out his Youtube here: https://www.youtube.com/watch?v=uFqTyadnN38&t=32s

So how does GSA help with token phising? A phished refresh token or a stolen cookie is only useful to an attacker because they can replay it from their own machine. There’s a lot of protections against it such as phishing resistant MFA, but even then its still hard to get everyone on that. This solution is slightly different; you put the Global Secure Access client on your devices, and Entra’s edge starts tagging every sign-in with “this came from a GSA client in tenant X”. Then you add one Conditional Access policy: block everything that does not come from the compliant network. The token still exists, but it only works from a device that is yours.

We built this into CIPP as a single baseline standard, and while doing that I hit a handful of things the documentation doesn’t tell you. So here’s the version for everyone who doesn’t run CIPP: one script, just the Graph PowerShell module, same end result.

What the script does

In order:

  1. Onboards the tenant to Global Secure Access and waits for it to finish.
  2. Enables the Microsoft 365 traffic forwarding profile and assigns it to all users.
  3. Turns on Conditional Access signaling and creates the “All Compliant Network locations” named location.
  4. Deploys the Windows client through an Intune platform script, with the registry keys that stop users from switching it off.
  5. Deploys the macOS client through a shell script, plus the three profiles Microsoft requires so the user never sees a “system extension blocked” prompt.
  6. Creates the Conditional Access policy in report-only mode, with your admin roles and break-glass accounts excluded.

You flip $Enforce to $true and run it again once the sign-in logs tell you everyone is coming through the compliant network, or you’ve built out your exclusions a little more.

Some cool things I’ve found out during building

The named location is not created by the API. The docs say “All Compliant Network locations” appears automatically when you enable signaling, but only because the portal does a second call the docs never mention: it POSTs the named location itself. If you’re doing this scripted, you have to create the named location via the script too.

The client only tunnels IPv4. If a device resolves login.microsoftonline.com over IPv6, that sign-in goes around the tunnel and your own policy blocks it. Microsoft’s Intune script sets IPv4 as preferred and asks for a reboot. The script does the same, so be warned if you’re using ipv6 for anything.

QUIC is fine. The guidance about disabling QUIC in browsers is for the Internet Access profile. For Microsoft 365 traffic it’s supported, so there’s nothing to do. I didn’t disable QUICc in the script because I like having it available if we don’t have a hard requirement to disable it.

The script

You need the Microsoft Graph PowerShell SDK and an account that holds the Global Secure Access Administrator, Conditional Access Administrator and Intune Administrator roles. Fill in the tenant and your break-glass account, run it, and read the output.

#### Settings
$TenantId = 'YourTenantId'
$BreakGlassAccounts = @('breakglass@contoso.com')
$Enforce = $false       # $true switches the policy from report-only to enabled
$DeployWindows = $true
$DeployMacOS = $true
$LockDownClient = $true # hides the disable/pause/sign-out buttons and needs admin rights to disable
$PreferIPv4 = $true     # the client only tunnels IPv4; this needs a reboot
####

$ErrorActionPreference = 'Stop'
Connect-MgGraph -TenantId $TenantId -NoWelcome -Scopes @(
    'NetworkAccess.ReadWrite.All', 'NetworkAccessPolicy.ReadWrite.All', 'Application.ReadWrite.All', 'Policy.Read.All', 'Policy.ReadWrite.ConditionalAccess',
    'DeviceManagementConfiguration.ReadWrite.All', 'DeviceManagementScripts.ReadWrite.All', 'User.Read.All'
)
$Graph = 'https://graph.microsoft.com/beta'
function Invoke-Graph {
    param([string]$Method = 'GET', [string]$Uri, $Body)
    $Params = @{ Method = $Method; Uri = $Uri; OutputType = 'PSObject' }
    if ($null -ne $Body) { $Params.Body = ($Body | ConvertTo-Json -Depth 20); $Params.ContentType = 'application/json' }
    Invoke-MgGraphRequest @Params
}
$AllDevices = @{ target = @{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } }

# --- 1. onboarding
$Status = Invoke-Graph -Uri "$Graph/networkAccess/tenantStatus"
if ($Status.onboardingStatus -ne 'onboarded') {
    if ($Status.onboardingStatus -ne 'onboardingInProgress') { Invoke-Graph -Method POST -Uri "$Graph/networkAccess/microsoft.graph.networkaccess.onboard" -Body @{} | Out-Null }
    do {
        Write-Host "Onboarding to Global Secure Access ($($Status.onboardingStatus))..."
        Start-Sleep -Seconds 15
        $Status = Invoke-Graph -Uri "$Graph/networkAccess/tenantStatus"
    } while ($Status.onboardingStatus -eq 'onboardingInProgress')
    if ($Status.onboardingStatus -ne 'onboarded') { throw "Onboarding ended in '$($Status.onboardingStatus)': $($Status.onboardingErrorMessage)" }
}
Write-Host 'Tenant is onboarded.'

# --- 2. Microsoft 365 traffic profile, assigned to all users
$TrafficProfile = (Invoke-Graph -Uri "$Graph/networkAccess/forwardingProfiles?`$expand=policies").value | Where-Object { $_.trafficForwardingType -eq 'm365' } | Select-Object -First 1
if ($TrafficProfile.state -ne 'enabled') { Invoke-Graph -Method PATCH -Uri "$Graph/networkAccess/forwardingProfiles/$($TrafficProfile.id)" -Body @{ state = 'enabled' } | Out-Null }
foreach ($Link in @($TrafficProfile.policies | Where-Object { $_.state -ne 'enabled' })) {
    Invoke-Graph -Method PATCH -Uri "$Graph/networkAccess/forwardingProfiles/$($TrafficProfile.id)/policies/$($Link.id)" -Body @{ state = 'enabled' } | Out-Null
}
# The profile's enterprise app decides who gets it; 'assignment required' off means everyone.
Invoke-Graph -Method PATCH -Uri "$Graph/servicePrincipals/$($TrafficProfile.servicePrincipal.id)" -Body @{ appRoleAssignmentRequired = $false } | Out-Null
Write-Host 'Microsoft 365 traffic profile is enabled for all users.'

# --- 3. Conditional Access signaling and the named location the portal creates behind the scenes
$Settings = Invoke-Graph -Uri "$Graph/networkAccess/settings/conditionalAccess"
if ($Settings.signalingStatus -ne 'enabled') { Invoke-Graph -Method PATCH -Uri "$Graph/networkAccess/settings/conditionalAccess" -Body @{ signalingStatus = 'enabled' } | Out-Null }
$Location = (Invoke-Graph -Uri "$Graph/identity/conditionalAccess/namedLocations").value | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.compliantNetworkNamedLocation' } | Select-Object -First 1
if (-not $Location) {
    $Location = Invoke-Graph -Method POST -Uri "$Graph/identity/conditionalAccess/namedLocations" -Body @{
        '@odata.type' = '#microsoft.graph.compliantNetworkNamedLocation'; displayName = 'All Compliant Network Locations'; compliantNetworkType = 'allTenantCompliantNetworks'; isTrusted = $false
    }
}
Write-Host "Signaling is on and the compliant network location exists ($($Location.id))."

# --- 4. Windows client: an Intune platform script that downloads, installs and locks down the client
if ($DeployWindows) {
    $Lock = [int]$LockDownClient
    $WindowsScript = @"
`$ProgramFiles = if (`$env:ProgramW6432) { `$env:ProgramW6432 } else { `$env:ProgramFiles }
`$Arch = if (`$env:PROCESSOR_ARCHITEW6432) { `$env:PROCESSOR_ARCHITEW6432 } else { `$env:PROCESSOR_ARCHITECTURE }
`$Url = if (`$Arch -eq 'ARM64') { 'https://aka.ms/GlobalSecureAccess-WindowsOnArm' } else { 'https://aka.ms/GlobalSecureAccess-windows' }
if (-not (Test-Path (Join-Path `$ProgramFiles 'Global Secure Access Client\TrayApp\GlobalSecureAccessClient.exe'))) {
    `$Installer = Join-Path `$env:TEMP 'GlobalSecureAccessClient.exe'
    [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
    Invoke-WebRequest -Uri `$Url -OutFile `$Installer -UseBasicParsing
    Start-Process -FilePath `$Installer -ArgumentList '/quiet /norestart' -Wait
}
`$Key = 'HKLM:\SOFTWARE\Microsoft\Global Secure Access Client'
if (-not (Test-Path `$Key)) { New-Item -Path `$Key -Force | Out-Null }
foreach (`$Name in 'HideDisableButton', 'HideSignOutButton', 'HideDisablePrivateAccessButton', 'RestrictNonPrivilegedUsers') {
    Set-ItemProperty -Path `$Key -Name `$Name -Value $Lock -Type DWord -Force
}
if (`$$PreferIPv4) {
    Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters' -Name DisabledComponents -Value 0x20 -Type DWord -Force
}
"@
    $Name = 'Global Secure Access Client (Windows)'
    $Existing = (Invoke-Graph -Uri "$Graph/deviceManagement/deviceManagementScripts?`$select=id,displayName").value | Where-Object { $_.displayName -eq $Name }
    if (-not $Existing) {
        $Script = Invoke-Graph -Method POST -Uri "$Graph/deviceManagement/deviceManagementScripts" -Body @{
            displayName = $Name; description = 'Installs the Global Secure Access client and locks it down.'; fileName = 'Install-GlobalSecureAccess.ps1'
            runAsAccount = 'system'; runAs32Bit = $false; enforceSignatureCheck = $false
            scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($WindowsScript))
        }
        Invoke-Graph -Method POST -Uri "$Graph/deviceManagement/deviceManagementScripts/$($Script.id)/assign" -Body @{ deviceManagementScriptAssignments = @($AllDevices) } | Out-Null
    }
    Write-Host 'Windows client script is in Intune.'
}

# --- 5. macOS client: shell script plus the three profiles Microsoft requires
if ($DeployMacOS) {
    $MacScript = @'
#!/bin/bash
[ -d "/Applications/GlobalSecureAccessClient" ] && exit 0
curl -L --fail --silent -o /tmp/GlobalSecureAccessClient.pkg "https://aka.ms/GlobalSecureAccess-macOS" || exit 1
/usr/sbin/installer -pkg /tmp/GlobalSecureAccessClient.pkg -target / || exit 1
rm -f /tmp/GlobalSecureAccessClient.pkg
'@
    $Name = 'Global Secure Access Client (macOS)'
    $Existing = (Invoke-Graph -Uri "$Graph/deviceManagement/deviceShellScripts?`$select=id,displayName").value | Where-Object { $_.displayName -eq $Name }
    if (-not $Existing) {
        $Script = Invoke-Graph -Method POST -Uri "$Graph/deviceManagement/deviceShellScripts" -Body @{
            displayName = $Name; description = 'Installs the Global Secure Access client.'; fileName = 'install-gsa.sh'
            runAsAccount = 'system'; retryCount = 3; blockExecutionNotifications = $true; executionFrequency = 'P1D'
            scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($MacScript))
        }
        Invoke-Graph -Method POST -Uri "$Graph/deviceManagement/deviceShellScripts/$($Script.id)/assign" -Body @{ deviceManagementScriptAssignments = @($AllDevices) } | Out-Null
    }

    # Allowed system extensions (settings catalog)
    $Str = { param($Value) @{ '@odata.type' = '#microsoft.graph.deviceManagementConfigurationStringSettingValue'; value = $Value } }
    $Name = 'Global Secure Access - macOS System Extensions'
    $Existing = (Invoke-Graph -Uri "$Graph/deviceManagement/configurationPolicies?`$select=id,name").value | Where-Object { $_.name -eq $Name }
    if (-not $Existing) {
        $Policy = Invoke-Graph -Method POST -Uri "$Graph/deviceManagement/configurationPolicies" -Body @{
            name = $Name; description = 'Lets the Global Secure Access system extensions load without prompting.'; platforms = 'macOS'; technologies = 'mdm'; roleScopeTagIds = @('0')
            settings = @(@{
                    '@odata.type'   = '#microsoft.graph.deviceManagementConfigurationSetting'
                    settingInstance = @{
                        '@odata.type'               = '#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance'
                        settingDefinitionId         = 'com.apple.system-extension-policy_allowedsystemextensions'
                        groupSettingCollectionValue = @(@{
                                '@odata.type' = '#microsoft.graph.deviceManagementConfigurationGroupSettingValue'
                                children      = @(
                                    @{ '@odata.type' = '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance'; settingDefinitionId = 'com.apple.system-extension-policy_allowedsystemextensions_generickey_keytobereplaced'; simpleSettingValue = (& $Str 'UBF8T346G9') }
                                    @{ '@odata.type' = '#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance'; settingDefinitionId = 'com.apple.system-extension-policy_allowedsystemextensions_generickey'; simpleSettingCollectionValue = @((& $Str 'com.microsoft.globalsecureaccess.tunnel'), (& $Str 'com.microsoft.globalsecureaccess')) }
                                )
                            })
                    }
                })
        }
        Invoke-Graph -Method POST -Uri "$Graph/deviceManagement/configurationPolicies/$($Policy.id)/assign" -Body @{ assignments = @($AllDevices) } | Out-Null
    }

    # Transparent proxy (Microsoft's published payload) and the client preference file
    $ProxyPlist = @'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>PayloadUUID</key><string>87cbb424-6af7-4748-9d43-f1c5dda7a0a6</string>
<key>PayloadType</key><string>Configuration</string>
<key>PayloadOrganization</key><string>Microsoft Corporation</string>
<key>PayloadIdentifier</key><string>com.microsoft.globalsecureaccess</string>
<key>PayloadDisplayName</key><string>Global Secure Access Proxy Configuration</string>
<key>PayloadVersion</key><integer>1</integer>
<key>PayloadEnabled</key><true/>
<key>PayloadRemovalDisallowed</key><true/>
<key>PayloadScope</key><string>System</string>
<key>PayloadContent</key><array><dict>
<key>PayloadUUID</key><string>04e13063-2bb8-4b72-b1ed-45290f91af68</string>
<key>PayloadType</key><string>com.apple.vpn.managed</string>
<key>PayloadOrganization</key><string>Microsoft Corporation</string>
<key>PayloadIdentifier</key><string>com.microsoft.globalsecureaccess</string>
<key>PayloadDisplayName</key><string>Global Secure Access Proxy Configuration</string>
<key>PayloadVersion</key><integer>1</integer>
<key>TransparentProxy</key><dict>
<key>AuthenticationMethod</key><string>Password</string>
<key>Order</key><integer>1</integer>
<key>ProviderBundleIdentifier</key><string>com.microsoft.globalsecureaccess.tunnel</string>
<key>ProviderDesignatedRequirement</key><string>identifier "com.microsoft.globalsecureaccess.tunnel" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = UBF8T346G9</string>
<key>ProviderType</key><string>app-proxy</string>
<key>RemoteAddress</key><string>100.64.0.0</string>
</dict>
<key>UserDefinedName</key><string>Global Secure Access Proxy Configuration</string>
<key>VPNSubType</key><string>com.microsoft.globalsecureaccess</string>
<key>VPNType</key><string>TransparentProxy</string>
</dict></array></dict></plist>
'@
    $Bool = if ($LockDownClient) { '<true/>' } else { '<false/>' }
    $SettingsPlist = "<?xml version=`"1.0`" encoding=`"UTF-8`"?><!DOCTYPE plist PUBLIC `"-//Apple//DTD PLIST 1.0//EN`" `"http://www.apple.com/DTDs/PropertyList-1.0.dtd`"><plist version=`"1.0`"><dict><key>HideDisableButton</key>$Bool<key>HidePauseButton</key>$Bool<key>HideDisablePrivateAccessButton</key>$Bool<key>HideQuitButton</key><true/></dict></plist>"
    $Profiles = @(
        @{ '@odata.type' = '#microsoft.graph.macOSCustomConfiguration'; displayName = 'Global Secure Access - macOS Transparent Proxy'; payloadName = 'Global Secure Access Proxy Configuration'; payloadFileName = 'GlobalSecureAccessProxy.mobileconfig'; payload = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ProxyPlist)); deploymentChannel = 'deviceChannel' }
        @{ '@odata.type' = '#microsoft.graph.macOSCustomAppConfiguration'; displayName = 'Global Secure Access - macOS Client Settings'; bundleId = 'com.microsoft.globalsecureaccess'; fileName = 'com.microsoft.globalsecureaccess.plist'; configurationXml = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($SettingsPlist)) }
    )
    $ExistingConfigs = (Invoke-Graph -Uri "$Graph/deviceManagement/deviceConfigurations?`$select=id,displayName").value
    foreach ($MacProfile in $Profiles) {
        if ($ExistingConfigs | Where-Object { $_.displayName -eq $MacProfile.displayName }) { continue }
        $Created = Invoke-Graph -Method POST -Uri "$Graph/deviceManagement/deviceConfigurations" -Body $MacProfile
        Invoke-Graph -Method POST -Uri "$Graph/deviceManagement/deviceConfigurations/$($Created.id)/assign" -Body @{ assignments = @($AllDevices) } | Out-Null
    }
    Write-Host 'macOS client script and profiles are in Intune.'
}

# --- 6. the policy: block everything that is not on the compliant network
$ExcludeUsers = foreach ($Upn in $BreakGlassAccounts) {
    try { (Invoke-Graph -Uri "$Graph/users/$Upn`?`$select=id").id } catch { throw "Break-glass account '$Upn' was not found in $TenantId. Fix the settings block before running this again." }
}
if ($Enforce -and -not $ExcludeUsers) { throw 'Refusing to enable the policy without a break-glass account: it would lock out every account on a device without the client.' }
$PolicyName = 'Require compliant network (Global Secure Access)'
$Policy = @{
    displayName   = $PolicyName
    state         = if ($Enforce) { 'enabled' } else { 'enabledForReportingButNotEnforced' }
    conditions    = @{
        users          = @{
            includeUsers = @('All'); excludeUsers = @($ExcludeUsers); includeGroups = @(); excludeGroups = @()
            # Global Administrator, Security Administrator, Conditional Access Administrator, Global Secure Access Administrator
            excludeRoles = @('62e90394-69f5-4237-9190-012177145e10', '194ae4cb-b126-40b2-bd5b-6091b380977d', 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9', 'ac434307-12b9-4fa1-a708-88bf58caabc1')
        }
        # Intune and Intune Enrollment stay reachable so devices can still enrol and sync.
        applications   = @{ includeApplications = @('All'); excludeApplications = @('0000000a-0000-0000-c000-000000000000', 'd4ebce55-015a-49b5-a083-c84d1797ae8c') }
        locations      = @{ includeLocations = @('All'); excludeLocations = @($Location.id) }
        clientAppTypes = @('all')
    }
    grantControls = @{ operator = 'OR'; builtInControls = @('block') }
}
$ExistingPolicy = (Invoke-Graph -Uri "$Graph/identity/conditionalAccess/policies?`$select=id,displayName").value | Where-Object { $_.displayName -eq $PolicyName }
if ($ExistingPolicy) {
    Invoke-Graph -Method PATCH -Uri "$Graph/identity/conditionalAccess/policies/$($ExistingPolicy.id)" -Body $Policy | Out-Null
} else {
    Invoke-Graph -Method POST -Uri "$Graph/identity/conditionalAccess/policies" -Body $Policy | Out-Null
}
Write-Host "Conditional Access policy '$PolicyName' is $($Policy.state). Done."

As always, Happy PowerShelling.

Recent Articles

Automating with PowerShell: making stolen tokens useless with Global Secure Access

Last week I watched Merill Fernando talk to Alex Pavlovsky and Marilee Tuscak from the Entra team about something I’d kinda put under the “needs a GSA licence” branch and i’d check it out later, turns out that like most people, I half-assed the documentation and it turns out it doesn’t. There’s two features that come with any P1 license; Compliant network check and Source IP Restoration. So BP, P1, or even frontline worker licenses already have this.

CIPP ❤️ #IntuneForMSPs

We’re Joining #IntuneForMSPs 🎉

Being invited by Microsoft to join a global initiative is a big moment for us, and we want to be clear about why it matters. #IntuneForMSPs is Microsoft’s program to help MSPs deliver Microsoft 365, Intune, and Copilot services at scale, and CIPP is now part of it, bringing the largest MSP community in the channel directly to Microsoft.

The return of CyberDrain CTF

CyberDrain CTF returns! (and so do I!)

It’s been since september that I actually picked up a digital pen equivalent and wrote anything down. This was due to me being busy with life but also my side projects like CIPP. I’m trying to get back into the game of scripting and blogging about these scripts. There’s still so much to automate and so little time, right? ;)