OpenDKIM off-by-one in dkim_qp_decode() leaves an unterminated buffer, causing an out-of-bounds read in dkim_sig_domainok() (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 (2.11.0~beta2-9.1+b1) and current upstream master. Latest version tested on 2026-07-30.
Vulnerable component: libopendkim/util.c, dkim_qp_decode() line 322; manifests in libopendkim/dkim.c, dkim_sig_domainok() lines 1604 and 1611
Vulnerability type: CWE-193 (Off-by-one Error) leading to CWE-125 (Out-of-bounds Read)
Attack vector: Remote, unauthenticated. One inbound e-mail carrying a DKIM-Signature header with an i= tag.
CVSS v3.1: 8.2 (High) — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:H
Status: AddressSanitizer-confirmed; the proposed fix was applied, libopendkim rebuilt, and the crashing input then ran clean.

Impact. A quoted-printable i= tag that fills the destination buffer destroys its NUL terminator, and the caller then runs strchr() past the end of the stack array.

Description

An out-of-bounds read of adjacent stack memory on every inbound signed message whose i= tag is long enough. The observed effect is a crash of the verifying process under a sanitizer or a hardened allocator, which for a milter means loss of mail processing. On an ordinary build the read usually returns adjacent stack bytes, which are then compared against the signing domain in strcasecmp(); that is undefined behaviour and a potential, though narrow, information-disclosure oracle rather than a demonstrated leak.

dkim_qp_decode(in, out, outlen) computes end = out + outlen and guards every output write with if (q <= end). The inclusive comparison permits a write to out[outlen] — precisely the byte the caller reserved for the NUL terminator.

dkim_sig_domainok() calls it with outlen = sizeof addr - 1 over a memset-zeroed stack array, then treats the result as a C string. A quoted-printable i= value that decodes to fill the buffer overwrites the terminator, and the following strchr() and strcasecmp() read past the end of the array.

The caller, in dkim.c:

u_char addr[MAXADDRESS + 1];
memset(addr, '\0', sizeof addr);
...
dkim_qp_decode(i, addr, sizeof addr - 1);   /* outlen = MAXADDRESS */
at = strchr((char *) addr, '@');            /* dkim.c:1604 - out-of-bounds read */
...
strcasecmp(at + 1, d);                      /* dkim.c:1611 */

With outlen = sizeof addr - 1, end points at &addr[MAXADDRESS], which is the last valid byte of the array and the byte holding the terminator. The q <= end test therefore lets the decoder consume it. Every following string operation on addr then walks off the end of the stack object.

==ERROR== AddressSanitizer: stack-buffer-overflow
READ of size 259 at 0x...
    #0 __interceptor_strchr
    #1 dkim_sig_domainok dkim.c:1604

The same unterminated-output hazard applies to the other string-consuming callers of dkim_qp_decode() in dkim.c (lines 7278, 7298 and 7367), which were not individually driven to a crash but share the defect.

Proof of Concept

A message whose DKIM-Signature carries an i= tag that is a long quoted-printable value decoding to at least MAXADDRESS bytes. The fuzzer-minimised input has the shape:

From: a@>>>>>:0rs000
DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=s1;
 i=<long quoted-printable value decoding to >= MAXADDRESS bytes>;
 h=from; bh=...; b=...

body
# libopendkim built with -fsanitize=address, verification harness linked
./fuzz_odkim_verify crashes_dkv/dkv-crash-1972e8dd...
==ERROR== AddressSanitizer: stack-buffer-overflow READ of size 259
          ... __interceptor_strchr <- dkim_sig_domainok

As with the h= tag write the signature need not verify. The AUID (i=) is decoded during dkim_eoh(), before the cryptographic check.

Mitigation

Change the bounds check from inclusive to exclusive, in all eight places it appears in dkim_qp_decode():

-                        if (q <= end)
+                        if (q < end)

This keeps one byte free for the terminator. As a belt-and-braces measure, write *q = '\0' before returning. The change was applied, libopendkim rebuilt, and the crashing input re-run: clean, with no sanitizer report.

Notes

Fixing this defect alone is not sufficient. Re-running the harness against a patched library surfaced opendkim-qp-truncated-escape, an independent input-side over-run in the same function, and opendkim-h-tag-oob-write in a different function. All three should be fixed together.

References