Platform: Code4rena
Start Date: 21/04/2022
Pot Size: $100,000 USDC
Total HM: 18
Participants: 60
Period: 7 days
Judge: gzeon
Total Solo HM: 10
Id: 112
League: ETH
Rank: 4/60
Findings: 5
Award: $10,066.23
🌟 Selected for report: 2
🚀 Solo Findings: 1
🌟 Selected for report: 0xDjango
Also found by: unforgiven
5790.1744 USDC - $5,790.17
I believe this to be a high severity vulnerability that is potentially included in the currently deployed StakerVault.sol
contract also. The team will be contacted immediately following the submission of this report.
In StakerVault.sol
, the user checkpoints occur AFTER the balances are updated in the transfer()
function. The user checkpoints update the amount of rewards claimable by the user. Since their rewards will be updated after transfer, a user can send funds between their own accounts and repeatedly claim maximum rewards since the pool's inception.
In every actionable function except transfer()
of StakerVault.sol
, a call to ILpGauge(lpGauge).userCheckpoint()
is correctly made BEFORE the action effects.
Assume a certain period of time has passed since the pool's inception. For easy accounting, assume poolStakedIntegral
of LpGauge.sol
equals 1
. The poolStakedIntegral
is used to keep track of the current reward rate.
Steps:
balances[A] += 1000
stakeFor()
function, userCheckpoint()
was already called so A will already have perUserShare[A]
set correctly based on their previously 0 balance and the current poolStakedIntegral
.transfer()
.perUserShare[B]
will be updated. The calculation for perUserShare
looks as follows.perUserShare[user] += ( (stakerVault.stakedAndActionLockedBalanceOf(user)).scaledMul( (poolStakedIntegral_ - perUserStakedIntegral[user]) ) );
Assuming Account B is new to the protocol, their perUserStakedIntegral[user]
will default to 0
.
perUserShare[B] += 1000 * (1 - 0) = 1000
claimRewards()
and mint all 1000 reward tokens.transfer()
and sends all 1000 staked tokens to Account C.Static review.
In StakerVault.transfer()
, move the call to ILpGauge(lpGauge).userCheckpoint()
to before the balances are updated.
#0 - chase-manning
2022-05-11T15:02:27Z
🌟 Selected for report: cccz
Also found by: 0x1f8b, 0xDjango, 0xkatana, Dravee, IllIllI, WatchPug, berndartmueller, defsec, horsefacts, hyh, kenta, rayn, reassor, sorrynotsorry
58.8714 USDC - $58.87
Calls to the Chainlink price oracle via getPriceUSD()
in ChainlinkOracleProvider.sol
use the correct function latestRoundData()
per Chainlink's documentation, but lack the recommended validations to ensure that the round is complete and does not return stale data. In addition, the current implementation checks that answer >= 0
, which is inclusive of a 0 price return.
Per the following Halborn audit, page 19, the recommended implementation is:
( roundId, rawPrice, , updateTime, answeredInRound ) = AggregatorV3Interface(XXXXX).latestRoundData(); require(rawPrice > 0 , "Chainlink price <= 0"); require(updateTime != 0 , "Incomplete round"); require(answeredInRound >= roundId , "Stale price");
The current Backd implementation performs a validation for data freshness via require(block.timestamp <= updatedAt + stalePriceDelay, Error.STALE_PRICE);
It is also recommended to add a check that answeredInRound >= roundId
to ensure the answer was computed in the latest round. Finally, the answer should not be allowed to equal 0, as this would allow for the unlimited purchase/minting of tokens.
Audit reports, manual review.
Firstly, change require(answer >= 0, Error.NEGATIVE_PRICE);
to be exclusive of 0. A zero price would be detrimental to the protocol.
Next, adding the require(answeredInRound >= roundId , "Stale price");
check will add extra validation that the data is not stale.
#0 - chase-manning
2022-04-28T11:28:18Z
Duplicate of #17
🌟 Selected for report: 0xDjango
3860.1163 USDC - $3,860.12
The _updateUserFeesOnDeposit()
function in LiquidityPool.sol
is used to update a user's withdrawal fees after an action such as deposit, transfer in, etc. The withdrawal fee decays toward a minimum withdrawal fee over a period of 1 or 2 weeks (discussed with developer). Since anyone can transfer lp tokens to any user, a griefer can transfer 1 wei of lp tokens to another user to reset their lastActionTimestamp
used in the withdrawal fee calculation.
The developers nicely weight the updated withdrawal fee by taking the original balance/original fee vs the added balance/added fee. The attacker will only be able to extend the runway of the withdrawal fee cooldown by resetting the lastActionTimestamp
for future calculations. Example below:
Assumptions:
100 wei
of shares1 wei
of shares, current withdrawal fee = 10%1 wei
of shares to User ABased on the formula to calculated User A's new feeRatio:
uint256 newFeeRatio = shareExisting.scaledMul(newCurrentFeeRatio) + shareAdded.scaledMul(feeOnDeposit);
In reality, User A's withdrawal fee will only increase by a negligible amount since the shares added were very small in proportion to the original shares. We can assume user A's current withdrawal fee is still 5%.
The issue is that the function then reset's User A's lastActionTimestamp
to the current time. This means that User A will have to wait the maximum 2 weeks for the withdrawal fee to reduce from 5% to 0%. Effectively the cooldown runway is the same length as the original runway length, so the decay down to 0% will take twice as long.
meta.lastActionTimestamp = uint64(_getTime());
Manual Review
Instead of resetting lastActionTimestamp
to the current time, scale it the same way the feeRatio
is scaled. I understand that this would technically not be the timestamp of the last action, so the variable would probably need to be renamed.
#0 - chase-manning
2022-05-11T15:00:28Z
🌟 Selected for report: IllIllI
Also found by: 0v3rf10w, 0x52, 0xDjango, 0xkatana, Dravee, Funen, Kenshin, Ruhum, StyxRave, Tadashi, TerrierLover, TrungOre, antonttc, berndartmueller, catchup, csanuragjain, defsec, dipp, fatherOfBlocks, hake, horsefacts, hubble, jayjonah8, joestakey, kebabsec, kenta, m4rio_eth, oyc_109, pauliax, peritoflores, rayn, remora, robee, securerodd, simon135, sorrynotsorry, sseefried, z3s
272.114 USDC - $272.11
When setting the community reserve address, should check for an input 0 address.
OpenZeppelin lists this function as deprecated. It is recommended to use safeIncreaseAllowance.
OpenZeppelin Documentation: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/fcf35e5722847f5eadaaee052968a8a54d03622a/contracts/token/ERC20/utils/SafeERC20.sol#L39-L45
Lack of access control on an initialize function can be front-run, leading to redeployment.
In a couple places, transfer of an important admin address occurs without validation that the receiving address is correct. It is recommended to set the desired new address as pending until the new address is able to confirm the change. If the admin/strategist addresses are transferred incorrectly, issues will follow.
USDT for example will not work with the current implementation. The initial approve will succeed, but subsequent approvals will fail without resetting the approval to 0 first.
The require checks the access of the sender to call the function, but the require message indicates that not enough BKD has been staked.
If the user calls register()
with a maxFee = 0
then the register action will fail while they send 0 ether. The action won't be able to be enacted upon, so it's simply creating waste within the protocol. This action will show up in other function calls needlessly.
If a token with fee on transfer is used, these actions will fail.
depositAmount and protocol are switched.
🌟 Selected for report: joestakey
Also found by: 0v3rf10w, 0x1f8b, 0x4non, 0xDjango, 0xNazgul, 0xkatana, 0xmint, Dravee, Funen, IllIllI, MaratCerby, NoamYakov, Tadashi, TerrierLover, Tomio, WatchPug, catchup, defsec, fatherOfBlocks, hake, horsefacts, kenta, oyc_109, pauliax, rayn, rfa, robee, saian, securerodd, simon135, slywaters, sorrynotsorry, tin537, z3s
84.957 USDC - $84.96