Vulnerability analysis · DKIM integrity
Postfix inserts one space at octet 998 of a header line longer than 998 octets. It does this after OpenDKIM has verified the message, so the delivered mail carries a trusted dkim=pass over a signed header that no longer verifies.
| Software | Postfix, in a milter chain with OpenDKIM and OpenDMARC |
|---|---|
| Vendor | The Postfix project (Wietse Venema) |
| Source | https://github.com/vdukhovni/postfix |
| Affected | Postfix 3.10.12 in a milter chain with OpenDKIM 2.11.0 and OpenDMARC 1.4.2. The finding was originally isolated on Postfix 3.7.11. The defect is one of pipeline ordering rather than of any single component's own logic. Newest published release checked on 2026-07-30. |
| Where | Milter pipeline ordering, the interaction between smtp_line_length_limit line rewriting and milter-time DKIM verification |
| Weakness | CWE-367 (Time-of-check Time-of-use (TOCTOU) Race Condition) |
| Reachable by | Remote, unauthenticated, but with an abnormal precondition: the message must carry a single header line longer than 998 octets. Above approximately 2048 octets the milter rejects the message with 550, so the usable window is bounded. |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:L/A:N (4.0 (Medium)) |
| Verification | Reproduced on 2026-07-28 against the current release (run 20260728-120957) with a scripted reproducer, negative controls, and a second independent implementation as the differential oracle. Deterministic, with signed and unsigned controls, two other MTAs as differential peers, a short-message control, an upper-bound control, and a byte-level verification split computed outside the chain. |
Postfix enforces smtp_line_length_limit=998 by writing a single bare space (0x20) into octet 998 of any header line that runs longer. RFC 5321 section 4.5.3.1.6 is where the 998-octet text line limit comes from. The insertion is not an RFC 5322 fold. There is no CRLF and no continuation whitespace, only one physical line before and one physical line after. It does not even bring the line under the limit; the measured line was still 1129 octets once the space was in.
That edit happens after the milter chain has run. OpenDKIM has already verified the message and stamped Authentication-Results: receiver.test; dkim=pass. The recipient gets the edited header together with the verdict computed on the unedited one. The signature over the delivered bytes does not verify.
The evidence that OpenDKIM verified the pre-insertion bytes is byte-level. It does not depend on re-verifying anything downstream. Take a signed message with d=victim, c=simple/simple and aligned domains, then check its single signature against each form of the From header:
| Header bytes the signature is checked against | Verification result |
|---|---|
the From as signed, no insertion | true |
the From as delivered, one space inserted at octet 998 | false |
The signer signed the un-spaced bytes. OpenDKIM reported dkim=pass. Only the pre-insertion form produces that verdict, so that is the form OpenDKIM read. Relaxed canonicalization does not change the outcome. The inserted space sits between two non-whitespace characters, and whitespace collapsing leaves it there rather than folding it away.
Five deterministic controls separate what Postfix does from what the milters do, and bound the precondition:
| Control | Observation | What it establishes |
|---|---|---|
| the same over-long header on an unsigned message | space still inserted at octet 998 | the edit is Postfix's, not OpenDKIM's |
| the same over-long header through OpenSMTPD, no milters | delivered verbatim at 1128 octets, no insertion | the rewrite belongs to this MTA and is not inherent to the transport |
| the same over-long header through Exim | rejected | a third behavior: refuse the input outright |
| a short signed message | no insertion, dkim=pass holds end to end | the rig is otherwise sane |
| a header line beyond approximately 2048 octets | 550 milter reject | the precondition window has an upper bound, and that end fails closed |
The sender decides where octet 998 falls. Padding the display name or the local part moves it into the display name, into the local part, or inside a domain label. In the last case the delivered header reads vic60 46c7.com, while header.from and header.d in Authentication-Results still name the intact vic6046c7.com with dkim=pass.
c=simple/simple, padding the From display name so the lineexceeds 998 octets and octet 998 lands inside the domain label.
From line against the same signatureoutside the chain.
The two input headers:
From: "AAAA...A (padding to place octet 998 inside the domain)" <ceo@vic6046c7.com>
DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=vic6046c7.com; s=sel;
h=from:to:subject; bh=...; b=...
Expected. Either the message is rejected, or the header is folded conformantly and the stamped verdict describes the bytes that are delivered.
Observed.
# header stamped by the chain
Authentication-Results: receiver.test; dkim=pass header.d=vic6046c7.com
header.from=vic6046c7.com
# the From line actually delivered
From: "AAAA...A" <ceo@vic60 46c7.com>
^ one space inserted at octet 998
Step 4 returns false for the delivered From and true for the un-spaced form. The trusted dkim=pass rides on bytes the recipient never receives.
Anyone running Postfix with milter-time DKIM verification is exposed. The attacker needs nothing beyond the ability to send one message, unauthenticated, from anywhere. Two things follow, and both are narrow.
End-to-end DKIM integrity is broken. DKIM exists so a recipient can confirm that the header fields it covers arrived unaltered. Here a covered field is altered inside the receiving infrastructure, after verification. The delivered message no longer matches the canonicalized header that was authenticated.
The trusted verdict is stale. The Authentication-Results header carries the receiver's own identifier and says dkim=pass for a message whose signed header is now broken. Consumers downstream trust that header without re-verifying, which is the whole reason it exists. They act on a verdict that no longer describes the message in hand.
The precondition is abnormal. Header lines over 998 octets do not occur in ordinary mail. Past roughly 2048 octets the milter rejects the message. The window is both unusual and bounded, and the severity above reflects that.
Fold or reject over-long header lines before the milters run. Failing that, re-verify after any post-milter rewriting. Either ordering makes the verified bytes the delivered bytes. The present ordering does not.
The rewrite is worth revisiting on its own terms. A bare space at octet 998 is not a conformant RFC 5322 fold, and it leaves the line over 998 octets anyway. A CRLF-plus-whitespace fold would be conformant. A rejection would at least be honest. The current edit changes what the field says and keeps it just as long.
This is not a spoof. The attacker does not choose what gets inserted. It is always one space, always at octet 998. A victim domain can therefore be corrupted but never substituted for an attacker's domain. The authenticated mailbox and the delivered mailbox stay the same apart from the corrupting space. What is left for an attacker is the integrity break, plus whatever downstream consumers do with the receiver's Authentication-Results when they do not re-verify.
Scope of the RFC claim. The defensible statement is narrow. The delivered message no longer matches the canonicalized header OpenDKIM authenticated, and the receiver still emits dkim=pass. RFC 6376 section 3.4.1 is cited for the canonicalization the signature is taken over, and RFC 5321 section 4.5.3.1.6 for the 998-octet trigger. It is deliberately not claimed that Postfix violates the canonicalization rules. The byte edit originates in Postfix, but the defect is that the receiving pipeline allows DKIM-covered header bytes to change after authentication while keeping the verdict written beforehand.
Methodology caveat. Re-verifying the delivered copy pulled from the laboratory sink is not a valid oracle, and it was not used as one. That copy is contaminated. The relay reorders header fields and prepends Return-Path, DMARC-Filter and Received, so even short control messages fail a naive verification of the sink copy. The evidence here is OpenDKIM's own Authentication-Results, the byte-level verification split computed outside the chain, and the controls above.
This finding is distinct from the inbound forged-Authentication-Results finding in the same series, and runs in the opposite direction. There an attacker injects a header from outside. Here the chain's own legitimately stamped verdict goes stale, because the message changes after the milter signed off.