Vulnerability analysis · SPF

OpenDMARC expands the SPF %{l} macro to "postmaster" for every sender, defeating per-user SPF policy [OpenDMARC ≤ 1.4.2, latest release checked 2026-07-30]

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

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.

SoftwareOpenDMARC (SPFSelfValidate)
VendorThe Trusted Domain Project
Sourcehttps://github.com/trusteddomainproject/OpenDMARC
AffectedOpenDMARC 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.
WhereSPF macro expansion in SPFSelfValidate, the %{l} and %{s} local-part substitution
WeaknessCWE-20 (Improper Input Validation) leading to CWE-290 (Authentication Bypass by Spoofing)
Reachable byRemote, unauthenticated, but conditional: the victim domain must publish SPF using the %{l} macro.
CVSS v3.1CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N (5.9 (Medium))
VerificationReproduced 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.

1. Overview

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.

2. Analysis

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 atOpenDMARC queriesOpenDMARC resultRspamd queriesRspamd result
alice._spf (the correct expansion)postmaster._spf, NXDOMAINspf none, then dmarc=fail (reject)alice._spf, matchspf pass, then dmarc=pass
postmaster._spfpostmaster._spf, matchspf pass, then dmarc=pass, a bypassalice._spf, NXDOMAINspf 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.

3. Reproduction

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
  1. Publish the zone above. Note that alice._spf.victim.example is absent.
  2. Send from 203.0.113.9 with MAIL FROM:<alice@victim.example> and

From: alice@victim.example.

  1. Read the authoritative server's query log and the verdict.

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.

4. Assessment

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.

5. Remediation

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.

6. Additional notes

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.

7. References