Vulnerability analysis · SPF
An SPF record padded with eleven or more DNS lookups before an authorizing ip4 term still returns pass on Rspamd. The mandatory permerror never happens.
| 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, the DNS-lookup processing limit, in the parse_spf_include path |
| Weakness | CWE-755 (Improper Handling of Exceptional Conditions) leading to CWE-290 (Authentication Bypass by Spoofing) |
| Reachable by | Remote, unauthenticated. The attacker publishes the SPF record at a domain they control. |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N (7.5 (High)) |
| 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 22-case sweep with boundary and negative controls, plus a root-cause log line. |
RFC 7208 section 4.6.4 caps the DNS-querying terms in an SPF evaluation at ten. The terms it counts are include, a, mx, ptr, exists, and redirect. The requirement is a MUST: "if this limit is exceeded, the implementation MUST return permerror". Section 5.2 adds that an include evaluating to permerror must propagate that result outward. Section 11.1 names crafted limit-exceeding records as the attack the cap defends against.
Rspamd handles an exhausted budget as a soft skip. It drops the term that hit the limit, then keeps evaluating the record left to right. A later ip4 term matches the sending host and Rspamd returns pass.
The debug log shows both halves of the defect in sequence. Rspamd detects the limit, then authorizes the sender anyway.
parse_spf_include: spf dns requests limit: 31 > 30 is reached
...
R_SPF_ALLOW{+ip4:203.0.113.7}
A 22-case sweep across two implementations locates the boundary. The rows in bold are the fail-open cases.
| Sender's SPF record | OpenDMARC | Rspamd |
|---|---|---|
include self-loop, then a matching ip4 | fail | pass, fail-open |
include self-loop, then a non-matching ip4 (negative control) | fail | fail |
include self-loop, then -all only | fail | fail |
10 exists terms, then an authorizing term (in budget, control) | pass | pass |
11 exists terms, then an authorizing term | pass | pass |
12 exists terms, then an authorizing term | fail | pass, fail-open |
13, 14, and 15 exists terms, then an authorizing term | fail | pass, fail-open |
include chain of depth 10 to 15 terminating in -all | fail | fail (consensus) |
N exists terms, all NXDOMAIN, no authorizing mechanism | fail | fail (consensus) |
The controls rule out the alternative explanations. A ten-lookup chain ending in an authorizing term passes on both implementations, so the rig does authorize correctly within budget. The same chain ending in -all fails on both. Point the self-loop record at a non-matching IP and Rspamd returns fail, so the pass comes from the ip4 term evaluated past the limit, not from some default applied to loops.
OpenDMARC sets its own ceiling near eleven terms rather than the specified ten. It still enforces a ceiling and fails closed beyond it. Rspamd enforces no ceiling at all in the matching path, up to its 30-request denial-of-service guard, which serves a different purpose.
Publish this record at a domain the attacker controls, with all twelve exists targets resolving NXDOMAIN.
attacker.example. IN TXT "v=spf1 exists:a1.%{d} exists:a2.%{d} exists:a3.%{d}
exists:a4.%{d} exists:a5.%{d} exists:a6.%{d}
exists:a7.%{d} exists:a8.%{d} exists:a9.%{d}
exists:a10.%{d} exists:a11.%{d} exists:a12.%{d}
ip4:203.0.113.7 -all"
Send mail from 203.0.113.7. The self-include form is shorter and reaches the same state.
attacker.example. IN TXT "v=spf1 include:attacker.example ip4:203.0.113.7 -all"
Expected (RFC 7208 section 4.6.4): permerror, which establishes no SPF authorization.
Observed (Rspamd 4.1.2): spf=pass with the symbol R_SPF_ALLOW.
Two details of the rig matter for anyone repeating this. Rspamd skips SPF evaluation entirely when the connecting address is private, so the rig supplies a public ip=. Rspamd also caches SPF results for 300 seconds, so the sender domain is regenerated on every run.
This is an SPF authorization bypass, reachable by any remote unauthenticated sender who can publish DNS for a domain. The attacker pads a record with eleven or more querying terms, or with a self-include loop, and places an ip4 term naming their sending host after the padding. A conforming verifier returns permerror and establishes no authorization. Rspamd returns pass.
DMARC inherits the result. Under relaxed alignment, aspf=r, an SPF pass aligned with the From: domain yields dmarc=pass. A message that should have carried no SPF authorization is delivered as fully authenticated.
The lookup cap exists to stop crafted records from steering evaluation, which is exactly what section 11.1 describes.
Abort the entire evaluation and return permerror the moment the querying-term budget is exhausted inside a term. Do not skip the term and continue. Propagate permerror out of a nested include as section 5.2 requires, and set the budget at ten.
One gap is recorded here but not claimed as a divergence. The void-lookup limit in section 4.6.4, which allows at most two NXDOMAIN or NODATA lookups before permerror, goes unenforced on both verifiers. Both behave the same way, so this is a shared weakness and not a difference between them.