Vulnerability analysis · ARC

dkimpy picks ARC-Set headers by wire position instead of by i=, so a valid chain serialized out of numeric order is rejected; published together with the rig correction that voided the earlier ARC baseline [dkimpy ≤ 1.1.8, latest release checked 2026-07-30]

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

No vendor submission is intended here. What the work produced was a rig correction that threw out every ARC measurement taken before it, plus a single dkimpy-side disagreement whose only effect is to refuse mail.

Softwaredkimpy (arc_verify)
VendorThe dkimpy maintainers
Sourcehttps://launchpad.net/dkimpy
Affecteddkimpy 1.1.8. The differential peer is Rspamd 4.1.2. Originally isolated on 3.10.2 and re-confirmed on 4.1.2 on 2026-07-28. Newest published release checked on 2026-07-30.
WhereARC chain validation, ARC.verify_instance and the header selection performed by verify_sig (RFC 8617 section 5.1.1)
WeaknessCWE-436 (Interpretation Conflict)
Reachable byNot a spoofing vector. The false rejection is triggered by a hop that re-serializes the ARC sets in a different physical order; the direction is denial of validation.
CVSS v3.1CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L (3.7 (Low))
VerificationRe-baselined and confirmed on 2026-06-15 under the corrected rig described below. Of 28 mutations, 27 reach Rspamd and dkimpy consensus and the cv= latch holds on both; one case diverges. Every ARC result taken before the rig correction is void.

1. Overview

Nothing here is going to a vendor as a vulnerability. The entry exists so that the record is complete. What the work produced is a repaired test rig rather than a defect in a product. Two unrelated rig faults had been corrupting every ARC measurement the laboratory held. One implementation disagreement outlived the repair. It belongs to dkimpy, and it fails closed: the chain it mishandles is refused, never let through, so an attacker takes nothing from it.

The disagreement is genuine all the same. Under RFC 8617 section 5.1.1 the bytes hashed for an ARC-Seal are the ARC-Set header values taken in increasing instance order from 1 upward. Instance numbers come from i=, and where a header happens to sit in the message has no bearing on that ordering. Rebuilding the chain by i= is therefore the validator's job. dkimpy computes as_include_headers correctly inside ARC.verify_instance. Then verify_sig returns to the message and picks the headers again, tail-popping them in wire order. Re-serialize the chain and those two halves stop agreeing: an instance other than the intended one lands in the hash, and the seal will not validate.

2. Analysis

Start with the rig correction, because the substance of this entry lies there. The ARC test rig carried two unrelated faults, and results had been coming out of both.

Fault one was cached keys. Selectors stayed stable from run to run while the signing keys behind them were regenerated each time. What the laboratory DNS and the verifier still held for a given selector was the previous public key, and the seal placed in front of it came from the new one. ARC_REJECT followed. It meant a key mismatch and nothing else, but it was read at the time as the chain logic turning the chain down. Correcting it took selectors unique to each process, never carried across a key generation, together with 2048-bit keys; in some arms the 1024-bit keys had collided with cached entries or landed under a verifier minimum.

Fault two was a signing step that failed without saying so. For any instance above 1, arc_sign in dkimpy hands back an empty list unless that hop's Authentication-Results header already carries an arc=<result> stamp. Chains leaving the multi-hop builder therefore held an i=1 set and nothing more. Mutations aimed at instances 2 and 3 landed on headers that were not present, and reported a consensus that had never been tested. The correction stamps arc=pass on every hop from 2 upward, then confirms on the wire that i=1, i=2 and i=3 are all there.

Both faults reach backward. The ledger had multi-hop ARC written down as a closed consensus; that entry lost its baseline and was re-opened. No ARC result from before the correction survives.

Re-baselining on the corrected rig. The controls come back healthy once the rig runs with process-unique selectors, 2048-bit keys, a domain regenerated per run, and TTL 0. dkimpy answers cv=pass and Rspamd ARC_ALLOW for a clean i=1 chain and for a clean i=1..3 chain alike, the same way across three runs. That accounts for the earlier multi-hop ARC_REJECT: it was the stale-key artifact, and it does not recur.

27 of the 28 mutations land on a deterministic consensus:

