During a routine penetration test for a mid-size company, our security team found something that made the client's CTO go silent on the call for about ten seconds.
We had just sent an email to the company's finance director from the CEO's email address. It asked for an urgent wire transfer to a new vendor. It landed in the inbox. No spam filter, no warning banner, no "this email might be suspicious" notice. It looked exactly like every other email the CEO had ever sent.
We did it from a laptop in Porto, with a single command and no access to the company's email infrastructure. No passwords, no hacked accounts, no insider access. Just a misconfigured DNS record that had been sitting in plain sight for over three years. From the DNS lookup to the delivered email, the whole thing took less than two minutes.
Here is why it worked, and how to close the hole.
What We Found
The company ran a normal setup: a hosted mailbox platform for staff, a transactional email service for automated notifications, and a marketing platform for newsletters. Their DNS had three problems:
- DMARC was set to
p=none. The policy existed, but it told receivers to take no action when a message failed. - SPF ended in a softfail (
~all) instead of a hard fail (-all). An unauthorized server earned a "suspicious" verdict, not a rejection. - DKIM was not configured for all their sending services.
We connected to the company's mail server, found through the public MX record, on port 25, which must be open for email to work. The receiving server checked SPF and saw a softfail, checked DMARC and saw p=none, and delivered the message. Anyone on the internet could have done the same.
The DMARC record decided the outcome: of the three, it is the only one that speaks about the address the recipient sees. It is the hole behind Business Email Compromise (BEC): fake supplier payments, fake bank-detail changes, no hacked account needed.
How the Spoof Works
An email carries two sender addresses:
- The envelope sender, the
MAIL FROMof the SMTP conversation, later stored asReturn-Path. The recipient never sees it. - The header From, the address the mail client displays. SMTP does not check it: the sender can write anything there.
Each standard checks something different:
- SPF checks whether the connecting server may send for the domain in the envelope sender. It never looks at the header From.
- DKIM checks that the message was signed with a key published under some domain and not changed since. That domain, the
d=value in theDKIM-Signatureheader, can be any domain. - DMARC asks whether a passing SPF or DKIM result belongs to the domain in the header From. That match is called alignment: a message passes DMARC if SPF passes and the envelope domain aligns with the From domain, or if DKIM passes and the
d=domain aligns with it. The policy (p=none,quarantineorreject) tells the receiver what to do when neither happens.
This is why SPF alone cannot stop the attack, even with -all. An attacker can put their own domain in the envelope, with an SPF record that passes, and your CEO's address in the From header. Only DMARC notices the pass belongs to someone else.
Received messages record the verdicts in their Authentication-Results header. A spoof of an unprotected domain:
Authentication-Results: mx.example.net;
spf=pass smtp.mailfrom=attacker.example.org;
dkim=none;
dmarc=fail header.from=example.comSPF passed, for the wrong domain; DMARC failed. Delivery now depends on the policy example.com publishes.
Check Your Own Domain
Three lookups show what an attacker sees (replace example.com with your domain):
dig +short TXT example.com
dig +short TXT _dmarc.example.com
dig +short TXT s2026a._domainkey.example.comThe first returns every TXT record at your domain. Exactly one line should start with "v=spf1; two is an error. One line of several quoted strings is fine: long records are split into 255-character strings that receivers rejoin. Then read the ending: -all (fail), ~all (softfail), ?all (neutral) or +all (pass for anyone).
The second returns your DMARC record, such as "v=DMARC1; p=none". No answer means no DMARC: receivers apply their own judgment and nobody sends you reports. Two v=DMARC1 answers mean receivers apply no DMARC policy at all. Anything short of p=reject, or quarantine on the way there, leaves the door open.
The third needs a selector, and DNS cannot list selectors. Take one from a real message: in its DKIM-Signature header, s=s2026a; d=example.com means the key lives at s2026a._domainkey.example.com. Repeat for each system that sends as your domain. The answer should contain v=DKIM1 and a long p= value; a CNAME to your provider prints its target first. An empty p= means a revoked key.
Knowing every system that belongs in these records is the hard part. Request a free email-spoofing assessment and our security team will pull your SPF, DKIM and DMARC records, count your SPF lookups, list every service sending as your domain and tell you whether it can be spoofed today.
SPF: Where It Quietly Breaks
A correct SPF record lists every system allowed to send as your domain, then a verdict for everything else:
example.com. IN TXT "v=spf1 include:_spf.mail.example.net include:spf.crm.example.org ip4:203.0.113.25 -all"Four mistakes account for most broken SPF:
- Two SPF records on one name. Adding a sender as a second
v=spf1record instead of editing the first makes SPF returnpermerror. Merge them. +allor?all. The first authorizes the entire internet. The second asserts nothing.- The 10-lookup limit.
include,a,mx,ptr,existsandredirecteach count as one lookup, including those inside records you include;ip4,ip6andalldo not count. More than 10 and the result ispermerror. - Forgotten senders. Invoicing, CRM, ticketing, the scanner that emails PDFs, monitoring alerts, the website's contact form. On WordPress that form usually runs through a plugin, which is also part of the site's attack surface.
To count, run dig +short TXT on each include target. Say your record has three includes and one mx: four lookups. If the mailbox platform's include holds three more, you are at seven. Near the limit, drop unused senders, move bulk senders to a subdomain with its own record, or replace a and mx with your own ip4 ranges. Never paste a vendor's IPs into your record: they change without notice.
Once DMARC enforces, ~all and -all are both defensible. Some receivers act on an SPF hard fail before evaluating DMARC, rejecting forwarded mail that DKIM would have saved, so some teams keep ~all. What fails is ~all without DMARC enforcement, the state we found.
Many hosted senders also put their own bounce domain in the envelope, so SPF passes for them, unaligned. A custom bounce domain under yours fixes that; DKIM as your domain is more robust.
DKIM: One Selector per Sender
DKIM usually survives plain forwarding where SPF does not, so it is the alignment route to rely on:
- One selector per sending system. The mailbox platform signs with
s2026a, the CRM withcrm2026. Systems never share a key, and offboarding a vendor means deleting one record. - Sign as your domain. Many platforms sign with their own domain until you set up custom domain signing. That signature passes but does not align:
d=should beexample.comor one of its subdomains. - 2048-bit RSA keys. The DKIM standard requires at least 1024 bits and recommends 2048.
To check the length of a published key:
dig +short TXT s2026a._domainkey.example.com | tr -d '" \n' | sed -n 's/.*;p=\([^;]*\).*/\1/p' | openssl base64 -d -A | openssl pkey -pubin -inform DER -noout -text | head -1Look for Public-Key: (2048 bit) in the output.
Rotate keys on a schedule you can keep, twice a year for example, and at once if a key may have leaked:
- 1Publish a new key under a new selector (
s2026b). - 2Once the DNS TTL has expired, switch signing to it.
- 3Keep the old record for a week or so, so delayed mail still verifies, then revoke it with an empty
p=or delete it.
DMARC: From p=none to p=reject Without Losing Mail
p=none is meant to be temporary: it shows who sends as your domain before you ask receivers to block anyone. Left in place, it reports the attack and delivers it.
Stage 1: Monitor
_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com"Receivers that support reporting send aggregate reports to the rua address, usually daily, as compressed XML. A mailbox on another domain must be authorized there with a TXT record "v=DMARC1" at example.com._report._dmarc.reports.example.net, or no reports arrive.
Stage 2: Read the Reports
Each record shows how many messages a source IP sent with your domain in the From, and how they fared:
<record>
<row>
<source_ip>198.51.100.42</source_ip>
<count>318</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>fail</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>example.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>crm.example.org</domain>
<result>pass</result>
</dkim>
<spf>
<domain>bounce.crm.example.org</domain>
<result>pass</result>
</spf>
</auth_results>
</record>That is a legitimate sender failing DMARC: the CRM signs and bounces with its own domain, so both checks pass and neither aligns. Sort every source into four groups:
- Yours and aligned: done.
- Yours and failing: your work list. Fix each with DKIM as your domain or a custom bounce domain.
- Forwarders and mailing lists: SPF breaks on forwarding; DKIM survives unless the message is modified, as lists often do. Not yours to fix.
- Unknown: look up the IP (
dig +short -x 198.51.100.42). It is a sender nobody told you about or someone spoofing you; know which before you enforce.
An open-source parser such as parsedmarc makes the XML searchable. Stay here until the work list has been empty for a full month, so month-end invoicing shows up.
Stage 3: Quarantine, Gradually
_dmarc.example.com. IN TXT "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-reports@example.com"pct=25 quarantines a quarter of failing messages; the rest get the next weaker policy, here none. Raise it to 50, then 100 (the default), reading the reports at each step.
Stage 4: Reject
_dmarc.example.com. IN TXT "v=DMARC1; p=reject; sp=reject; rua=mailto:dmarc-reports@example.com"Two tags deserve a decision:
sp=sets the policy for subdomains without their own DMARC record; leave it out and they inheritp=. A spoof frombilling.example.com, a name that never sent mail, has no SPF or DKIM to pass, so the subdomain policy stops it. An explicitsp=rejectholds while you adjustp=.adkim=andaspf=set alignment. Both default to relaxed (r), wheremail.example.comaligns withexample.com. Strict (s) demands an exact match; choose it only when every sender uses exactly your domain.
Domains That Send No Mail
Parked domains, defensive typo domains and old brand domains that only redirect the website are easy to spoof because nobody watches them. Declare that they send and receive nothing:
example.org. IN MX 0 .
example.org. IN TXT "v=spf1 -all"
_dmarc.example.org. IN TXT "v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s; rua=mailto:dmarc-reports@example.com"
example.org._report._dmarc.example.com. IN TXT "v=DMARC1"The null MX (RFC 7505) says the domain accepts no mail, so senders fail at once instead of retrying. SPF authorizes no server, DMARC rejects anything claiming the domain or its subdomains, and the last line, in your main zone, lets the reports reach your mailbox.
After p=reject: Keep Watching
Mail setups drift:
- Keep
ruaforever. Review reports weekly; alert on a new source failing in volume, or a legitimate sender whose pass rate drops. - Gate new senders. Before a new tool sends as your domain: DKIM as your domain, SPF lookups recounted, and a test message showing
dmarc=pass. - Offboard senders. Remove the include and the selector when the contract ends.
- Control DNS changes. A second SPF record often comes from a hurried registrar-panel edit. If you run your own BIND 9 servers, DNS Manager, Privum's open-source web interface for BIND 9, puts record edits behind role-based access and an audit trail.
- Check from the outside. Run this from cron or CI against every domain you own.
#!/usr/bin/env bash
# Usage: check-mail-auth.sh example.com (exits 1 on failure)
set -eu
d="$1"
dmarc=$(dig +short TXT "_dmarc.$d")
dmarc_n=$(echo "$dmarc" | grep -ci '^"v=DMARC1' || true)
spf=$(dig +short TXT "$d" | grep -ci '^"v=spf1' || true)
[ "$dmarc_n" -eq 1 ] || { echo "FAIL $d: $dmarc_n DMARC records, expected 1"; exit 1; }
echo "$dmarc" | grep -Eq ';[[:space:]]*p=reject' || { echo "FAIL $d: DMARC is not at p=reject"; exit 1; }
[ "$spf" -eq 1 ] || { echo "FAIL $d: $spf SPF records, expected 1"; exit 1; }
echo "OK $d"Records can look right and still leave a gap, such as a subdomain with its own permissive DMARC record. The proof is a spoof attempt against your mail server, which is how we found this one in a penetration test.
Find out whether your domain can be spoofed before someone else does. Book a free email-spoofing assessment: we check SPF, DKIM and DMARC on every domain you own, parked ones included, identify each service that sends as you, and hand you a staged plan to p=reject that keeps invoices and newsletters flowing.