← All cheat sheets

EMAIL & BEC TRIAGE

Plain-text reference · 19 KB. Read it, search it (Ctrl-F) or print it.

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