Email Analysis: Dissecting a Phishing Email.
Phishing triage is a daily part of my job. Someone reports a suspicious email, a ticket lands in the queue, and I have a few minutes to decide whether it gets purged from every mailbox in the company or handed back with a note saying it's fine. After a few hundred of these, the process settles into a routine. This post is that routine written down.
Two rules before anything else, because they matter more than any technique below. Never
click the link and never open the attachment. All of the analysis here happens on headers,
hashes and sandboxes, not on the live payload. And every indicator in this post is
defanged: hxxps:// instead of https://, and evil[.]com instead of the clickable version. Copy that habit. A live URL
pasted into a ticket becomes a clickable link for whoever reads it next. And every
brand, domain and address in the examples here is made up.
Reading the headers
The body of an email is what the sender wants you to see. The headers are what the mail servers wrote down while passing it along, and servers are worse liars than people. Every client can show them: Gmail calls it Show original, Outlook calls it View message source. That raw text is where the analysis starts.
From proves nothing. It's a text field the sender fills in, the same way you can write any name you like on the back of an envelope. Every spoofed email has a convincing From line. That's the whole point of spoofing.
Return-Path is more honest. During the SMTP handshake the sending server
declares an address for bounces, called the envelope sender, and your receiving server
writes it into the message on delivery. The reader never sees this field, so attackers
often don't bother making it pretty. When From says service@acmepay.com and
Return-Path says x9k2@bounce.secure-notify[.]top, the mail is telling on
itself. One caveat from real triage: bulk senders like SendGrid and Mailchimp legitimately
use their own bounce domains, so treat a mismatch as a lead, not a conviction.
Reply-To decides where your reply actually goes. The classic business email compromise pattern is a director's name in From and a free Gmail address in Reply-To, so the conversation quietly moves into the attacker's mailbox. Newsletters and CRM platforms use Reply-To legitimately, which is why I look at the pair: an executive display name plus a freemail reply address is a strong signal on its own.
Received headers are the travel log. Every server that handles the message adds one to the top, so you read them bottom to top. Here is the part nobody tells beginners: the sender controls everything below the moment the message reached your infrastructure, and attackers do insert fake Received lines to look respectable. The only hops you can trust are the ones your own servers wrote, and the connecting IP recorded at that handoff is the honest answer to where the email really came from.
Message-ID is a unique tag stamped by the sending system, and the part
after the @ normally matches the sending infrastructure. Mail claiming to be from AcmePay
with a Message-ID minted on srv2.secure-notify[.]top has some explaining to
do. It's forgeable, so it stays supporting evidence, never the verdict.
Authentication-Results is where your own gateway records the SPF, DKIM and DMARC verdicts. Note the wording: your own gateway. Attackers sometimes pre-insert a fake copy of this header full of reassuring pass results, which is exactly why gateways strip inbound copies that claim to be theirs. Trust the one your infrastructure stamped and ignore any others.
Here's what a bad day looks like in seven lines:
Authentication-Results: spf=pass (sender IP is 203.0.113.11)
smtp.mailfrom=bounce.secure-notify.top; dkim=none;
dmarc=fail header.from=acmepay.com
From: "AcmePay Support" <service@acmepay.com>
Reply-To: <billing.desk.verify@gmail.com>
Return-Path: <x9k2@bounce.secure-notify.top>
Message-ID: <20260901071542.9A3F1B@srv2.secure-notify.top> Four problems in one glance: SPF passed, but for the attacker's own bounce domain, not AcmePay. DMARC failed against the domain the reader actually sees. Replies go to a Gmail address. And the Message-ID was minted on the attacker's server. Any one of these is worth a closer look. All four together is a verdict.
SPF, DKIM and DMARC, without the acronym soup
These three verdicts sit in every Authentication-Results header and confuse people endlessly, so here is the version I give non-technical colleagues.
SPF is a guest list. A domain publishes the list of servers allowed
to send mail on its behalf. The list lives in DNS, the internet's public directory, so
anyone can look it up. When a message arrives, the
receiving server checks whether the server that delivered it is on the list. On the list,
pass. Not on the list, fail. The trap: SPF checks the Return-Path domain, not the From
address a human reads. That's how the AcmePay fake above earned its spf=pass.
The attacker's own domain vouched for the attacker's own server, which is technically
true and completely meaningless.
A real SPF record is one line of DNS:
v=spf1 include:_spf.google.com ~all Read it as "Google's servers may send for me, and everything else gets a soft no". That squiggle at the end matters more than you'd think.
The ~all versus -all question. The record
ends with a symbol that tells receivers what to do about every server that isn't on the
list. There are four options:
| Qualifier | Result | What the receiver does |
|---|---|---|
+ | pass | Server is authorized |
? | neutral | Domain takes no position, rare in practice |
~ | softfail | "Probably not authorized." Accept, but hold it against the message in spam scoring |
- | fail | "Not authorized." Receivers may refuse the message outright |
So ~all is a soft no and -all is a hard no. You would expect
everyone to run the hard no, and yet most domains use ~all. The reason is
forwarding. A forwarded message arrives from the forwarding server's address, which
the original domain never listed, and a strict -all would get real mail
rejected. Domains soften the SPF answer instead and let DMARC, which I'll get to in a
moment, make the final call.
DKIM is a tamper seal. The sending server stamps each message with a
seal only it can produce, and publishes the pattern for checking that seal where any
receiving server can look it up. The seal covers the important headers and the body, so
if anything changes in transit, the seal breaks. For the technical readers: the seal is
a signature made with a private key, and the public key sits in DNS at <selector>._domainkey.<domain>. Two things DKIM does not prove:
that the mail is safe, or that the visible From is real. An attacker can seal flawlessly
with their own domain. dkim=pass only means "this domain signed it and
nobody has edited it since".
DMARC ties the two together. It's a policy the domain owner publishes,
and it reads like a standing order: "if a message claims to be from us in the From
header, then SPF or DKIM must pass for our domain, not somebody else's, and here is what
to do when neither does." The options are p=none (deliver it, just report
it), p=quarantine (spam
folder) and p=reject (refuse it). DMARC is the only one of the three that
looks at the From address a human actually sees, which makes dmarc=fail the
single most damning line you'll find in a header.
Now the uncomfortable part: all three can pass on a phish. A compromised mailbox sends through real infrastructure with real keys, and a lookalike domain registered yesterday carries its own perfectly valid SPF, DKIM and DMARC. A display name costs nothing. Authentication verifies the envelope, not the intent, and I regularly mark emails as malicious that pass every check on this page.
The easy way to read headers
You don't have to eyeball raw headers every time. Copy them into the MxToolbox Email Header Analyzer and it lays out every relay hop with timing, then pulls the SPF, DKIM and DMARC results into a table you can read in ten seconds. It's the tool I point new analysts at first, and the quick second opinion I still use myself.
Two alternatives worth knowing: Google's Messageheader draws a clean hop-by-hop view of where delivery slowed down, and Microsoft's Message Header Analyzer is the best of the three at decoding the antispam headers Microsoft 365 stuffs into every message.
One warning applies to all of them: headers carry your internal hostnames, IPs and the recipient's address. Trim anything sensitive before pasting into a third-party site. These tools need the headers only, never the body.
The body and the links
Headers come first because they're objective, but the body is where you can feel the con. It's the same handful of levers in ticket after ticket. A generic greeting, "Dear Customer", from a company that bills you by name. Manufactured urgency: a 24-hour deadline, a suspended account, a missed parcel. And a request no legitimate company makes over email. Nobody real asks for your password or MFA code. Nobody real asks to be paid in gift cards.
Links get the closest look. The text of a link and its destination are completely
independent, so a link reading acmepay.com/verify can point anywhere. Hover
on desktop, long-press on mobile, and then read the actual domain from the end, right to
left. There's a diagram of this trick in the next section, because it fools more people
than any other.
Shorteners and open redirects are the same idea one layer deeper: the chain starts on a reputable domain and ends somewhere ugly, so judge the final destination, never the first hop. urlscan.io will follow the chain and screenshot the landing page without you ever touching it. Set the scan visibility to unlisted for anything work-related. Public scans are searchable by anyone, including the attacker, who may be watching for their own URLs. For decoding, extracting and defanging in bulk, CyberChef runs entirely in your browser and uploads nothing.
QR codes are the same trick in image form. Mail filters have only recently learned to decode them and still miss plenty, and the scan moves the click to a phone outside your company's controls.
Attachments: hash first, open never
The order matters. Hash the file first, Get-FileHash on Windows or sha256sum on Linux, then search that hash on VirusTotal.
A hash search sends nothing. Uploading is different: submitted files are shared with AV
vendors and downloadable by paying customers, so uploading a company invoice is a small
data breach you performed on yourself, and it can tip off an attacker watching for their
sample. If the hash is unknown and I need to see behavior, the file goes to a sandbox
like ANY.RUN,
never to my own machine. Keep in mind that free sandbox tiers make submissions public
too. Same rule as urlscan.
Extensions that make me sit up straighter: .html and .htm,
which are either a fake login page delivered as a file or HTML smuggling, where script in
the attachment assembles the real payload in the browser so the gateway only ever scanned
harmless-looking HTML. Then .iso and .img disk images, .lnk shortcuts inside ZIPs, the macro formats .docm and .xlsm, and the old classic double extension, invoice.pdf.exe,
with Windows helpfully hiding the second half.
And a clean VirusTotal result proves only that the file isn't already known to be malicious. Fresh payloads start the day at zero detections.
Phishing vs a normal email, side by side
Everything above condenses into one principle: legitimate email is consistent. The sender's domain, the greeting, the tone, where the links go and what the message asks you to do all agree with each other. Phishing always breaks somewhere, because the sender isn't who the mail claims to be.
- The display name says AcmePay. The address is on a domain AcmePay has never heard of.
- Panic in the subject line. Real statements don't shout.
- A generic greeting from a company that knows your name.
- A deadline and a threat, there to make you act before you think.
- The link text and its real destination disagree.
- An attachment nobody asked for, in a risky format.
Look closely at the domains themselves, because lookalikes are better than most people
think. A 1 swapped in for a lowercase l. acrnepay[.]com, where r and n sit
close enough together to read as m. Cyrillic letters that render identically to Latin
ones. And cousin domains like acmepay-security-alerts[.]com, which
technically impersonate nothing: registered normally, carrying their own valid SPF, DKIM
and DMARC, passing every authentication check while never touching AcmePay's actual
domain. And anything to the left of a registered domain is a subdomain, a prefix the
owner can name however they like, which is how acmepay.com ends up decorating an
attacker's address. This is why reading from the end is the habit worth building:
One more, since most email is read on phones now: mobile clients usually show only the
display name and hide the address entirely. On a phone, "AcmePay Support" is all anyone
sees. The real address, service@secure-notify[.]top, is one tap away, and
almost nobody makes that tap.
Making the call, and what happens after
The ticket leads with the verdict: phishing, spearphishing, legitimate, or occasionally inconclusive. Evidence goes in order of strength. Authentication and header findings first, then infrastructure (a domain registered nine days ago on a hosting provider that shows up in half our tickets), then the payload, then the wording of the body. What the email says is the weakest evidence. What the servers recorded is the strongest.
The indicators go into a table, defanged. This campaign ran two lures, the spoofed AcmePay header above and the lookalike sender from the diagram, so both end up in the same table:
| Type | Indicator | Context |
|---|---|---|
| Domain | bounce.secure-notify[.]top | Return-Path / SPF domain |
| IP | 203.0.113[.]11 | Delivering server |
| URL | hxxps://acmepay.com.account-verify[.]net/login | Credential harvesting page |
billing.desk.verify@gmail[.]com | Reply-To | |
| SHA-256 | a1b2c3... (attachment) | invoice.html |
One reported email is rarely one email, and this is where triage turns into analysis. I pivot in the SIEM (Stellar Cyber, in my case) on the sender domain, the source IP, the subject line and the attachment hash. Who else received this campaign, and did anyone click? The campaign is purged from every mailbox, the indicators blocked at the gateway, and anyone who clicked gets a credential reset and a conversation. The person who reported it gets a thank you, because reporting is the behavior you want more of.
The checklist
The whole post in one box, in the order I actually work:
- Get the raw email as a .eml file. Click nothing.
- From vs Return-Path vs Reply-To: do the domains agree?
- Received chain, bottom to top: which IP handed it to your infrastructure?
- Authentication-Results: SPF, DKIM, and above all DMARC against the From domain.
- Short on time? Paste the headers into MxToolbox.
- Links: real destination, read right to left, unwrap shorteners.
- Attachments: SHA-256, search VirusTotal, sandbox if unknown. Never open.
- Weigh it: headers beat infrastructure, infrastructure beats payload, payload beats prose.
- Write the verdict, defang the IOCs, close the ticket.
- Pivot: find every other recipient, purge, block, reset whoever clicked.
Most of this is learnable in a week of triage. The discipline of never clicking is the part you keep forever.