Vulnerability analysis · SPF
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.
| Software | Rspamd |
|---|---|
| Vendor | Rspamd project (Vsevolod Stakhov) |
| Source | https://github.com/rspamd/rspamd |
| Affected | Rspamd 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. |
| Where | SPF module, macro expansion (expand_spf_macro) and the exists term evaluation path (parse_spf_exists) |
| Weakness | CWE-755 (Improper Handling of Exceptional Conditions) |
| Reachable by | Remote, 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.1 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:L (4.8 (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 3 of 3 by a DNS toggle, with a swapped macro-letter control and a root-cause debug log line naming the unsupported letter. |
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.
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 test | OpenDMARC | Rspamd | Required |
|---|---|---|---|
exists:%{p}._chk.%{d} (validated PTR) | spf=pass, expands to m.victim, queries, matches | spf=fail, the term is dropped | pass, or permerror if the letter is unsupported |
exists:%{i}._chk.%{d} (same rig, swapped letter, control) | pass | pass | pass (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} term | Rspamd result | Required |
|---|---|---|
-all | fail | permerror |
~all | softfail | permerror |
?all | neutral | permerror |
a later non-matching +ip4 | fail | permerror |
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.
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
203.0.113.45 with MAIL FROM:<alice@sender.example>.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.
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.
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.
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.