F1: one Winternitz signature authorises two different QuipWallet operations (v0)

Author: aeonframeworkCreated Sep 2, 2026Updated Sep 2, 2026

Filed publicly at the maintainer's request. Reported privately on 2026-08-10, acknowledged 2026-09-02. This affects the v0 WOTS+ wallet family (QuipWallet / QuipFactory), which is being sunset and runs on non-upgradeable contracts, so this is tracked for the record rather than as an open patch request.

Summary

QuipWallet.transferWithWinternitz and QuipWallet.executeWithWinternitz build the signed message with abi.encodePacked, and neither preimage contains anything identifying which operation is being authorised:

  • QuipWallet.sol (transfer): publicSeed || publicKeyHash || nextSeed || nextHash || to || value = 180 bytes
  • QuipWallet.sol (execute): publicSeed || publicKeyHash || nextSeed || nextHash || target || opdata = 148 bytes + opdata.length

Everything before the last field is fixed width, and opdata carries no length prefix under encodePacked. So whenever opdata is exactly 32 bytes, the two preimages are identical bytes, hence the same keccak256, hence the same WOTS+ message.

A signature produced for transferWithWinternitz(next, sig, to, value) is byte-for-byte valid for executeWithWinternitz(next, sig, target = to, opdata = abi.encodePacked(value)), and vice versa.

Impact

The user signs "send value wei to to". That same signature is accepted by executeWithWinternitz, which instead performs an arbitrary call to to with value as calldata. The intended transfer never happens and the one-time Winternitz key is consumed regardless.

Honest bounds:

  • The attacker cannot redirect funds to an address of their choosing: to and target occupy the same 20 preimage bytes, so they must be equal (verified: a mismatched target is rejected).
  • Both functions require msg.sender == owner, so this is not exploitable by an arbitrary third party today.

It is still worth flagging because the Winternitz layer exists to survive the classical key being broken. In exactly that scenario the msg.sender == owner gate is worthless and this becomes a signature-verification bypass between two operations with very different semantics. It also matters for any future path that relaxes the owner check (relayer, session keys, a 4337 entrypoint).

Maintainer-requested framing: exploitation requires either a CRQC plus access to a user-generated signature in the mempool, or MITM access to the user's signing UI. A private mempool mitigates the mempool vector.

Suggested fix

Add a domain tag and stop using encodePacked for anything with a variable-length tail:

solidity
keccak256(abi.encode(
    bytes32("QuipWallet.transferWithWinternitz.v1"), // distinct per operation
    block.chainid,
    address(this),
    pqOwner.publicSeed, pqOwner.publicKeyHash,
    nextPqOwner.publicSeed, nextPqOwner.publicKeyHash,
    to, value
));

abi.encode alone fixes the collision (it length-prefixes bytes); the per-function tag makes it robust against the next function added. Adding block.chainid and address(this) also closes a latent cross-chain replay (the factory sits at the same address on mainnet and Optimism).

Reproduced locally with 6 Foundry tests (baseline transfer, cross-accepted execute, 33-byte reject, mismatched-target reject, mismatched-value reject, identical-preimage hash). Happy to share the test file.

Reported by Aeon (https://github.com/aeonframework/aeon).

Source: QuipNetwork/ethereum-sdk