Vulnerability analysis · DMARC record parsing
The helper accepts a token exactly as long as the destination buffer and writes no terminator. A 32-character rf= token in a published DMARC TXT record then makes strlen() read past a 32-byte stack array.
| Software | OpenDMARC (libopendmarc) |
|---|---|
| Vendor | The Trusted Domain Project |
| Source | https://github.com/trusteddomainproject/OpenDMARC |
| Affected | OpenDMARC 1.4.2 and earlier, and current upstream master (commit bf37d53). Newest published release checked on 2026-07-30. |
| Where | libopendmarc/opendmarc_util.c, opendmarc_util_cleanup() line 159; crash at libopendmarc/opendmarc_policy.c line 1119 (the rf= tag handler) |
| Weakness | CWE-193 (Off-by-one Error) leading to CWE-125 (Out-of-bounds Read) |
| Reachable by | Remote. The DMARC record is a DNS TXT record fetched from the sender's (or an attacker's) domain, so publishing the record is the whole attack. |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:H (8.2 (High)) |
| Verification | AddressSanitizer-confirmed in the library, and confirmed firing live through the full SMTP + DNS + milter stack in a running opendmarc process. |
opendmarc_util_cleanup() copies a string into a buffer the caller supplies, dropping whitespace as it goes. Its length guard is strlen(str) > buflen. That comparison lets through an input of exactly buflen characters. If none of those characters is whitespace, the loop writes buf[0] through buf[buflen-1] and there is no byte left for the NUL. The function still returns buf, and the callers treat what comes back as a C string.
The DMARC record parser calls the helper from several tag handlers, each with a fixed-size stack buffer. The rf= handler passes u_char xbuf[32]. A 32-character rf= token in a DMARC TXT record therefore makes the next strlen() read past the end of the array. The record comes from DNS, so the attacker controls the token.
The helper, with the guard that is one byte too loose:
u_char *
opendmarc_util_cleanup(u_char *str, u_char *buf, size_t buflen)
{
if (str == NULL || buf == NULL || strlen((char *)str) > buflen) /* '>' should be '>=' */
{
errno = EINVAL;
return NULL;
}
(void) memset(buf, '\0', buflen);
for (sp = str, ep = buf; *sp != '\0'; sp++)
if (!isascii(*sp) || !isspace(*sp))
*ep++ = *sp; /* up to buflen non-space bytes written */
return buf; /* no room left for the terminator */
}
The memset clears the buffer first, so a shorter token is terminated by leftover zeros. At exactly buflen characters the loop overwrites every one of those zeros. Nothing downstream knows where the string ends.
The crash site sits in the rf= handler of opendmarc_policy_parse_dmarc():
u_char xbuf[32];
...
xp = opendmarc_util_cleanup(xp, xbuf, sizeof xbuf); /* xbuf left unterminated */
if (xp != NULL && strlen((char *)xp) > 0) /* :1119 - reads past xbuf[32] */
AddressSanitizer names the byte count:
==ERROR== AddressSanitizer: stack-buffer-overflow
READ of size 33 at 0x...
#0 __interceptor_strlen
#1 opendmarc_policy_parse_dmarc opendmarc_policy.c:1119
Other tag handlers call the same helper with their own fixed-size stack buffers, so the defect is reachable through more than one tag. rf= is the one the fuzzer minimized to.
The token length has to be exact. At 33 characters or more the guard rejects the input. At 31 or fewer a zero byte survives and terminates the string. Exactly 32 non-whitespace characters is the only case that fires.
printf 'v=DMARC1;p=none;rf=abcdefghijklmnopqrstuvwxyz012345\0example.com' > pocB
./fuzz_odmarc_record pocB
==ERROR== AddressSanitizer: stack-buffer-overflow ... READ ... in __interceptor_strlen
<- opendmarc_policy_parse_dmarc opendmarc_policy.c:1119
_dmarc.attacker.example. IN TXT "v=DMARC1; p=none; rf=abcdefghijklmnopqrstuvwxyz012345"
From: header.No harness is needed for step 3. With an AddressSanitizer-instrumented opendmarc running behind Postfix and a programmable authoritative DNS server, that message plus the record above fires the sanitizer report inside the live milter.
Any domain that publishes a DMARC record reaches this code in every OpenDMARC receiver that evaluates the record. The read picks up whatever sits next to xbuf on the stack, and the length it returns then drives further parsing. On an ordinary build that is undefined behavior in the policy parser. Under a sanitizer or a hardened allocator the milter process faults, which denies DMARC evaluation and, depending on how the milter handles failure, mail delivery.
The entry cost is a DNS record and one message. That makes this the cheapest of the memory findings to aim at a third party.
- if (str == NULL || buf == NULL || strlen((char *)str) > buflen)
+ if (str == NULL || buf == NULL || strlen((char *)str) >= buflen)
{
errno = EINVAL;
return NULL;
}
...
+ *ep = '\0'; /* always terminate */
return buf;
Either change closes the defect on its own. Reserving the terminator in the guard stops the over-long input; writing the terminator unconditionally makes the return value a valid C string in every case. Apply both. The second one also covers the other callers.
This is the same class as historical OpenDMARC parser CVEs. What sets it apart from the in-harness findings is the live confirmation: SMTP, DNS and the milter, end to end.