Mutation classdkimpy and Rspamd
break the AMS, AS or AAR at i1, i2 or i3; body tamper; forge spf=fail or dmarc=pass inside the AARconsensus reject
force cv=none to cv=pass on i1; strip the seal; strip an intermediate set; renumber i2 to i4 leaving an i= gap; duplicate the i1 seal; swap the i1 and i3 numberingconsensus reject
clean i=1; clean i=1..3; strip the final set, both falling back to the inner valid i=1..2 sub-chainconsensus pass
i1 broken then cv=pass forced; i2 cv=fail with i3 cv=passconsensus reject, the section 5.1.1 cv= latch holds on both

The remaining case. This mutation rewrites the ARC sets into a wire order that no longer follows the i= order. Every i= value and every byte of every header stays as it was.

Casedkimpy arc_verifyRspamdRequired
wire order not equal to i= order, bytes intactcv=FAIL, “ARC-Seal[3] did not validate”ARC_ALLOWvalid, per section 5.1.1

Two controls establish which way the fault runs. Put the reordered headers back into i= order and dkimpy passes again, so the cryptography of the chain is untouched and position alone accounts for the failure. Run the reorder with a body tamper, then run it with a corrupted i2 AMS, and Rspamd rejects on both, so Rspamd is validating the reordered chain rather than accepting it unexamined.

3. Reproduction

  1. Build a valid three-hop chain under the corrected rig: process-unique selectors,

2048-bit keys, a fresh per-run domain, TTL 0.

  1. Re-serialize the ARC-Set headers so their physical order on the wire no longer matches

their i= values. Change no bytes inside any header.

# wire order after re-serialisation, i= values and header bytes unchanged
ARC-Seal: i=2; ...
ARC-Message-Signature: i=2; ...
ARC-Authentication-Results: i=2; ...
ARC-Seal: i=1; ...
ARC-Message-Signature: i=1; ...
ARC-Authentication-Results: i=1; ...
ARC-Seal: i=3; ...
ARC-Message-Signature: i=3; ...
ARC-Authentication-Results: i=3; ...
  1. Validate the chain with dkimpy arc_verify and with Rspamd.

Expected, under RFC 8617 section 5.1.1. Reconstruction proceeds by i=, every seal checks out, and the result is cv=pass.

Observed. Rspamd answers ARC_ALLOW. dkimpy answers cv=FAIL, citing “ARC-Seal[3] did not validate”.

Re-sorting those same headers into i= order returns dkimpy to a pass.

4. Assessment

Who is affected: anyone verifying with dkimpy, and then only when some hop along the path writes the ARC sets out of i= order. What they lose is the ARC pass on a forwarding path whose chain would otherwise have carried through. Carrying it through is the whole point of ARC.

An attacker gets nothing out of this. Every direction tested closes rather than opens. Chains that are misordered, and chains that are mismatched, come back rejected and never accepted, and the reorder-plus-tamper controls show that the reorder introduces no acceptance that was not already there. Hence the Low rating, and hence no submission.

The bill is paid by the laboratory, not by any deployment. Every ARC number recorded here before the rig correction has to be treated as untrustworthy. That is why the correction leads this entry and the divergence follows it.

5. Remediation

Change the header selection in verify_sig so that the seal hash is built from ARC-Set headers chosen by i= value rather than by where they appear in the message. Done that way, the hash input lines up with the increasing-instance order ARC.verify_instance already computes, which is the order RFC 8617 section 5.1.1 specifies. Wire order should have no bearing on the result.

6. Additional notes

Why nothing is submitted. There are two reasons, and either one settles it. The first: what this work produced is a repaired test rig, not a product defect. A stale cached public key and an arc_sign call that quietly returned a single instance were both generating false results, and the worth of this entry lies in retiring those results and putting corrected ones in their place. The second: the surviving divergence only ever refuses. dkimpy turns down a chain it ought to accept, and it accepts nothing it ought to turn down. That makes it a caveat about the reference oracle, and material for an interoperability report to the dkimpy maintainers. It does not make it a vulnerability submission.

A second harness lesson from the same period is worth recording, since it produced a false positive of its own. OpenDMARC 1.4.2 knows nothing about ARC. An Authentication-Results header injected from outside is not the receiver's verdict and must never be read as one. An ARC override that seems to arrive by that route came from the harness.

Still to do: put the entire ARC vein back through the corrected rig, settle the multi-hop consensus on clean keys one way or the other, and revise the ledger entry that called multi-hop ARC closed.

7. References