Vulnerability analysis · SPF

An SPF term built on the %{p} validated-PTR macro disappears from Rspamd's evaluation, with no permerror raised for the unsupported letter [Rspamd ≤ 4.1.2, latest release checked 2026-07-30]

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

Rspamd's macro expander has no letter p. A sender that publishes exists:%{p} therefore loses the whole term unexpanded, its own mail draws the wrong verdict, and the syntax error the specification attaches to an unknown letter is never raised.

SoftwareRspamd
VendorRspamd project (Vsevolod Stakhov)
Sourcehttps://github.com/rspamd/rspamd
AffectedRspamd 4.1.2. Originally isolated on 3.10.2 and re-confirmed on 4.1.2 on 2026-07-28. OpenDMARC 1.4.2 SPFSelfValidate expands the macro correctly and is the differential oracle. Newest published release checked on 2026-07-30.
WhereSPF module, macro expansion (expand_spf_macro) and the exists term evaluation path (parse_spf_exists)
WeaknessCWE-755 (Improper Handling of Exceptional Conditions)
Reachable byRemote, unauthenticated. The denial-of-validation direction needs no attacker action at all; the fail-open shape additionally requires the victim to publish a permissive fallback qualifier.
CVSS v3.1CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:L (4.8 (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 3 of 3 by a DNS toggle, with a swapped macro-letter control and a root-cause debug log line naming the unsupported letter.

1. Overview

Two obligations sit behind the letter p in an SPF record. RFC 7208 section 7.2 says the letter stands for the validated domain name of the client IP address, which section 5.5 defines as a PTR answer confirmed by a matching forward lookup. Section 7.3 says any macro letter the verifier does not recognize is a syntax error, and a record carrying a syntax error MUST come back permerror.

Rspamd meets neither obligation. The letter p is absent from its macro expander. A term built around that letter is marked unresolvable and then removed from the evaluation with no diagnostic, after which the record's all qualifier decides the outcome. permerror never appears. OpenDMARC's SPFSelfValidate takes the other path: it expands the macro, issues the query, and matches.

2. Analysis

The zone under test carries a PTR for 203.0.113.45 naming m.victim, plus a forward A for m.victim holding 203.0.113.45. Section 5.5 is satisfied on both sides, so %{p} validates to m.victim. The sender publishes v=spf1 exists:%{p}._chk.%{d} -all, and the node the expanded term points at, m.victim._chk.<sender>, is present.

Term under testOpenDMARCRspamdRequired
exists:%{p}._chk.%{d} (validated PTR)spf=pass, expands to m.victim, queries, matchesspf=fail, the term is droppedpass, or permerror if the letter is unsupported
exists:%{i}._chk.%{d} (same rig, swapped letter, control)passpasspass (consensus)

Row two is what rules out every other explanation. Nothing differs between the rows except the letter inside the macro: same rig, same zone, same connecting address, same record shape. %{i} works on both verifiers, and %{p} is where they part.

Rspamd logs the whole sequence itself. Two consecutive debug lines cover what it noticed and what it then failed to do:

expand_spf_macro: ... unknown or unsupported spf macro p ...
parse_spf_exists: unresolvable exists element

Line one is accurate reporting. Line two is the defect. Having established that the letter is unsupported, the exists path files the result under unresolvable terms; section 7.3 files it under syntax errors, and the two categories carry different outcomes.

What remains after the drop depends on the rest of the record, so the fallback qualifier was swept as well. Every run used a %{p} that validated and that OpenDMARC matched. Rspamd's answer came from the surviving terms alone:

Record after the exists:%{p} termRspamd resultRequired
-allfailpermerror
~allsoftfailpermerror
?allneutralpermerror
a later non-matching +ip4failpermerror

With -all the verdict is wrong and nothing worse. With ~all and ?all the error tilts toward acceptance. Should the surviving terms of such a record happen to cover the connecting host, that host is authorized outright, and the term the verifier was unable to evaluate never weighs against it.

3. Reproduction

  1. Publish a zone for a sender that uses validated-PTR SPF.
sender.example.             IN TXT "v=spf1 exists:%{p}._chk.%{d} -all"
m.victim._chk.sender.example. IN A   203.0.113.45
45.113.0.203.in-addr.arpa.  IN PTR "m.victim."
m.victim.                   IN A   203.0.113.45
  1. Send from 203.0.113.45 with MAIL FROM:<alice@sender.example>.
  1. Read the SPF result.

Expected. m.victim is what %{p} validates to, the probe node underneath it is present, so the exists term matches and spf=pass follows. A verifier that lacks the letter owes permerror instead, per section 7.3. Either answer conforms. OpenDMARC gives the first of them, spf=pass.

Observed. Rspamd gives neither. It reports spf=fail, having removed the term and applied -all.

4. Assessment

Denial of validation is the first and larger consequence. Validated-PTR SPF is a construction section 7.2 explicitly provides for, and a domain that relies on it sees its own legitimate mail failed at every Rspamd receiver. Where aspf alignment applies, that SPF failure travels onward and the message fails DMARC too, for mail the domain did in fact send.

Swallowing the error is the second consequence. Dropping the term instead of escalating it leaves the verifier holding a verdict that nothing supports. Pair that with a permissive fallback and the answer is neutral or softfail in a place the specification reserves for permerror. Any record whose surviving terms cover the sending host then authorizes that host on the back of an evaluation that never ran to completion. This is the fail-open side of one defect, and it is why the entry is not filed as an interoperability note.

5. Remediation

Teach the macro expander the letter p. Look up the PTR for the client address, then confirm each candidate name with a forward lookup that contains that same client address; RFC 7208 sections 7.2 and 5.5 define both halves. Where no candidate survives confirmation, the expansion is unknown.

A second change stands on its own, whatever set of letters the expander ends up supporting. An unrecognized letter has to stop the evaluation and return permerror, which is what section 7.3 requires, rather than quietly rendering its term unresolvable. This is the cheaper of the two changes, and it removes the fail-open behavior without the first.

6. Additional notes

One DNS toggle aimed at a single macro letter is a method that generalizes. It also turned up rspamd-macro-digit-transformer, the %{dN} digit transformer on Rspamd, and a separate gap on %{l} in OpenDMARC. The two Rspamd macro-expansion gaps run through the same code path, so they belong in one disclosure.

Other results from the same sweep agreed across both verifiers and are recorded as consensus, not as defects. The reversed-dotted client address %{ir} expands the same way on each: the forward-order probe fails on both, the reversed-order probe passes on both. For IPv4, %{v} comes out as in-addr on both. The ptr: mechanism is a separate thing from the %{p} macro, and it behaves correctly on both, forward-mismatch case included. Put %{c}, %{r} or %{t} inside a mechanism, where those macros belong only in exp, and both return spf=fail with neither escalating to permerror; that is where the industry has settled, so it is not a differential. This laboratory runs OpenDMARC over IPv4 only, which left native IPv6 %{v} untested on that side.

Harness note. The container network hands OpenDMARC's SPFSelfValidate a private SMTP peer address to evaluate, and a private connecting address is one Rspamd declines to run SPF on at all. Getting both to look at the same public address took two temporary rig changes. Postfix XCLIENT was switched on and XCLIENT ADDR=203.0.113.45 injected, with the Received: header confirming it took effect. A PTR branch was grafted onto the laboratory authoritative server, confirmed on the wire by drill -x. Both changes came back out after the run. Keeping the PTR branch in place would make %{p} a standing capability of the rig.

7. References