Vulnerability analysis · DKIM verification

OpenDKIM's quoted-printable decoder writes one byte too far and eats the NUL terminator, so dkim_sig_domainok() reads off the end of a stack array [OpenDKIM ≤ 2.11.0, latest release checked 2026-07-30]

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

dkim_qp_decode() guards its output with an inclusive comparison. An i= tag that decodes to fill the buffer takes the terminator with it, and the caller's strchr() keeps going.

SoftwareOpenDKIM (libopendkim)
VendorThe Trusted Domain Project
Sourcehttps://github.com/trusteddomainproject/OpenDKIM
AffectedOpenDKIM 2.11.0 (2.11.0~beta2-9.1+b1) and current upstream master. Newest published release checked on 2026-07-30.
Wherelibopendkim/util.c, dkim_qp_decode() line 322; manifests in libopendkim/dkim.c, dkim_sig_domainok() lines 1604 and 1611
WeaknessCWE-193 (Off-by-one Error) leading to CWE-125 (Out-of-bounds Read)
Reachable byRemote, unauthenticated. One inbound e-mail carrying a DKIM-Signature header with an i= tag.
CVSS v3.1CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:H (8.2 (High))
VerificationAddressSanitizer-confirmed; the proposed fix was applied, libopendkim rebuilt, and the crashing input then ran clean.

1. Overview

dkim_qp_decode(in, out, outlen) sets end = out + outlen and wraps every output write in if (q <= end). That comparison is inclusive, so the decoder may write to out[outlen]. The caller reserved that byte for the NUL terminator.

dkim_sig_domainok() is one such caller. It passes outlen = sizeof addr - 1 over a stack array it has zeroed with memset, and then treats the decoded result as a C string. A quoted-printable i= value long enough to fill the buffer overwrites the terminator. The strchr() and strcasecmp() that follow read past the end of the array.

2. Analysis

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]. That is the last valid byte of the array, and it is the byte holding the terminator.

as written by the caller, so the buffer stays a valid C string.

gone, and every string operation on addr afterward walks off the end of the stack object.

AddressSanitizer catches it in strchr():

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

Three other callers in dkim.c consume the decoder's output as a string, at lines 7278, 7298 and 7367. They carry the same hazard. None of them was individually driven to a crash.

3. Reproduction

  1. Build libopendkim with -fsanitize=address and link the verification harness.
  2. Feed it a message whose DKIM-Signature carries an i= tag holding a long

quoted-printable value that decodes to at least MAXADDRESS bytes. The fuzzer-minimized input has this 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
  1. Read the sanitizer report.
# 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

The signature does not need to verify, the same as for the h= tag write. The AUID (i=) is decoded during dkim_eoh(), ahead of the cryptographic check.

4. Assessment

Any OpenDKIM verifier is exposed. The attacker sends one message with a long enough i= tag. Nothing else is required, since decoding precedes the signature check.

The read touches adjacent stack memory. Under a sanitizer or a hardened allocator the verifying process crashes, which for a milter means mail processing stops. That is the observed effect.

On an ordinary build the read normally returns whatever stack bytes sit next to addr, and strcasecmp() then compares them against the signing domain. That is undefined behavior, and it forms a narrow oracle at most. No disclosure was demonstrated, and none is claimed.

5. Remediation

Make the bounds check exclusive in all eight places it appears in dkim_qp_decode():

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

One byte then stays free for the terminator. Writing *q = '\0' before returning adds a second layer of protection. The change was applied, libopendkim was rebuilt, and the crashing input was re-run. It ran clean, with no sanitizer report.

6. Additional notes

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

7. References