Vulnerability analysis · SPF
The null-sender substitution is applied to senders that are not null. Every message is checked as if it came from postmaster, so per-local-part deny entries never match and a forged local part can reach spf=pass.
| Software | OpenDMARC (SPFSelfValidate) |
|---|---|
| Vendor | The Trusted Domain Project |
| Source | https://github.com/trusteddomainproject/OpenDMARC |
| Affected | OpenDMARC 1.4.2, built from the upstream tarball rel-opendmarc-1-4-2. The finding was originally isolated on the Debian package 1.4.2-1+deb12u1 and re-confirmed against pristine upstream. Earlier 1.4.x and 1.3.x releases are expected to share the behavior. Newest published release checked on 2026-07-30. |
| Where | SPF macro expansion in SPFSelfValidate, the %{l} and %{s} local-part substitution |
| Weakness | CWE-20 (Improper Input Validation) leading to CWE-290 (Authentication Bypass by Spoofing) |
| Reachable by | Remote, unauthenticated, but conditional: the victim domain must publish SPF using the %{l} macro. |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N (5.9 (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. Confirmed over five or more independent runs with fresh random domain suffixes, using the authoritative server's query log as independent ground truth. |
RFC 7208 section 7.3 defines the macro letters l, s and o in terms of the local part of the checked identity. Sections 2.4 and 4.3 allow one exception. The literal string postmaster is substituted only when the reverse path is null, that is MAIL FROM:<>.
OpenDMARC's SPFSelfValidate substitutes postmaster for %{l} in every case, including a MAIL FROM that names a real mailbox. The SPF check therefore runs against the wrong identity, and per-local-part policy is never consulted.
The victim publishes v=spf1 exists:%{l}._spf.%{d} -all and p=reject. The envelope is MAIL FROM:<alice@victim>. One variable changes between the two runs: where the A record sits. The authoritative server's query log records the exact name each verifier asks for.
| A record published at | OpenDMARC queries | OpenDMARC result | Rspamd queries | Rspamd result |
|---|---|---|---|---|
alice._spf (the correct expansion) | postmaster._spf, NXDOMAIN | spf none, then dmarc=fail (reject) | alice._spf, match | spf pass, then dmarc=pass |
postmaster._spf | postmaster._spf, match | spf pass, then dmarc=pass, a bypass | alice._spf, NXDOMAIN | spf fail, then dmarc=fail |
Three controls narrow this to the %{l} letter alone. The domain-macro form exists:%{d}._spf.%{d} passes on both implementations, so the divergence sits in the local-part macro. A null sender, MAIL FROM:<>, makes both implementations query postmaster._spf. That is the decisive one: OpenDMARC is applying the null-sender rule to senders that are not null, rather than failing to expand the macro at all. Varying the local part and setting a HELO domain different from the MAIL FROM domain leaves OpenDMARC still asking for postmaster._spf, while %{d} follows the MAIL FROM domain correctly.
victim.example. IN TXT "v=spf1 exists:%{l}._spf.%{d} -all"
postmaster._spf.victim.example. IN A 203.0.113.9
_dmarc.victim.example. IN TXT "v=DMARC1; p=reject"
# alice._spf.victim.example does not exist
alice._spf.victim.example is absent.203.0.113.9 with MAIL FROM:<alice@victim.example> andFrom: alice@victim.example.
Expected (RFC 7208 section 7.3): %{l} expands to alice, the query for alice._spf.victim.example returns NXDOMAIN, -all applies, SPF fails, and DMARC fails.
Observed: the query goes to postmaster._spf.victim.example and matches. SPF passes and DMARC passes.
The victim domain must publish per-user SPF policy through exists:%{l}.... That is a standard technique, described in RFC 7208 section 5.7, but it is not universal, and this precondition is what holds the score below the unconditional findings. The CVSS vector records it as attack complexity.
On such a domain the policy does not work under OpenDMARC. Every sender is checked as postmaster, so per-local-part deny entries never match. The mechanism resolves at postmaster._spf, or the record falls through to a trailing +all or ?all. Either way a remote unauthenticated attacker forging a local part obtains SPF pass, and therefore DMARC pass, on mail that p=reject should have stopped.
Expand %{l} and %{s} from the local part of the identity being checked. Substitute postmaster only when the reverse path is null, per RFC 7208 sections 7.3, 2.4 and 4.3. The defect is in the local-part wiring of OpenDMARC's SPF engine.
This entry corrects the root cause recorded in the earlier entry opendmarc-local-part-macro-superseded. That entry said SPFSelfValidate does not expand %{l}, an inference drawn from a setup whose A record sat at the real local part. The real behavior is the unconditional postmaster substitution. It explains the failure that entry observed and adds the pass direction that entry missed. Treat the earlier entry as superseded on the mechanism.
One observation on the Rspamd side is interoperability only. Rspamd does not URL-encode macro-special characters in the local part, so a+b queries a._spf, and a=b and a non-ASCII local part abort the query. Each of these resolves to SPF fail, so nothing passes that should not.