Platform: Code4rena
Start Date: 05/07/2023
Pot Size: $390,000 USDC
Total HM: 136
Participants: 132
Period: about 1 month
Judge: LSDan
Total Solo HM: 56
Id: 261
League: ETH
Rank: 65/132
Findings: 7
Award: $345.29
π Selected for report: 0
π Solo Findings: 0
π Selected for report: zzzitron
Also found by: 0xRobocop, 0xSky, 0xrugpull_detector, KIntern_NA, RedOneN, bin2chen, carrotsmuggler, chaduke, kutugu, minhtrng, mojito_auditor, plainshift, zzebra83
46.9428 USDC - $46.94
BigBang only considers totalBorrow.elastic == 0
and totalBorrow.elastic > _maxDebtPoint
case, but does not take into account 0 <totalBorrow.elastic < debtStartPoint
.
When this happens, the subtraction will overflow, causing getDebtRate
and _accrue
revert, and since it is an interest calculation, it will DOS the lending, withdrawing, and clearing operations of the relevant markets.
function getDebtRate() public view returns (uint256) { if (_isEthMarket) return penrose.bigBangEthDebtRate(); // default 0.5% if (totalBorrow.elastic == 0) return minDebtRate; uint256 _ethMarketTotalDebt = BigBang(penrose.bigBangEthMarket()) .getTotalDebt(); uint256 _currentDebt = totalBorrow.elastic; uint256 _maxDebtPoint = (_ethMarketTotalDebt * debtRateAgainstEthMarket) / 1e18; if (_currentDebt >= _maxDebtPoint) return maxDebtRate; // @audit may revert here uint256 debtPercentage = ((_currentDebt - debtStartPoint) * DEBT_PRECISION) / (_maxDebtPoint - debtStartPoint); uint256 debt = ((maxDebtRate - minDebtRate) * debtPercentage) / DEBT_PRECISION + minDebtRate; if (debt > maxDebtRate) return maxDebtRate; return debt; }
Manual review
if (totalBorrow.elastic < debtStartPoint) return minDebtRate;
Context
#0 - c4-pre-sort
2023-08-04T22:26:54Z
minhquanym marked the issue as duplicate of #1370
#1 - c4-pre-sort
2023-08-04T22:30:38Z
minhquanym marked the issue as duplicate of #1046
#2 - c4-judge
2023-09-18T13:13:52Z
dmvt changed the severity to 3 (High Risk)
#3 - c4-judge
2023-09-18T13:14:23Z
dmvt marked the issue as satisfactory
π Selected for report: GalloDaSballo
Also found by: 0xfuje, GalloDaSballo, Ruhum, Vagner, jasonxiale, kutugu, mojito_auditor
58.8874 USDC - $58.89
https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/compound/CompoundStrategy.sol#L115 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/compound/CompoundStrategy.sol#L159
Note that this also applies to other strategy contracts: When currentBalance is calculated, the view method is used to obtain exchangeRate, which is stale value.
As a result, the user will burn more share in the withdraw, and the amount of extra redeem will not be transferred to the user, but only the amount of input will be transferred, resulting in the extra funds being stuck in the contract forever.
function _currentBalance() internal view override returns (uint256 amount) { uint256 shares = cToken.balanceOf(address(this)); uint256 pricePerShare = cToken.exchangeRateStored(); uint256 invested = (shares * pricePerShare) / (10 ** 18); uint256 queued = wrappedNative.balanceOf(address(this)); return queued + invested; }
The stale exchangeRate value will cause two problems:
function _withdraw( address to, uint256 amount ) internal override nonReentrant { uint256 available = _currentBalance(); // @audit undervalue currentBalance may revert require(available >= amount, "CompoundStrategy: amount not valid"); uint256 queued = wrappedNative.balanceOf(address(this)); if (amount > queued) { uint256 pricePerShare = cToken.exchangeRateStored(); uint256 toWithdraw = (((amount - queued) * (10 ** 18)) / pricePerShare); cToken.redeem(toWithdraw); INative(address(wrappedNative)).deposit{ value: address(this).balance }(); } require( wrappedNative.balanceOf(address(this)) >= amount, "CompoundStrategy: not enough" ); // @audit overvalue currentBalance may revert wrappedNative.safeTransfer(to, amount); emit AmountWithdrawn(to, amount); }
function _withdraw( address to, uint256 amount ) internal override nonReentrant { uint256 available = _currentBalance(); require(available >= amount, "CompoundStrategy: amount not valid"); uint256 queued = wrappedNative.balanceOf(address(this)); if (amount > queued) { uint256 pricePerShare = cToken.exchangeRateStored(); uint256 toWithdraw = (((amount - queued) * (10 ** 18)) / pricePerShare); cToken.redeem(toWithdraw); INative(address(wrappedNative)).deposit{ value: address(this).balance }(); } require( wrappedNative.balanceOf(address(this)) >= amount, "CompoundStrategy: not enough" ); // @audit undervalue toWithdraw may revert // @audit overvalue toWithdraw may loss funds, excess balance is stuck in the contract wrappedNative.safeTransfer(to, amount); emit AmountWithdrawn(to, amount); }
Since there are many scenarios involved, I will only describe the most impactful scene of users' funds loss:
toWithdraw
is higher than the actual value, so the user needs to burn more share and redeem more WEther.Manual review
Use exchangeRateCurrent
instead of exchangeRateStored
Context
#0 - c4-pre-sort
2023-08-06T12:42:58Z
minhquanym marked the issue as duplicate of #411
#1 - c4-pre-sort
2023-08-06T12:44:57Z
minhquanym marked the issue as duplicate of #1330
#2 - c4-judge
2023-09-26T14:59:16Z
dmvt changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-09-26T14:59:24Z
dmvt marked the issue as satisfactory
π Selected for report: mojito_auditor
Also found by: KIntern_NA, Udsen, bin2chen, chaduke, jasonxiale, kutugu, peakbolt, rvierdiiev
37.0991 USDC - $37.10
According to the code comments, emissionForWeek is the amount of emitted TAP for a specific week.
However, in mint function, only check the number of mint for a single time, not the total number of mint this week, minter can always multiple mint emissionForWeek - 1
to exceed emissionForWeek.
/// @notice returns the amount of emitted TAP for a specific week /// @dev week is computed using (timestamp - emissionStartTime) / WEEK mapping(uint256 => uint256) public emissionForWeek; function extractTAP(address _to, uint256 _amount) external notPaused { require(msg.sender == minter, "unauthorized"); require(_amount > 0, "amount not valid"); uint256 week = _timestampToWeek(block.timestamp); // @audit Here should check `emissionForWeek[week] >= mintedInWeek[week] + amount` require(emissionForWeek[week] >= _amount, "exceeds allowable amount"); _mint(_to, _amount); mintedInWeek[week] += _amount; emit Minted(msg.sender, _to, _amount); }
For specific lines of code, see the comments above.
minter can bypass condition checking each time mint emissionForWeek - 1
.
Another problem is the excess number in the previous week mint, resulting in the subsequent failure to trigger emitForWeek and always revert:
function emitForWeek() external notPaused returns (uint256) { require(_getChainId() == governanceChainIdentifier, "chain not valid"); uint256 week = _timestampToWeek(block.timestamp); if (emissionForWeek[week] > 0) return 0; // Update DSO supply from last minted emissions dso_supply -= mintedInWeek[week - 1]; // Compute unclaimed emission from last week and add it to the current week emission // @audit if `mintedInWeek > emissionForWeek`, there will be revert uint256 unclaimed = emissionForWeek[week - 1] - mintedInWeek[week - 1]; uint256 emission = uint256(_computeEmission()); emission += unclaimed; emissionForWeek[week] = emission; emit Emitted(week, emission); return emission; }
The user's execution path is: exerciseOption -> _processOTCDeal -> extractTAP, specify mintedInWeek - 1
each time to bypass the restriction and permanently DOS emitForWeek
.
Manual review
should check emissionForWeek[week] >= mintedInWeek[week] + amount
instead of emissionForWeek[week] >= _amount
.
Context
#0 - c4-pre-sort
2023-08-04T23:07:11Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-18T17:10:21Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-judge
2023-09-27T11:59:10Z
dmvt changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-09-27T11:59:29Z
dmvt marked the issue as satisfactory
#4 - c4-judge
2023-09-27T12:00:17Z
dmvt marked issue #1241 as primary and marked this issue as a duplicate of 1241
58.8874 USDC - $58.89
curve classic problem read-only reentrant, can manipulate get_virtual_price to manipulate the ARBTriCryptoOracle.
function _get() internal view returns (uint256 _maxPrice) { uint256 _vp = TRI_CRYPTO.get_virtual_price(); // Get the prices from chainlink and add 10 decimals uint256 _btcPrice = uint256(BTC_FEED.latestAnswer()) * 1e10; uint256 _wbtcPrice = uint256(WBTC_FEED.latestAnswer()) * 1e10; uint256 _ethPrice = uint256(ETH_FEED.latestAnswer()) * 1e10; uint256 _usdtPrice = uint256(USDT_FEED.latestAnswer()) * 1e10; uint256 _minWbtcPrice = (_wbtcPrice < 1e18) ? (_wbtcPrice * _btcPrice) / 1e18 : _btcPrice; uint256 _basePrices = (_minWbtcPrice * _ethPrice * _usdtPrice); _maxPrice = (3 * _vp * FixedPointMathLib.cbrt(_basePrices)) / 1 ether; // ((A/A0) * (gamma/gamma0)**2) ** (1/3) uint256 _g = (TRI_CRYPTO.gamma() * 1 ether) / GAMMA0; uint256 _a = (TRI_CRYPTO.A() * 1 ether) / A0; uint256 _discount = Math.max((_g ** 2 / 1 ether) * _a, 1e34); // handle qbrt nonconvergence // if discount is small, we take an upper bound _discount = (FixedPointMathLib.sqrt(_discount) * DISCOUNT0) / 1 ether; _maxPrice -= (_maxPrice * _discount) / 1 ether; }
As you can see from the code, the contract reads get_virtual_price directly without reentrant protectio.
D: uint256 = self.get_D(self._balances(), self._A()) # D is in the units similar to DAI (e.g. converted to precision 1e18) # When balanced, D = n * x_u - total virtual value of the portfolio # @audit Malicious users can manipulate token_supply through flashloan token_supply: uint256 = ERC20(self.lp_token).totalSupply() return D * PRECISION / token_supply
The get_virtual_price depended on its balances and its LP tokenβs total supply. Malicious users can use flashloan to first remove liquidity, increase get_virtual_price, and then re-enter to manipulate ARBTriCryptoOracle.
Manual review
Use claim_admin_fees to prevent the curve reentrant attack
Reentrancy
#0 - c4-pre-sort
2023-08-05T06:46:14Z
minhquanym marked the issue as primary issue
#1 - 0xRektora
2023-08-16T23:33:28Z
Duplicate of #618
#2 - c4-sponsor
2023-08-16T23:33:33Z
0xRektora marked the issue as sponsor acknowledged
#3 - c4-judge
2023-09-13T08:57:27Z
dmvt marked issue #889 as primary and marked this issue as a duplicate of 889
#4 - c4-judge
2023-09-13T08:57:40Z
dmvt marked the issue as satisfactory
#5 - c4-judge
2023-09-20T20:12:27Z
dmvt changed the severity to 2 (Med Risk)
π Selected for report: 0xWaitress
Also found by: Ack, BPZ, Breeje, LosPollosHermanos, Madalad, Nyx, Ruhum, SaeedAlipoor01988, ayeslick, c7e7eff, carrotsmuggler, cergyk, dirk_y, hack3r-0m, iglyx, kaden, kodyvim, kutugu, ladboy233, ltyu, mojito_auditor, n1punp, rvierdiiev, unsafesol, zzzitron
2.1417 USDC - $2.14
https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/lido/LidoEthStrategy.sol#L150 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/lido/LidoEthStrategy.sol#L162
LidoEthStrategy withdraw usually revert because of toWithdraw miscalculation. There are a couple of issues here:
function _withdraw( address to, uint256 amount ) internal override nonReentrant { uint256 available = _currentBalance(); require(available >= amount, "LidoStrategy: amount not valid"); uint256 queued = wrappedNative.balanceOf(address(this)); if (amount > queued) { // @audit need multiply 102.5% uint256 toWithdraw = amount - queued; //1:1 between eth<>stEth uint256 minAmount = toWithdraw - (toWithdraw * 250) / 10_000; //2.5% uint256 obtainedEth = curveStEthPool.exchange( 1, 0, toWithdraw, minAmount ); INative(address(wrappedNative)).deposit{value: obtainedEth}(); } queued = wrappedNative.balanceOf(address(this)); // @audit minAmount = 97.5% toWithdraw and require usually revert require(queued >= amount, "LidoStrategy: not enough"); wrappedNative.safeTransfer(to, amount); emit AmountWithdrawn(to, amount); }
Actual tests also revert:
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-std/Test.sol"; interface IERC20 { function balanceOf(address account) external view returns (uint256); function approve(address spender, uint256 amount) external returns (bool); } interface ICurveEthStEthPool { function exchange( int128 i, int128 j, uint256 dx, uint256 min_dy ) external payable returns (uint256); function get_dy( int128 i, int128 j, uint256 dx ) external view returns (uint256); } interface INative { function deposit() external payable; } contract CounterTest is Test { address constant wrappedNative = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address constant stEth = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; address constant curveStEthPool = 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022; function setUp() public { vm.createSelectFork("https://rpc.ankr.com/eth", 17819438); } function testCurveSwap() public { address me = 0x176F3DAb24a159341c0509bB36B833E7fdd0a132; deal(wrappedNative, me, 0); vm.startPrank(me); IERC20(stEth).approve(curveStEthPool, type(uint256).max); uint256 queued = IERC20(wrappedNative).balanceOf(address(this)); uint256 amount = 1 ether; if (amount > queued) { uint256 toWithdraw = amount - queued; //1:1 between eth<>stEth uint256 minAmount = toWithdraw - (toWithdraw * 250) / 10_000; //2.5% uint256 obtainedEth = ICurveEthStEthPool(curveStEthPool).exchange( 1, 0, toWithdraw, minAmount ); INative(wrappedNative).deposit{value: obtainedEth}(); } queued = IERC20(wrappedNative).balanceOf(me); vm.expectRevert(); require(queued >= amount, "LidoStrategy: not enough"); } }
Foundry
Consider that the slippage should be calculated upward: toWithdraw = (amount - queued) * (1000 + 25) / 1000 minAmount = amount - queued
Math
#0 - c4-pre-sort
2023-08-07T03:52:25Z
minhquanym marked the issue as duplicate of #1437
#1 - c4-judge
2023-09-21T11:49:27Z
dmvt marked the issue as not a duplicate
#2 - c4-judge
2023-09-21T11:49:40Z
dmvt marked the issue as duplicate of #245
#3 - c4-judge
2023-09-22T22:15:36Z
dmvt marked the issue as satisfactory