F2: depositToWinternitz has no caller binding, anyone can create your vault with their own PQ key (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

QuipFactory.depositToWinternitz(bytes32 vaultId, address payable to, WinternitzAddress pqTo) is public payable with no access control. The caller supplies to and pqTo independently, and the function CREATE2-deploys the wallet and immediately calls initialize(pqTo) on it.

QuipWallet.initialize is correctly guarded (msg.sender == owner || msg.sender == quipFactory), but the factory calls it so the guard passes, and the pqTo passed through is whatever the caller supplied. There is no check that msg.sender == to.

The vault address is CREATE2(factory, vaultId, initcode(factory, to)), depending only on public inputs, and the SDK exports getVaultAddress() / computeVaultAddress() so it can be precomputed before creation.

So a third party can create your vault at exactly the address your SDK shows you, with a post-quantum key they generated:

  • the classical owner is you, so they cannot spend (msg.sender == owner blocks them);
  • the pqOwner is theirs, so you cannot spend (you cannot produce a valid WOTS+ signature);
  • any ETH that reaches that address is stuck permanently: no admin escape, no re-initialise (Already initialized has fired).

Impact

  • Unconditional, no preconditions: anyone can permanently deny any (owner, vaultId) pair for the cost of gas. Your own depositToWinternitz for that vaultId then reverts (CREATE2 to an occupied address fails).
  • Conditional on client flow: funds are permanently lost only if someone sends ETH to the precomputed address rather than going through depositToWinternitz. QuipWallet has an open receive()/fallback() and the SDK precomputes the address, so this is reachable.

Honest bounds (maintainer-requested framing): users who successfully initialize their vault first are not affected on that chain. This cannot be used to steal: claiming any funds sent to the squatted address would itself require a CRQC EOA break, since the classical owner remains the victim. Loss also relies on funds being sent outside the factory on the chain where the attacker created the vault.

Suggested fix

Bind creation to the caller with require(msg.sender == to), or, if third-party funding is desired, bind the key by folding keccak256(pqTo) into the CREATE2 salt so an attacker-chosen key produces a different, harmless address. Folding the key into the salt also removes the denial-of-vaultId problem.

Reproduced locally with 3 Foundry tests (third-party creates victim vault; neither victim nor attacker can move 1 ETH sent to it; victim can no longer create their own vault at that vaultId).

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

Source: QuipNetwork/ethereum-sdk