Vulnerability analysis · DKIM verification
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.
| Software | OpenDKIM (libopendkim) |
|---|---|
| Vendor | The Trusted Domain Project |
| Source | https://github.com/trusteddomainproject/OpenDKIM |
| Affected | OpenDKIM 2.11.0 (2.11.0~beta2-9.1+b1) and current upstream master. Newest published release checked on 2026-07-30. |
| Where | libopendkim/util.c, dkim_qp_decode() line 322; manifests in libopendkim/dkim.c, dkim_sig_domainok() lines 1604 and 1611 |
| Weakness | CWE-193 (Off-by-one Error) leading to CWE-125 (Out-of-bounds Read) |
| Reachable by | Remote, unauthenticated. One inbound e-mail carrying a DKIM-Signature header with an i= tag. |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:H (8.2 (High)) |
| Verification | AddressSanitizer-confirmed; the proposed fix was applied, libopendkim rebuilt, and the crashing input then ran clean. |
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.
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.
dkim_qp_decode() writes at most outlen bytes, leaving out[outlen]as written by the caller, so the buffer stays a valid C string.
q <= end test admits a write at out[outlen]. The terminator isgone, 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.
libopendkim with -fsanitize=address and link the verification harness.i= tag holding a longquoted-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
# 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.
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.
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.
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.