Defensive Security · lesson 8 of 8
Intermediate
6- 2 Sigma Detection Rules: Catch SSH Brute Force and Map It to ATT&CK
- 3 Suricata on a Packet Capture: Write an IDS Rule That Fires
- 4 Alert Triage and Tuning: From 41 Alerts to the 3 That Matter
- 5 Incident Response: Preserve the Evidence, Then Contain
- 6 Building an Incident Timeline from Auth, Web and IDS Logs
- 7 Static File Analysis with EICAR and YARA: Hashes, file, strings and Rules
Also part of Defensive Security
6- + A Firing Alert Doesn't Mean Anyone Was Notified from Monitoring & Observability
- + MTTR Isn't a Guess — Compute It From Alertmanager's Own Timestamps from Monitoring & Observability
- + Logging Security Events Without Logging Secrets from Security Fundamentals
- + Watching the TCP Handshake with tcpdump from Networking
- + Host Firewalls with nftables: DROP Times Out, REJECT Refuses from Networking
- + Hunting in AWS CloudTrail Logs with Python from Cloud Security
Email Header and DKIM Triage: Signatures, Alignment and the Received Chain
Sign an email with DKIM locally, break it with a small edit, then triage a lookalike whose DKIM passes but isn't aligned with From. No real phishing.
By SkillByExample · Published
Reported phishing is a large share of a SOC’s daily queue, and the first look is always the same: the
headers. In this lesson you’ll create a signing key, DKIM-sign an email the way a mail server does,
and watch the signature fail when one word of the body changes. Then you’ll triage a lookalike message
whose DKIM check passes and is still a fake, and see which header fields give it away. Everything is
generated locally with reserved example domains; there are no real phishing samples. Tested with Docker
Engine 29.8.2, Python 3.12.15 (python:3.12-slim) and dkimpy 1.1.8.
DKIM in one paragraph
The sending domain’s mail server signs selected headers and a hash of the body with a private key, and
adds the result as a DKIM-Signature header. The public key is published in DNS as a TXT record at
<selector>._domainkey.<domain>. A receiving server fetches that record and checks the signature. A pass
proves two things: the signed parts haven’t changed since signing, and the domain in the signature’s
d= tag signed it. It doesn’t prove the domain in the visible From line had anything to do with it.
The lab
# Dockerfile
FROM python:3.12-slim
RUN pip install --no-cache-dir --root-user-action=ignore dkimpy==1.1.8
Build it as dkim-lab. Every command below runs as
docker run --rm -v "$PWD":/work -w /work dkim-lab <command>.
dkimpy ships dknewkey, which creates a 2048-bit RSA key and the DNS record that would publish it.
Create one key for example.com, the real sender, and one for example.net, the lookalike.
$ dknewkey lab
generating lab.key
extracting lab.key
writing lab.dns
$ cut -c1-60 lab.dns
v=DKIM1; k=rsa; h=sha256; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
$ dknewkey other
generating other.key
extracting other.key
writing other.dns
lab.dns is the TXT record’s value: version, key type, hash, and p=, the public key in base64. In
production it would be published as lab._domainkey.example.com. Here a small function plays the part of
DNS.
Signing and verifying
The script writes a short invoice email from example.com and signs it, as the sending server would.
include_headers lists the headers the signature covers.
# sign_email.py: write an email from example.com and DKIM-sign it, as its mail server would
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
import dkim
msg = EmailMessage()
msg["From"] = "Billing <billing@example.com>"
msg["To"] = "alice@example.org"
msg["Subject"] = "Invoice 4471 is due on Friday"
msg["Date"] = formatdate(usegmt=True)
msg["Message-ID"] = make_msgid(idstring="4471", domain="mail.example.com")
msg.set_content("Hello Alice,\n\nInvoice 4471 for $120.00 is due on Friday.\n\nBilling\n")
raw = msg.as_bytes()
signature = dkim.sign(raw, selector=b"lab", domain=b"example.com",
privkey=open("lab.key", "rb").read(),
include_headers=[b"From", b"To", b"Subject", b"Date", b"Message-ID"])
open("signed.eml", "wb").write(signature + raw)
print(signature.decode()[:200])
$ python sign_email.py
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.com;
i=@example.com; q=dns/txt; s=lab; t=1791966959; h=from : to : subject
: date : message-id; bh=4D91ZI9RPjW/n7keH8TqVUnqchCKR6hSSC
The tags tell a verifier everything it needs: d=example.com and s=lab say where to find the key,
h= lists the signed headers, and bh= is the hash of the body. c=relaxed/simple sets how strictly
whitespace is treated in the headers and the body. The signature itself, b=, follows; the print cuts it
off.
The verifier looks up the key with a local function in place of DNS, and reports why a check fails.
# verify.py: check each email's DKIM signature against our local "DNS" records
import sys
import dkim
RECORDS = {
b"lab._domainkey.example.com.": open("lab.dns", "rb").read().strip(),
b"lab._domainkey.example.net.": open("other.dns", "rb").read().strip(),
}
def local_dns(name, timeout=5): # stands in for the DNS TXT lookup
return RECORDS.get(name)
for path in sys.argv[1:]:
try:
ok = dkim.DKIM(open(path, "rb").read()).verify(dnsfunc=local_dns)
why = "" if ok else " (signature does not match the signed headers)"
except dkim.DKIMException as e:
ok, why = False, f" ({str(e).split(' (got')[0]})"
print(f"{path}: dkim={'pass' if ok else 'fail'}{why}")
$ python verify.py signed.eml
signed.eml: dkim=pass
$ sed 's/due on Friday\./due today./' signed.eml > edited.eml
$ python verify.py edited.eml
edited.eml: dkim=fail (body hash mismatch)
$ sed 's/^Subject: .*/Subject: URGENT: Invoice 4471 overdue/' signed.eml > subject.eml
$ python verify.py subject.eml
subject.eml: dkim=fail (signature does not match the signed headers)
Changing two words of the body changes its hash, so bh= no longer matches. Changing the subject breaks
the signature over the headers. dkimpy raises an exception for the first case and returns False for the
second, which is why verify.py handles both. Either way, a message altered after signing fails.
What DKIM doesn’t cover
The signature only covers the headers listed in h=. Add one that isn’t listed:
$ { echo 'Reply-To: payments@example.net'; cat signed.eml; } > replyto.eml
$ python verify.py replyto.eml
replyto.eml: dkim=pass
Still a pass, although replies now go to a different domain. Anyone who handles the message after signing
can add an unsigned header like this. Mail software is expected to add headers in transit, such as
Received, so DKIM leaves unsigned headers alone. The practice tasks show how a sender protects
Reply-To.
Triage: a lookalike that passes
Now the case analysts actually face. This message claims to be from billing@example.com but was sent
and signed by example.net, a domain its sender controls. The script also writes the Return-Path and
Received headers that receiving servers add, so the file looks the way it would in an inbox.
# phish.py: a lookalike email, signed by the sender's own domain, as it lands in the inbox
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
import dkim
msg = EmailMessage()
msg["From"] = "Billing <billing@example.com>"
msg["Reply-To"] = "payments@example.net"
msg["To"] = "alice@example.org"
msg["Subject"] = "Invoice 4471: our bank details have changed"
msg["Date"] = formatdate(usegmt=True)
msg["Message-ID"] = make_msgid(idstring="4471", domain="mail.example.net")
msg.set_content("Hello Alice,\n\nPlease pay invoice 4471 to our new account today.\n\nBilling\n")
raw = msg.as_bytes()
raw = dkim.sign(raw, b"lab", b"example.net", open("other.key", "rb").read()) + raw
now = formatdate(usegmt=True) # the receiving servers add these on the way in
hops = (f"Return-Path: <bounce@example.net>\n"
f"Received: from mx.example.org (mx.example.org [192.0.2.25])\n by mailbox.example.org with LMTP; {now}\n"
f"Received: from mail.example.net (mail.example.net [203.0.113.45])\n by mx.example.org with ESMTPS; {now}\n")
open("phish.eml", "wb").write(hops.encode() + raw)
print("wrote phish.eml")
$ python phish.py
wrote phish.eml
$ python verify.py phish.eml
phish.eml: dkim=pass
$ head -12 phish.eml
Return-Path: <bounce@example.net>
Received: from mx.example.org (mx.example.org [192.0.2.25])
by mailbox.example.org with LMTP; Wed, 14 Oct 2026 08:36:30 GMT
Received: from mail.example.net (mail.example.net [203.0.113.45])
by mx.example.org with ESMTPS; Wed, 14 Oct 2026 08:36:30 GMT
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.net;
i=@example.net; q=dns/txt; s=lab; t=1791966990; h=from : reply-to : to
: subject : date : message-id : content-type :
content-transfer-encoding : mime-version : from;
bh=Fx4w6Op6Q/FlwzesSioGEJoNGqi9uKyCBhdWUSdbycc=;
b=DPDQk3k615Eah/68QKs2ORq52k1eaeGbeGxnvAPJGULmk+GhcRhsoYsQ/UH9K10QTaeNg
p5ZKWWlvO+V4u8wltiQcTrE7PxVXwpJBjudWeqWiGv/QDNPPfsM3dPPqSje+UKCizB+TN8l
DKIM passes, because the message really was signed by example.net and hasn’t been changed. That’s all
DKIM checks. Without include_headers, dkimpy signed its default list, and from appears twice in h=:
listing a header once more than it occurs stops anyone adding a second From line later. The addresses
192.0.2.25 and 203.0.113.45 are from ranges reserved for documentation.
Headers are added at the top as a message travels, so read the Received chain from the bottom up. The
line that matters most is the lowest one your own server added: it records which address handed the
message to you from outside. Anything below that came from the sender’s side and can be forged. Here
your servers added both lines, so it’s the bottom one.
# headers.py: read the headers an analyst checks first
import re, sys
from email import policy
from email.parser import BytesParser
from email.utils import parseaddr
msg = BytesParser(policy=policy.default).parse(open(sys.argv[1], "rb"))
domain = lambda header: parseaddr(str(msg.get(header, "")))[1].rpartition("@")[2] or "-"
print("Received chain, first hop first:")
for n, hop in enumerate(reversed(msg.get_all("Received", [])), 1):
print(f" {n}. {' '.join(str(hop).split(';')[0].split())}")
dkim_d = re.search(r"\bd=([^;\s]+)", str(msg.get("DKIM-Signature", "")))
signer = dkim_d[1] if dkim_d else "-"
print(f"From domain: {domain('From')}")
print(f"Return-Path domain: {domain('Return-Path')}")
print(f"Reply-To domain: {domain('Reply-To')}")
print(f"DKIM signing (d=): {signer}")
print("DKIM aligned with From:", signer == domain("From"))
$ python headers.py phish.eml
Received chain, first hop first:
1. from mail.example.net (mail.example.net [203.0.113.45]) by mx.example.org with ESMTPS
2. from mx.example.org (mx.example.org [192.0.2.25]) by mailbox.example.org with LMTP
From domain: example.com
Return-Path domain: example.net
Reply-To domain: example.net
DKIM signing (d=): example.net
DKIM aligned with From: False
$ python headers.py signed.eml
Received chain, first hop first:
From domain: example.com
Return-Path domain: -
Reply-To domain: -
DKIM signing (d=): example.com
DKIM aligned with From: True
Every technical field points to example.net: the server that handed the message in, the bounce address,
the reply address and the DKIM signer. Only the From line, the one the recipient sees, says
example.com. That mismatch is what DMARC formalises as alignment: a DMARC pass needs SPF or DKIM to
pass for a domain that matches From. Here DKIM passes for the wrong domain, so DMARC fails, and
example.com could publish a DMARC policy telling receivers to reject such mail. SPF, the other half,
checks the Return-Path domain against the sending server’s IP address, which needs real DNS and a real
connection, so this lab doesn’t run it.
The genuine message has no Received or Return-Path lines because it never travelled; sign_email.py
wrote it straight to a file. Its signer matches its From domain.
That completes the topic. The roadmap shows where defensive security fits among the other security topics.
Practice
- Protect
Reply-Toon the genuine message: re-signsigned.emlwithReply-Toadded toinclude_headers, although the message has noReply-To. Then add one, as before, and verify.
Solution
# oversign.py: re-sign signed.eml, this time also covering Reply-To, which it doesn't have
import re
import dkim
data = open("signed.eml", "rb").read()
raw = re.sub(rb"^DKIM-Signature:.*?\n(?=\S)", b"", data, count=1, flags=re.S)
signature = dkim.sign(raw, b"lab", b"example.com", open("lab.key", "rb").read(),
include_headers=[b"From", b"To", b"Subject", b"Date", b"Message-ID",
b"Reply-To"])
open("oversigned.eml", "wb").write(signature + raw)
print(signature.decode().split("bh=")[0])
$ python oversign.py
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.com;
i=@example.com; q=dns/txt; s=lab; t=1791967011; h=from : to : subject
: date : message-id : reply-to;
$ python verify.py oversigned.eml
oversigned.eml: dkim=pass
$ { echo 'Reply-To: payments@example.net'; cat oversigned.eml; } > replyto2.eml
$ python verify.py replyto2.eml
replyto2.eml: dkim=fail (signature does not match the signed headers)
Signing a header that isn’t there signs its absence. Adding one later changes the signed data, so the check fails. This is called oversigning.
Don’t count on a library’s defaults to do it for you. dkimpy 1.1.8 oversigns only From
(dkim.DKIM.FROZEN is (b'from',)); every other header in its default list is signed only if the
message has it. reply-to appears in phish.eml’s h= because that message has a Reply-To. Sign
the genuine message with the defaults and repeat the test:
# default_sign.py: re-sign signed.eml with dkimpy's default header list
import re
import dkim
data = open("signed.eml", "rb").read()
raw = re.sub(rb"^DKIM-Signature:.*?\n(?=\S)", b"", data, count=1, flags=re.S)
signature = dkim.sign(raw, b"lab", b"example.com", open("lab.key", "rb").read())
open("default.eml", "wb").write(signature + raw)
print(signature.decode().split("bh=")[0])
$ python default_sign.py
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=example.com;
i=@example.com; q=dns/txt; s=lab; t=1791968796; h=from : to : subject
: date : message-id : content-type : content-transfer-encoding :
mime-version : from;
$ { echo 'Reply-To: payments@example.net'; cat default.eml; } > replyto3.eml
$ python verify.py default.eml replyto3.eml
default.eml: dkim=pass
replyto3.eml: dkim=pass
from is listed twice, and reply-to not at all, so the added Reply-To passes. To protect a header the
message doesn’t have, name it in include_headers yourself, as oversign.py does. These two runs used a
freshly generated key pair, which is why the t= timestamps differ from the earlier listing.
- DMARC’s default alignment is relaxed:
mail.example.comcounts as aligned withexample.com, because they share an organisational domain. Write analigned()function with a relaxed and a strict mode and test three pairs.
Solution
# aligned.py: DMARC-style relaxed alignment between the DKIM d= domain and the From domain
def org(domain): # naive: the last two labels; real code uses the Public Suffix List
return ".".join(domain.lower().split(".")[-2:])
def aligned(signer, from_domain, strict=False):
return signer == from_domain if strict else org(signer) == org(from_domain)
for signer, sender in [("example.com", "example.com"), ("mail.example.com", "example.com"),
("example.net", "example.com")]:
print(f"{signer:<17} {sender:<12} relaxed={aligned(signer, sender)}"
f" strict={aligned(signer, sender, strict=True)}")
$ python aligned.py
example.com example.com relaxed=True strict=True
mail.example.com example.com relaxed=True strict=False
example.net example.com relaxed=False strict=False
The lookalike fails both modes. The “last two labels” shortcut is wrong for domains like example.co.uk,
where the organisational domain has three labels; real DMARC code uses the Public Suffix List to find it.
- In
phish.eml, which IP address would you look up in your threat-intel and mail logs, and why not the other one?
Solution
203.0.113.45, from the bottom Received line. It’s where your own server, mx.example.org, received
the message from outside, so your server wrote that line and you can trust it. 192.0.2.25 is your own
mail exchanger handing the message to the mailbox server. Any Received lines below the first one your
servers added were written by the sender’s side and can be invented, so trust stops at your own border.