Platform: Code4rena
Start Date: 30/11/2021
Pot Size: $100,000 USDC
Total HM: 15
Participants: 36
Period: 7 days
Judge: 0xean
Total Solo HM: 4
Id: 62
League: ETH
Rank: 5/36
Findings: 6
Award: $5,433.20
🌟 Selected for report: 4
🚀 Solo Findings: 1
385.4189 USDC - $385.42
hyh
depositTokenFlashloanFeeAmount
can be stolen with side effect of either rewardTokenFeeAmount
or depositTokenAmount
to be frozen within the contract.
If there are deposit token flash loan fees an attacker can wait for endDepositLock
timestamp to pass and immediately run recoverTokens
which doesn't account for depositTokenFlashloanFeeAmount
and will allow attacker to withdraw all the flash loan fees accumulated for deposit tokens flash loans.
Side effect of this is that either
rewardTokenFeeAmount
will be frozen within the contract as claimFees require both fee amounts, rewardTokenFeeAmount
and depositTokenFlashloanFeeAmount
to be transferred successfully, while the latter will be already gone, so transfer will fail on any run:
https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L625or
creatorClaimSoldTokens
will fail in the similar manner on transfer attempt if claimFees
was run first, retrieving depositTokenFlashloanFeeAmount
at the expense of depositTokenAmount
, which will be frozen in the contract this way:
https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L597Both (1) and (2) will lead to fund freeze because recoverTokens
do account for depositTokenAmount
and will not allow fund retrieval when the contract balance is less than that. Which variant will happen depends on what function be called first, correspondingly creatorClaimSoldTokens
or claimFees
.
depositTokenFlashloanFeeAmount
isn't accounted in recoverTokens
: https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L654
This way after endDepositLock
timestamp pass anyone will be able to retrieve depositTokenFlashloanFeeAmount
, which recoverTokens
will treat as extra unaccounted funds.
Knowing this, an attacker will track the timestamp and run the transaction with highest economically viable gas price in the closest time slot, retrieving depositTokenFlashloanFeeAmount
and causing either (1) or (2) scenario of deposit token funds freeze as a side effect (i.e. this is not grieving attack per se, but fund freeze comes a side effect as some part of the funds become missing, disabling the retrieval functionality).
Add deposit flash loans fee to the recoverTokens
logic (also including here the Sale case logic described in another issue posted earlier as this is an unrelated improvement to the same lines of code, i.e. 'to be' here is a merged version):
Now:
if (token == depositToken) { ... uint256 excess = ERC20(token).balanceOf(address(this)) - (depositTokenAmount - redeemedDepositTokens);
To be:
if (token == depositToken) { ... uint256 accountedDepositTotal = depositTokenAmount; uint256 redeemedDepositTotal = isSale ? (claimedDepositTokens ? accountedDepositTotal : 0) : redeemedDepositTokens; uint256 excess = ERC20(token).balanceOf(address(this)) - (accountedDepositTotal - redeemedDepositTotal + depositTokenFlashloanFeeAmount);
#0 - 0xean
2022-01-14T20:51:50Z
duplicate of #241
🌟 Selected for report: cmichel
Also found by: GeekyLumberjack, hyh, kenzo, pedroais
hyh
Reward tokens accidently sent to the Stream contract cannot be recovered with recoverTokens if some reward tokens were already claimed with claimReward. As recoverTokens is the only recovering functionality in the contract the corresponding reward tokens will be frozen.
claimReward doesn't change rewardTokenAmount as other user's rewards are calculated based off it. But reward token balance of the Stream contract does change with each reward payoff: https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L575
This way reward tokens already claimed aren't accounted for in recoverTokens as only rewardTokenAmount is used there: https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L672
It looks like no fund stealing is possible here, the vulnerability only allows for freezing of the reward tokens accidently sent to the contract.
Add claimedRewardTokens variable tracking cumulative reward tokens claimed
Now:
uint112 private rewardTokenAmount; ... function claimReward() public lock { ... uint256 rewardAmt = ts.rewards; ... ERC20(rewardToken).safeTransfer(msg.sender, rewardAmt); emit RewardsClaimed(msg.sender, rewardAmt); } ... function recoverTokens(address token, address recipient) public lock { ... if (token == rewardToken) { ... uint256 excess = ERC20(token).balanceOf(address(this)) - (rewardTokenAmount + rewardTokenFeeAmount); ERC20(token).safeTransfer(recipient, excess); ...
To be:
uint112 private rewardTokenAmount; uint112 private claimedRewardTokens; ... function claimReward() public lock { ... uint256 rewardAmt = ts.rewards; ... claimedRewardTokens += rewardAmt; ERC20(rewardToken).safeTransfer(msg.sender, rewardAmt); emit RewardsClaimed(msg.sender, rewardAmt); } ... function recoverTokens(address token, address recipient) public lock { ... if (token == rewardToken) { ... uint256 excess = ERC20(token).balanceOf(address(this)) - (rewardTokenAmount - claimedRewardTokens + rewardTokenFeeAmount); ERC20(token).safeTransfer(recipient, excess); ...
#0 - 0xean
2022-01-14T21:14:02Z
dupe of #214
🌟 Selected for report: harleythedog
hyh
Stream's recoverTokens will not allow recover any deposit tokens after Stream creator has claimed the depositTokenAmount of deposit tokens via creatorClaimSoldTokens in the sale Stream type case.
As there are no other token recovery mechanics the remaining funds, if any, will be effectively frozen within the contract.
When the Stream is a Sale after creatorClaimSoldTokens was successfully called the depositTokenAmount of depositToken will be transferred away: https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L583
Since this the recoverTokens logic will not work for depositToken as it will not allow withdrawing up to depositTokenAmount: https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L646
While there shouldn't be any deposit tokens left on the balance and so all extra deposit token should be withdrawable. This way any deposit tokens left will be frozen within the contract.
Update the logic to account for creator's ability to claim deposit token stash when Stream type is Sale.
Now:
if (token == depositToken) { ... uint256 excess = ERC20(token).balanceOf(address(this)) - (depositTokenAmount - redeemedDepositTokens);
To be (adding extra memory variable to save on storage reads):
if (token == depositToken) { ... uint256 accountedDepositTotal = depositTokenAmount; uint256 redeemedDepositTotal = isSale ? (claimedDepositTokens ? accountedDepositTotal : 0) : redeemedDepositTokens; uint256 excess = ERC20(token).balanceOf(address(this)) - (accountedDepositTotal - redeemedDepositTotal);
#0 - brockelmore
2022-01-05T17:26:46Z
duplicate #121
🌟 Selected for report: hyh
2417.4456 USDC - $2,417.45
hyh
Any airdrop gathered with arbitraryCall will be immediately lost as an attacker can track arbitraryCall transactions and back run them with calls to recoverTokens, which doesn't track any tokens besides reward, deposit and incentive tokens, and will give the airdrop away.
arbitraryCall requires that tokens to be gathered shouldn't be reward, deposit or incentive tokens: https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L735
Also, the function doesn't mark gathered tokens in any way. Thus, the airdrop is freely accessible for anyone to be withdrawn with recoverTokens: https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L687
Add airdrop tokens balance mapping, record what is gathered in arbitraryCall and prohibit their free withdrawal in recoverTokens similarly to incentives[].
Now:
mapping (address => uint112) public incentives; ... function recoverTokens(address token, address recipient) public lock { ... if (incentives[token] > 0) { ... uint256 excess = ERC20(token).balanceOf(address(this)) - incentives[token]; ... }
To be:
mapping (address => uint112) public incentives; mapping (address => uint112) public airdrops; ... function recoverTokens(address token, address recipient) public lock { ... if (incentives[token] > 0) { ... uint256 excess = ERC20(token).balanceOf(address(this)) - incentives[token]; ... } if (airdrops[token] > 0) { ... uint256 excess = ERC20(token).balanceOf(address(this)) - airdrops[token]; ... } ... // we do know what airdrop token will be gathered function arbitraryCall(address who, bytes memory data, address token) public lock externallyGoverned { ... // get token balances uint256 preDepositTokenBalance = ERC20(depositToken).balanceOf(address(this)); uint256 preRewardTokenBalance = ERC20(rewardToken).balanceOf(address(this)); uint256 preAirdropBalance = ERC20(token).balanceOf(address(this)); (bool success, bytes memory _ret) = who.call(data); require(success); uint256 postAirdropBalance = ERC20(token).balanceOf(address(this)); require(postAirdropBalance <= type(uint112).max, "air_112"); uint112 amt = uint112(postAirdropBalance - preAirdropBalance); require(amt > 0, "air"); airdrops[token] += amt;
#0 - brockelmore
2021-12-06T18:28:12Z
The intention is that the claim airdrop + transfer is done atomically. Compound-style governance contracts come with this ability out of the box.
#1 - 0xean
2022-01-15T01:17:29Z
Going to agree with the warden that as the code is written this is an appropriate risk to call out and be aware of. Downgrading in severity because it relies on external factors but there is no on chain enforcement that this call will be operated correctly and therefore believe it represent a valid concern even if the Sponsor has a mitigation plan in place.
hyh
As different compiler versions have critical behavior specifics if the contract gets accidentally deployed using another compiler version compared to one they tested with, various types of undesired behavior can be introduced.
LockeERC20 contract use pragma solidity >=0.8.0
:
https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/LockeERC20.sol#L2
Fix the compiler version. ^ modifier is viable as it's done in Locke: https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L2
#0 - brockelmore
2021-12-06T16:58:13Z
duplicate #19
🌟 Selected for report: hyh
Also found by: 0x1f8b, Meta0xNull
hyh
Being instantiated with wrong configuration, the contract is inoperable and deploy gas costs will be lost. If misconfiguration is noticed too late the various types of malfunctions become possible.
The checks for zero addresses during contract construction and initialization are best-practices.
Now LockeERC20 and Stream contracts do not check for correctness of constructor arguments:
LockeERC20 https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/LockeERC20.sol#L56
Stream https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L263
Add zero-address checks and key non-address variables checks in all contract constructors. Small increase of gas costs are far out weighted by wrong deploy costs savings and additional coverage against misconfiguration.
18.2633 USDC - $18.26
hyh
Gas is overspent on check (compiler is 0.8.0). As withdraw is a core user facing function even small optimizations are recommended.
withdraw function: https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L455
Also, depositTokenAmount variable always changes along with ts.tokens one, with the only difference that ts.tokens can be reduced by updateStreamInternal logic: https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L229
This way depositTokenAmount is always not less than ts.tokens and can be decreased unchecked.
Now:
require(ts.tokens >= amount, "amt"); ts.tokens -= amount; ... depositTokenAmount -= amount;
To be:
require(ts.tokens >= amount, "amt"); unchecked { ts.tokens -= amount; depositTokenAmount -= amount; }
#0 - 0xean
2022-01-17T13:42:51Z
dupe of #238
🌟 Selected for report: hyh
hyh
Gas is overspent on storage access.
https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L203
Save repeatedly accessed storage variables (lastUpdate, ts.lastUpdate, ts.tokens) to memory and use them. As each storage read is costly this will reduce function gas consumption.
Now:
if (block.timestamp >= startTime) { // set lastUpdates if need be if (ts.lastUpdate == 0) { ts.lastUpdate = uint32(block.timestamp); } if (lastUpdate == 0) { lastUpdate = uint32(block.timestamp); } ... // update users unstreamed balance uint32 acctTimeDelta = uint32(block.timestamp) - ts.lastUpdate; if (acctTimeDelta > 0 && ts.tokens > 0) { // some time has passed since this user last interacted // update ts not yet streamed ts.tokens -= uint112(acctTimeDelta * ts.tokens / (endStream - ts.lastUpdate));
To be:
if (block.timestamp >= startTime) { uint32 lastUp = lastUpdate; uint32 tsLastUp = ts.lastUpdate; uint32 tsTokens = ts.tokens; uint32 blockT = uint32(block.timestamp); // set lastUpdates if need be if (tsLastUp == 0) { ts.lastUpdate = blockT; tsLastUp = blockT; } if (lastUp == 0) { lastUpdate = uint32(block.timestamp); lastUp = blockT; } ... // update users unstreamed balance uint32 acctTimeDelta = blockT - tsLastUp; if (acctTimeDelta > 0 && tsTokens > 0) { // some time has passed since this user last interacted // update ts not yet streamed ts.tokens -= uint112(acctTimeDelta * tsTokens / (endStream - tsLastUp));
🌟 Selected for report: hyh
hyh
Gas is overspent on storage access and operations.
claimReward function has some excess operations and the number of times it reads storage can be reduced: https://github.com/code-423n4/2021-11-streaming/blob/main/Streaming/src/Locke.sol#L555
Save repeatedly accessed storage variables to memory and use them, simplify the logic:
Now:
... cumulativeRewardPerToken = rewardPerToken(); // update user rewards ts.rewards = earned(ts, cumulativeRewardPerToken); // update users last cumulative reward per token ts.lastCumulativeRewardPerToken = cumulativeRewardPerToken; lastUpdate = lastApplicableTime(); uint256 rewardAmt = ts.rewards; ts.rewards = 0; require(rewardAmt > 0, "amt"); // transfer the tokens ERC20(rewardToken).safeTransfer(msg.sender, rewardAmt); ...
To be:
... uint256 _cumulativeRewardPerToken = rewardPerToken(); uint256 rewardAmt = earned(ts, _cumulativeRewardPerToken); require(rewardAmt > 0, "amt"); cumulativeRewardPerToken = _cumulativeRewardPerToken; lastUpdate = lastApplicableTime(); // update users last cumulative reward per token and rewards ts.lastCumulativeRewardPerToken = _cumulativeRewardPerToken; ts.rewards = 0; // transfer the tokens ERC20(rewardToken).safeTransfer(msg.sender, rewardAmt); ...