Replay Window
Briefing
An off-chain service signs vouchers that let users claim reward tokens. The same contract is deployed at the same address on two networks.
// Quantix Security Research Network — training challenge.// Intentionally vulnerable. Written for education; do not deploy.// SPDX-License-Identifier: MITpragma solidity 0.8.24; interface IMintable { function mint(address to, uint256 amount) external;} contract VoucherClaims { address public immutable signer; IMintable public immutable token; constructor(address _signer, IMintable _token) { signer = _signer; token = _token; } function claim(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { bytes32 digest = keccak256(abi.encodePacked(to, amount)); address recovered = ecrecover(digest, v, r, s); require(recovered == signer, "bad signature"); token.mint(to, amount); }}Hints
Reveal one at a time. Each moves a step closer to the answer.
- H1
Hidden
- H2
Hidden
- H3
Hidden
Solution
Try writing your own explanation and fix before opening it.
The digest commits only to `to` and `amount`. Nothing records that a voucher was used, so it can be replayed on the same chain indefinitely. Because the digest contains no chain ID or contract address, the same voucher is also valid on the other network and on any other contract that trusts the same signer.
There is no expiry, so a voucher leaked months later is still valid. `ecrecover` returns the zero address for malformed signatures; if `signer` were misconfigured as zero, any garbage signature would pass. The raw `ecrecover` also accepts malleable high-s signatures, which matters as soon as signatures are used as identifiers.
Fix: use EIP-712 typed data with a domain separator that includes chain ID and verifying contract, add a per-voucher nonce (or ID) recorded as used, add a deadline, and verify with a library that rejects malleable signatures and the zero address.
+import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";+import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";+-contract VoucherClaims {+contract VoucherClaims is EIP712("VoucherClaims", "1") {+ bytes32 private constant VOUCHER_TYPEHASH =+ keccak256("Voucher(address to,uint256 amount,uint256 nonce,uint256 deadline)");+ mapping(uint256 => bool) public usedNonce; ...- function claim(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {- bytes32 digest = keccak256(abi.encodePacked(to, amount));- address recovered = ecrecover(digest, v, r, s);- require(recovered == signer, "bad signature");+ function claim(address to, uint256 amount, uint256 nonce, uint256 deadline, bytes calldata sig) external {+ require(block.timestamp <= deadline, "expired");+ require(!usedNonce[nonce], "used");+ bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, to, amount, nonce, deadline));+ require(ECDSA.recover(_hashTypedDataV4(structHash), sig) == signer, "bad signature");+ usedNonce[nonce] = true; token.mint(to, amount); }