F1: one Winternitz signature authorises two different QuipWallet operations (v0)
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 bytesQuipWallet.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:
toandtargetoccupy 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:
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