Platform: Code4rena
Start Date: 16/02/2023
Pot Size: $144,750 USDC
Total HM: 17
Participants: 154
Period: 19 days
Judge: Trust
Total Solo HM: 5
Id: 216
League: ETH
Rank: 9/154
Findings: 3
Award: $3,838.19
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: hyh
Also found by: Koolex, Parad0x, chaduke, hansfriese, jasonxiale, koxuan
1140.5041 USDC - $1,140.50
Detailed description of the impact of this finding.
When a user calls _withdraw()
(via withdraw()
or redeem()
) to withdraw assets, the user might lose funds due to the adjustment of value
at L400-L402.
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
We show how a user Bob might lose funds when he tries to withdraw assets from a ReaperVaultERC4626
vault.
Suppose Bob likes to withdraw 1,000,000 underlying assets, which corresponds to 1,000,000 shares (1/1)
Bob calls _withdraw(1,000,1000 Bob, Bob)
via withdraw()
or redeem()
.
_burn(_owner, _shares);
Suppose token.balanceOf(address(this) = 800,000
. Then, we are 200,000 short. The if-part gets executed, which tries to withdraw more assets from the underlying list of strategies. Since there is a loss when such withdrawal occurs, totalLoss
is used to keep track of the total loss of these withdrawals .
After making the efforts of withdrawing more assets from strategies, the asset check is performed again. Suppose now token.balanceOf(address(this) = 900,000
, that is, we are still 100,000
short. Then, in the following, the smaller of the two values, the asset balance and the original value
, is chosen as the real withdrawal amount - value
is updated.
token.balanceOf(address(this) vaultBalance = token.balanceOf(address(this)); if (value > vaultBalance) { value = vaultBalance; }
value
and the final real withdrawal amount. Suppose this totalLoss
control check passes.require( totalLoss <= ((value + totalLoss) * withdrawMaxLoss) / PERCENT_DIVISOR, "Withdraw loss exceeds slippage" );
token.safeTransfer(_receiver, value);
VScode
value
amount of underlying assets.maxWithdaw()
function to reflect both the token.balanceOf(address(this))
and the accommodation of the underlying strategies.#0 - c4-judge
2023-03-10T16:26:11Z
trust1995 marked the issue as duplicate of #723
#1 - c4-judge
2023-03-10T16:26:15Z
trust1995 marked the issue as satisfactory
2636.4342 USDC - $2,636.43
Detailed description of the impact of this finding.
lastFeeOperationTime
is not modified correctly in function _updateLastFeeOpTime()
. As a result, decayBaseRateFromBorrowing()
will decay the base rate more slowly than expected (worst case half slower).
Since borrowing base rate is so fundamental to the protocol, I would rate this finding as H.
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
We show how decayBaseRateFromBorrowing()
will decay the base rate more slowly than expected because of the wrong modification of lastFeeOperationTime
in _updateLastFeeOpTime()
:
decayBaseRateFromBorrowing()
calls _calcDecayedBaseRate()
to calculate the decayed base rate based how many minutes elapsed since last recorded lastFeeOperationTime
.function decayBaseRateFromBorrowing() external override { _requireCallerIsBorrowerOperations(); uint decayedBaseRate = _calcDecayedBaseRate(); assert(decayedBaseRate <= DECIMAL_PRECISION); // The baseRate can decay to 0 baseRate = decayedBaseRate; emit BaseRateUpdated(decayedBaseRate); _updateLastFeeOpTime(); } function _calcDecayedBaseRate() internal view returns (uint) { uint minutesPassed = _minutesPassedSinceLastFeeOp(); uint decayFactor = LiquityMath._decPow(MINUTE_DECAY_FACTOR, minutesPassed); return baseRate.mul(decayFactor).div(DECIMAL_PRECISION); } function _minutesPassedSinceLastFeeOp() internal view returns (uint) { return (block.timestamp.sub(lastFeeOperationTime)).div(SECONDS_IN_ONE_MINUTE); }
decayBaseRateFromBorrowing()
then calls _updateLastFeeOpTime()
to set lastFeeOperationTime
to the current time if at least 1 minute pass.function _updateLastFeeOpTime() internal { uint timePassed = block.timestamp.sub(lastFeeOperationTime); if (timePassed >= SECONDS_IN_ONE_MINUTE) { lastFeeOperationTime = block.timestamp; emit LastFeeOpTimeUpdated(block.timestamp); } }
The problem with such an update of lastFeeOperationTime
is, if 1.999 minutes had passed, the base rate will only decay for 1 minute, at the same time, 1.999 minutes will be added onlastFeeOperationTime
. In other words, in a worst scenario, for every 1.999 minutes, the base rate will only decay for 1 minute. Therefore, the base rate will decay more slowly then expected.
The borrowing base rate is very fundamental to the whole protocol. Any small deviation is accumulative. In the worse case, the decay speed will slow down by half; on average, it will be 0.75 slower.
VSCode
Using the effective elapsed time that is consumed by the model so far to revise lastFeeOperationTime
.
function _updateLastFeeOpTime() internal { uint timePassed = block.timestamp.sub(lastFeeOperationTime); if (timePassed >= SECONDS_IN_ONE_MINUTE) { - lastFeeOperationTime = block.timestamp; + lastFeeOperationTime += _minutesPassedSinceLastFeeOp()*SECONDS_IN_ONE_MINUTE; emit LastFeeOpTimeUpdated(block.timestamp); } }
#0 - c4-judge
2023-03-08T10:52:21Z
trust1995 marked the issue as duplicate of #850
#1 - c4-judge
2023-03-08T10:52:32Z
trust1995 changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-03-10T16:48:41Z
trust1995 marked the issue as satisfactory
#3 - c4-judge
2023-03-20T15:09:45Z
trust1995 marked the issue as selected for report
🌟 Selected for report: GalloDaSballo
Also found by: 0x3b, 0xAgro, 0xSmartContract, 0xTheC0der, 0xackermann, 0xnev, 0xsomeone, ABA, BRONZEDISC, Bjorn_bug, Bnke0x0, Breeje, Co0nan, CodeFoxInc, CodingNameKiki, DadeKuma, DeFiHackLabs, IceBear, Josiah, Kaysoft, Lavishq, MohammedRizwan, PaludoX0, PawelK, Phantasmagoria, Raiders, RaymondFam, Rickard, Rolezn, Sathish9098, SleepingBugs, SuperRayss, UdarTeam, Udsen, Viktor_Cortess, arialblack14, ast3ros, bin2chen, brgltd, btk, catellatech, ch0bu, chaduke, chrisdior4, codeislight, cryptonue, delfin454000, descharre, dontonka, emmac002, fs0c, hacker-dom, hansfriese, imare, lukris02, luxartvinsec, martin, matrix_0wl, peanuts, rbserver, shark, tnevler, trustindistrust, tsvetanovv, vagrant, yongskiws, zzzitron
61.2601 USDC - $61.26
QA1. The updateDistributionPeriod()
function has no range check for the input _newDistributionPeriod
. As a result, it might cause an overflow for L112 of function fund()
:
In addition, as _newDistributionPeriod
is an important configuration parameter, a timelock should be introduced help a smooth transition.
Mitigation: 1) introduce a range check for _newDistributionPeriod
; 2) introduce a timelock delay;
QA2. issueOath()
and fund()
have a division-followed-by-multiplication rounding error issue, because the fund()
performs a division first to calculate rewardPerSecond
and then issueOath()
uses a multiplication to calculate the amount issuance
to issue. For example, there will be around $871 lost for distributing 1M over the given 14 days period using this division-followed-by-multiplication approach.
Mitigation: we multiply rewardPerSecond
by WAD and then divide issuance
by WAD afterwards, where WAD = 1e18:
function issueOath() external override returns (uint issuance) { _requireCallerIsStabilityPool(); if (lastIssuanceTimestamp < lastDistributionTime) { uint256 endTimestamp = block.timestamp > lastDistributionTime ? lastDistributionTime : block.timestamp; uint256 timePassed = endTimestamp.sub(lastIssuanceTimestamp); - issuance = timePassed.mul(rewardPerSecond); + issuance = timePassed.mul(rewardPerSecond)/WAD; totalOATHIssued = totalOATHIssued.add(issuance); } lastIssuanceTimestamp = block.timestamp; emit TotalOATHIssuedUpdated(totalOATHIssued); } /* @dev funds the contract and updates the distribution @param amount: amount of $OATH to send to the contract */ function fund(uint amount) external onlyOwner { require(amount != 0, "cannot fund 0"); OathToken.transferFrom(msg.sender, address(this), amount); // roll any unissued OATH into new distribution if (lastIssuanceTimestamp < lastDistributionTime) { uint timeLeft = lastDistributionTime.sub(lastIssuanceTimestamp); uint notIssued = timeLeft.mul(rewardPerSecond); amount = amount.add(notIssued); } - rewardPerSecond = amount.div(distributionPeriod); + rewardPerSecond = amount.div(distributionPeriod)*WAD; lastDistributionTime = block.timestamp.add(distributionPeriod); lastIssuanceTimestamp = block.timestamp; emit LogRewardPerSecond(rewardPerSecond); }
QA3. The permit()
function fails to check v ∈ {27, 28}, so it might be subject to signature malleability.
Mitigation: Use Zeppelin's ECDSA: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol
QA4. The BorrowerOperations
contract might not work for fees-on-transfer collateral tokens.
For example, the function _moveTokensAndCollateralFromAdjust calls
safeTransferFrom()at L 497 to try to move
_collchangeamount of collateral tokens to the
BorrowerOperationscontract, however, the contract might receive less amount due to fees-on-transfer, as a result, the next statement that pull
_collChange, the same amount, to the
_activePool`` will fail due to insufficient balance of collateral tokens.
Mitigation: always measure the balance before and after transfer to decide the amount that has been transferred for fees-on-transfer tokens.
QA5: _computeNominalCR()
assumes the debt token has a 18 decimals. Need to make this explicit or adjust the debit tokens to 18 decimals as well.
QA6. Nothing concrete regarding lqtystaking
is implemented now in TroveManager.sol
, so state variable or event can be deleted regarding lqtystaking
or complete the implementation if necessary.
address public lqtyStakingAddress;
QA7. updateGovernance()
and updateGuardian()
use only one step to change address.
Changing adminS in one step is risky: if the new address is a wrong input by mistake, we will lose all the privileges of the owner.
Recommendation: Use OpenZeppelin's Ownable2Step. https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable2Step.sol
QA8. There is no way to revoke a permit of approval. It the approval permit was given to the wrong person or with the wrong amount, a revocation is necessary.
Mitigation: One can revoke a permit by increasing the nonce number.
QA9. permit()
will bypass signature check when owner = 0x0
, allowing allowance approval from the zero address and possible other future exploits (such as transfer LUSD from zero address to the attacker's address)
For example, Bob can call permit(address(0), Bob, balanceOf(adress(0)), deadline, v, r, s)
to get an allowance from zero address with the amount of the LUSD balance of the zero address.
He will pick a value s
such that it will pass the check
if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { revert('LUSD: Invalid s value'); }
Pick a future deadline so that it will pass
require(deadline >= now, 'LUSD: expired deadline');
Pick v = 27, and the value of r does not matter.
Then recoveredAddress = 0
since it will be an invalid signature.
However, the following two lines will pass and as a result, Bob gets the needed allowance from the Zero address.
require(recoveredAddress == owner, 'LUSD: invalid signature'); _approve(owner, spender, amount);
Mitigation: 1) add a check that recoveredAddress != 0
and 2) use Zeppelin's ECDSA.
QA10. The _deposit()
might suffer from a divide-by-zero error.
This will happen when _freeFunds() = 0
. Then the following line will have a divide-by-zero revert, nobody can deposit anymore
shares = (_amount * totalSupply()) / freeFunds; // use "freeFunds" instead of "pool"
Mitigation: keep a minimum reserve so that users cannot withdraw. In this way, we never have _freeFunds() = 0
when totalSupply() > 0
. Locked profit does not help since it is not part of the _freeFunds() = 0
.
QA 11. fund()
should call issueOath()
first in the beginning. Otherwise, it might move past issuance to the future.
function fund(uint amount) external onlyOwner { require(amount != 0, "cannot fund 0"); + issueOath(); OathToken.transferFrom(msg.sender, address(this), amount); // roll any unissued OATH into new distribution if (lastIssuanceTimestamp < lastDistributionTime) { uint timeLeft = lastDistributionTime.sub(lastIssuanceTimestamp); uint notIssued = timeLeft.mul(rewardPerSecond); amount = amount.add(notIssued); } rewardPerSecond = amount.div(distributionPeriod); lastDistributionTime = block.timestamp.add(distributionPeriod); lastIssuanceTimestamp = block.timestamp; emit LogRewardPerSecond(rewardPerSecond); }
#0 - c4-judge
2023-03-10T17:00:08Z
trust1995 marked the issue as grade-b
#1 - c4-sponsor
2023-03-29T01:01:26Z
0xBebis marked the issue as sponsor acknowledged