Vulnerability analysis · DKIM verification
strchr() matches the terminator, dkim_qp_decode() accepts the escape, and the unconditional p += 2 lands outside the allocation.
| 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 344; reached from dkim_sig_domainok() at dkim.c:1602 |
| Weakness | 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 against a library already patched for the two earlier libopendkim defects, the output-side off-by-one in the same function and the h= tag write. |
The quoted-printable decoder moves its input pointer forward by two once it has recognized a hexadecimal escape. A truncated escape at the end of the input still counts as recognized. A trailing =, or = followed by one hexadecimal digit, is enough, because strchr(hexdigits, next) matches the NUL terminator. The pointer then advances two positions and lands past the end of the string. The next loop iteration dereferences *p outside the allocation.
for (p = in, q = out; *p != '\0'; p++) /* util.c:344 - *p reads out of bounds */
{
switch (*p)
{
case '=':
next1 = *(p + 1);
if (next1 != '\0') next2 = *(p + 2);
...
pos = strchr(hexdigits, next1); /* matches the NUL terminator */
...
p += 2; /* overshoots the NUL */
The trap is in strchr(). It counts its own terminating NUL as part of the string it searches, so it returns a non-NULL pointer when the needle is NUL. The code reads that result as "this is a hexadecimal digit".
or ignored, and p never moves beyond the terminator.
p += 2 puts the cursor past the end of the input buffer.
==ERROR== AddressSanitizer: heap-buffer-overflow
READ of size 1 at 0x...
#0 dkim_qp_decode util.c:344
#1 dkim_sig_domainok dkim.c:1602
This defect and opendkim-qp-off-by-one sit in the same function but are not the same bug. That one runs off the output end. This one runs off the input end. Each needs its own fix. This one became reachable only after the off-by-one and the h= tag write were patched, because those crashes hit first and masked it.
libopendkim for the two earlier defects and rebuild it with-fsanitize=address.
i= value ends in a bare=, or in = followed by exactly one hexadecimal digit.
DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=s1;
i=user@example.com:=001;
h=from; bh=...; b=...
# libopendkim patched for the two earlier defects, rebuilt with -fsanitize=address
./fuzz_odkim_verify crashes_dkv2/d-crash-...
==ERROR== AddressSanitizer: heap-buffer-overflow READ of size 1
... dkim_qp_decode util.c:344 <- dkim_sig_domainok dkim.c:1602
Every OpenDKIM verifier is exposed. The attacker sends one inbound signed message whose i= tag ends in a truncated escape. Nothing more is needed.
The read goes past the heap buffer holding the DKIM-Signature tag value. Under a sanitizer or a hardened allocator the verifier crashes and mail processing stops. On an ordinary build the decoder pulls adjacent heap bytes into the decoded AUID, and that AUID is then compared against the signing domain.
case '=':
next1 = *(p + 1);
+ if (next1 == '\0') break; /* truncated escape at end of input */
if (next1 != '\0') next2 = *(p + 2);
...
pos = strchr(hexdigits, next1);
if (pos == NULL) return -1;
+ if (next2 == '\0') break;
pos = strchr(hexdigits, next2);
...
p += 2;
The thing to guard against is strchr(hexdigits, '\0') matching the terminator. Reject NUL as a hexadecimal digit explicitly. Never advance p past the terminator.
The finding came from re-running the verification fuzzer against a library already patched for opendkim-qp-off-by-one and opendkim-h-tag-oob-write. That is the reason to treat it as its own issue rather than as an incomplete fix for the off-by-one.