OpenDKIM heap out-of-bounds write in dkim_canon_selecthdrs() via the DKIM-Signature h= tag (OpenDKIM ≤ 2.11.0; latest version tested 2026-07-30)

Author: Weitong Li, Virginia Tech <weitongli@vt.edu>
Date: 2026-07-31
Vendor: The Trusted Domain Project
Software: OpenDKIM (libopendkim)
Source code: https://github.com/trusteddomainproject/OpenDKIM
Affected version: OpenDKIM 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. Latest version tested on 2026-07-30.
Vulnerable component: libopendkim/dkim-canon.c, dkim_canon_selecthdrs() line 1052, reached from dkim_canon_runheaders() during dkim_eoh()
Vulnerability type: CWE-787 (Out-of-bounds Write)
Attack vector: Remote, unauthenticated. One inbound e-mail carrying a DKIM-Signature header.
CVSS v3.1: 8.2 (High) — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H
Status: AddressSanitizer-confirmed with a fuzzer-derived crashing input; the proposed fix was applied, libopendkim rebuilt, and the same input then ran clean.

Impact. A crafted signed-header list writes an 8-byte pointer one element past a heap allocation on every inbound signed message.

Description

An out-of-bounds heap write of a controlled-position, fixed-value (NULL) pointer. The immediate, reliably demonstrated consequence is memory corruption of whatever the allocator placed after lhdrs, which in a milter process means a crash and therefore a denial of service against mail processing. Because the overflow writes NULL rather than attacker-chosen bytes, and the position past the buffer is bounded, this is not a straightforward path to code execution; the precise effect depends on heap layout and is not fully characterised here.

Reachability is the significant part: the trigger is the h= tag of a DKIM-Signature header, which is parsed on every inbound signed message by every OpenDKIM deployment, with no authentication and no user interaction.

dkim_canon_selecthdrs() allocates the lhdrs array to hold exactly dkim_hdrcnt header pointers — one slot per header present in the message — but then iterates over the tokens of the attacker-supplied h= tag. Each iteration begins by writing lhdrs[shcnt] = NULL before any bounds test. A h= tag that lists more matching header names than the message actually contains drives shcnt up to dkim_hdrcnt, and the following iteration stores a pointer one element past the end of the buffer.

The function does contain a bounds check, if (shcnt > nptrs), but it runs after the loop has finished and therefore never prevents the write. The h= tag is fully attacker-controlled and is parsed on every inbound signed message, so the write is reachable remotely without authentication.

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 */

Two counters are conflated. lhdrs is sized by the number of headers in the message, while the loop is driven by the number of tokens in h=. RFC 6376 permits a signer to list a header name in h= more than once (over-signing), and the verifier must tolerate an h= that names headers the message does not carry, so the two counts are independent by design and a signature that makes them diverge is not itself malformed.

The AddressSanitizer report:

==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

Proof of Concept

The crashing input was produced by an in-process libFuzzer harness driving the public verification path (dkim_chunk() then dkim_eom()) under AddressSanitizer. It is an ordinary RFC 5322 message with a small header block whose DKIM-Signature h= tag lists more matching header names than the message has headers, for example:

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

Build libopendkim with -fsanitize=address, link the verification harness, and feed it the message. The signature does not need to verify: the crash happens during header canonicalisation in dkim_eoh(), 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

Mitigation

Bound the index inside the loop, before the write:

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

Equivalently, size lhdrs for MAX(dkim_hdrcnt, number of h= tokens) and keep the existing post-loop check. The first form was applied to the tree, libopendkim rebuilt, and the crashing input then ran clean with no AddressSanitizer report.

Notes

Distinct from the two dkim_qp_decode() defects, opendkim-qp-off-by-one and opendkim-qp-truncated-escape: this crash still reproduces against a library patched for both of those.

Found against upstream master. The exploitability ceiling beyond a crash has not been investigated, and this report deliberately does not claim code execution.

References