EMAIL & BEC TRIAGE
A triage reference for a reported phishing or business email compromise (BEC) case: how to keep the message as evidence, read its headers and parts safely, and check the mailbox and the cloud tenant for what an attacker did next (inbox rules, forwarding, delegates, app consents, mail access). It is for investigation and response, NOT for sending phishing. Sections 1 to 5 work on a saved .eml file with python3 and dig; sections 6 to 9 assume read access to Exchange Online / Microsoft Entra or Google Workspace. Basic header reading and SPF/DKIM/DMARC are in the Email Header Analysis sheet; the general account response order is in the Account Compromise Triage sheet. All names below (example.org, alice@example.org, supplier.example) are placeholders.
1. PRESERVE THE MESSAGE (ATT&CK T1566)#
Get the original, with full headers, before anyone deletes or "cleans" it:
Outlook / Exchange: ask the user to forward it AS AN ATTACHMENT, or pull
it from the mailbox as an admin; a normal forward
rewrites the headers.
Gmail: More (three dots) > Show original > Copy to clipboard,
then save the text as msg.eml.
Inspect:
sha256sum msg.eml | tee msg.eml.sha256
chmod a-w msg.eml
Exchange Online message trace (who else got it; output is in UTC):
Get-MessageTraceV2 -SenderAddress bad@supplier.example `
-StartDate (Get-Date).AddDays(-10) -EndDate (Get-Date)
Get-MessageTraceV2 -MessageId '<id@supplier.example>' `
-StartDate (Get-Date).AddDays(-10) -EndDate (Get-Date)
Notes: Get-MessageTraceV2 replaces Get-MessageTrace. It covers 90 days, at
most 10 days per query, and only 48 hours if you give no dates; default
1,000 results, maximum 5,000 (-ResultSize). Google Workspace: Admin console
> Reporting > Email Log Search (no message content; older than 30 days
needs the recipient and the message ID).
Flags: the same Message-ID or sender delivered to many people (a campaign,
not a one-off); the user already replied or clicked.
2. READ THE HEADERS (ATT&CK T1684.002)#
Inspect (prints the Received chain oldest hop first, then the key headers):
python3 -I -c 'if 1:
import sys, email
from email import policy
m = email.message_from_binary_file(open(sys.argv[1], "rb"),
policy=policy.default)
for h in reversed(m.get_all("Received", [])): # oldest hop first
print("Received:", " ".join(h.split()))
for k in ("Authentication-Results", "ARC-Seal",
"ARC-Authentication-Results", "Return-Path", "From",
"Sender", "Reply-To", "Message-ID", "In-Reply-To",
"References", "Date"):
for v in m.get_all(k, []): # top (newest) first
print(k + ":", " ".join(str(v).split()))
' msg.eml
Rules for reading the results:
- Trust only the Authentication-Results header added by YOUR own
mail system (its authserv-id, the first word, is your server name).
Anything lower down was written by the sender side and can be fake;
RFC 8601 says a receiver must delete such headers that claim its own
authserv-id but did not come from a trusted server.
- dmarc=pass only proves the message really came from the domain shown
in header.from. A lookalike domain passes SPF, DKIM and DMARC easily.
- ARC (ARC-Seal cv=none/pass/fail, instance i=) records results seen by
forwarders and mailing lists. RFC 8617 is Experimental; treat it as a
hint, not proof.
- Received lines below your own border server are written by the sender
and can be forged; the first hop your server added is the real source.
Flags: header.from of a lookalike domain; an Authentication-Results lower
in the list that disagrees with yours; Return-Path, From and Reply-To on
three different domains; a Date far from the Received times.
3. SENDER DOMAIN DNS (ATT&CK T1583.001)#
Inspect (replace example.com with the domain in header.from):
dig +short TXT example.com # SPF: v=spf1 ...
dig +short TXT _dmarc.example.com # DMARC policy
dig +short MX example.com # "0 ." = null MX
dig +short TXT default._domainkey.example.com # DKIM key (s= selector)
Notes:
- DMARC is now RFC 9989 (May 2026), which obsoletes RFC 7489. Tags: p=
(domain policy), sp= (subdomains), np= (non-existent subdomains), t=y
(testing mode), psd=. The old pct=, rf= and ri= tags were removed.
- A DKIM record with an empty p= means the key was revoked. Keys can be
rotated after the message was sent, so a DKIM check repeated days
later can fail on a message that passed on delivery.
- Check when the domain was registered (registry RDAP or WHOIS); a domain
created days before the message is a strong lookalike sign.
Flags: a domain with no MX that still sends you mail; p=none or no DMARC
record on a domain that claims to be a bank or supplier; a domain that is
new, or differs from the real partner by one character or one TLD.
4. LOOKALIKE ADDRESSES AND DISPLAY NAMES (ATT&CK T1684.001)#
Inspect (decodes each address and flags the usual tricks):
python3 -I -c 'if 1:
import sys, email
from email import policy
m = email.message_from_binary_file(open(sys.argv[1], "rb"),
policy=policy.default)
sys.stdout.reconfigure(errors="backslashreplace")
for k in ("From", "Sender", "Reply-To"):
for a in getattr(m[k], "addresses", ()):
d, n = a.domain, a.display_name
print(k, repr(n), a.addr_spec,
"" if d.isascii() else "non-ASCII domain",
"" if n.isascii() else "non-ASCII name",
"xn--" if "xn--" in d else "",
"@ in name" if "@" in n else "")
' msg.eml
Decode a punycode (xn--) domain and name every non-ASCII letter in it:
python3 -I -c 'if 1:
import sys, unicodedata as u
for c in sys.argv[1].encode().decode("idna"):
if not c.isascii(): print(c, u.name(c, "?"))
' xn--suppler-vog.example
# prints: і CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I
Flags: a display name that is a person or a supplier you know but the
address is a free-mail or new domain; an address inside the display name
("ceo@example.org" <x@supplier.example>); Cyrillic or Greek letters that
look Latin; digit-for-letter swaps (1 for l, 0 for o, rn for m).
5. LINKS AND FILES (ATT&CK T1566.001, T1566.002, T1204.001, T1204.002)#
Read-only: never open parts on your workstation. Save them named by hash:
mkdir -p parts && python3 -I -c 'if 1:
import sys, email, hashlib, re
from email import policy
m = email.message_from_binary_file(open(sys.argv[1], "rb"),
policy=policy.default)
A = re.compile(r"<a\s[^>]*href=[\"\x27]([^\"\x27]+)[\"\x27][^>]*>"
r"(.*?)</a>", re.I | re.S)
for p in m.walk():
if p.is_multipart():
continue
b = p.get_payload(decode=True) or b""
h = hashlib.sha256(b).hexdigest()
print(p.get_content_type(), p.get_filename(), len(b), h)
open("parts/" + h, "wb").write(b) # named by hash, not name
if p.get_content_type() == "text/html":
t = b.decode(p.get_content_charset() or "utf-8", "replace")
for href, text in A.findall(t):
print(" href", href, "| shown", re.sub("<[^>]+>", "", text))
' msg.eml
file parts/* # real type, not the name
python3 -I -m zipfile -l parts/<sha256> # list a zip, do not extract
Notes: the regex is a quick triage aid, not a full HTML parser; read the
HTML part as text too. Search the hashes in your mail gateway and EDR;
submit to a sandbox only if your policy allows sharing the file.
Flags: link text showing one domain while href points to another; double
extensions (invoice.pdf.html, invoice.pdf.exe); HTML, ISO, IMG, LNK, ZIP
or password-protected archives; a declared type that "file" disagrees
with; login pages or QR codes in an invoice.
6. EXCHANGE RULES, FORWARDING (ATT&CK T1114.003, T1564.008, T1098.002)#
Inspect (Exchange Online PowerShell, read-only):
Get-InboxRule -Mailbox alice@example.org -IncludeHidden |
Format-List Name,Enabled,RedirectTo,Forward*,Identity
Get-InboxRule -Identity "<Identity>" -IncludeHidden | Format-List
Get-Mailbox -Identity alice@example.org |
Format-List Forwarding*Address,DeliverTo*
Get-MailboxPermission -Identity alice@example.org | Format-List
Get-RecipientPermission -Identity alice@example.org # SendAs
Get-RecipientPermission -Trustee bob@example.org # what bob has
Graph alternative: Get-MgUserMailFolderMessageRule -UserId <id>
-MailFolderId inbox (actions: forwardTo, forwardAsAttachmentTo,
redirectTo, moveToFolder, markAsRead, delete, permanentDelete).
Notes: without -IncludeHidden some rules are not shown. Get-InboxRule does
not work for the View-Only Organization Management role group or the
Global Reader role. If both ForwardingAddress and ForwardingSmtpAddress are
set, only ForwardingAddress is used. Outlook client rules can also start
an application and custom forms (IPM.Note.<name>) can run code; Microsoft
documents this attack separately.
Flags: rules with ForwardTo, ForwardAsAttachmentTo or RedirectTo outside
your domain; DeleteMessage, SoftDeleteMessage, MarkAsRead or MoveToFolder
(RSS Feeds, Archive, Conversation History) with words such as invoice,
payment, bank, wire or the CEO's name; short or dot-only rule names; a
ForwardingSmtpAddress you did not set (DeliverToMailboxAndForward $true
keeps a copy, so the user notices nothing); FullAccess or SendAs for an
account that should not have it.
7. EXCHANGE AUDIT LOG AND MAIL ACCESS (ATT&CK T1114.002, T1070.008)#
Check that auditing is on and nobody is excluded:
Get-OrganizationConfig | Format-List AuditDisabled # False = on
Get-Mailbox -Identity alice@example.org | Format-List DefaultAuditSet
Get-MailboxAuditBypassAssociation -ResultSize unlimited |
Where-Object AuditBypassEnabled | Format-Table Name,AuditBypassEnabled
Inspect (unified audit log; Search-MailboxAuditLog is being retired in
the cloud):
$s = (Get-Date).AddDays(-30); $e = Get-Date
$ops = 'New-InboxRule', 'Set-InboxRule', 'UpdateInboxRules', 'Send',
'SendAs', 'SendOnBehalf', 'SoftDelete', 'HardDelete',
'MoveToDeletedItems'
Search-UnifiedAuditLog -StartDate $s -EndDate $e `
-UserIds alice@example.org -Operations $ops -ResultSize 5000
Search-UnifiedAuditLog -StartDate $s -EndDate $e `
-RecordType ExchangeAdmin -FreeText alice@example.org -ResultSize 5000
Search-UnifiedAuditLog -StartDate $s -EndDate $e `
-UserIds alice@example.org -Operations MailItemsAccessed `
-ResultSize 1000 |
Where {$_.AuditData -like '*"MailAccessType","Value":"Sync"*'}
# same with "Bind" for single messages read
Notes: -ResultSize defaults to 100 (max 5,000); for more, use one
-SessionId with -SessionCommand ReturnLargeSet (up to 50,000, unsorted)
and repeat the call. A new record can take 30 minutes or more to appear.
MailItemsAccessed covers POP, IMAP, MAPI, EWS, Exchange ActiveSync and
REST; Sync records come only from desktop Outlook and mean the whole
folder was downloaded (what was read offline after that is not logged);
Bind records carry the InternetMessageId and are grouped per 2 minutes.
Compare ClientIPAddress, ClientInfoString and SessionId with the user's
normal sign-ins.
Flags: New-InboxRule or Set-Mailbox -ForwardingSmtpAddress from an IP the
user never used; a Sync from an unknown client or IP (treat the whole
folder as read); SoftDelete or HardDelete of the attacker's sent mail;
AuditBypassEnabled True for a user; Send from the mailbox just before a
payment change request reached a customer.
8. ENTRA SIGN-INS AND APP CONSENTS (ATT&CK T1078.004, T1528, T1550.001)#
Inspect (Microsoft Graph PowerShell, AuditLog.Read.All; UPNs lowercase):
$f = "userPrincipalName eq 'alice@example.org' and " +
"createdDateTime ge 2026-10-01T00:00:00Z"
Get-MgAuditLogSignIn -All -Filter $f |
Format-Table CreatedDateTime,IpAddress,AppDisplayName,ClientAppUsed
Get-MgAuditLogSignIn -All -Filter "clientAppUsed eq 'IMAP'"
Get-MgUserOauth2PermissionGrant -UserId alice@example.org |
Format-List ClientId,ConsentType,ResourceId,Scope
Get-MgAuditLogDirectoryAudit -All `
-Filter "activityDisplayName eq 'Consent to application'"
Get-MgAuditLogDirectoryAudit -All `
-Filter "activityDisplayName eq 'Add service principal credentials'"
Notes: ClientId of a grant is the object ID of the app's service principal
(Get-MgServicePrincipal -ServicePrincipalId <ClientId>). ConsentType
AllPrincipals means an admin consented for every user. Legacy clients
show as IMAP, POP, SMTP, MAPI, Exchange ActiveSync or "Other clients" in
clientAppUsed.
Flags: a successful sign-in from a new country, hosting provider or
anonymiser just before the rule was created; legacy-protocol sign-ins;
a new app with Mail.Read, Mail.ReadWrite, Mail.Send, MailboxSettings or
offline_access scopes; an unknown publisher; new credentials added to an
existing app.
9. GOOGLE WORKSPACE (ATT&CK T1114.003, T1528)#
Admin console (needs the Audit & Investigation privilege):
Reporting > Audit and investigation > Gmail log events / OAuth log
events / User log events (the default view is the last 7 days).
Reports API (read-only):
GET https://admin.googleapis.com/admin/reports/v1/activity/users/all/
applications/user_accounts?eventName=email_forwarding_out_of_domain
GET .../activity/users/all/applications/token?eventName=authorize
GET .../activity/users/all/applications/login?eventName=suspicious_login
Directory and Gmail API (per user):
GET https://admin.googleapis.com/admin/directory/v1/users/{userKey}/tokens
GET https://gmail.googleapis.com/gmail/v1/users/{userId}/settings/
autoForwarding | filters | forwardingAddresses | delegates | sendAs
Notes: reading another user's Gmail settings needs a service account with
domain-wide delegation. The sendAs entries carry replyToAddress, which an
attacker can change.
Flags: email_forwarding_out_of_domain events; filters that forward, delete
or skip the inbox; a new delegate; a sendAs replyToAddress outside your
domain; authorize events for an unknown app with Gmail scopes;
suspicious_login or account_disabled_hijacked for the user.
10. BEC SIGNS IN THE CONVERSATION (ATT&CK T1657, T1684.001, T1534)#
Inspect: read the thread with the finance or sales person, side by side
with the headers from section 2.
Flags:
- A change of bank details, a new account "for an audit", or a request
to pay an invoice early, with pressure or a request for secrecy.
- A reply in an old, real thread (In-Reply-To and References point at
real Message-IDs) but sent from a lookalike domain or with a new
Reply-To: the attacker read the real conversation first.
- A new participant added to the thread, or the real contact dropped.
- The request came from an internal account (internal spearphishing):
check that mailbox with sections 6 to 8.
- Gift cards, crypto or a "confidential acquisition".
Response (FBI IC3 advice): verify any payment or account change by phone
on a number you already had, never one from the email. If money was sent,
call your bank at once and ask for a recall, then report it to the police
or your national reporting centre.
11. CONTAIN (CHANGES STATE; record everything above first)#
Exchange Online / Entra:
Revoke-MgUserSignInSession -UserId alice@example.org
Update-MgUser -UserId alice@example.org -AccountEnabled:$false
Disable-InboxRule -Identity "<Identity>" # keep it; Remove- deletes
Set-Mailbox -Identity alice@example.org -ForwardingSmtpAddress $null `
-ForwardingAddress $null -DeliverToMailboxAndForward $false
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId <id>
New-TenantAllowBlockListItems -ListType Sender -Block `
-Entries supp1ier.example -NoExpiration
Tenant-wide, stop silent external forwarding:
Set-HostedOutboundSpamFilterPolicy -Identity Default `
-AutoForwardingMode Off
Notes: session revocation can take a few minutes and does not cover
external (guest) users. A password reset does not revoke app passwords.
For accounts synced from Active Directory, reset the password in AD
(Microsoft advises twice). Do not send the new password by email.
AutoForwardingMode Off blocks external auto-forwarding for cloud mailboxes
(the sender gets an NDR); internal forwarding still works.
Google Workspace: Users > the user > Security: reset sign-in cookies,
revoke connected applications and application-specific passwords, then
reset the password. Resetting cookies does not sign the user out of apps
such as Gmail or Drive for desktop; suspend the user if you must cut
everything.
Then follow the Account Compromise Triage sheet (MFA re-registration,
devices, other services that used the same password).
QUICK TRIAGE ORDER#
1. Save the original message, hash it, find every recipient (section 1). 2. Read your own Authentication-Results and the From/Reply-To/Return-Path domains; check the domain's age and DNS (sections 2 to 4). 3. Extract links and attachments by hash; search them in the gateway and EDR; find who clicked or opened (section 5). 4. If anyone typed a password or approved a prompt: inbox rules (hidden too), forwarding, delegates and audit log for that mailbox (6 and 7). 5. Sign-ins and app consents for the same window (section 8 or 9). 6. Check the conversation for payment changes and call the partner on a known number; if money moved, call the bank now (section 10). 7. Contain: revoke sessions, remove forwarding and rules (after recording them), revoke grants, block the sender (section 11). Header parsing and DNS steps were run with Python 3 and dig on synthetic test messages that use only reserved example names. Cmdlets, parameters, API paths and audit operation names were checked against Microsoft Learn (Exchange Online, Microsoft Graph and Purview audit docs) and Google Workspace Admin Help and API references, but not run against a live tenant. Standards: RFC 5322, RFC 7208 (SPF), RFC 6376 (DKIM), RFC 9989 (DMARC), RFC 8601 and RFC 8617 (ARC). BEC advice per FBI IC3 PSA I-091124-PSA; technique IDs per MITRE ATT&CK Enterprise v20.
EMAIL & BEC TRIAGE
==================
A triage reference for a reported phishing or business email compromise
(BEC) case: how to keep the message as evidence, read its headers and parts
safely, and check the mailbox and the cloud tenant for what an attacker did
next (inbox rules, forwarding, delegates, app consents, mail access). It is
for investigation and response, NOT for sending phishing. Sections 1 to 5
work on a saved .eml file with python3 and dig; sections 6 to 9 assume read
access to Exchange Online / Microsoft Entra or Google Workspace.
Basic header reading and SPF/DKIM/DMARC are in the Email Header Analysis
sheet; the general account response order is in the Account Compromise
Triage sheet. All names below (example.org, alice@example.org,
supplier.example) are placeholders.
1. PRESERVE THE MESSAGE (ATT&CK T1566)
---------------------------------------
Get the original, with full headers, before anyone deletes or "cleans" it:
Outlook / Exchange: ask the user to forward it AS AN ATTACHMENT, or pull
it from the mailbox as an admin; a normal forward
rewrites the headers.
Gmail: More (three dots) > Show original > Copy to clipboard,
then save the text as msg.eml.
Inspect:
sha256sum msg.eml | tee msg.eml.sha256
chmod a-w msg.eml
Exchange Online message trace (who else got it; output is in UTC):
Get-MessageTraceV2 -SenderAddress bad@supplier.example `
-StartDate (Get-Date).AddDays(-10) -EndDate (Get-Date)
Get-MessageTraceV2 -MessageId '<id@supplier.example>' `
-StartDate (Get-Date).AddDays(-10) -EndDate (Get-Date)
Notes: Get-MessageTraceV2 replaces Get-MessageTrace. It covers 90 days, at
most 10 days per query, and only 48 hours if you give no dates; default
1,000 results, maximum 5,000 (-ResultSize). Google Workspace: Admin console
> Reporting > Email Log Search (no message content; older than 30 days
needs the recipient and the message ID).
Flags: the same Message-ID or sender delivered to many people (a campaign,
not a one-off); the user already replied or clicked.
2. READ THE HEADERS (ATT&CK T1684.002)
---------------------------------------
Inspect (prints the Received chain oldest hop first, then the key headers):
python3 -I -c 'if 1:
import sys, email
from email import policy
m = email.message_from_binary_file(open(sys.argv[1], "rb"),
policy=policy.default)
for h in reversed(m.get_all("Received", [])): # oldest hop first
print("Received:", " ".join(h.split()))
for k in ("Authentication-Results", "ARC-Seal",
"ARC-Authentication-Results", "Return-Path", "From",
"Sender", "Reply-To", "Message-ID", "In-Reply-To",
"References", "Date"):
for v in m.get_all(k, []): # top (newest) first
print(k + ":", " ".join(str(v).split()))
' msg.eml
Rules for reading the results:
- Trust only the Authentication-Results header added by YOUR own
mail system (its authserv-id, the first word, is your server name).
Anything lower down was written by the sender side and can be fake;
RFC 8601 says a receiver must delete such headers that claim its own
authserv-id but did not come from a trusted server.
- dmarc=pass only proves the message really came from the domain shown
in header.from. A lookalike domain passes SPF, DKIM and DMARC easily.
- ARC (ARC-Seal cv=none/pass/fail, instance i=) records results seen by
forwarders and mailing lists. RFC 8617 is Experimental; treat it as a
hint, not proof.
- Received lines below your own border server are written by the sender
and can be forged; the first hop your server added is the real source.
Flags: header.from of a lookalike domain; an Authentication-Results lower
in the list that disagrees with yours; Return-Path, From and Reply-To on
three different domains; a Date far from the Received times.
3. SENDER DOMAIN DNS (ATT&CK T1583.001)
----------------------------------------
Inspect (replace example.com with the domain in header.from):
dig +short TXT example.com # SPF: v=spf1 ...
dig +short TXT _dmarc.example.com # DMARC policy
dig +short MX example.com # "0 ." = null MX
dig +short TXT default._domainkey.example.com # DKIM key (s= selector)
Notes:
- DMARC is now RFC 9989 (May 2026), which obsoletes RFC 7489. Tags: p=
(domain policy), sp= (subdomains), np= (non-existent subdomains), t=y
(testing mode), psd=. The old pct=, rf= and ri= tags were removed.
- A DKIM record with an empty p= means the key was revoked. Keys can be
rotated after the message was sent, so a DKIM check repeated days
later can fail on a message that passed on delivery.
- Check when the domain was registered (registry RDAP or WHOIS); a domain
created days before the message is a strong lookalike sign.
Flags: a domain with no MX that still sends you mail; p=none or no DMARC
record on a domain that claims to be a bank or supplier; a domain that is
new, or differs from the real partner by one character or one TLD.
4. LOOKALIKE ADDRESSES AND DISPLAY NAMES (ATT&CK T1684.001)
------------------------------------------------------------
Inspect (decodes each address and flags the usual tricks):
python3 -I -c 'if 1:
import sys, email
from email import policy
m = email.message_from_binary_file(open(sys.argv[1], "rb"),
policy=policy.default)
sys.stdout.reconfigure(errors="backslashreplace")
for k in ("From", "Sender", "Reply-To"):
for a in getattr(m[k], "addresses", ()):
d, n = a.domain, a.display_name
print(k, repr(n), a.addr_spec,
"" if d.isascii() else "non-ASCII domain",
"" if n.isascii() else "non-ASCII name",
"xn--" if "xn--" in d else "",
"@ in name" if "@" in n else "")
' msg.eml
Decode a punycode (xn--) domain and name every non-ASCII letter in it:
python3 -I -c 'if 1:
import sys, unicodedata as u
for c in sys.argv[1].encode().decode("idna"):
if not c.isascii(): print(c, u.name(c, "?"))
' xn--suppler-vog.example
# prints: і CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I
Flags: a display name that is a person or a supplier you know but the
address is a free-mail or new domain; an address inside the display name
("ceo@example.org" <x@supplier.example>); Cyrillic or Greek letters that
look Latin; digit-for-letter swaps (1 for l, 0 for o, rn for m).
5. LINKS AND FILES (ATT&CK T1566.001, T1566.002, T1204.001, T1204.002)
-----------------------------------------------------------------------
Read-only: never open parts on your workstation. Save them named by hash:
mkdir -p parts && python3 -I -c 'if 1:
import sys, email, hashlib, re
from email import policy
m = email.message_from_binary_file(open(sys.argv[1], "rb"),
policy=policy.default)
A = re.compile(r"<a\s[^>]*href=[\"\x27]([^\"\x27]+)[\"\x27][^>]*>"
r"(.*?)</a>", re.I | re.S)
for p in m.walk():
if p.is_multipart():
continue
b = p.get_payload(decode=True) or b""
h = hashlib.sha256(b).hexdigest()
print(p.get_content_type(), p.get_filename(), len(b), h)
open("parts/" + h, "wb").write(b) # named by hash, not name
if p.get_content_type() == "text/html":
t = b.decode(p.get_content_charset() or "utf-8", "replace")
for href, text in A.findall(t):
print(" href", href, "| shown", re.sub("<[^>]+>", "", text))
' msg.eml
file parts/* # real type, not the name
python3 -I -m zipfile -l parts/<sha256> # list a zip, do not extract
Notes: the regex is a quick triage aid, not a full HTML parser; read the
HTML part as text too. Search the hashes in your mail gateway and EDR;
submit to a sandbox only if your policy allows sharing the file.
Flags: link text showing one domain while href points to another; double
extensions (invoice.pdf.html, invoice.pdf.exe); HTML, ISO, IMG, LNK, ZIP
or password-protected archives; a declared type that "file" disagrees
with; login pages or QR codes in an invoice.
6. EXCHANGE RULES, FORWARDING (ATT&CK T1114.003, T1564.008, T1098.002)
-----------------------------------------------------------------------
Inspect (Exchange Online PowerShell, read-only):
Get-InboxRule -Mailbox alice@example.org -IncludeHidden |
Format-List Name,Enabled,RedirectTo,Forward*,Identity
Get-InboxRule -Identity "<Identity>" -IncludeHidden | Format-List
Get-Mailbox -Identity alice@example.org |
Format-List Forwarding*Address,DeliverTo*
Get-MailboxPermission -Identity alice@example.org | Format-List
Get-RecipientPermission -Identity alice@example.org # SendAs
Get-RecipientPermission -Trustee bob@example.org # what bob has
Graph alternative: Get-MgUserMailFolderMessageRule -UserId <id>
-MailFolderId inbox (actions: forwardTo, forwardAsAttachmentTo,
redirectTo, moveToFolder, markAsRead, delete, permanentDelete).
Notes: without -IncludeHidden some rules are not shown. Get-InboxRule does
not work for the View-Only Organization Management role group or the
Global Reader role. If both ForwardingAddress and ForwardingSmtpAddress are
set, only ForwardingAddress is used. Outlook client rules can also start
an application and custom forms (IPM.Note.<name>) can run code; Microsoft
documents this attack separately.
Flags: rules with ForwardTo, ForwardAsAttachmentTo or RedirectTo outside
your domain; DeleteMessage, SoftDeleteMessage, MarkAsRead or MoveToFolder
(RSS Feeds, Archive, Conversation History) with words such as invoice,
payment, bank, wire or the CEO's name; short or dot-only rule names; a
ForwardingSmtpAddress you did not set (DeliverToMailboxAndForward $true
keeps a copy, so the user notices nothing); FullAccess or SendAs for an
account that should not have it.
7. EXCHANGE AUDIT LOG AND MAIL ACCESS (ATT&CK T1114.002, T1070.008)
--------------------------------------------------------------------
Check that auditing is on and nobody is excluded:
Get-OrganizationConfig | Format-List AuditDisabled # False = on
Get-Mailbox -Identity alice@example.org | Format-List DefaultAuditSet
Get-MailboxAuditBypassAssociation -ResultSize unlimited |
Where-Object AuditBypassEnabled | Format-Table Name,AuditBypassEnabled
Inspect (unified audit log; Search-MailboxAuditLog is being retired in
the cloud):
$s = (Get-Date).AddDays(-30); $e = Get-Date
$ops = 'New-InboxRule', 'Set-InboxRule', 'UpdateInboxRules', 'Send',
'SendAs', 'SendOnBehalf', 'SoftDelete', 'HardDelete',
'MoveToDeletedItems'
Search-UnifiedAuditLog -StartDate $s -EndDate $e `
-UserIds alice@example.org -Operations $ops -ResultSize 5000
Search-UnifiedAuditLog -StartDate $s -EndDate $e `
-RecordType ExchangeAdmin -FreeText alice@example.org -ResultSize 5000
Search-UnifiedAuditLog -StartDate $s -EndDate $e `
-UserIds alice@example.org -Operations MailItemsAccessed `
-ResultSize 1000 |
Where {$_.AuditData -like '*"MailAccessType","Value":"Sync"*'}
# same with "Bind" for single messages read
Notes: -ResultSize defaults to 100 (max 5,000); for more, use one
-SessionId with -SessionCommand ReturnLargeSet (up to 50,000, unsorted)
and repeat the call. A new record can take 30 minutes or more to appear.
MailItemsAccessed covers POP, IMAP, MAPI, EWS, Exchange ActiveSync and
REST; Sync records come only from desktop Outlook and mean the whole
folder was downloaded (what was read offline after that is not logged);
Bind records carry the InternetMessageId and are grouped per 2 minutes.
Compare ClientIPAddress, ClientInfoString and SessionId with the user's
normal sign-ins.
Flags: New-InboxRule or Set-Mailbox -ForwardingSmtpAddress from an IP the
user never used; a Sync from an unknown client or IP (treat the whole
folder as read); SoftDelete or HardDelete of the attacker's sent mail;
AuditBypassEnabled True for a user; Send from the mailbox just before a
payment change request reached a customer.
8. ENTRA SIGN-INS AND APP CONSENTS (ATT&CK T1078.004, T1528, T1550.001)
------------------------------------------------------------------------
Inspect (Microsoft Graph PowerShell, AuditLog.Read.All; UPNs lowercase):
$f = "userPrincipalName eq 'alice@example.org' and " +
"createdDateTime ge 2026-10-01T00:00:00Z"
Get-MgAuditLogSignIn -All -Filter $f |
Format-Table CreatedDateTime,IpAddress,AppDisplayName,ClientAppUsed
Get-MgAuditLogSignIn -All -Filter "clientAppUsed eq 'IMAP'"
Get-MgUserOauth2PermissionGrant -UserId alice@example.org |
Format-List ClientId,ConsentType,ResourceId,Scope
Get-MgAuditLogDirectoryAudit -All `
-Filter "activityDisplayName eq 'Consent to application'"
Get-MgAuditLogDirectoryAudit -All `
-Filter "activityDisplayName eq 'Add service principal credentials'"
Notes: ClientId of a grant is the object ID of the app's service principal
(Get-MgServicePrincipal -ServicePrincipalId <ClientId>). ConsentType
AllPrincipals means an admin consented for every user. Legacy clients
show as IMAP, POP, SMTP, MAPI, Exchange ActiveSync or "Other clients" in
clientAppUsed.
Flags: a successful sign-in from a new country, hosting provider or
anonymiser just before the rule was created; legacy-protocol sign-ins;
a new app with Mail.Read, Mail.ReadWrite, Mail.Send, MailboxSettings or
offline_access scopes; an unknown publisher; new credentials added to an
existing app.
9. GOOGLE WORKSPACE (ATT&CK T1114.003, T1528)
----------------------------------------------
Admin console (needs the Audit & Investigation privilege):
Reporting > Audit and investigation > Gmail log events / OAuth log
events / User log events (the default view is the last 7 days).
Reports API (read-only):
GET https://admin.googleapis.com/admin/reports/v1/activity/users/all/
applications/user_accounts?eventName=email_forwarding_out_of_domain
GET .../activity/users/all/applications/token?eventName=authorize
GET .../activity/users/all/applications/login?eventName=suspicious_login
Directory and Gmail API (per user):
GET https://admin.googleapis.com/admin/directory/v1/users/{userKey}/tokens
GET https://gmail.googleapis.com/gmail/v1/users/{userId}/settings/
autoForwarding | filters | forwardingAddresses | delegates | sendAs
Notes: reading another user's Gmail settings needs a service account with
domain-wide delegation. The sendAs entries carry replyToAddress, which an
attacker can change.
Flags: email_forwarding_out_of_domain events; filters that forward, delete
or skip the inbox; a new delegate; a sendAs replyToAddress outside your
domain; authorize events for an unknown app with Gmail scopes;
suspicious_login or account_disabled_hijacked for the user.
10. BEC SIGNS IN THE CONVERSATION (ATT&CK T1657, T1684.001, T1534)
-------------------------------------------------------------------
Inspect: read the thread with the finance or sales person, side by side
with the headers from section 2.
Flags:
- A change of bank details, a new account "for an audit", or a request
to pay an invoice early, with pressure or a request for secrecy.
- A reply in an old, real thread (In-Reply-To and References point at
real Message-IDs) but sent from a lookalike domain or with a new
Reply-To: the attacker read the real conversation first.
- A new participant added to the thread, or the real contact dropped.
- The request came from an internal account (internal spearphishing):
check that mailbox with sections 6 to 8.
- Gift cards, crypto or a "confidential acquisition".
Response (FBI IC3 advice): verify any payment or account change by phone
on a number you already had, never one from the email. If money was sent,
call your bank at once and ask for a recall, then report it to the police
or your national reporting centre.
11. CONTAIN (CHANGES STATE; record everything above first)
-----------------------------------------------------------
Exchange Online / Entra:
Revoke-MgUserSignInSession -UserId alice@example.org
Update-MgUser -UserId alice@example.org -AccountEnabled:$false
Disable-InboxRule -Identity "<Identity>" # keep it; Remove- deletes
Set-Mailbox -Identity alice@example.org -ForwardingSmtpAddress $null `
-ForwardingAddress $null -DeliverToMailboxAndForward $false
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId <id>
New-TenantAllowBlockListItems -ListType Sender -Block `
-Entries supp1ier.example -NoExpiration
Tenant-wide, stop silent external forwarding:
Set-HostedOutboundSpamFilterPolicy -Identity Default `
-AutoForwardingMode Off
Notes: session revocation can take a few minutes and does not cover
external (guest) users. A password reset does not revoke app passwords.
For accounts synced from Active Directory, reset the password in AD
(Microsoft advises twice). Do not send the new password by email.
AutoForwardingMode Off blocks external auto-forwarding for cloud mailboxes
(the sender gets an NDR); internal forwarding still works.
Google Workspace: Users > the user > Security: reset sign-in cookies,
revoke connected applications and application-specific passwords, then
reset the password. Resetting cookies does not sign the user out of apps
such as Gmail or Drive for desktop; suspend the user if you must cut
everything.
Then follow the Account Compromise Triage sheet (MFA re-registration,
devices, other services that used the same password).
QUICK TRIAGE ORDER
------------------
1. Save the original message, hash it, find every recipient (section 1).
2. Read your own Authentication-Results and the From/Reply-To/Return-Path
domains; check the domain's age and DNS (sections 2 to 4).
3. Extract links and attachments by hash; search them in the gateway and
EDR; find who clicked or opened (section 5).
4. If anyone typed a password or approved a prompt: inbox rules (hidden
too), forwarding, delegates and audit log for that mailbox (6 and 7).
5. Sign-ins and app consents for the same window (section 8 or 9).
6. Check the conversation for payment changes and call the partner on a
known number; if money moved, call the bank now (section 10).
7. Contain: revoke sessions, remove forwarding and rules (after recording
them), revoke grants, block the sender (section 11).
Header parsing and DNS steps were run with Python 3 and dig on synthetic
test messages that use only reserved example names. Cmdlets, parameters,
API paths and audit operation names were checked against Microsoft Learn
(Exchange Online, Microsoft Graph and Purview audit docs) and Google
Workspace Admin Help and API references, but not run against a live
tenant. Standards: RFC 5322, RFC 7208 (SPF), RFC 6376 (DKIM), RFC 9989
(DMARC), RFC 8601 and RFC 8617 (ARC). BEC advice per FBI IC3 PSA
I-091124-PSA; technique IDs per MITRE ATT&CK Enterprise v20.
Defensive reference on CyberRamen. Offensive / red-team sheets live on OffensiveRamen.com.