Vulnerability analysis · DKIM verification

OpenDKIM treats the NUL terminator as a hexadecimal digit, so a truncated escape in an i= tag steps the decoder past the end of its input [OpenDKIM ≤ 2.11.0, latest release checked 2026-07-30]

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

strchr() matches the terminator, dkim_qp_decode() accepts the escape, and the unconditional p += 2 lands outside the allocation.

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 344; reached from dkim_sig_domainok() at dkim.c:1602
WeaknessCWE-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 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.

1. Overview

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.

2. Analysis

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.

3. Reproduction

  1. Patch libopendkim for the two earlier defects and rebuild it with

-fsanitize=address.

  1. Feed the verification harness a message whose DKIM-Signature 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=...
  1. Read the sanitizer report.
# 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

4. Assessment

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.

5. Remediation

       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.

6. Additional notes

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.

7. References