Vulnerability analysis · SPF
RFC 7208 gives a transformer digit one job, keep that many rightmost labels. Rspamd reads the digit and then expands as if it were absent, so a term written exists:%{d2}.k.probe.example.com is resolved against a name derived from all of m.n.o.example.com.
| 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. Newest published release checked on 2026-07-30. |
| Where | SPF module, macro expansion, specifically the transformer digit in %{dN} and %{oN} |
| Weakness | CWE-20 (Improper Input Validation) |
| Reachable by | Remote. No attacker action is required: any sender whose published record uses a digit transformer is evaluated against the wrong name on every Rspamd receiver. |
| 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. A DNS-toggle experiment publishing the A record at exactly one of the two candidate names, with %{d} and %{dr} as consensus controls, re-verified on a second host. |
A digit may follow the macro letter inside an SPF macro. RFC 7208 section 7.3 assigns it one meaning: once the macro has been expanded, discard all but that many rightmost labels. Under %{d2}, a sender domain of m.n.o.example.com therefore reduces to example.com.
Rspamd's parser reads the digit. Its expander never applies it, so the untruncated domain is what reaches the assembled query name. All four DNS-querying mechanisms build their target the same way, so exists:, a:, mx:, and include: inherit the problem equally. The name that goes on the wire is not the name the record describes, and the match outcome follows the wrong name.
An exists: mechanism decides on a single question: does the assembled name resolve. That makes it a usable probe. Publish an A record under one of two candidate names, leave the other candidate empty, and the verdict identifies which name the verifier asked for. The connecting address never enters the decision.
Sender domain: m.n.o.example.com. Published record: v=spf1 exists:%{d2}.k.probe.example.com -all. A conforming expander turns %{d2} into example.com.
| Name carrying the A record | OpenDMARC | Rspamd | Reading |
|---|---|---|---|
example.com.k.probe.example.com (the conforming target, two rightmost labels) | spf=pass | R_SPF_FAIL | OpenDMARC's lookup went to example.com; Rspamd's lookup went somewhere else |
m.n.o.example.com.k.probe.example.com (untruncated) | spf=fail | R_SPF_ALLOW | Rspamd's lookup carried all of m.n.o.example.com, so the 2 had no effect |
One name exists at any instant and the other does not, so the two rows cannot both hold. A verifier passes in exactly the row whose published name is the one it asked for. Read side by side, the rows fix each expansion.
Two controls keep the blame on the digit. With no transformer, %{d} agrees across both verifiers. With the reverse transformer, %{dr} agrees as well. Reversal and delimiter splitting therefore work in Rspamd, and truncation is the one missing step. Substituting %{d3} or %{d4} reproduces the split, and so does %{o2} against the MAIL FROM domain. The fault belongs to the code that handles transformers, not to any one macro letter.
Five labels in the sender name is a deliberate choice. The laboratory publishes example.com as a public suffix, and stretching the name to five labels keeps that fixture out of the outcome: %{d2} here strips three labels, and no organizational-domain computation is involved.
digit transformer, and publish the A record under the conforming name alone.
m.n.o.example.com. IN TXT "v=spf1 exists:%{d2}.k.probe.example.com -all"
example.com.k.probe.example.com. IN A 192.0.2.1
; and deliberately nothing at m.n.o.example.com.k.probe.example.com
MAIL FROM:<alice@m.n.o.example.com>
RCPT TO:<user@receiver.test>
Expected, per RFC 7208 section 7.3: %{d2} becomes example.com, the assembled name example.com.k.probe.example.com resolves, the exists: mechanism matches, and the verdict is pass. OpenDMARC's SPFSelfValidate, given the same zone and the same message, returns spf=pass.
Observed: Rspamd reports R_SPF_FAIL. The name it looked up was m.n.o.example.com.k.probe.example.com, for which nothing is published.
the conforming one. Rspamd flips to R_SPF_ALLOW and OpenDMARC flips to spf=fail. The two halves together leave no other explanation.
Any domain whose record uses a digit transformer is enforced on Rspamd receivers as something other than what it published. This is not an obscure corner of the syntax. Subdomain senders reach for it when they want to name their organizational domain, and operators running many tenants or many address ranges generate exists: terms from it.
Most of the time the outcome is restrictive. The intended name is never asked for, so the mechanism cannot match, so evaluation runs on to the -all that ends the record. Aligned mail from a legitimate sender comes out as spf=fail, and DMARC can fail behind it under the sender's own policy.
Loosening is possible, with a precondition. Something has to answer at the untruncated name: a wildcard covering the probe zone, or a name someone published for an unrelated reason. Where that holds, a mechanism with no business matching does match, and a host outside the intended set is authorized. The untruncated name has to be there already, by accident or by an attacker who arranged it, which makes this the narrower of the two cases.
Either outcome enforces something other than what the record says, and one record draws different verdicts from Rspamd than from verifiers that follow the specification.
Move the truncation into the expander itself. Expand the macro letter, split the result on the delimiter set, then drop everything but the requested count of rightmost fields, which is what RFC 7208 section 7.3 asks for. The expander already gets reversal and delimiter handling right, so the digit should take effect at that same stage instead of being read and then thrown away.
This defect stands apart from the other SPF findings in the series. Record selection is not involved, and neither is the mapping of temporary errors. The fault sits in the macro transformer layer, which earlier tests had reached no further than bare %{d} and %{o}.
The connecting address plays no role. exists: looks only at whether the name resolves, which is what makes the toggle a valid probe.