Vulnerability analysis · DKIM verification

OpenDKIM writes past the end of the lhdrs array when the h= tag names more headers than the message carries [OpenDKIM ≤ 2.11.0, latest release checked 2026-07-30]

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

The signed-header list is sized from the message, indexed from the signature, and stored into before either count is checked.

SoftwareOpenDKIM (libopendkim)
VendorThe Trusted Domain Project
Sourcehttps://github.com/trusteddomainproject/OpenDKIM
AffectedOpenDKIM 2.11.0 (Debian package 2.11.0~beta2-9.1+b1) and current upstream master. The defective loop is long-standing; earlier 2.x releases are expected to be affected. Newest published release checked on 2026-07-30.
Wherelibopendkim/dkim-canon.c, dkim_canon_selecthdrs() line 1052, reached from dkim_canon_runheaders() during dkim_eoh()
WeaknessCWE-787 (Out-of-bounds Write)
Reachable byRemote, unauthenticated. One inbound e-mail carrying a DKIM-Signature header.
CVSS v3.1CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H (8.2 (High))
VerificationAddressSanitizer-confirmed with a fuzzer-derived crashing input; the proposed fix was applied, libopendkim rebuilt, and the same input then ran clean.

1. Overview

dkim_canon_selecthdrs() builds the list of headers a signature covers. It allocates lhdrs with one slot per header present in the message, a count it holds in dkim_hdrcnt. It then runs one loop iteration per token in the h= tag of the DKIM-Signature header. The two numbers come from different places, and nothing keeps them in step.

Each iteration opens with lhdrs[shcnt] = NULL. That store happens before any test on shcnt. An h= tag that names more matching headers than the message actually carries pushes shcnt to dkim_hdrcnt, and the next iteration writes an 8-byte pointer one element past the end of the allocation. The function does hold a bounds check, if (shcnt > nptrs), but it sits after the loop and so cannot stop the store. The h= tag is sender-chosen and is parsed on every inbound signed message.

2. Analysis

The allocation and the loop, condensed from dkim-canon.c:

n = dkim->dkim_hdrcnt * sizeof(struct dkim_header *);
lhdrs = DKIM_MALLOC(dkim, n);            /* exactly dkim_hdrcnt elements */
...
shcnt = 0;
for (c = 0; c < n /* number of h= tokens */; c++)
{
        lhdrs[shcnt] = NULL;             /* dkim-canon.c:1052 - unbounded write */
        ...
        for (hdr = ...; hdr; hdr = hdr->hdr_next)
                if (match) lhdrs[shcnt] = hdr;
        if (lhdrs[shcnt] != NULL)
        {
                lhdrs[shcnt]->hdr_flags |= DKIM_HDR_SIGNED;
                shcnt++;
        }
}

if (shcnt > nptrs) { ... }                /* bounds check runs only after the loop */

The buffer size and the loop trip count are two separate quantities that the code treats as one. The size comes from the message. The trip count comes from the signature. A signer may legitimately list the same header name in h= more than once, a practice RFC 6376 allows and calls over-signing, and a verifier is required to tolerate an h= that names headers the message does not carry. The two counts are therefore independent by specification. A signature that makes them diverge is well formed.

h= token, or the loop stops when it would not.

lhdrs[dkim_hdrcnt], eight bytes past the allocation.

AddressSanitizer reports the store at dkim-canon.c:1052:

==ERROR== AddressSanitizer: heap-buffer-overflow
WRITE of size 8 at 0x... thread T0
    #0 dkim_canon_selecthdrs dkim-canon.c:1052
    #1 dkim_canon_runheaders

3. Reproduction

An in-process libFuzzer harness drove the public verification path, dkim_chunk() followed by dkim_eom(), under AddressSanitizer and produced the crashing input. The input is an ordinary RFC 5322 message. Its header block is small and its DKIM-Signature h= tag names more matching headers than the message holds.

  1. Build libopendkim with -fsanitize=address.
  2. Link the verification harness against it.
  3. Feed the harness the following message.
From: a@example.com
Subject: x
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=s1;
 h=from:from:from:from:subject:subject:subject:subject:from:subject;
 bh=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=;
 b=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

body

The signature does not have to verify. The write happens during header canonicalization inside dkim_eoh(), which runs before any cryptographic check.

# build libopendkim with ASAN, then
./fuzz_odkim_verify crashes_dkv/dkv-crash-42df1857...
==ERROR== AddressSanitizer: heap-buffer-overflow WRITE of size 8
          ... dkim_canon_selecthdrs dkim-canon.c:1052

4. Assessment

Every OpenDKIM verifier is exposed. An attacker needs one thing: the ability to send a message to the target. No authentication, no user interaction, and no valid signature.

The write corrupts whatever the allocator placed after lhdrs. In a milter process that means a crash, which stops mail processing for as long as the process is down. That consequence is the one demonstrated.

The claim stops there. The value stored is a fixed NULL pointer, not attacker-chosen bytes, and the position past the buffer is bounded. Code execution is not a straightforward consequence, the precise effect depends on heap layout, and this report does not characterize it.

5. Remediation

Move the bound inside the loop, ahead of the store:

 for (c = 0; c < n; c++)
 {
+        if (shcnt >= dkim->dkim_hdrcnt)   /* or >= nptrs */
+                break;
         lhdrs[shcnt] = NULL;

A second form works as well: size lhdrs for MAX(dkim_hdrcnt, number of h= tokens) and leave the post-loop check where it is. The first form was applied to the tree, libopendkim was rebuilt, and the crashing input then ran clean with no AddressSanitizer report.

6. Additional notes

This defect is separate from the two dkim_qp_decode() defects, opendkim-qp-off-by-one and opendkim-qp-truncated-escape. A library patched for both of those still crashes on this input.

The finding was made against upstream master. How far exploitation could be pushed beyond a crash was not investigated, and this report does not claim code execution.

7. References