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: 8/169
Findings: 5
Award: $1,689.18
π Selected for report: 1
π Solo Findings: 0
916.4102 USDC - $916.41
https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L406 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L427 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L274
Reward deltaIndex
in _accrueRewards()
is multiplied by 10**decimals()
but eventually divided by rewards.ONE
(which is equal to 10**IERC20Metadata(address(rewardToken)).decimals()
) in _accrueUser()
.
If the number of decimals in MultiRewardEscrow share token differs from the number of decimals in the reward token, then all rewards are multipled by 10 ** (decimals() - rewardToken.decimals())
.
Therefore, for example, if an admin adds USDT as the reward token with decimals=6, it will result in the reward for any user to be multiplied by ``10**(18-6) = 1000000000000` on the next block. This will at best lead to a DOS where no one will be able to withdraw funds. But at worst, users will drain the entire reward fund due to inflated calculations in the next block.
Put the following test in ./test/
folder and run with forge test --mc DecimalMismatchTest
. The test fails because of incorrect supplierDelta
calculations:
// SPDX-License-Identifier: GPL-3.0 // Docgen-SOLC: 0.8.15 pragma solidity ^0.8.15; import { Test } from "forge-std/Test.sol"; import { SafeCastLib } from "solmate/utils/SafeCastLib.sol"; import { MockERC20 } from "./utils/mocks/MockERC20.sol"; import { IMultiRewardEscrow } from "../src/interfaces/IMultiRewardEscrow.sol"; import { MultiRewardStaking, IERC20 } from "../src/utils/MultiRewardStaking.sol"; import { MultiRewardEscrow } from "../src/utils/MultiRewardEscrow.sol"; contract DecimalMismatchTest is Test { using SafeCastLib for uint256; MockERC20 stakingToken; MockERC20 rewardToken; MultiRewardStaking staking; MultiRewardEscrow escrow; address alice = address(0xABCD); address bob = address(0xDCBA); address feeRecipient = address(0x9999); function setUp() public { vm.label(alice, "alice"); vm.label(bob, "bob"); // staking token has 18 decimals stakingToken = new MockERC20("Staking Token", "STKN", 18); // reward token has 6 decimals (for example USDT) rewardToken = new MockERC20("RewardsToken1", "RTKN1", 6); escrow = new MultiRewardEscrow(address(this), feeRecipient); staking = new MultiRewardStaking(); staking.initialize(IERC20(address(stakingToken)), IMultiRewardEscrow(address(escrow)), address(this)); rewardToken.mint(address(this), 1000 ether); rewardToken.approve(address(staking), 1000 ether); staking.addRewardToken( // rewardToken IERC20(address(rewardToken)), // rewardsPerSecond 1e10, // amount 1e18, // useEscrow false, // escrowPercentage 0, // escrowDuration 0, // offset 0 ); } function testWrongSupplierDelta() public { stakingToken.mint(address(bob), 1); vm.prank(bob); stakingToken.approve(address(staking), 1); vm.prank(bob); staking.deposit(1); assert (staking.balanceOf(bob) == 1); vm.warp(block.timestamp + 1); IERC20[] memory a = new IERC20[](1); a[0] = IERC20(address(rewardToken)); vm.prank(bob); // 1 second elapsed, so Bob must get a little reward // but instead this will REVERT with "ERC20: transfer amount exceeds balance" // because the `supplierDelta` is computed incorrect and becomes too large staking.claimRewards(bob, a); } }
Manual analysis
Use the same number of decimals when calculating deltaIndex
and supplierDelta
.
#0 - c4-judge
2023-02-16T06:56:51Z
dmvt marked the issue as duplicate of #404
#1 - c4-sponsor
2023-02-18T12:07:19Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-25T20:57:55Z
dmvt marked the issue as selected for report
π 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/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L182 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L179 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L200
A hacker can drain an ERC-777 reward token funds via reentrancy. This is because in the claimRewards()
function, the transfer of the reward token which triggers the hacker's ERC-777 hook takes place before setting accruedRewards[user][_rewardTokens[i]]
to zero.
The attack scenario:
Step 1. Admin adds an ERC-777 hookable token as the reward token. In such a token, a user can set a callback that will be called every time someone sends money to them.
Step 2. Hacker accumulates rewards for this token and calls claimRewards(hackerAddress, [erc777token])
.
https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L170
Step 3. The function reads the hacker's reward amount from the contract's storage:
uint256 rewardAmount = accruedRewards[user][_rewardTokens[i]];
Step 4. The positive amount of tokens is sent to the hacker. Either on the line https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L182
_rewardTokens[i].transfer(user, rewardAmount)
or on the line https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L200
rewardToken.safeTransfer(user, payout);
Step 5. The ERC-777 hook is triggered. The hacker takes control and calls the claimRewards()
function again with the same parameters.
Step 6. In the MultiRewardStaking contract, the hacker's state has not changed, and accruedRewards[user][_rewardTokens[i]]
is still equal to its original positive value. Therefore, funds are transferred to the hacker again, and the hook is triggered again, from which they can repeat the attack again.
Step 7. After the hacker repeatedly executes the reentrancy, the code flow finally reaches line 186 and sets the hacker's accrued rewards to zero without any revert. https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L186
accruedRewards[user][_rewardTokens[i]] = 0;
Since no errors occur, the hacker gets a profit.
Manual analysis
Follow the Check-Effect-Interaction pattern and set the state variable accruedRewards[user][_rewardTokens[i]]
to zero before doing an external transfer call.
#0 - c4-judge
2023-02-16T07:40:30Z
dmvt marked the issue as duplicate of #54
#1 - c4-sponsor
2023-02-18T12:11:23Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:46:36Z
dmvt marked the issue as partial-50
#3 - c4-judge
2023-03-01T00:42:25Z
dmvt marked the issue as satisfactory
π Selected for report: waldenyan20
Also found by: KingNFT, hansfriese, ulqiorra
704.9309 USDC - $704.93
https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L124 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L127
Function _withdraw()
can be called from an approved caller
to withdraw owner
funds. The function accrues rewards for caller
and receiver
but misses the accrual for owner
.
If, for example, the owner didn't accrue any reward from the beginning of time and all staking tokens are withdrawn from the owner
by the caller
, then the owner
will be left with zero shares and his supplierDelta
will be calculated as zero. This means that the owner may lose all rewards regardless of how long their stake was active.
Put the following test in ./test/
folder and run with forge test --mc MissedAccrueTest
. The test fails because Alice's rewards were not accrued:
// SPDX-License-Identifier: GPL-3.0 // Docgen-SOLC: 0.8.15 pragma solidity ^0.8.15; import { Test } from "forge-std/Test.sol"; import { SafeCastLib } from "solmate/utils/SafeCastLib.sol"; import { MockERC20 } from "./utils/mocks/MockERC20.sol"; import { IMultiRewardEscrow } from "../src/interfaces/IMultiRewardEscrow.sol"; import { MultiRewardStaking, IERC20 } from "../src/utils/MultiRewardStaking.sol"; import { MultiRewardEscrow } from "../src/utils/MultiRewardEscrow.sol"; contract MissedAccrueTest is Test { using SafeCastLib for uint256; MockERC20 stakingToken; MockERC20 rewardToken; MultiRewardStaking staking; MultiRewardEscrow escrow; address alice = address(0xABCD); address bob = address(0xDCBA); address feeRecipient = address(0x9999); function setUp() public { vm.label(alice, "alice"); vm.label(bob, "bob"); stakingToken = new MockERC20("Staking Token", "STKN", 18); rewardToken = new MockERC20("RewardsToken1", "RTKN1", 18); escrow = new MultiRewardEscrow(address(this), feeRecipient); staking = new MultiRewardStaking(); staking.initialize(IERC20(address(stakingToken)), IMultiRewardEscrow(address(escrow)), address(this)); rewardToken.mint(address(this), 1000 ether); rewardToken.approve(address(staking), 1000 ether); staking.addRewardToken( // rewardToken IERC20(address(rewardToken)), // rewardsPerSecond 1e10, // amount 1e18, // useEscrow false, // escrowPercentage 0, // escrowDuration 0, // offset 0 ); } function testMissedAccrual() public { assert (stakingToken.balanceOf(bob) == 0); assert (stakingToken.balanceOf(alice) == 0); stakingToken.mint(address(bob), 1); stakingToken.mint(address(alice), 1); vm.prank(bob); stakingToken.approve(address(staking), 1); vm.prank(alice); stakingToken.approve(address(staking), 1); vm.prank(bob); staking.deposit(1); vm.prank(alice); staking.deposit(1); vm.prank(alice); staking.approve(bob, 1); assert (staking.balanceOf(bob) == 1); assert (staking.balanceOf(alice) == 1); vm.warp(block.timestamp + 1); vm.prank(bob); staking.withdraw(1, bob, alice); IERC20[] memory a = new IERC20[](1); a[0] = IERC20(address(rewardToken)); staking.claimRewards(bob, a); assert (stakingToken.balanceOf(bob) > 0); // NOW THIS INCORRECTLY FAILS BECAUSE ALICE ACCRUAL WAS MISSED staking.claimRewards(alice, a); assert (stakingToken.balanceOf(alice) > 0); } }
Manual analysis
Accrue owner
in _withdraw()
function.
#0 - c4-judge
2023-02-16T06:49:34Z
dmvt marked the issue as duplicate of #385
#1 - c4-sponsor
2023-02-18T12:06:37Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-sponsor
2023-02-18T12:06:41Z
RedVeil marked the issue as disagree with severity
#3 - c4-judge
2023-02-28T15:27:23Z
dmvt marked the issue as satisfactory
27.7503 USDC - $27.75
https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L308 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L357
The changeRewardSpeed()
function computes rewardsEndTimestamp
incorrectly for the case block.timestamp < prevEndTime
. For example, it may increase the rewardsEndTimestamp
by several years forward despite the fact that the fund will be enough for one day only. As a result, some users will claim extra rewards at the expense of other users.
The function changeRewardSpeed()
calculates rewardsEndTimestamp
via
_calcRewardsEnd( prevEndTime > block.timestamp ? prevEndTime : block.timestamp.safeCastTo32(), rewardsPerSecond, remainder )
If the prevEndTime > block.timestamp
then it can be reduced to _calcRewardsEnd(prevEndTime, rewardsPerSecond, remainder)
where the remainder
is the contract's reward token balance.
Then, in the _calcRewardsEnd()
the first condition is met and the amount
value is increased from remainder
to remainder + rewardsPerSecond * (timeLeft)
:
if (rewardsEndTimestamp > block.timestamp) amount += uint256(rewardsPerSecond) * (rewardsEndTimestamp - block.timestamp);
Thus, the amount
becomes larger the original contract's reward token balance and the new rewardsEndTimestamp
is calculated incorrectly:
return (block.timestamp + (amount / uint256(rewardsPerSecond))).safeCastTo32();
You can use the following forge test to check for the error. Put the following test in ./test/
folder and run with forge test --mc IncorrectComputation
. The test fails because rewardsEndTimestamp
calculations are wrong:
// SPDX-License-Identifier: GPL-3.0 // Docgen-SOLC: 0.8.15 pragma solidity ^0.8.15; import { Test } from "forge-std/Test.sol"; import { SafeCastLib } from "solmate/utils/SafeCastLib.sol"; import { MockERC20 } from "./utils/mocks/MockERC20.sol"; import { IMultiRewardEscrow } from "../src/interfaces/IMultiRewardEscrow.sol"; import { MultiRewardStaking, IERC20 } from "../src/utils/MultiRewardStaking.sol"; import { MultiRewardEscrow } from "../src/utils/MultiRewardEscrow.sol"; import { RewardInfo, EscrowInfo } from "../src/interfaces/IMultiRewardStaking.sol"; contract IncorrectComputation is Test { using SafeCastLib for uint256; MockERC20 stakingToken; MockERC20 rewardToken; MultiRewardStaking staking; MultiRewardEscrow escrow; address alice = address(0xABCD); address bob = address(0xDCBA); address feeRecipient = address(0x9999); uint160 constant rps = 1 ether; uint constant funds = 100 ether; function setUp() public { vm.label(alice, "alice"); vm.label(bob, "bob"); stakingToken = new MockERC20("Staking Token", "STKN", 18); rewardToken = new MockERC20("RewardsToken1", "RTKN1", 18); escrow = new MultiRewardEscrow(address(this), feeRecipient); staking = new MultiRewardStaking(); staking.initialize(IERC20(address(stakingToken)), IMultiRewardEscrow(address(escrow)), address(this)); rewardToken.mint(address(this), 1000 ether); rewardToken.approve(address(staking), 1000 ether); staking.addRewardToken( // rewardToken IERC20(address(rewardToken)), // rewardsPerSecond rps, // amount funds, // useEscrow false, // escrowPercentage 0, // escrowDuration 0, // offset 0 ); } function testIncorrectComputation() public { stakingToken.mint(address(bob), 1); vm.prank(bob); stakingToken.approve(address(staking), 1); vm.prank(bob); staking.deposit(1); ( /*uint64 ONE1*/, /*uint160 rewardsPerSecond1*/, uint32 rewardsEndTimestamp1, /*uint224 index1*/, /*uint32 lastUpdatedTimestamp1*/ ) = staking.rewardInfos(IERC20(address(rewardToken))); assert (rewardsEndTimestamp1 == block.timestamp + funds / rps); // now we change the speed to the same number // so it shouldn't change anything // (but it incorrectly does) staking.changeRewardSpeed(IERC20(address(rewardToken)), rps); ( /*uint64 ONE2*/, /*uint160 rewardsPerSecond2*/, uint32 rewardsEndTimestamp2, /*uint224 index2*/, /*uint32 lastUpdatedTimestamp2*/ ) = staking.rewardInfos(IERC20(address(rewardToken))); // THIS WILL INCORRECTLY REVERT BECAUSE COMPUTATIONS IN changeRewardSpeed() are wrong! assert (rewardsEndTimestamp2 == rewardsEndTimestamp1); } }
Manual analysis
Fix rewardsEndTimestamp
calculations
#0 - c4-judge
2023-02-16T03:56:49Z
dmvt marked the issue as duplicate of #156
#1 - c4-sponsor
2023-02-18T11:59:21Z
RedVeil marked the issue as disagree with severity
#2 - c4-sponsor
2023-02-18T11:59:32Z
RedVeil marked the issue as sponsor confirmed
#3 - c4-judge
2023-02-23T16:05:17Z
dmvt changed the severity to QA (Quality Assurance)
#4 - Simon-Busch
2023-02-23T16:15:48Z
Revert back to M as requested by @dmvt
#5 - c4-judge
2023-02-23T22:32:25Z
dmvt marked the issue as satisfactory
#6 - c4-judge
2023-02-25T15:07:17Z
dmvt marked the issue as partial-50
π Selected for report: IllIllI
Also found by: 0x3b, 0xAgro, 0xBeirao, 0xMirce, 0xNineDec, 0xRobocop, 0xSmartContract, 0xTraub, 0xWeiss, 2997ms, 41i3xn, Awesome, Aymen0909, Bauer, Bnke0x0, Breeje, Cryptor, DadeKuma, Deathstore, Deekshith99, DevABDee, DevTimSch, Dewaxindo, Diana, Ermaniwe, Guild_3, H0, IceBear, Inspectah, JDeryl, Kaiziron, Kaysoft, Kenshin, Mukund, Praise, RaymondFam, Rickard, Rolezn, Ruhum, Sathish9098, SkyWalkerMan, SleepingBugs, UdarTeam, Udsen, Walter, aashar, adeolu, apvlki, arialblack14, ast3ros, btk, chaduke, chandkommanaboyina, chrisdior4, climber2002, codetilda, cryptonue, cryptostellar5, csanuragjain, ddimitrov22, descharre, dharma09, doublesharp, eccentricexit, ethernomad, fs0c, georgits, halden, hansfriese, hashminer0725, immeas, lukris02, luxartvinsec, matrix_0wl, merlin, mookimgo, mrpathfindr, nadin, olegthegoat, pavankv, rbserver, rebase, savi0ur, sayan, scokaf, seeu, shark, simon135, tnevler, tsvetanovv, ulqiorra, ustas, waldenyan20, y1cunhui, yongskiws, yosuke
35.4779 USDC - $35.48
Instead of implementing custom Owned
contract the Open Zeppelin's Ownable2Step could be used:
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable2Step.sol
Note that Open Zeppelin has Ownable2StepUpgradeable version too.
Consider using the type-aware abi.encodeCall
method instead of abi.encodeWithSignature
and abi.encodeWithSelector
.
if (caller != owner) _approve(owner, msg.sender, allowance(owner, msg.sender) - shares);
the caller
must be used instead of msg.sender
.
#0 - c4-judge
2023-02-27T14:30:35Z
dmvt marked the issue as grade-c
#1 - c4-judge
2023-02-28T14:30:28Z
dmvt marked the issue as grade-b