Tunnel Vision: Breaking Microsoft Global Secure Access — Part 3
The Attack
In Part 1 I explained what Global Secure Access is: Microsoft's ZTNA offering, an identity-anchored tunnel to the corporate network. In Part 2 I took it apart: the kernel driver, the ALPC bus, the gRPC tunnel, and the protobuf I rebuilt from the binary's own descriptor.
This is the interesting part. The attack part.
The scenario is straightforward. You're on a red-team engagement. You already have initial access on a target endpoint, with a beacon running as a standard user, and you want to move around the internal network. Normally you'd spawn a SOCKS proxy: through your C2 beacon, over SSH, or by sideloading chisel as a DLL. But every one of those adds tooling to the endpoint, and every bit of extra tooling raises your chances of getting caught.
Then you notice the endpoint has GSA deployed. That's your big win. The machine already did the hard part: it built an authenticated tunnel straight into the corporate network, and it did it with the blessing of every security control in the environment.
So why build your own way in at all? What if you could take the tunnel the machine is already using to reach the internal network, and redirect it to yourself?
That's what this post is about.
The old way: VPN certificate theft
This isn't a new concept. Red teamers have been stealing VPN access for years. The classic attack against an Always-On VPN deployment (DirectAccess, Cisco AnyConnect with cert auth, GlobalProtect) looks like this:
- Compromise an endpoint.
- Escalate to local administrator.
- Export the machine certificate from the local machine store (or steal the PKCS12 from disk).
- Install the certificate on your attack machine.
- Connect to the VPN gateway as if you were the compromised machine.
It works. I've done it on engagements. But step 2 is doing a lot of heavy lifting. Machine certificates live in HKLM\SOFTWARE\Microsoft\SystemCertificates or in the local machine's certificate store, and exporting them with the private key requires SYSTEM or local administrator privileges. On a hardened endpoint with Credential Guard and a responsive SOC, getting to local admin isn't free. It's noisy, it takes time, and it's often the step that gets you caught.
The critical difference with GSA: you don't need admin.
The tunnel isn't authenticated by a machine certificate. It's authenticated by Entra ID tokens JWTs. And those tokens? They're cached under the user's profile, protected by the user's own DPAPI key. Any code running as that user can read them. No escalation. No certificate export. No touching LSASS.
Standard user privileges on the target system. That's the whole requirement.
Where the tokens live
In Part 2 I showed that the GSA tunnel needs three JWTs:
| Token | Audience | Purpose |
|---|---|---|
| TUNNEL | e92b9b37-... |
Authenticates the gRPC control channel |
| PORTO | 79486f61-... |
Per-flow authorization (the app_token in ClientFlowMetadata) |
| APS | b3fa0115-... |
Bearer token on the CreateFlow data stream |
These aren't exotic secrets stored in a hardware TPM or locked behind a service account. They're standard Entra ID access tokens acquired through MSAL, and Windows caches them in two places that any code running as the target user can reach:
- Process memory of the GSA services (live tokens, in cleartext)
- TokenBroker cache on disk (persisted tokens, DPAPI-encrypted under the user's key)
Let me explain the second one, because it's the more interesting attack vector.
TBRES files: Windows TokenBroker's cache
Windows has a component called TokenBroker that acts as a centralized credential broker. When an application needs an authentication token, Office asking for a Graph token, the GSA client asking for a tunnel token, Teams authenticating to its backend, it doesn't manage the token lifecycle itself. It delegates to TokenBroker, which handles acquisition, caching, and renewal.
TokenBroker persists its cache as .tbres files (TokenBroker Response files) on disk:
%USERPROFILE%\AppData\Local\Microsoft\TokenBroker\Cache\*.tbres
Each .tbres file is a JSON structure (sometimes UTF-16LE encoded) containing metadata about the token request and, critically, the token response. The response is stored in a field called ResponseBytes with the structure:
{
"IsProtected": true,
"Value": "<base64-encoded DPAPI blob>"
}
The Value field is base64-encoded ciphertext. The encryption? DPAPI with the user's master key. No additional entropy. No service-specific secret. Just CryptProtectData under the current user's context.
This means any process running as that user can call CryptUnprotectData on the blob and get back cleartext JWTs. No password prompt. No UAC. No elevation required.
This caching mechanism isn't specific to GSA. Adam Chester (xpnsec) documented the broader TokenBroker/WAM ecosystem in his research on Microsoft Account token extraction. His work showed that Office, Teams, and other Microsoft applications all rely on these same TBRES files for token persistence, and that the DPAPI protection is trivially reversible in the user's own session. The same CryptUnprotectData call that gives you an Office 365 token from the cache also gives you GSA tunnel tokens because they're stored by the same broker, in the same format, with the same protection.
The BOF arsenal
I wrote three Beacon Object Files for this attack chain, each targeting a different phase:
1. gsa_enum - Reconnaissance
Before stealing anything, you need to know if GSA is even deployed and what the security posture looks like. This BOF enumerates:
- Installation: checks
C:\Program Files\Global Secure Access Client\, pulls version info from each binary - Services: queries the state of all four GSA services (Tunneling, Engine, Policy Retriever, Driver)
- Processes: snapshots the process table for GSA executables
- Registry: dumps
HKLM\SOFTWARE\Microsoft\Global Secure Access Clientand the HKCU equivalent - Security posture: checks
RestrictNonPrivilegedUsers,HideDisableButton, mTLS configuration tells you whether the deployment is hardened or default - Forwarding profile: reads the full JSON forwarding profile from the registry, shows you which traffic channels are active (M365, Private Access, Internet Access), how many rules exist, what the edge server is, and what the tenant ID is
This matters operationally. If RestrictNonPrivilegedUsers is 0 (the default), any user can disable the GSA client entirely from the tray icon. If the forwarding profile shows Private Access is active with routes to 192.168.0.0/16, you know exactly what internal networks are reachable through the tunnel once you have tokens.
2. gsa_tbres_steal - Token extraction from TokenBroker
This is the core extraction BOF. It:
- Resolves
%USERPROFILE%and navigates toAppData\Local\Microsoft\TokenBroker\Cache\ - Enumerates all
*.tbresfiles - Reads each file, detects encoding (UTF-8 or UTF-16LE)
- Finds the
"IsProtected":true,"Value":"..."pattern in the JSON - Base64-decodes the value to get the raw DPAPI blob
- Calls
CryptUnprotectDatato decrypt (succeeds because we're running as the target user) - Scans the decrypted buffer for JWTs (identified by the
eyJprefix) - Parses each JWT's payload to identify GSA-specific tokens by their audience claim:
e92b9b37→ TUNNEL tokenb3fa0115→ APS token79486f61→ PORTO token
- Outputs the full JWT for each extracted token, ready to use
The entire operation is a sequence of CreateFileA → ReadFile → CryptUnprotectData → string scanning. No network calls. No suspicious API patterns. No memory injection into other processes. To an EDR, it looks like a process reading some JSON files from the user's AppData folder, which is exactly what thousands of legitimate processes do every day.
3. aad_prt - Primary Refresh Token extraction
This is the alternative path. Instead of reading tokens from disk, this BOF extracts the Primary Refresh Token cookie via Windows' own COM interface.
The PRT is the master credential that Azure AD joined devices use for SSO. It's managed by the Cloud AP plugin and accessed through the IProofOfPossessionCookieInfoManager COM interface (CLSID {A9927F85-A304-4390-8B23-A75F1C668600}). The BOF:
- Fetches a nonce from
login.microsoftonline.com(or accepts one manually) - Constructs a URI with the nonce as the
sso_nonceparameter - Calls
GetCookieInfoForUri()on the COM interface - Retrieves the
x-ms-RefreshTokenCredential- the PRT cookie
This PRT cookie can then be exchanged for any token the user is authorized to obtain, including all three GSA tokens. The trade-off: each PRT cookie has a single-use nonce, so you need one BOF execution per token exchange. But unlike TBRES extraction (which gives you tokens that might be near expiry), PRT-derived tokens are freshly minted with full lifetime.
Two attack paths
With these tools, there are two distinct ways to get from "beacon on endpoint" to "tunnel tokens in hand":
Path A: Direct TBRES extraction
Beacon → gsa_tbres_steal BOF → DPAPI decrypt → Three GSA JWTs
Pros: Single BOF execution, immediate results, no network calls from the target. Cons: Tokens may be near expiry (TokenBroker caches until expiry), no refresh token for renewal.
Path B: PRT cookie → token exchange
Beacon → aad_prt BOF (×3) → PRT cookies → roadrecon auth → Three fresh .auth files (access + refresh tokens)
Pros: Fresh tokens with full lifetime, includes refresh tokens for indefinite renewal without re-touching the target.
Cons: Three BOF executions needed (one PRT cookie per token), requires roadrecon/roadtx on attacker infrastructure, creates authentication logs in Azure AD.
In practice, I use Path A for initial validation ("is this target enrolled in GSA? are tokens present?") and Path B when I need persistent access. The refresh tokens from Path B are the real prize, once you have them, you can renew GSA access tokens indefinitely from your attack infrastructure without ever touching the compromised endpoint again.
Token exchange with roadtools
The PRT cookie exchange uses ROADtools by Dirk-jan Mollema. Each GSA service has a specific client ID and resource:
# TUNNEL token (control channel auth)
roadrecon auth --prt-cookie '<COOKIE>' \
-c cde6adac-58fd-4b78-8d6d-9beaf1b0d668 \
-r e92b9b37-1b47-4c01-9fbc-91d84450870e \
-t <TENANT_ID> -f gsa_tunnel.auth
# PORTO token (per-flow app_token)
roadrecon auth --prt-cookie '<COOKIE>' \
-c 760282b4-0cfc-4952-b467-c8e0298fee16 \
-r 79486f61-d9f0-430e-9f7c-1713c8d9a2f9 \
-t <TENANT_ID> -f gsa_porto.auth
# APS token (CreateFlow bearer)
roadrecon auth --prt-cookie '<COOKIE>' \
-c ca01d00c-bfd6-46d6-ae7d-be5b5267d037 \
-r b3fa0115-39b3-4bec-8cc6-8c4fcd33e69d \
-t <TENANT_ID> \
-ru 'ms-appx-web://Microsoft.AAD.BrokerPlugin/ca01d00c-bfd6-46d6-ae7d-be5b5267d037' \
-f gsa_aps.auth
Note the APS token's redirect URI, it requires the Windows broker plugin redirect (ms-appx-web://Microsoft.AAD.BrokerPlugin/...). This is because the APS client application is registered as a broker-only app in the tenant's app registration. I discovered this after getting AADSTS50011 errors with the default redirect URI and tracing what the real GSA client sends during authentication.
Each .auth file contains both an access token and a refresh token. The refresh tokens are bound to their respective client IDs and can be renewed independently:
# Renew from attacker machine
roadtx gettokens --refresh-token "$(jq -r .refreshToken gsa_tunnel.auth)" \
-c cde6adac-58fd-4b78-8d6d-9beaf1b0d668 \
-r e92b9b37-1b47-4c01-9fbc-91d84450870e \
-t <TENANT_ID> -f gsa_tunnel.auth
This is the persistence story. Three refresh tokens, stored on your attack infrastructure, renewable at will. The compromised endpoint can be wiped, reimaged, thrown in a fire, your tunnel access persists until someone revokes those refresh tokens or the user's session is terminated in Azure AD.
The rogue client
With tokens in hand (from either path), the next step is building something that speaks the protocol. In Part 2 I reconstructed the full ztna_v2.proto from the binary's embedded descriptor. Feed that to protoc --python_out and you have working gRPC stubs. From there, the rogue client, gsa_tunnel.py, does seven things:
- Creates a TUN device (
/dev/net/tun) on the attacker's Linux machine - Opens a gRPC channel to the tenant's GSA edge server
- Establishes the tunnel by sending
CreateTunnelMessagewith the stolen tunnel token and fabricatedClientDeviceInfo - Routes traffic from the TUN interface into per-destination gRPC flows
- Handles per-flow auth on the control channel using the PORTO token
- Sends bearer auth on each
CreateFlowdata stream using the APS token - Bidirectionally forwards raw IP packets between the TUN device and the gRPC stream
Under the hood, gsa_tunnel.py is ~1,100 lines of Python split across four components: a TokenManager that owns the three token slots (TUNNEL, PORTO, APS) and refreshes them in a background thread five minutes before expiry, persisting fresh credentials back to the .auth files; a TunDevice that creates the gsa0 Layer 3 interface (MTU 1400 to fit the gRPC framing overhead) and manages kernel routes, restoring them on shutdown; a GSATunnel protocol engine driving the persistent CreateControlChannel stream; and a per-flow model where every unique destination (IP, port, and protocol) gets its own CreateFlow gRPC stream, its own worker thread, and its own send queue. That last part is what separates it from a dumb VPN: an SMB session, an RDP connection, and a web request run simultaneously, each on its own authenticated stream, all multiplexed through one tunnel. When the control channel drops, the client tears down flows, clears stale tunnel state, and reconnects with exponential backoff (5s → 60s cap, up to 5 attempts) while the TUN device stays up the whole time, so the route to the corporate network never disappears.
The client is scoped to one thing: the Private Access profile, the channel that reaches internal corporate resources. It doesn't implement the M365 or Internet Access traffic profiles, the compliant_network_check channel, or GSA's DNS interception layer (it routes by CIDR and targets IPs directly, which is what internal pentesting usually needs anyway). That focus is deliberate. Private Access is the surface that matters for red-team work, and everything the client does bends toward keeping that one tunnel alive and authenticated.
The ClientDeviceInfo message is entirely client-controlled strings (I pointed this out in Part 2). The rogue client sends:
ClientDeviceInfo(
client_agent_version="GsaTunnelSvc/2.1.1.0",
client_os_type="Windows",
client_os_version="10.0.26100",
client_device_id="<fabricated UUID>",
client_os_name="Windows 11 Enterprise",
client_device_name="DESKTOP-GSA",
client_os_architecture="x86_64",
)
The edge doesn't validate any of this. It's running on a Linux box. The edge sees "Windows 11 Enterprise" because that's what the protobuf says, and there's no attestation mechanism to prove otherwise. The device ID is a UUID I made up. The version string is copied from the real client.
What happens when you run it
With raw token files (no auto-refresh):
sudo python3 gsa_tunnel.py \
--tunnel-token /tmp/gsa_tunnel.txt \
--porto-token /tmp/gsa_porto.txt \
--aps-token /tmp/gsa_aps.txt \
--routes 192.168.255.0/24
Or better with roadrecon .auth files (automatic refresh, persistent tunnel):
sudo python3 gsa_tunnel.py \
--auth-dir ~/gsa_tokens/ \
--tenant 69f12dc0-0a14-4a28-8b0e-cd27f04b2430 \
--routes 192.168.255.0/24
09:14:02 INFO [gsa-tunnel] Loaded tokens from ~/gsa_tokens/ (auto-refresh enabled)
09:14:02 INFO [gsa-tunnel] TUNNEL: expires in 3412s [refresh: yes]
09:14:02 INFO [gsa-tunnel] PORTO: expires in 3408s [refresh: yes]
09:14:02 INFO [gsa-tunnel] APS: expires in 3405s [refresh: yes]
09:14:02 INFO [gsa-tunnel] Connecting to 69f12dc0-...private.client.globalsecureaccess.microsoft.com:443
09:14:03 INFO [gsa-tunnel] gRPC channel ready
09:14:03 INFO [gsa-tunnel] Tunnel established: a3f7c2e1-8b9d...
09:14:03 INFO [gsa-tunnel] Region: West Europe | Geo: Netherlands
09:14:03 INFO [gsa-tunnel] TUN device 'gsa0' created
09:14:03 INFO [gsa-tunnel] TUN configured: 10.128.0.2/24, MTU=1400, 1 routes forced through tunnel
09:14:03 INFO [gsa-tunnel] ==================================================
09:14:03 INFO [gsa-tunnel] TUNNEL ACTIVE — routing traffic through GSA
09:14:03 INFO [gsa-tunnel] Interface: gsa0 (10.128.0.2)
09:14:03 INFO [gsa-tunnel] Routes: 192.168.255.0/24
09:14:03 INFO [gsa-tunnel] Auto-refresh: enabled
09:14:03 INFO [gsa-tunnel] Press Ctrl+C to disconnect
09:14:03 INFO [gsa-tunnel] ==================================================
When the --auth-dir mode is used, the client runs a background refresh loop. Five minutes before any token expires, it exchanges the refresh token for a fresh access token via Azure AD's /oauth2/v2.0/token endpoint. If the tunnel drops (server closes it due to token expiry, network blip, or edge rotation), the client automatically reconnects with freshly-refreshed tokens. Updated tokens are persisted back to the .auth files on disk, so even if the client process crashes and restarts, it picks up the latest tokens.
At this point, any traffic to 192.168.255.0/24 from the Linux box enters the TUN interface, gets wrapped in a ClientFlowMessage, sent over the gRPC stream to Microsoft's GSA edge, which forwards it to the Private Network Connector sitting in the corporate network, which delivers it to the internal destination.
From the internal network's perspective, the traffic originates from the connector's IP address. From the endpoint's perspective, nothing happened, the beacon didn't create any network connections to internal resources. From the SOC's perspective, there's a legitimate-looking GSA tunnel session that happens to be coming from an unexpected source.
The full kill chain
Let me put the whole thing together as a sequence:
Phase 1: Initial Access
├─ Phishing / exploit / supply chain → code execution as CORP\user
└─ Beacon established (Cobalt Strike, Brute Ratel, Havoc, etc.)
Phase 2: Reconnaissance (gsa_enum BOF)
├─ Confirm GSA is installed and services are running
├─ Read security posture (RestrictNonPrivilegedUsers, mTLS, hardening)
├─ Extract forwarding profile → discover tenant ID, edge server, internal routes
└─ Identify Private Access configuration → know what internal CIDRs are routable
Phase 3: Token Acquisition
│
├─ Path A (fast, tokens may be near expiry):
│ └─ gsa_tbres_steal BOF → DPAPI decrypt → extract TUNNEL + PORTO + APS JWTs
│
└─ Path B (persistent, includes refresh tokens):
├─ aad_prt BOF → PRT cookie #1 → roadrecon auth → gsa_tunnel.auth
├─ aad_prt BOF → PRT cookie #2 → roadrecon auth → gsa_porto.auth
└─ aad_prt BOF → PRT cookie #3 → roadrecon auth → gsa_aps.auth
Phase 4: Tunnel Establishment (attacker's Linux machine)
├─ gsa_tunnel.py with stolen tokens
├─ TUN interface created, routes configured
├─ gRPC tunnel to GSA edge established
└─ Internal network reachable from attacker machine
Phase 5: Internal Operations (through the tunnel)
├─ SMB access to file servers
├─ RDP to internal hosts
├─ Web application access
├─ Database connections
└─ All traffic proxied through Microsoft's own infrastructure
The entire path from Phase 1 to Phase 5 requires:
- No privilege escalation (all operations work as a standard user)
- No lateral movement on the compromised endpoint
- No credential dumping
- No persistent implant on additional hosts
- No direct network connection from the beacon to internal resources
The EDR on the compromised endpoint sees a beacon process reading some files from AppData and making COM calls. That's it. The actual network access to internal resources happens from a completely different machine, through Microsoft's own cloud infrastructure, using legitimately-issued tokens.
Token lifetime and renewal
A token stolen via Path A (TBRES extraction) has whatever remaining lifetime it had in the cache. GSA access tokens typically have a 60-90 minute lifetime. After expiry, the tunnel dies and you need to re-extract from the target.
Tokens from Path B (PRT exchange) come with refresh tokens. Refresh token lifetime for first-party Microsoft applications is typically 90 days, or until explicitly revoked. This means:
- You touch the target three times (one PRT cookie per GSA token)
- You get three refresh tokens
- For the next 90 days, you can renew all three access tokens from your own infrastructure
- The tunnel persists indefinitely as long as you renew before the refresh token expires
- The compromised endpoint is no longer needed
This is the attacker's persistence model: three refresh tokens on your C2 infrastructure, renewable on a schedule, producing fresh access tokens that authenticate a tunnel into the corporate network. The target user's session can be terminated, the endpoint reimaged, the user's password changed, none of that revokes the refresh tokens unless the admin explicitly goes into Azure AD and revokes all refresh tokens for that user, or the Conditional Access policy requires re-authentication.
What the edge server sees (and doesn't)
When the rogue client connects, the GSA edge validates:
- The tunnel token is a valid, non-expired JWT with the correct audience
- The token was issued by the tenant's Azure AD instance
- The user has the appropriate role assignments for Private Access
What it does NOT validate:
- Whether the connecting machine is actually a Windows device
- Whether the GSA client software is actually installed and running
- Whether the device is enrolled/compliant in Intune
- Whether the
ClientDeviceInfofields are truthful - Whether the connection originates from the same IP as previous legitimate sessions
- Whether the device ID in the protobuf matches any real device in the tenant
There is an optional mTLS path in the protocol (I found the handler in the tunneling service binary it checks for a registry key CertCommonName). When enabled, the client presents a device certificate during the TLS handshake, which would bind the tunnel to a specific managed device. But this is behind a feature flag, not enabled by default, and none of the tenants I tested had it active.
The CreateTunnelNoTokenMessage variant I documented in Part 2 also exists in the protocol, suggesting there was (or will be) a tokenless path that relies entirely on mTLS. But today, in production deployments, the tunnel authenticates with a JWT and a bag of self-described strings.
Operational considerations
A few things I learned while building and testing this:
Edge server
The edge server is <tenant-id>.private.client.globalsecureaccess.microsoft.com. You get the tenant ID from the forwarding profile (which gsa_enum dumps) or from any of the stolen tokens' tid claim.
The client_invoked_process_name field
In ClientFlowMetadata, there's a field for the process name that initiated the connection. The real GSA client populates this with whatever process triggered the WFP callout. The rogue client can put anything here; I use svchost.exe because it's what you'd expect from a system service making network calls. This field is logged on the Microsoft side and could be used for anomaly detection, but spoofing it is trivial.
DNS resolution
The GSA client has its own DNS interception layer (the DnsAcquisition ALPC port from Part 2, the synthetic 6.6.x.x IPs). The rogue client doesn't replicate this, it relies on the tunnel routing and standard DNS. For internal resources reachable by IP (which is most of what you care about during internal pentesting), this is fine. For FQDN-based private apps, you'd need to know the internal IP or configure split DNS on your attack machine.
Token refresh automation
The rogue client handles this internally now. When you use --auth-dir or --tunnel-auth mode (pointing at roadrecon .auth files), it:
- Monitors token expiry in a background thread (checks every 30s)
- Refreshes proactively 5 minutes before expiry via Azure AD's token endpoint
- Persists fresh tokens back to the
.authfiles - If the tunnel drops anyway (network issue, edge rotation), auto-reconnects with exponential backoff
No cron jobs needed. Start the client once, it runs indefinitely:
# Run as a systemd service for persistence
sudo python3 gsa_tunnel.py --auth-dir /opt/gsa/tokens/ --tenant <TID> --routes 192.168.255.0/24 --no-dns
The only thing that kills the tunnel is explicit refresh token revocation in Azure AD, or the refresh token hitting its 90-day lifetime without renewal.
Concurrent sessions
Nothing in the protocol prevents multiple tunnels from being active simultaneously for the same user. I tested with the real GSA client running on the target endpoint while the rogue client was connected from my Linux box. Both tunnels worked. The edge accepted both. There's no "you're already connected" rejection.
Detection and defense
I'm going to keep this section deliberately high-level. Detection for this attack chain has enough depth specific log queries, Sentinel rules, Conditional Access configurations, response playbooks, that it deserves its own dedicated write-up. I may write that as a separate blog post. For now, here are the detection surfaces that matter and why they matter.
The fundamental detection problem
This attack is hard to detect because it doesn't look like an attack at any single observation point. On the compromised endpoint, you see a process reading JSON files from AppData, indistinguishable from normal application behavior. On the network, you see a GSA tunnel session using valid tokens, exactly what a legitimate endpoint does every day. There's no malware signature. No exploit. No shellcode. The attacker uses Microsoft's own protocol, Microsoft's own cloud infrastructure, and Microsoft's own authentication tokens.
Detection has to come from correlating signals across multiple layers. No single log entry will tell you this is happening. The pattern becomes visible only when you stitch events together.
Azure AD sign-in logs
This is the richest detection surface. Every token acquisition and refresh creates a sign-in event in Azure AD (Entra ID).
PRT cookie usage from unexpected sources. When the attacker uses roadrecon auth --prt-cookie to exchange PRT cookies for GSA tokens, those token acquisitions show up as sign-in events. The key signal: the sign-in originates from the attacker's infrastructure, not the compromised endpoint. If user [email protected] normally acquires GSA tokens from IP 203.0.113.50 (their office), and suddenly three token acquisitions for GSA-specific resource IDs (e92b9b37, 79486f61, b3fa0115) come from 198.51.100.99 (the attacker's VPS), that's your signal.
Token refresh from non-corporate IPs. Once the attacker has refresh tokens, every 60-90 minute refresh cycle creates a new sign-in event. These will consistently come from the attacker's IP, not the user's device. A user whose GSA tokens are being refreshed from two different IPs simultaneously, one from their endpoint, one from somewhere else is a strong indicator of token theft.
Unusual client IDs and resource combinations. GSA uses specific first-party client IDs (cde6adac, 760282b4, ca01d00c) for its three token types. Seeing these client IDs in sign-in logs from non-Windows user-agents or from IPs with no corresponding device in Intune is suspicious.
GSA tunnel telemetry
Microsoft logs tunnel sessions on the backend. The Global Secure Access traffic logs in the Entra admin center show tunnel establishment events, per-flow connection details, and session metadata.
Concurrent tunnels. The same user having two active Private Access tunnels simultaneously one from the legitimate endpoint and one from the rogue client is anomalous. As I mentioned in the operational considerations section, the edge doesn't reject this, but it is visible in the logs.
Source IP mismatch. The tunnel connects from an IP that doesn't match any registered device for that user. If the legitimate endpoint is on a corporate network at 10.0.0.50 (with a NAT gateway at 203.0.113.50) and a second tunnel appears from 198.51.100.99, that's worth investigating.
Preventive controls
Conditional Access is the most impactful defense. Specifically:
- Require compliant device for the GSA resource applications. If CA policy requires the requesting device to be Intune-enrolled and compliant, the attacker's Linux box can't satisfy that check. The token acquisition will fail.
- Require device to be Entra joined same idea. The rogue client isn't domain-joined.
- Named locations / trusted IPs restricting GSA token issuance to known corporate networks prevents the attacker from exchanging PRT cookies from their VPS.
mTLS (device certificate binding). I mentioned in the "what the edge doesn't validate" section that there's an optional mTLS path in the protocol. When enabled, the TLS handshake requires a device certificate, binding the tunnel to a specific managed device. The rogue client can't produce this certificate (it would require machine cert theft, which needs local admin, the whole escalation step this attack avoids). Enabling mTLS for GSA Private Access tunnels is the single strongest mitigation against this attack. Check your GSA deployment for the CertCommonName registry configuration.
Token lifetime policies. Shorter access token lifetimes mean shorter windows. Continuous Access Evaluation (CAE) can help by re-evaluating access on IP change, if the token was issued at one IP and the tunnel connects from another, CAE can revoke it. But CAE support for first-party Microsoft applications varies, and I haven't confirmed whether the specific GSA resource applications fully support CAE-based IP enforcement.
Refresh token revocation. The persistence story relies entirely on refresh tokens surviving. Revoking all refresh tokens for a compromised user (via Revoke-AzureADUserAllRefreshToken or the Entra admin center) kills the attacker's ability to renew access. This should be part of any incident response playbook for compromised identities.
I'll go deeper on all of this specific KQL queries, Sentinel detection rules, CA policy templates, and a detection lab walkthrough in a future post. For now, the takeaway: detection is possible but requires deliberate instrumentation across Azure AD logs, GSA telemetry, and endpoint monitoring. Default configurations won't catch this.
Closing
Three posts. Tens of thousands of words. Here's what this was all about.
In Part 1 I explained what Global Secure Access is: Microsoft's answer to the death of the corporate VPN. A ZTNA product built on identity, not network perimeter. A kernel driver on the endpoint, a gRPC tunnel to Microsoft's cloud edge, and Private Network Connectors that bridge the gap to internal resources. The pitch is compelling,replace your legacy VPN with something identity-aware, cloud-native, and integrated with Conditional Access.
In Part 2 I took it apart. A month of reverse engineering through the GSA client binaries, its lightweight filter driver, the ALPC bus connecting its components, the protobuf protocol I reconstructed from embedded descriptors. That work revealed how traffic decisions are made, how the tunnel authenticates, and where the seams are. Every attack in Part 3 exists because of something I found in Part 2.
This post, the Part 3 is the payoff. A standard user on a compromised endpoint can extract GSA tunnel tokens from the Windows TokenBroker cache using nothing but CryptUnprotectData on DPAPI-protected .tbres files. No privilege escalation. No lateral movement. No credential dumping. Alternatively, the PRT cookie path yields refresh tokens that persist for up to 90 days, renewable from attacker infrastructure without ever touching the endpoint again.
With those tokens, the rogue client we built gsa_tunnel.py speaks Microsoft's own ZTNA v2 gRPC protocol from a Linux machine. It creates a TUN interface, establishes per-destination flows through the GSA edge, handles token refresh automatically, reconnects on tunnel drops, and routes traffic into the corporate network through Microsoft's own cloud infrastructure. From the internal network's perspective, it looks like traffic from the Private Network Connector. From the endpoint's perspective, nothing happened. The attacker gets full network access to everything the GSA Private Access profile routes without deploying a SOCKS proxy, without writing a single packet from the beacon to an internal host, without any of the tooling that EDRs are trained to catch.
The tools
Everything described in this series is backed by working code:
gsa_enum- Beacon Object File for reconnaissance. Enumerates GSA installation, services, security posture, and the full forwarding profile. Tells you what's routable before you steal anything.gsa_tbres_steal- Beacon Object File for token extraction. Reads TokenBroker cache, DPAPI-decrypts, identifies GSA tokens by audience claim. Single execution, no network calls, no injection.aad_prt- Beacon Object File for PRT cookie extraction. Hits the Cloud AP COM interface to get PRT cookies exchangeable for fresh tokens with full lifetime.gsa_tunnel.py- The rogue tunnel client. ~1,100 lines of Python that reimplements the GSA Private Access tunnel on Linux. Four token input modes, automatic refresh, reconnection with backoff, per-flow multiplexing, clean route management.ztna_v2.proto- The reconstructed protobuf definition for the ZTNA v2 protocol, recovered from the binary's embedded descriptor.
The bigger picture
This isn't a vulnerability in the traditional sense. There's no CVE here. Microsoft's code works exactly as designed. The tokens are protected by DPAPI, which is correct for user-scoped secrets. The tunnel authenticates with JWTs which is the standard approach for cloud-native services. The protocol accepts self-described client metadata which is typical for telemetry fields.
The issue is architectural. ZTNA products sell the promise that identity replaces perimeter. But when identity is a JWT cached on disk under the user's DPAPI key, "steal the identity" becomes "read a file as the user." The entire security model collapses to the access controls on that file and in this case, those access controls are "any code running as the user." There's no TPM binding. No hardware attestation by default. No cryptographic proof that the tunnel endpoint is actually the managed device it claims to be.
Traditional VPN certificate theft needed local admin. GSA token theft needs standard user. That's not a step forward in security posture it's a step sideways at best, and arguably a step backward for environments where local admin is genuinely hard to get.
The defenses exist. Conditional Access requiring compliant devices. mTLS for tunnel binding. Token lifetime policies. Continuous Access Evaluation. But these are opt-in configurations, not defaults. Out of the box, a GSA Private Access deployment is vulnerable to exactly the attack chain described in this post.
Final thought
I didn't set out to break GSA. I set out to understand it. The attacks fell out of the understanding. Every ZTNA product that stakes its security on bearer tokens cached in user-accessible storage has this same fundamental exposure. GSA just happens to be the one I spent five months reverse engineering.
If you're deploying GSA, enable mTLS, enforce device compliance through Conditional Access, and monitor your sign-in logs for token acquisition from unexpected sources. If you're on a red team, well now you know where the tokens are.
Thanks for reading all three parts. I hope the depth was worth it.
