Vulnerability analysis · DMARC From parsing

A From value that normalizes to nothing makes opendmarc_util_finddomain() dereference the byte before its stack array [OpenDMARC ≤ 1.4.2, latest release checked 2026-07-30]

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

A From value of a bare comma is emptied by the routine's own normalization. The backwards scan for an angle bracket then starts at copy minus one and reads that byte.

SoftwareOpenDMARC (libopendmarc)
VendorThe Trusted Domain Project
Sourcehttps://github.com/trusteddomainproject/OpenDMARC
AffectedOpenDMARC 1.4.2 and earlier, and current upstream master (commit bf37d53). Newest published release checked on 2026-07-30.
Wherelibopendmarc/opendmarc_util.c, opendmarc_util_finddomain() line 278, called from opendmarc_policy_store_from_domain()
WeaknessCWE-125 (Out-of-bounds Read)
Reachable byRemote in the library. In the shipped milter the From parser guards this vector, so direct remote reachability is not established.
CVSS v3.1CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:L (4.8 (Medium))
VerificationAddressSanitizer-confirmed in the library with a two-byte proof of concept. Not demonstrated through the shipped milter.

1. Overview

opendmarc_util_finddomain() pulls the domain out of a From value. It copies the value into a stack buffer, strips quotes, and cuts the string at the first unquoted comma. That normalization can leave the buffer empty. When it does, the backwards scan for < begins at copy - 1. The loop condition b > copy is false on the first test, so the loop body never runs, and the if (*b == '<') that follows reads the byte before the array.

2. Analysis

u_char copy[BUFSIZ];
...
for (cp = copy; *cp != '\0'; ++cp)
{
    ...
    if (numquotes == 0 && *cp == ',')   /* empties copy when the comma is first */
    {
        *cp = '\0';
        break;
    }
    ...
}
ep = copy + strlen((char *) copy);      /* ep == copy when copy is now "" */
for (b = ep - 1; b > copy; --b)         /* b = copy - 1; the body never runs */
{
    if (*b == '<')
        break;
}
if (*b == '<')                          /* line 278 - dereferences copy[-1] */

The comma handling is what makes the buffer empty. A leading comma writes the terminator at offset zero, strlen then returns zero, and ep lands on copy. Every later step follows from that.

AddressSanitizer reports the underflow at the guilty line:

==ERROR== AddressSanitizer: stack-buffer-underflow
READ of size 1 at 0x... offset 31
    ... underflows this variable 'copy'
    #0 opendmarc_util_finddomain opendmarc_util.c:278

The caller checks one thing. opendmarc_policy_store_from_domain() tests strlen(from_domain) == 0. That test looks at the input before normalization, so it turns away a value that arrives empty. It does nothing about a non-empty value such as "," that becomes empty inside finddomain.

3. Reproduction

The harness reads input in the form <dmarc-record>\0<from-domain>. Two bytes are enough: an empty record and a From domain of ,.

  1. Build the input and run the harness:
printf '\0,' > pocA
./fuzz_odmarc_record pocA
==ERROR== AddressSanitizer: stack-buffer-underflow ... READ of size 1
          ... in opendmarc_util_finddomain opendmarc_util.c:278
  1. At the library entry point, pass a From value of ,, or any value that the comment

and quote stripping reduces to the empty string.

Expected: an empty working buffer yields no domain and the routine returns without touching memory outside copy.

Observed: a one-byte read at copy[-1], reported by AddressSanitizer as a stack-buffer underflow at opendmarc_util.c:278.

4. Assessment

The read is one byte, and it is before the array rather than after it. On a normal build it picks up an adjacent stack byte and usually does not fault. Under AddressSanitizer, a hardened allocator, or an unlucky stack layout it faults, which is a denial of service. It is undefined behavior in every build.

5. Remediation

 ep = copy + strlen((char *) copy);
 b = ep - 1;
 for (; b > copy; --b)
 {
     if (*b == '<')
         break;
 }
-if (*b == '<')
+if (b >= copy && *b == '<')     /* or: if (ep > copy && *b == '<') */

The rule is simple: do not dereference b when the working buffer is empty. Either guard shown above enforces it.

6. Additional notes

The scope needs stating plainly. The defect is confirmed in libopendmarc through the direct library entry point. The milter has its own From parser, and that parser guards the vector before this function runs, so a remotely delivered message was not shown to reach it. Treat this as a library-level defect that affects third-party consumers of libopendmarc. The milter path is unproven, and the rating reflects that.

7. References