Vulnerability analysis · SPF

Rspamd skips the over-limit SPF term and keeps evaluating, returning pass where RFC 7208 requires permerror [Rspamd ≤ 4.1.2, latest release checked 2026-07-30]

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

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.

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. Newest published release checked on 2026-07-30.
WhereSPF module, the DNS-lookup processing limit, in the parse_spf_include path
WeaknessCWE-755 (Improper Handling of Exceptional Conditions) leading to CWE-290 (Authentication Bypass by Spoofing)
Reachable byRemote, unauthenticated. The attacker publishes the SPF record at a domain they control.
CVSS v3.1CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N (7.5 (High))
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. A 22-case sweep with boundary and negative controls, plus a root-cause log line.

1. Overview

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.

2. Analysis

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 recordOpenDMARCRspamd
include self-loop, then a matching ip4failpass, fail-open
include self-loop, then a non-matching ip4 (negative control)failfail
include self-loop, then -all onlyfailfail
10 exists terms, then an authorizing term (in budget, control)passpass
11 exists terms, then an authorizing termpasspass
12 exists terms, then an authorizing termfailpass, fail-open
13, 14, and 15 exists terms, then an authorizing termfailpass, fail-open
include chain of depth 10 to 15 terminating in -allfailfail (consensus)
N exists terms, all NXDOMAIN, no authorizing mechanismfailfail (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.

3. Reproduction

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.

4. Assessment

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.

5. Remediation

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.

6. Additional notes

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.

7. References