From 15a244cb301d1647672b88da2f1cd5e87d42f4d2 Mon Sep 17 00:00:00 2001 From: Carlos Polop Date: Tue, 5 May 2026 17:58:20 +0200 Subject: [PATCH] Improve Azure VM managed identity discovery --- .../linpeas_parts/3_cloud/8_Azure_VM.sh | 147 +++++++- .../winPEAS/Info/CloudInfo/AzureInfo.cs | 345 ++++++++++++++++++ 2 files changed, 486 insertions(+), 6 deletions(-) diff --git a/linPEAS/builder/linpeas_parts/3_cloud/8_Azure_VM.sh b/linPEAS/builder/linpeas_parts/3_cloud/8_Azure_VM.sh index 6266d5f..9b34df4 100644 --- a/linPEAS/builder/linpeas_parts/3_cloud/8_Azure_VM.sh +++ b/linPEAS/builder/linpeas_parts/3_cloud/8_Azure_VM.sh @@ -9,10 +9,108 @@ # Functions Used: check_az_vm, exec_with_jq, print_2title, print_3title # Global Variables: $is_az_vm # Initial Functions: check_az_vm -# Generated Global Variables: $API_VERSION, $HEADER, $az_req, $URL +# Generated Global Variables: $API_VERSION, $HEADER, $az_req, $URL, $_az_vm_token_url, $_az_vm_instance_json, $_az_vm_resource_id, $_az_vm_mgmt_token_json, $_az_vm_mgmt_token, $_az_vm_arm_json, $_az_vm_uai_id, $_az_vm_uai_client_id, $_az_vm_uai_principal_id, $_az_vm_wire_data, $_az_vm_wire_client_id, $_az_vm_wire_res_id, $_az_vm_wire_header, $_az_vm_wire_url # Fat linpeas: 0 # Small linpeas: 1 +az_vm_json_value() { + if [ "$(command -v jq || echo -n '')" ]; then + jq -r "$1 // empty" 2>/dev/null + elif [ "$(command -v python3 || echo -n '')" ]; then + python3 -c 'import json,sys +obj=json.load(sys.stdin) +cur=obj +for p in sys.argv[1].strip(".").split("."): + if not p: + continue + cur = cur.get(p, {}) if isinstance(cur, dict) else {} +print(cur if isinstance(cur, str) else "")' "$1" 2>/dev/null + else + sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -n 1 + fi +} + +az_vm_request() { + if [ "$(command -v curl || echo -n '')" ]; then + curl -s -f -L -H "$HEADER" "$1" 2>/dev/null + elif [ "$(command -v wget || echo -n '')" ]; then + wget -q -O - --header "$HEADER" "$1" 2>/dev/null + fi +} + +az_vm_request_arm() { + if [ "$(command -v curl || echo -n '')" ]; then + curl -s -f -L -H "Authorization: Bearer $1" "$2" 2>/dev/null + elif [ "$(command -v wget || echo -n '')" ]; then + wget -q -O - --header "Authorization: Bearer $1" "$2" 2>/dev/null + fi +} + +az_vm_print_token() { + _az_vm_token_url="$URL/identity/oauth2/token?api-version=$API_VERSION\&resource=$2" + if [ "$3" ]; then + _az_vm_token_url="${_az_vm_token_url}\&$3" + fi + print_3title "$1" "T1552.005,T1580" + exec_with_jq eval $az_req "$_az_vm_token_url" + echo "" +} + +az_vm_request_wireserver() { + _az_vm_wire_header="$1" + _az_vm_wire_url="$2" + if [ "$(command -v curl || echo -n '')" ]; then + if [ "$_az_vm_wire_header" ]; then + curl -s -f -L --connect-timeout 2 --max-time 5 -H "$_az_vm_wire_header" "$_az_vm_wire_url" 2>/dev/null + else + curl -s -f -L --connect-timeout 2 --max-time 5 "$_az_vm_wire_url" 2>/dev/null + fi + elif [ "$(command -v wget || echo -n '')" ]; then + if [ "$_az_vm_wire_header" ]; then + wget -q -O - --timeout 5 --tries 1 --header "$_az_vm_wire_header" "$_az_vm_wire_url" 2>/dev/null + else + wget -q -O - --timeout 5 --tries 1 "$_az_vm_wire_url" 2>/dev/null + fi + fi +} + +az_vm_try_wire_identity_tokens() { + print_3title "WireServer/HostGAPlugin managed identity fallback" "T1552.005,T1580" + print_info "ARM identity discovery failed. Trying WireServer GoalState, ExtensionsConfig and HostGAPlugin /vmSettings for identity-looking selectors. These endpoints are environment-dependent and may expose no managed identity data." + + _az_vm_wire_data="$( + az_vm_request_wireserver "x-ms-version: 2012-11-30" "http://168.63.129.16/machine?comp=goalstate" + az_vm_request_wireserver "x-ms-version: 2012-11-30" "http://168.63.129.16/machine/?comp=goalstate" + az_vm_request_wireserver "" "http://168.63.129.16:32526/vmSettings" + )" + + if [ "$_az_vm_wire_data" ]; then + printf "%s\n" "$_az_vm_wire_data" | grep -Eio '([A-Za-z0-9_./:-]*Identity[A-Za-z0-9_./:-]*|Microsoft\.ManagedIdentity/userAssignedIdentities/[^"<>[:space:]]+|clientId["[:space:]:=]+[0-9a-fA-F-]{36}|IdentityClientId[^0-9a-fA-F]*[0-9a-fA-F-]{36})' | sort -u | head -n 80 + + if [ "$(command -v jq || echo -n '')" ]; then + printf "%s" "$_az_vm_wire_data" | jq -r '.. | objects | to_entries[]? | select((.key|test("(?i)(clientId|IdentityClientId)$")) and (.value|type=="string")) | .value' 2>/dev/null | sort -u | while read -r _az_vm_wire_client_id; do + if printf "%s" "$_az_vm_wire_client_id" | grep -Eq '^[0-9a-fA-F-]{36}$'; then + print_info "Trying IMDS tokens for WireServer-discovered client_id=$_az_vm_wire_client_id" + az_vm_print_token "Management token for WireServer client_id $_az_vm_wire_client_id" "https://management.azure.com/" "client_id=$_az_vm_wire_client_id" + az_vm_print_token "Graph token for WireServer client_id $_az_vm_wire_client_id" "https://graph.microsoft.com/" "client_id=$_az_vm_wire_client_id" + az_vm_print_token "Vault token for WireServer client_id $_az_vm_wire_client_id" "https://vault.azure.net/" "client_id=$_az_vm_wire_client_id" + az_vm_print_token "Storage token for WireServer client_id $_az_vm_wire_client_id" "https://storage.azure.com/" "client_id=$_az_vm_wire_client_id" + fi + done + fi + + printf "%s\n" "$_az_vm_wire_data" | grep -Eio '/subscriptions/[^"<>[:space:]]+/resourceGroups/[^"<>[:space:]]+/providers/Microsoft\.ManagedIdentity/userAssignedIdentities/[^"<>[:space:]]+' | sort -u | while read -r _az_vm_wire_res_id; do + print_info "Trying IMDS tokens for WireServer-discovered msi_res_id=$_az_vm_wire_res_id" + az_vm_print_token "Management token for WireServer msi_res_id" "https://management.azure.com/" "msi_res_id=$_az_vm_wire_res_id" + az_vm_print_token "Graph token for WireServer msi_res_id" "https://graph.microsoft.com/" "msi_res_id=$_az_vm_wire_res_id" + az_vm_print_token "Vault token for WireServer msi_res_id" "https://vault.azure.net/" "msi_res_id=$_az_vm_wire_res_id" + az_vm_print_token "Storage token for WireServer msi_res_id" "https://storage.azure.com/" "msi_res_id=$_az_vm_wire_res_id" + done + else + echo "WireServer/HostGAPlugin did not return data from this context." + fi + echo "" +} if [ "$is_az_vm" = "Yes" ]; then print_2title "Azure VM Enumeration" "T1552.005,T1580" @@ -47,24 +145,61 @@ if [ "$is_az_vm" = "Yes" ]; then echo "" print_3title "Management token" "T1552.005,T1580" - print_info "It's possible to assign 1 system MI and several user MI to a VM. LinPEAS can only get the token from the default one. More info in https://book.hacktricks.wiki/en/pentesting-web/ssrf-server-side-request-forgery/cloud-ssrf.html#azure-vm" + print_info "This is the default VM managed identity token. If several user-assigned identities exist and no system identity is present, Azure may require client_id/object_id/msi_res_id." exec_with_jq eval $az_req "$URL/identity/oauth2/token?api-version=$API_VERSION\&resource=https://management.azure.com/" echo "" print_3title "Graph token" "T1552.005,T1580" - print_info "It's possible to assign 1 system MI and several user MI to a VM. LinPEAS can only get the token from the default one. More info in https://book.hacktricks.wiki/en/pentesting-web/ssrf-server-side-request-forgery/cloud-ssrf.html#azure-vm" + print_info "This is the default VM managed identity token." exec_with_jq eval $az_req "$URL/identity/oauth2/token?api-version=$API_VERSION\&resource=https://graph.microsoft.com/" echo "" print_3title "Vault token" "T1552.005,T1580" - print_info "It's possible to assign 1 system MI and several user MI to a VM. LinPEAS can only get the token from the default one. More info in https://book.hacktricks.wiki/en/pentesting-web/ssrf-server-side-request-forgery/cloud-ssrf.html#azure-vm" + print_info "This is the default VM managed identity token." exec_with_jq eval $az_req "$URL/identity/oauth2/token?api-version=$API_VERSION\&resource=https://vault.azure.net/" echo "" print_3title "Storage token" "T1552.005,T1580" - print_info "It's possible to assign 1 system MI and several user MI to a VM. LinPEAS can only get the token from the default one. More info in https://book.hacktricks.wiki/en/pentesting-web/ssrf-server-side-request-forgery/cloud-ssrf.html#azure-vm" + print_info "This is the default VM managed identity token." exec_with_jq eval $az_req "$URL/identity/oauth2/token?api-version=$API_VERSION\&resource=https://storage.azure.com/" echo "" + + print_3title "Attached user-assigned managed identities and tokens" "T1552.005,T1580" + print_info "LinPEAS tries to discover all attached UAIs by using the default Management token to read the VM ARM identity block. If that token cannot read Microsoft.Compute/virtualMachines/read, IMDS can still issue tokens for known client_id/object_id/msi_res_id values, but the full attached identity list cannot be discovered from IMDS alone." + + _az_vm_instance_json="$(az_vm_request "$URL/instance?api-version=$API_VERSION")" + _az_vm_resource_id="$(printf "%s" "$_az_vm_instance_json" | az_vm_json_value ".compute.resourceId" "resourceId")" + _az_vm_mgmt_token_json="$(az_vm_request "$URL/identity/oauth2/token?api-version=$API_VERSION\&resource=https://management.azure.com/")" + _az_vm_mgmt_token="$(printf "%s" "$_az_vm_mgmt_token_json" | az_vm_json_value ".access_token" "access_token")" + + if [ "$_az_vm_resource_id" ] && [ "$_az_vm_mgmt_token" ]; then + _az_vm_arm_json="$(az_vm_request_arm "$_az_vm_mgmt_token" "https://management.azure.com${_az_vm_resource_id}?api-version=2024-07-01")" + if printf "%s" "$_az_vm_arm_json" | grep -q '"userAssignedIdentities"'; then + if [ "$(command -v jq || echo -n '')" ]; then + printf "%s" "$_az_vm_arm_json" | jq '.identity' + printf "%s" "$_az_vm_arm_json" | jq -r '.identity.userAssignedIdentities // {} | to_entries[] | [.key, .value.clientId, .value.principalId] | @tsv' 2>/dev/null | while IFS="$(printf '\t')" read -r _az_vm_uai_id _az_vm_uai_client_id _az_vm_uai_principal_id; do + if [ "$_az_vm_uai_client_id" ]; then + print_info "Requesting tokens for UAI client_id=$_az_vm_uai_client_id principal_id=$_az_vm_uai_principal_id resource_id=$_az_vm_uai_id" + az_vm_print_token "Management token for UAI $_az_vm_uai_client_id" "https://management.azure.com/" "client_id=$_az_vm_uai_client_id" + az_vm_print_token "Graph token for UAI $_az_vm_uai_client_id" "https://graph.microsoft.com/" "client_id=$_az_vm_uai_client_id" + az_vm_print_token "Vault token for UAI $_az_vm_uai_client_id" "https://vault.azure.net/" "client_id=$_az_vm_uai_client_id" + az_vm_print_token "Storage token for UAI $_az_vm_uai_client_id" "https://storage.azure.com/" "client_id=$_az_vm_uai_client_id" + fi + done + else + echo "$_az_vm_arm_json" | sed "s,access_token,${SED_RED},g" + print_info "Install jq to parse all attached user-assigned identities and request tokens for each one automatically." + az_vm_try_wire_identity_tokens + fi + else + echo "Could not read attached user-assigned identities from ARM with the default managed identity token." + az_vm_try_wire_identity_tokens + fi + else + echo "Could not obtain the VM resource ID or default Management token needed for ARM identity discovery." + az_vm_try_wire_identity_tokens + fi + echo "" fi echo "" -fi \ No newline at end of file +fi diff --git a/winPEAS/winPEASexe/winPEAS/Info/CloudInfo/AzureInfo.cs b/winPEAS/winPEASexe/winPEAS/Info/CloudInfo/AzureInfo.cs index d82dcdf..bca9559 100644 --- a/winPEAS/winPEASexe/winPEAS/Info/CloudInfo/AzureInfo.cs +++ b/winPEAS/winPEASexe/winPEAS/Info/CloudInfo/AzureInfo.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.IO; using System.Net; using System.Diagnostics; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; namespace winPEAS.Info.CloudInfo { @@ -28,6 +30,7 @@ namespace winPEAS.Info.CloudInfo const string AZURE_BASE_URL = "http://169.254.169.254/metadata/"; const string API_VERSION = "2021-12-13"; const string CONTAINER_API_VERSION = "2019-08-01"; + const string ARM_VM_API_VERSION = "2024-07-01"; public static bool DoesProcessExist(string processName) { @@ -122,6 +125,8 @@ namespace winPEAS.Info.CloudInfo IsAttackVector = tuple.Item3 }); } + + AddAzureVmUserAssignedIdentityTokens(_endpointDataList); } else { @@ -164,6 +169,346 @@ namespace winPEAS.Info.CloudInfo return _endpointData; } + private void AddAzureVmUserAssignedIdentityTokens(List endpointDataList) + { + endpointDataList.Add(new EndpointData() + { + EndpointName = "Managed identity discovery note", + Data = "winPEAS can request default managed identity tokens directly from IMDS. To discover every attached user-assigned identity, it tries to read the VM ARM identity block with the default Management token. If that token lacks Microsoft.Compute/virtualMachines/read, IMDS can still issue tokens for known client_id/object_id/msi_res_id values, but the full attached identity list cannot be discovered from IMDS alone.", + IsAttackVector = false + }); + + string instanceJson = CreateMetadataAPIRequest( + $"{AZURE_BASE_URL}instance?api-version={API_VERSION}", + "GET", + new WebHeaderCollection() { { "Metadata", "true" } }); + string vmResourceId = GetJsonString(instanceJson, "compute", "resourceId"); + + string managementTokenJson = CreateMetadataAPIRequest( + $"{AZURE_BASE_URL}identity/oauth2/token?api-version={API_VERSION}&resource=https://management.azure.com/", + "GET", + new WebHeaderCollection() { { "Metadata", "true" } }); + string managementToken = GetJsonString(managementTokenJson, "access_token"); + + if (string.IsNullOrEmpty(vmResourceId) || string.IsNullOrEmpty(managementToken)) + { + endpointDataList.Add(new EndpointData() + { + EndpointName = "Attached user-assigned managed identities", + Data = "Could not obtain the VM resource ID or default Management token needed for ARM identity discovery.", + IsAttackVector = false + }); + AddAzureVmWireServerIdentityTokens(endpointDataList); + return; + } + + string armUrl = $"https://management.azure.com{vmResourceId}?api-version={ARM_VM_API_VERSION}"; + string vmJson = CreateMetadataAPIRequest( + armUrl, + "GET", + new WebHeaderCollection() { { "Authorization", $"Bearer {managementToken}" } }); + + if (string.IsNullOrEmpty(vmJson)) + { + endpointDataList.Add(new EndpointData() + { + EndpointName = "Attached user-assigned managed identities", + Data = "Could not read the VM identity block from ARM with the default managed identity token.", + IsAttackVector = false + }); + AddAzureVmWireServerIdentityTokens(endpointDataList); + return; + } + + JsonNode root; + try + { + root = JsonNode.Parse(vmJson); + } + catch + { + endpointDataList.Add(new EndpointData() + { + EndpointName = "Attached user-assigned managed identities", + Data = vmJson, + IsAttackVector = false + }); + AddAzureVmWireServerIdentityTokens(endpointDataList); + return; + } + + JsonNode identityNode = root?["identity"]; + JsonObject userAssignedIdentities = identityNode?["userAssignedIdentities"] as JsonObject; + + endpointDataList.Add(new EndpointData() + { + EndpointName = "VM ARM identity block", + Data = identityNode?.ToJsonString() ?? "No identity block found in ARM VM response.", + IsAttackVector = false + }); + + if (userAssignedIdentities == null || userAssignedIdentities.Count == 0) + { + AddAzureVmWireServerIdentityTokens(endpointDataList); + return; + } + + foreach (var identity in userAssignedIdentities) + { + string identityResourceId = identity.Key; + string clientId = identity.Value?["clientId"]?.GetValue(); + string principalId = identity.Value?["principalId"]?.GetValue(); + + if (string.IsNullOrEmpty(clientId)) + { + continue; + } + + endpointDataList.Add(new EndpointData() + { + EndpointName = $"User-assigned MI {clientId}", + Data = $"ResourceId: {identityResourceId}\nPrincipalId: {principalId}", + IsAttackVector = false + }); + + foreach (var tokenEndpoint in GetAzureVmTokenEndpoints($"&client_id={Uri.EscapeDataString(clientId)}")) + { + string result = CreateMetadataAPIRequest( + $"{AZURE_BASE_URL}{tokenEndpoint.Item2}", + "GET", + new WebHeaderCollection() { { "Metadata", "true" } }); + + endpointDataList.Add(new EndpointData() + { + EndpointName = $"{tokenEndpoint.Item1} for UAI {clientId}", + Data = result, + IsAttackVector = true + }); + } + } + } + + private void AddAzureVmWireServerIdentityTokens(List endpointDataList) + { + endpointDataList.Add(new EndpointData() + { + EndpointName = "WireServer/HostGAPlugin managed identity fallback note", + Data = "ARM identity discovery failed or returned no user-assigned identities. Trying WireServer GoalState and HostGAPlugin /vmSettings for identity-looking selectors. These endpoints are environment-dependent and may expose no managed identity data.", + IsAttackVector = false + }); + + string wireData = ""; + wireData += CreateMetadataAPIRequest( + "http://168.63.129.16/machine?comp=goalstate", + "GET", + new WebHeaderCollection() { { "x-ms-version", "2012-11-30" } }); + wireData += "\n"; + wireData += CreateMetadataAPIRequest( + "http://168.63.129.16/machine/?comp=goalstate", + "GET", + new WebHeaderCollection() { { "x-ms-version", "2012-11-30" } }); + wireData += "\n"; + wireData += CreateMetadataAPIRequest( + "http://168.63.129.16:32526/vmSettings", + "GET"); + + if (string.IsNullOrEmpty(wireData)) + { + endpointDataList.Add(new EndpointData() + { + EndpointName = "WireServer/HostGAPlugin managed identity fallback", + Data = "WireServer/HostGAPlugin did not return data from this context.", + IsAttackVector = false + }); + return; + } + + HashSet clientIds = new HashSet(StringComparer.OrdinalIgnoreCase); + HashSet resourceIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + CollectWireServerIdentitySelectors(wireData, clientIds, resourceIds); + + endpointDataList.Add(new EndpointData() + { + EndpointName = "WireServer/HostGAPlugin identity-looking hints", + Data = GetWireServerIdentityHints(wireData), + IsAttackVector = false + }); + + foreach (string clientId in clientIds) + { + endpointDataList.Add(new EndpointData() + { + EndpointName = $"WireServer-discovered client_id {clientId}", + Data = "Trying IMDS tokens for this client_id.", + IsAttackVector = false + }); + + foreach (var tokenEndpoint in GetAzureVmTokenEndpoints($"&client_id={Uri.EscapeDataString(clientId)}")) + { + string result = CreateMetadataAPIRequest( + $"{AZURE_BASE_URL}{tokenEndpoint.Item2}", + "GET", + new WebHeaderCollection() { { "Metadata", "true" } }); + + endpointDataList.Add(new EndpointData() + { + EndpointName = $"{tokenEndpoint.Item1} for WireServer client_id {clientId}", + Data = result, + IsAttackVector = true + }); + } + } + + foreach (string resourceId in resourceIds) + { + endpointDataList.Add(new EndpointData() + { + EndpointName = $"WireServer-discovered msi_res_id {resourceId}", + Data = "Trying IMDS tokens for this msi_res_id.", + IsAttackVector = false + }); + + foreach (var tokenEndpoint in GetAzureVmTokenEndpoints($"&msi_res_id={Uri.EscapeDataString(resourceId)}")) + { + string result = CreateMetadataAPIRequest( + $"{AZURE_BASE_URL}{tokenEndpoint.Item2}", + "GET", + new WebHeaderCollection() { { "Metadata", "true" } }); + + endpointDataList.Add(new EndpointData() + { + EndpointName = $"{tokenEndpoint.Item1} for WireServer msi_res_id", + Data = result, + IsAttackVector = true + }); + } + } + } + + private static void CollectWireServerIdentitySelectors(string wireData, HashSet clientIds, HashSet resourceIds) + { + TryCollectWireServerJsonSelectors(wireData, clientIds, resourceIds); + + foreach (Match match in Regex.Matches(wireData, @"(?i)(clientId|IdentityClientId|client_id)[^0-9a-fA-F]{0,80}([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})")) + { + clientIds.Add(match.Groups[2].Value); + } + + foreach (Match match in Regex.Matches(wireData, @"(?i)/subscriptions/[^""<>\s]+/resourceGroups/[^""<>\s]+/providers/Microsoft\.ManagedIdentity/userAssignedIdentities/[^""<>\s]+")) + { + resourceIds.Add(match.Value); + } + } + + private static void TryCollectWireServerJsonSelectors(string wireData, HashSet clientIds, HashSet resourceIds) + { + try + { + JsonNode root = JsonNode.Parse(wireData); + CollectJsonIdentitySelectors(root, clientIds, resourceIds); + } + catch + { + } + } + + private static void CollectJsonIdentitySelectors(JsonNode node, HashSet clientIds, HashSet resourceIds) + { + if (node == null) + { + return; + } + + if (node is JsonObject obj) + { + foreach (var prop in obj) + { + string value = null; + try + { + value = prop.Value?.GetValue(); + } + catch + { + } + if (!string.IsNullOrEmpty(value)) + { + if (Regex.IsMatch(prop.Key, @"(?i)(clientId|IdentityClientId)$") && Regex.IsMatch(value, @"^[0-9a-fA-F-]{36}$")) + { + clientIds.Add(value); + } + if (Regex.IsMatch(value, @"(?i)/subscriptions/.+/providers/Microsoft\.ManagedIdentity/userAssignedIdentities/")) + { + resourceIds.Add(value); + } + } + CollectJsonIdentitySelectors(prop.Value, clientIds, resourceIds); + } + } + else if (node is JsonArray arr) + { + foreach (JsonNode child in arr) + { + CollectJsonIdentitySelectors(child, clientIds, resourceIds); + } + } + } + + private static string GetWireServerIdentityHints(string wireData) + { + List hints = new List(); + foreach (Match match in Regex.Matches(wireData, @"(?i)([A-Za-z0-9_./:-]*Identity[A-Za-z0-9_./:-]*|Microsoft\.ManagedIdentity/userAssignedIdentities/[^""<>\s]+|clientId["":=\s]+[0-9a-fA-F-]{36}|IdentityClientId[^0-9a-fA-F]*[0-9a-fA-F-]{36})")) + { + if (!hints.Contains(match.Value)) + { + hints.Add(match.Value); + } + if (hints.Count >= 80) + { + break; + } + } + return hints.Count > 0 ? string.Join("\n", hints) : "No identity-looking strings found in WireServer/HostGAPlugin responses."; + } + + private static List> GetAzureVmTokenEndpoints(string selectorSuffix = "") + { + return new List>() + { + new Tuple("Management token", $"identity/oauth2/token?api-version={API_VERSION}&resource=https://management.azure.com/{selectorSuffix}", true), + new Tuple("Graph token", $"identity/oauth2/token?api-version={API_VERSION}&resource=https://graph.microsoft.com/{selectorSuffix}", true), + new Tuple("Vault token", $"identity/oauth2/token?api-version={API_VERSION}&resource=https://vault.azure.net/{selectorSuffix}", true), + new Tuple("Storage token", $"identity/oauth2/token?api-version={API_VERSION}&resource=https://storage.azure.com/{selectorSuffix}", true) + }; + } + + private static string GetJsonString(string json, params string[] path) + { + if (string.IsNullOrEmpty(json)) + { + return null; + } + + try + { + JsonNode current = JsonNode.Parse(json); + foreach (string key in path) + { + current = current?[key]; + if (current == null) + { + return null; + } + } + return current.GetValue(); + } + catch + { + return null; + } + } + public override bool TestConnection() { if (IsContainer())