Vulnerability analysis · DKIM

A signature that leaves Content-Type out of h= and caps the body with l= lets a later hop swap the MIME boundary, so a dkim=pass message displays a part the signer never wrote [OpenDKIM ≤ 2.11.0, latest release checked 2026-07-30]

Yongzhe Xu, Virginia Tech  ·  yongzhe@vt.edu  ·  2026-07-31

Recorded for completeness rather than reported to a vendor. The verifier follows RFC 6376 exactly. The weakness comes from what the signer chose to cover, and RFC 6376 section 8.2 already names that surface.

SoftwareOpenDKIM (verifier under test)
VendorNot applicable - the DKIM verifier is specification-correct; the fix belongs with each signer
Sourcehttps://github.com/trusteddomainproject/OpenDKIM
AffectedOpenDKIM 2.11.0 (Debian package 2.11.0~beta2-9.1+b1). The verifier behavior is not version-specific: any conforming DKIM verifier returns the same result on this input. The header-duplication mapping in the notes was made against Postfix 3.7.11. Newest published release checked on 2026-07-30.
WhereDKIM signer policy, h= header-field selection combined with the l= body-length limit. The verifier path is not implicated.
WeaknessCWE-345 (Insufficient Verification of Data Authenticity) at the composed-system level; no verifier defect
Reachable byRemote. The attacker must be able to modify the message after signing, which means any hop on the delivery path, any mailing list or forwarder, or simply resending a copy of a signed message obtained from the signer.
CVSS v3.1Not applicable
VerificationReproduced 3 of 3 through fuzz/generators/mime_boundary_cases.py, end to end from signer to delivered message, with the Content-Transfer-Encoding variant of the same shape confirmed 3 of 3 alongside it.

1. Overview

This entry is recorded for completeness. It is not being submitted to a vendor. The verifier does the right thing. No RFC clause obliges a signer to put Content-Type in h=, so dkim=pass is the correct verdict on this message. What goes wrong is a signer policy, and RFC 6376 section 8.2 already describes the surface that policy opens.

A signer that leaves Content-Type out of h= and also sets l= covers neither the MIME boundary declaration nor the body bytes past the cut. Any hop after the signer can then prepend a second Content-Type with a different boundary= value and append a MIME part that uses the new boundary. This was long a common configuration at large senders. The signed bytes still hash to the signed value, so the verifier reports dkim=pass. The mail client reads the topmost Content-Type, so it renders the attacker's MIME tree.

The delivered message is authenticated under DKIM and DMARC. What the recipient sees was written by someone else.

2. Analysis

The laboratory signer stands in for a vulnerable producer. It signs with h=from:to:subject:date:message-id:mime-version, which omits content-type, and with l=145, the length of the canonicalised body. The key is a fresh 1024-bit RSA key published at a per-run selector. XXXX below is the random suffix the laboratory picks for each run, so no two runs share a domain or a cached DNS answer.

Signed message:

From: Alice <alice@sender-XXXX.example.com>
To: Bob <bob@receiver.example.com>
Subject: ct-boundary-substitution unsigned-ct
Date: Tue, 01 Jan 2030 00:00:00 +0000
Message-ID: <ct-boundary-...@sender-XXXX.example.com>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="AAA_signed_boundary"

--AAA_signed_boundary
Content-Type: text/plain; charset=us-ascii

Legitimate signed content from the real sender.

--AAA_signed_boundary--

The mutation after signing is two edits. One header goes above the signed block. One MIME part goes past the byte counted by l=.

Content-Type: multipart/mixed; boundary="ZZZ_attacker_boundary"
<original signed message>
<original CRLF.CRLF>
--ZZZ_attacker_boundary
Content-Type: text/plain; charset=us-ascii

ATTACKER-CONTROLLED CONTENT - would render in MUA but was never signed.
--ZZZ_attacker_boundary--

Neither edit changes a byte the signature covers. Two different readers now disagree about which four elements matter:

ElementCovered by the signatureConsulted by the mail client
The signed Content-Type with AAA_signed_boundaryno, absent from h=no, it is the second instance
The prepended Content-Type with ZZZ_attacker_boundaryno, added after signingyes, it is the topmost instance
The signed body prefix, 145 octetsyesrendered as an unreferenced fragment
The appended ZZZ_attacker_boundary partno, past the l= cutyes, it matches the topmost boundary

The verifier hashes the four h= headers and the first 145 body octets. All of them are intact. The client parses the first Content-Type it meets and finds the attacker's boundary. The delivered message therefore carries two MIME trees and a passing signature:

