Vulnerability analysis · DKIM verification
The signed-header list is sized from the message, indexed from the signature, and stored into before either count is checked.
| Software | OpenDKIM (libopendkim) |
|---|---|
| Vendor | The Trusted Domain Project |
| Source | https://github.com/trusteddomainproject/OpenDKIM |
| Affected | 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. Newest published release checked on 2026-07-30. |
| Where | libopendkim/dkim-canon.c, dkim_canon_selecthdrs() line 1052, reached from dkim_canon_runheaders() during dkim_eoh() |
| Weakness | CWE-787 (Out-of-bounds Write) |
| Reachable by | Remote, unauthenticated. One inbound e-mail carrying a DKIM-Signature header. |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H (8.2 (High)) |
| Verification | AddressSanitizer-confirmed with a fuzzer-derived crashing input; the proposed fix was applied, libopendkim rebuilt, and the same input then ran clean. |
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.
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.
lhdrs stays below dkim_hdrcnt for everyh= token, or the loop stops when it would not.
shcnt reaches dkim_hdrcnt and the next iteration stores throughlhdrs[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
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.
libopendkim with -fsanitize=address.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
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.
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.
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.