Vulnerability analysis · SPF

Superseded: OpenDMARC SPFSelfValidate gets the SPF %{l} local-part macro wrong [OpenDMARC ≤ 1.4.2, latest release checked 2026-07-30]

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

This entry names the wrong root cause and is kept only as a record. It reads the defect as a macro that is never expanded. The macro is in fact always expanded to postmaster, which also permits a pass. opendmarc-spf-postmaster-macro replaces this entry.

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} local-part substitution. Superseded by opendmarc-spf-postmaster-macro.
WeaknessCWE-20 (Improper Input Validation) - assessed at opendmarc-spf-postmaster-macro, not here
Reachable byNot assessed separately. See opendmarc-spf-postmaster-macro, which establishes the correct mechanism and the conditions under which it is reachable.
CVSS v3.1Not assessed separately - see `opendmarc-spf-postmaster-macro`
VerificationThe observation itself is reproducible and was confirmed with a domain-macro control. Its root cause was subsequently corrected by opendmarc-spf-postmaster-macro, whose query-log evidence establishes the true mechanism.

1. Overview

This entry is kept for the record. It was not submitted to the vendor, because a later entry replaces it. The root cause written here is wrong. It says that OpenDMARC does not expand %{l}. The authoritative server's query log shows something else: OpenDMARC always expands %{l} to the literal string postmaster. The corrected mechanism and the assessment live in opendmarc-spf-postmaster-macro.

The two readings differ in what they permit. A macro that is never expanded can only deny validation, and that is the only impact this entry recorded. A macro that always expands to postmaster denies validation the same way. It also grants a pass. Publish an A record at postmaster._spf and OpenDMARC matches it, returning SPF pass for a local part the sender never used. This entry missed that direction entirely.

2. Analysis

The measurement recorded here holds up. The victim record was exists:%{l}.k.probe.example.com, the envelope local part was alice, and the probe node was published at the real local part.

Macro under testOpenDMARCRspamd
%{l}, local part alicespf=fail; alice.k.probe... is never matchedspf=pass; queries alice.k.probe..., tracking alice to alice and bob123 to bob123
%{o} and %{d}, the domain macros (control)passpass

Both implementations agree on the domain-macro control. The gap therefore sits in the local-part macro letter, and that localization stands.

The inference drawn from it does not. With the A record at the real local part, a macro that is never expanded and a macro that expands to the wrong value produce the same verdict. Neither one matches. The two cases separate as soon as the A record moves to postmaster._spf. The authoritative server's query log then shows OpenDMARC asking for postmaster._spf while Rspamd asks for alice._spf. A null sender, MAIL FROM:<>, makes both implementations query postmaster._spf. That fixes the mechanism: OpenDMARC applies the null-sender substitution rule to senders that are not null.

This entryThe corrected entry
Stated root cause%{l} is not expanded%{l} is always expanded to postmaster
Evidenceverdict only, with the probe at the real local partverdicts plus the authoritative server's query log, with the probe moved
Directionfail-closed onlyfail-closed and fail-open
Assessmentnot assessed separatelyCVSS 5.9 (Medium)

3. Reproduction

The probe as originally run, kept for the record:

victim.example. IN TXT "v=spf1 exists:%{l}.k.probe.example.com -all"
alice.k.probe.example.com. IN A 203.0.113.9
# MAIL FROM:<alice@victim.example>
  1. Publish the two records above.
  2. Send from alice@victim.example.
  3. Read the verdict on each implementation.

Expected (RFC 7208 section 7.3): both implementations expand %{l} to alice, query alice.k.probe.example.com, and return spf=pass.

Observed: OpenDMARC returns spf=fail, Rspamd returns spf=pass.

This setup cannot tell the two candidate causes apart. Use the proof of concept in opendmarc-spf-postmaster-macro instead, which puts the A record at postmaster._spf and covers the pass case.

4. Assessment

The impact recorded here is denial of validation, and nothing beyond it. A sender who publishes per-user SPF through %{l} has its own aligned mail failed by OpenDMARC. Under aspf alignment that failure carries into DMARC. The %{s} sender macro runs through the same code path.

That account stops short. The real mechanism also produces SPF pass, and therefore dmarc=pass, on a forged local part, whenever the victim's zone resolves at postmaster._spf. Read opendmarc-spf-postmaster-macro for the full impact statement. Do not rely on this entry alone.

5. Remediation

The fix is stated in opendmarc-spf-postmaster-macro. 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. One change closes both directions. That is a further reason to carry one entry rather than two.

6. Additional notes

Why this is not submitted separately. It describes the same defect as opendmarc-spf-postmaster-macro, less accurately. Two submissions would report one code path twice. The weaker of the two names a root cause that is not the real one and drops the pass direction, so it would understate the finding to the vendor. The entry stays here so that the correction is visible and the earlier framing is not quietly deleted.

One methodological point is worth keeping. A verdict-only differential cannot separate "the macro is not expanded" from "the macro is expanded to the wrong value" when the probe node sits at exactly the value a correct expansion would produce. Both cases fail to match. What separated them was moving the probe and reading the authoritative server's query log instead of the verdict.

7. References