From 406b2549aa858eab719db01a68093c267bb44f5e Mon Sep 17 00:00:00 2001 From: Carlos Polop Date: Tue, 5 May 2026 16:27:01 +0200 Subject: [PATCH] Replace brittle managed identity enumeration examples --- .../azure-security/az-services/vms/README.md | 296 ++++++------------ 1 file changed, 104 insertions(+), 192 deletions(-) diff --git a/src/pentesting-cloud/azure-security/az-services/vms/README.md b/src/pentesting-cloud/azure-security/az-services/vms/README.md index 4f08f6e7f..ff2aadbe4 100644 --- a/src/pentesting-cloud/azure-security/az-services/vms/README.md +++ b/src/pentesting-cloud/azure-security/az-services/vms/README.md @@ -889,44 +889,17 @@ However, not every process will necessarily get the same practical result: Therefore, if a request works from Run Command but fails from SSH, the usual explanation is a difference in OS user, environment, routing, proxy, firewall, or namespace, not a general Azure rule that only agent execution contexts can reach `168.63.129.16`. -### Run Command vs SSH Context +### Managed Identity Access From Inside the VM -Azure provides multiple ways to execute commands inside a VM, but **they do not run in the same context**. +The reliable way to use a VM's managed identities is the **IMDS managed identity endpoint** at `169.254.169.254`, not the WireServer `ExtensionsConfig` XML. Scripts that only search `ExtensionsConfig` for `UserAssignedIdentity` nodes are not reliable because: -#### Run Command +- The VM's managed identity assignment is not guaranteed to be represented as `UserAssignedIdentity` nodes in extension XML. +- They miss **system-assigned managed identities**. +- They only find user-assigned identities if the current GoalState/extension data happens to expose the expected XML shape. -Run Command is an Azure feature that executes scripts via the **VM Agent**. +Microsoft's documented security model is that **all code running on the VM can request tokens for the managed identities available on that VM**. If several user-assigned identities exist, request a specific one with `client_id`, `object_id`, or `msi_res_id`. -- Uses: `Microsoft.Compute/virtualMachines/runCommand/action` -- Runs through the **Azure VM Agent** -- Usually runs with elevated local privileges (`root` on Linux or `SYSTEM` on Windows) -- Can often reach WireServer/GoalState/ExtensionsConfig even when a low-privileged user is blocked by local controls - -Example: - -```bash -az vm run-command invoke \ - --resource-group \ - --name \ - --command-id RunShellScript \ - --scripts @script.sh -``` - -#### SSH Session - -When connecting via SSH: - -- Runs as a **regular OS user** -- Uses standard network stack -- Does **not** have VM Agent privileges by default - -As a result: - -- Requests to `168.63.129.16` can work from SSH if the guest configuration allows it -- Requests may fail if blocked by local firewall, proxy, routing, network namespace, or user-level controls -- GoalState requests require the correct endpoint path and headers - -**Script Examples to get attached managed identities:** +The following examples first request a token. Then they try to read the VM resource from Azure Resource Manager and print its `identity` property. The second step only works if the managed identity has permissions such as `Microsoft.Compute/virtualMachines/read` on the VM. {{#tabs }} {{#tab name="Linux" }} @@ -935,193 +908,133 @@ As a result: #!/usr/bin/env bash set -euo pipefail -ws="http://168.63.129.16" +imds="http://169.254.169.254/metadata" +api_version="2021-02-01" +resource="${1:-https://management.azure.com/}" -echo "[*] Getting Goal State..." +# Optional. Examples: +# export MSI_SELECTOR='client_id=' +# export MSI_SELECTOR='object_id=' +# export MSI_SELECTOR='msi_res_id=/subscriptions/.../userAssignedIdentities/name' +selector="${MSI_SELECTOR:-}" -goal_urls=( - "$ws/machine?comp=goalstate" - "$ws/machine/?comp=goalstate" -) +urlencode() { + python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1" +} -goal_xml="" -for url in "${goal_urls[@]}"; do - if goal_xml="$(curl -fsS -H "x-ms-version: 2012-11-30" "$url" 2>/dev/null)"; then - echo "[+] GoalState OK via $url" - break - fi -done - -if [[ -z "$goal_xml" ]]; then - echo "[-] No GoalState endpoint responded" - exit 1 +token_url="$imds/identity/oauth2/token?api-version=$api_version&resource=$(urlencode "$resource")" +if [[ -n "$selector" ]]; then + token_url="$token_url&$selector" fi -ext_url="$( - GOAL_XML="$goal_xml" python3 - <<'PY' -import os -import xml.etree.ElementTree as ET +echo "[*] Requesting managed identity token for: $resource" +token_json="$(curl -fsS --noproxy "*" -H "Metadata:true" "$token_url")" -xml = os.environ["GOAL_XML"].strip() -root = ET.fromstring(xml) - -def lname(tag): - return tag.rsplit("}", 1)[-1] - -for el in root.iter(): - if lname(el.tag) == "ExtensionsConfig" and (el.text or "").strip(): - print(el.text.strip()) - break +access_token="$( + TOKEN_JSON="$token_json" python3 - <<'PY' +import json, os +print(json.loads(os.environ["TOKEN_JSON"])["access_token"]) PY )" -if [[ -z "$ext_url" ]]; then - echo "[-] No ExtensionsConfig URL found in GoalState" - echo "[*] Identity-like nodes seen in GoalState:" - GOAL_XML="$goal_xml" python3 - <<'PY' -import os -import xml.etree.ElementTree as ET +TOKEN="$access_token" python3 - <<'PY' +import base64, json, os -xml = os.environ["GOAL_XML"].strip() -root = ET.fromstring(xml) +token = os.environ["TOKEN"] +payload = token.split(".")[1] +payload += "=" * (-len(payload) % 4) +claims = json.loads(base64.urlsafe_b64decode(payload)) -def lname(tag): - return tag.rsplit("}", 1)[-1] - -found = False -for el in root.iter(): - name = lname(el.tag) - if "Identity" in name: - found = True - text = (el.text or "").strip() - print(f"<{name}>{text}") - -if not found: - print(" (none)") +print("[+] Token acquired") +for key in ("tid", "appid", "oid", "xms_mirid"): + if key in claims: + print(f" {key}: {claims[key]}") PY - exit 0 + +echo "[*] Trying to read the VM identity property through ARM..." +compute_json="$(curl -fsS --noproxy "*" -H "Metadata:true" "$imds/instance/compute?api-version=$api_version")" +vm_id="$( + COMPUTE_JSON="$compute_json" python3 - <<'PY' +import json, os +print(json.loads(os.environ["COMPUTE_JSON"])["resourceId"]) +PY +)" + +arm_url="https://management.azure.com${vm_id}?api-version=2024-07-01" +if vm_json="$(curl -fsS -H "Authorization: Bearer $access_token" "$arm_url" 2>/dev/null)"; then + VM_JSON="$vm_json" python3 - <<'PY' +import json, os +vm = json.loads(os.environ["VM_JSON"]) +print(json.dumps(vm.get("identity", {}), indent=2)) +PY +else + echo "[-] Could not read the VM resource with this identity. The token may still be valid, but it lacks ARM read permissions on the VM." fi - -echo "[*] Getting ExtensionsConfig..." -ext_xml="$(curl -fsS -H "x-ms-version: 2012-11-30" "$ext_url")" - -EXT_XML="$ext_xml" python3 - <<'PY' -import os -import xml.etree.ElementTree as ET - -xml = os.environ["EXT_XML"].strip() -root = ET.fromstring(xml) - -def lname(tag): - return tag.rsplit("}", 1)[-1] - -ids = [el for el in root.iter() if lname(el.tag) == "UserAssignedIdentity"] - -if not ids: - print("[-] No UserAssignedIdentity nodes found") - print("[*] Identity-like nodes present in ExtensionsConfig:") - shown = False - for el in root.iter(): - name = lname(el.tag) - if "Identity" in name: - shown = True - text = (el.text or "").strip() - attrs = " ".join(f'{k}="{v}"' for k, v in el.attrib.items()) - if attrs: - print(f" <{name} {attrs}>{text}") - else: - print(f" <{name}>{text}") - if not shown: - print(" (none)") - raise SystemExit(0) - -for idnode in ids: - client_id = "" - object_id = "" - resource_id = "" - - for child in idnode.iter(): - name = lname(child.tag) - text = (child.text or "").strip() - if name == "IdentityClientId": - client_id = text - elif name == "IdentityObjectId": - object_id = text - elif name == "IdentityResourceId": - resource_id = text - - print() - print("[+] Managed Identity:") - print(f" ClientId : {client_id}") - print(f" ObjectId : {object_id}") - print(f" ResourceId : {resource_id}") -PY ``` {{#endtab }} {{#tab name="Windows" }} -```bash -$ws = "http://168.63.129.16" -$h = @{ - "x-ms-version" = "2012-11-30" +```powershell +$imds = "http://169.254.169.254/metadata" +$apiVersion = "2021-02-01" +$resource = "https://management.azure.com/" + +# Optional. Examples: +# $env:MSI_SELECTOR = "client_id=" +# $env:MSI_SELECTOR = "object_id=" +# $env:MSI_SELECTOR = "msi_res_id=/subscriptions/.../userAssignedIdentities/name" +$selector = $env:MSI_SELECTOR + +function Invoke-Imds { + param([string]$Uri) + + $params = @{ + Method = "GET" + Uri = $Uri + Headers = @{ Metadata = "true" } + UseBasicParsing = $true + } + if ((Get-Command Invoke-RestMethod).Parameters.ContainsKey("NoProxy")) { + $params.NoProxy = $true + } + Invoke-RestMethod @params } -Write-Host "[*] Getting Goal State..." -ForegroundColor Cyan +function Decode-JwtPayload { + param([string]$Token) -$goalUrls = @( - "$ws/machine?comp=goalstate", - "$ws/machine/?comp=goalstate" -) - -$gs = $null - -foreach ($url in $goalUrls) { - try { - $gs = Invoke-WebRequest -Uri $url -Headers $h -UseBasicParsing -ErrorAction Stop - Write-Host "[+] GoalState OK via $url" -ForegroundColor Green - break - } catch {} + $payload = $Token.Split(".")[1].Replace("-", "+").Replace("_", "/") + switch ($payload.Length % 4) { + 2 { $payload += "==" } + 3 { $payload += "=" } + } + [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) | ConvertFrom-Json } -if (-not $gs) { - Write-Host "[-] No GoalState endpoint responded" -ForegroundColor Red - return +$encodedResource = [uri]::EscapeDataString($resource) +$tokenUri = "$imds/identity/oauth2/token?api-version=$apiVersion&resource=$encodedResource" +if ($selector) { + $tokenUri = "$tokenUri&$selector" } -[xml]$xml = $gs.Content -$cfg = $xml.GoalState.Container.RoleInstanceList.RoleInstance.Configuration +Write-Host "[*] Requesting managed identity token for: $resource" +$tokenResponse = Invoke-Imds -Uri $tokenUri +$claims = Decode-JwtPayload -Token $tokenResponse.access_token -$extUrl = $cfg.ExtensionsConfig +Write-Host "[+] Token acquired" +$claims | Select-Object tid, appid, oid, xms_mirid | Format-List -Write-Host "[*] Getting ExtensionsConfig..." -ForegroundColor Cyan +Write-Host "[*] Trying to read the VM identity property through ARM..." +$compute = Invoke-Imds -Uri "$imds/instance/compute?api-version=$apiVersion" +$armUri = "https://management.azure.com$($compute.resourceId)?api-version=2024-07-01" try { - $ext = Invoke-WebRequest -Uri $extUrl -Headers $h -UseBasicParsing -ErrorAction Stop - [xml]$extXml = $ext.Content + $vm = Invoke-RestMethod -Method GET -Uri $armUri -Headers @{ Authorization = "Bearer $($tokenResponse.access_token)" } -UseBasicParsing + $vm.identity | ConvertTo-Json -Depth 20 } catch { - Write-Host "[-] Error getting ExtensionsConfig" -ForegroundColor Red - return -} - -# Extract Managed Identity info -$ids = $extXml.SelectNodes("//UserAssignedIdentity") - -if (!$ids) { - Write-Host "[-] No User Assigned Identities found" -ForegroundColor Red - return -} - -foreach ($id in $ids) { - $clientId = $id.IdentityClientId - $objectId = $id.IdentityObjectId - $resourceId = $id.IdentityResourceId - - Write-Host "`n[+] Managed Identity:" -ForegroundColor Green - Write-Host " ClientId : $clientId" - Write-Host " ObjectId : $objectId" - Write-Host " ResourceId : $resourceId" + Write-Host "[-] Could not read the VM resource with this identity. The token may still be valid, but it lacks ARM read permissions on the VM." } ``` @@ -1158,6 +1071,7 @@ foreach ($id in $ids) { - [https://learn.microsoft.com/en-us/azure/virtual-machines/overview](https://learn.microsoft.com/en-us/azure/virtual-machines/overview) - [https://hausec.com/2022/05/04/azure-virtual-machine-execution-techniques/](https://hausec.com/2022/05/04/azure-virtual-machine-execution-techniques/) - [https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service](https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service) +- [https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token) - [https://learn.microsoft.com/en-us/azure/virtual-network/what-is-ip-address-168-63-129-16](https://learn.microsoft.com/en-us/azure/virtual-network/what-is-ip-address-168-63-129-16) - [https://learn.microsoft.com/en-us/azure/virtual-machines/linux/no-agent](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/no-agent) - [https://learn.microsoft.com/en-us/azure/virtual-machines/run-command](https://learn.microsoft.com/en-us/azure/virtual-machines/run-command) @@ -1165,5 +1079,3 @@ foreach ($id in $ids) { - [https://www.cybercx.com.au/blog/azure-ssrf-metadata/](https://www.cybercx.com.au/blog/azure-ssrf-metadata/) {{#include ../../../../banners/hacktricks-training.md}} - -