Authentication-Results: receiver.test; spf=pass smtp.mailfrom=sender-XXXX.example.com
Authentication-Results: receiver.test;
    dkim=pass (1024-bit key; unprotected) header.d=sender-XXXX.example.com ...
Content-Type: multipart/mixed; boundary="ZZZ_attacker_boundary"
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; ... l=145;
 h=from : to : subject : date : message-id : mime-version; ...
From: Alice <alice@sender-XXXX.example.com>
...
Content-Type: multipart/mixed; boundary="AAA_signed_boundary"

--AAA_signed_boundary
Content-Type: text/plain; charset=us-ascii

Legitimate signed content from the real sender.

--AAA_signed_boundary--

--ZZZ_attacker_boundary
Content-Type: text/plain; charset=us-ascii

ATTACKER-CONTROLLED CONTENT - would render in MUA but was never signed.
--ZZZ_attacker_boundary--

OpenDKIM writes unprotected in its result. That token says headers outside h= are not covered, which is accurate. It is informational and leaves the pass in place.

3. Reproduction

Pick any signer whose h= omits content-type and whose signature carries an l= tag, then:

  1. Obtain a signed message from that signer. Any copy will do; the attacker does not need

the signing key.

  1. Prepend one header above every existing header:
Content-Type: multipart/mixed; boundary="ZZZ_attacker_boundary"
  1. Append one MIME part after the final body octet counted by l=:
--ZZZ_attacker_boundary
Content-Type: text/plain; charset=us-ascii

ATTACKER-CONTROLLED CONTENT
--ZZZ_attacker_boundary--
  1. Deliver the edited message and read the Authentication-Results header.

The verifier reports dkim=pass. When d= matches the From domain, the message is DMARC-aligned as well. Expected and observed agree here, and that agreement is the entry. The gap is not between verifier and specification. It is between the bytes that were signed and the tree that gets rendered.

4. Assessment

The product of the attack is a deliverable message with dkim=pass, an aligned d=, and therefore dmarc=pass, whose visible content the attacker chose. Exposed parties are the signer's recipients and every downstream consumer that reads a DKIM pass as evidence about the rendered message, including brand-indicator rendering and reputation scoring. That inference is the thing that breaks.

The attacker needs only the ability to modify a signed message before delivery. Any relay hop, mailing list, or forwarder qualifies, and so does anyone who obtains a signed copy and resends it. No key material and no access to the signer are required.

Severity for an individual signer is high when its From domain is worth impersonating. Severity for the verifier is nil. The verifier behaved as specified.

The same shape works on other headers. Postfix 3.7.11 rejects duplicate From, Reply-To, To, Cc, Subject, Date, Message-ID, In-Reply-To and References, but accepts duplicate Content-Type, Content-Transfer-Encoding, MIME-Version and the whole X- namespace. Any header that the transport accepts twice and the client reads once is a candidate. The Content-Transfer-Encoding variant, where the attacker prepends base64 above a signed 7bit and picks body bytes that are valid base64, reproduced 3 of 3.

5. Remediation

The fix belongs to the signer. Name Content-Type and Content-Transfer-Encoding in h=. Oversign both, meaning list each one more time than it actually occurs, so that an added instance breaks the signature. Drop l= unless a particular deployment needs it; section 8.2 identifies l= as the half of this surface that makes body insertion possible.

A verifier change is optional and additive, not a correction. A verifier or filter can re-canonicalise the MIME tree and warn when a signature passes but the outer Content-Type that will be rendered differs from the signed instance. Report that as a separate Authentication-Results token, not as a DKIM failure.

6. Additional notes

Why no vendor submission. The verifier is correct. RFC 6376 section 3.5 gives the choice of h= to the signer. Section 5.4 tells the signer to choose carefully, because header fields the signature does not protect can be changed in transit. Section 8.2 names l= together with unsigned headers as a third-party body insertion surface. A verifier that returned anything but pass on this message would be non-conformant itself. There is no vendor defect to assign, and the mitigation is a policy change at each signer.

The primitive is not new. It reproduces the case Zone.eu disclosed publicly in May 2024 against several large brands. What is recorded here is a reproducible artifact against a current verifier, plus a generator that can test any signer's h= policy at scale. No claim of a novel weakness is made.

The line that would turn this into a submittable finding is worth stating. If a verifier were seen to diverge from the specification-conformant behavior on these inputs, that verifier's behavior would be a separate, reportable finding. No such divergence was observed.

7. References