Sparkn

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

Funds can be stuck in Proxy contract forever if receiver is blacklisted on the distributed token (USDC, USDT)

Summary

If the ERC20 token used for the contest's reward is having additional functionality that can make the token transfer revert (2 links added above), basically that will break the entire contest distribution process, as it will revert, even if only one transfer fails, which have multiples negavite impacts. But the worst will be in the case the protocol address (aka STADIUM_ADDRESS) is causing this revert, which is why this is submitted as a High (even if low probability), as this would make the funds unretrievable and lost.

Vulnerability Details

As a concrete example, USDC and USDT contract have a blacklist functionality, which allow those contracts to blacklist certain address according to their own criterias which we can't predict, and if any supporter or the STADIUM_ADDRESS is blacklisted, then the distribution process will revert. This affects all the distrdeployProxyAndDistribute* functions and distributeByOwner.

Impact

  1. supporter being blacklisted:
    The only way to retreive the funds in this case will be to wait for the contest to expire and then the owner will be able to call distributeByOwner to get the fund back.

  2. STADIUM_ADDRESS being blacklisted:
    This would be critical, as all the current deployed contests will be stuck with their funds not being able to be distributed to anyone, not even to the protocol. The only way out of this hole would be to have the token to unBlacklist the protocol address, but that is not something the protocol want to get into I assume.

PoC

I added this test testFailsIfBlacklisted in ProxyFactoryTest.t.sol and added blacklist functionality to MockERC20 as follow, which confirm the revert when calling distributeByOwner with protocol address being blacklisted.

function testFailsIfBlacklisted()
public
setUpContestForJasonAndSentJpycv2Token(organizer)
{
// before
assertEq(MockERC20(jpycv2Address).balanceOf(user1), 0 ether);
assertEq(MockERC20(jpycv2Address).balanceOf(stadiumAddress), 0 ether);
// prepare for data
bytes32 randomId_ = keccak256(abi.encode("Jason", "001"));
bytes32 salt_ = keccak256(abi.encode(organizer, randomId_, address(distributor)));
bytes memory data = createData();
// calculate proxy address
address calculatedProxyAddress = proxyFactory.getProxyAddress(salt_, address(distributor));
// owner deploy and distribute
vm.warp(16 days);
vm.startPrank(factoryAdmin);
address proxyAddress =
proxyFactory.deployProxyAndDistributeByOwner(organizer, randomId_, address(distributor), data);
vm.stopPrank();
assertEq(proxyAddress, calculatedProxyAddress);
// sponsor send token to proxy by mistake
vm.startPrank(sponsor);
MockERC20(jpycv2Address).transfer(proxyAddress, 10000 ether);
vm.stopPrank();
// some time passed and during that time the stadiumAddress was blacklisted in USDC or USDT
vm.warp(30 days);
MockERC20(jpycv2Address).blacklist(stadiumAddress); // COMMENT THIS LINE WILL MAKE THE TEST PASS
// we try to recover the fund, but that fails.
bytes memory dataToSendToAdmin = createDataToSendToAdmin();
vm.startPrank(factoryAdmin);
proxyFactory.distributeByOwner(
calculatedProxyAddress, organizer, randomId_, address(distributor), dataToSendToAdmin
);
vm.stopPrank();
// after
assertEq(MockERC20(jpycv2Address).balanceOf(user1), 9500 ether);
assertEq(MockERC20(jpycv2Address).balanceOf(stadiumAddress), 10500 ether);
// stadiumAddress get 500 ether from sponsor and then get all the token sent from sponsor by mistake.
}
contract MockERC20 is ERC20, Ownable {
error MockERC20__AmountMustBeMoreThanZero();
mapping(address => bool) private blacklisted;
/**
* @dev Throws if argument account is blacklisted
* @param _account The address to check
*/
modifier notBlacklisted(address _account) {
require(
!blacklisted[_account],
"Blacklistable: account is blacklisted"
);
_;
}
constructor(string memory name, string memory symbol) ERC20(name, symbol) {
_mint(msg.sender, 100000 * 10 ** decimals());
}
function mint(address _to, uint256 _amount) external onlyOwner returns (bool) {
if (_amount == 0) {
revert MockERC20__AmountMustBeMoreThanZero();
}
_mint(_to, _amount);
return true;
}
function blacklist(address _badboy) external {
blacklisted[_badboy] = true;
}
function transfer(address to, uint256 amount)
public
override
notBlacklisted(msg.sender)
notBlacklisted(to)
returns (bool)
{
super.transfer(to, amount);
return true;
}
}

Tools Used

VS code and test suite.

Recommendations

  • A supporter being blacklisted should not prevent the whole distribution. So for example you could use transfer instead of safeTransfer and use a try/catch to avoid the revert (as shown below), and probably send this failed percentage to the STADIUM_ADDRESS.

  • The STADIUM_ADDRESS being blacklisted is more critical and tricky to address. I would probably add a function in ProxyFactory with an onlyOwner that would allow to update the protocol address of a deployed contest (which would make it not immutable, granted)

@@ -144,7 +144,12 @@ contract Distributor {
uint256 winnersLength = winners.length; // cache length
for (uint256 i; i < winnersLength;) {
uint256 amount = totalAmount * percentages[i] / BASIS_POINTS;
- erc20.safeTransfer(winners[i], amount);
+
+ try erc20.transfer(winners[i], amount) {
+ } catch {
+ }
+
unchecked {
++i;

Informational

Please remove this magic number.

diff --git a/src/Distributor.sol b/src/Distributor.sol
index 2b8d67a..cec2984 100644
--- a/src/Distributor.sol
+++ b/src/Distributor.sol
@@ -126,17 +126,17 @@ contract Distributor {
uint256 percentagesLength = percentages.length;
uint256 totalPercentage;
for (uint256 i; i < percentagesLength;) {
totalPercentage += percentages[i];
unchecked {
++i;
}
}
// check if totalPercentage is correct
- if (totalPercentage != (10000 - COMMISSION_FEE)) {
+ if (totalPercentage != (BASIS_POINTS - COMMISSION_FEE)) {
revert Distributor__MismatchedPercentages();
}
IERC20 erc20 = IERC20(token);
uint256 totalAmount = erc20.balanceOf(address(this));
// if there is no token to distribute, then revert
if (totalAmount == 0) revert Distributor__NoTokenToDistribute();

Support

FAQs

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