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: 8/132
Findings: 20
Award: $8,731.70
🌟 Selected for report: 4
🚀 Solo Findings: 2
🌟 Selected for report: Ack
Also found by: 0xG0P1, 0xRobocop, 0xStalin, KIntern_NA, Koolex, Oxsadeeq, RedOneN, TiesStevelink, ayeslick, bin2chen, cergyk, kaden, ladboy233, ltyu, plainshift, rvierdiiev, xuwinnie, zzzitron
20.4247 USDC - $20.42
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L288 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/singularity/SGLCollateral.sol#L21-L29 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L550-L552
Malicious user can hijack approvals to BigBang or Singularity from any other user
We can see that BigBang and Singularity have a addCollateral
function accepting a from
parameter.
Although it checks if msg.sender
has allowance for the shares of collateral to add, by using the allowedBorrow
modifier:
function addCollateral( address from, address to, bool skim, uint256 amount, uint256 share ) public allowedBorrow(from, share) notPaused { _addCollateral(from, to, skim, amount, share); }
It does not check if msg.sender has the additional allowance for the amount
requested.
in the _addCollateral
function, if shares == 0
the amount of shares is set to be the amount:
function _addCollateral( address from, address to, bool skim, uint256 amount, uint256 share ) internal { if (share == 0) { share = yieldBox.toShare(collateralId, amount, false); } userCollateralShare[to] += share; uint256 oldTotalCollateralShare = totalCollateralShare; totalCollateralShare = oldTotalCollateralShare + share; _addTokens(from, collateralId, share, oldTotalCollateralShare, skim); emit LogAddCollateral(skim ? address(yieldBox) : from, to, share); }
So a malicious user Alice
can simply call:
BigBang.addCollateral( Bob, Alice, false, big_amount, 0 )
to hijack the approval that Bob made to the BigBang/Singularity contract and add collateral for her account.
Manual review
Check also the allowance for the amount
variable, not only shares
Access Control
#0 - c4-pre-sort
2023-08-05T02:56:08Z
minhquanym marked the issue as duplicate of #55
#1 - c4-judge
2023-09-12T17:33:31Z
dmvt marked the issue as satisfactory
🌟 Selected for report: GalloDaSballo
Also found by: carrotsmuggler, cergyk, kaden
471.2071 USDC - $471.21
https://github.com/Tapioca-DAO/YieldBox/blob/f5ad271b2dcab8b643b7cf622c2d6a128e109999/contracts/YieldBox.sol#L118-L160 https://github.com/Tapioca-DAO/YieldBox/blob/f5ad271b2dcab8b643b7cf622c2d6a128e109999/contracts/YieldBox.sol#L102-L104 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/balancer/BalancerStrategy.sol#L130-L134
The function updateCache()
in BalancerStrategy is manipulatable and not rate limited, which can enable transaction sandwiching in a safe way for a mev bot.
Since the function BalancerStrategy::updateCache()
sets in storage the price to be used for the next deposit/withdraw/emergencyWithdraw, a sandwich bot Alice can do the following:
BalancerStrategy::updateCache()
Bob deposits and receives less shares than due, due to reduced _tokenBalanceOf in YieldBox
:
function depositAsset( uint256 assetId, address from, address to, uint256 amount, uint256 share ) public allowed(from, assetId) returns (uint256 amountOut, uint256 shareOut) { // Checks Asset storage asset = assets[assetId]; require(asset.tokenType != TokenType.Native && asset.tokenType != TokenType.ERC721, "YieldBox: can't deposit type"); // Effects uint256 totalAmount = _tokenBalanceOf(asset); if (share == 0) { // value of the share may be lower than the amount due to rounding, that's ok share = amount._toShares(totalSupply[assetId], totalAmount, false); } else { // amount may be lower than the value of share due to rounding, in that case, add 1 to amount (Always round up) amount = share._toAmount(totalSupply[assetId], totalAmount, true); } _mint(to, assetId, share); //...Rest of the function }
because _tokenBalanceOf in turn calls strategy._currentBalance()
:
function _tokenBalanceOf(Asset storage asset) internal view returns (uint256 amount) { return asset.strategy.currentBalance(); }
And BalancerStrategy::currentBalance
uses _cachedCalculatedAmount
:
function _currentBalance() internal view override returns (uint256 amount) { uint256 queued = wrappedNative.balanceOf(address(this)); return _cachedCalculatedAmount + queued; }
Manual review
Make updateCache() private on BalancerStrategy
MEV
#0 - c4-pre-sort
2023-08-06T09:04:40Z
minhquanym marked the issue as duplicate of #1447
#1 - c4-judge
2023-09-27T17:36:49Z
dmvt marked the issue as satisfactory
🌟 Selected for report: GalloDaSballo
Also found by: carrotsmuggler, cergyk, kaden, ladboy233, rvierdiiev
254.4518 USDC - $254.45
https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/aave/AaveStrategy.sol#L103-L114 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/aave/AaveStrategy.sol#L225-L230 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/convex/ConvexTricryptoStrategy.sol#L134-L143 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/lido/LidoEthStrategy.sol#L118-L125 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/lido/LidoEthStrategy.sol#L118-L125
For some strategies, the value returned by _currentBalance()
is manipulatable in a given block, which in turn may lead to the manipulation of the share price for yieldboxes.
The vulnerable strategies are the ones relying on a spot price to evaluate current balance:
get_dy
)Since the share/amount ratio in yieldbox is calculated using strategy.currentBalance()
, the manipulation of currentBalance results in the direct manipulation of share price in a yieldbox.
Let's take the example of AaveStrategy:
If there is a non zero claimable amount in compoundAmount()
:
function compoundAmount() public view returns (uint256 result) { uint256 claimable = stakedRewardToken.stakerRewardsToClaim( address(this) ); result = 0; if (claimable > 0) { ISwapper.SwapData memory swapData = swapper.buildSwapData( address(rewardToken), address(wrappedNative), claimable, 0, false, false ); result = swapper.getOutputAmount(swapData, ""); result = result - (result * 50) / 10_000; //0.5% } }
a sandwich on the withdraw
method associated with a yieldbox using this strategy can:
share
value small enough so the calculated inflated amount is lower than or equal to queued
:function _withdraw( address to, uint256 amount ) internal override nonReentrant { uint256 available = _currentBalance(); require(available >= amount, "AaveStrategy: amount not valid"); uint256 queued = wrappedNative.balanceOf(address(this)); if (amount > queued) { compound(""); uint256 toWithdraw = amount - queued; uint256 obtainedWrapped = lendingPool.withdraw( address(wrappedNative), toWithdraw, address(this) ); if (obtainedWrapped > toWithdraw) { amount += (obtainedWrapped - toWithdraw); } } wrappedNative.safeTransfer(to, amount); emit AmountWithdrawn(to, amount); }
The attacker has withdrawn all the queued amount for less shares than needed.
Manual review
Use a manipulation resistant price to evaluate _currentBalance
, such as a TWAP or chainlink feed
Uniswap
#0 - c4-pre-sort
2023-08-06T04:16:03Z
minhquanym marked the issue as duplicate of #828
#1 - c4-judge
2023-09-27T12:32:53Z
dmvt marked the issue as satisfactory
🌟 Selected for report: peakbolt
Also found by: Kaysoft, carrotsmuggler, cergyk, windhustler, xuwinnie
127.2259 USDC - $127.23
Tokens are not returned to caller during cross chain call if it fails. This can mean that these tokens are lost for the user if retrying the call cannot succeed.
In USDOLeverageModule
, BaseTOFTLeverageModule
, BaseTOFTStrategyModule
in respectively leverageUp
, leverageDown
, strategyDeposit
functions, we can see that if the call fails, tokens are attempted to be returned to the caller:
if (!success) { if (balanceAfter - balanceBefore >= amount) { IERC20(address(this)).safeTransfer(leverageFor, amount); } revert(_getRevertMsg(reason)); //forward revert because it's handled by the main executor }
However since the call always reverts right after the transfer, funds are never returned to the user
Manual review
do not revert the call, return instead
call/delegatecall
#0 - c4-pre-sort
2023-08-06T03:52:28Z
minhquanym marked the issue as duplicate of #1410
#1 - c4-judge
2023-09-18T16:32:03Z
dmvt marked the issue as partial-50
🌟 Selected for report: Ack
Also found by: 0x73696d616f, 0xrugpull_detector, ACai, BPZ, Breeje, CrypticShepherd, Kaysoft, carrotsmuggler, cergyk, kodyvim, ladboy233, offside0011
56.1709 USDC - $56.17
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/usd0/modules/USDOLeverageModule.sol#L169 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/usd0/modules/USDOMarketModule.sol#L168 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/usd0/modules/USDOOptionsModule.sol#L174 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTLeverageModule.sol#L184
Unchecked delegate calls may be used to deactivate libraries and leave bricked funds
Some contracts have unchecked delegate calls to arbitrary addresses: https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/usd0/modules/USDOLeverageModule.sol#L169
Since the function is unpermissioned and module is an unchecked argument, a user can create a contract with a function using the selfdestruct opcode and call it using this delegate call. This will leave the modules contract without any code, and brick the funds of the contracts relying on it.
Please note that eip-4758 is planned to remove the selfdestruct opcode, but since it is not yet implemented, defense against this is advised.
Manual review
Please add a check on this module argument, or make it a immutable value in the contract
call/delegatecall
#0 - c4-pre-sort
2023-08-05T11:09:38Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-14T22:02:26Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-judge
2023-09-13T10:24:01Z
dmvt marked issue #1083 as primary and marked this issue as a duplicate of 1083
#3 - c4-judge
2023-09-13T10:24:32Z
dmvt marked the issue as satisfactory
58.8874 USDC - $58.89
Read-only reentrancy makes ARBTriCryptoOracle vulnerable to manipulation
When getting the price, ARBTriCryptoOracle makes use of TRI_CRYPTO.get_virtual_price()
, which is vulnerable to the read-only reentrancy attack well documented here:
https://chainsecurity.com/curve-lp-oracle-manipulation-post-mortem/
Here are the impacted code lines:
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; }
Manual review
Make a call to remove_liquidity with 0 value in order to trigger nonReentrant
modifier on the curve pool
Reentrancy
#0 - c4-pre-sort
2023-08-05T12:32:00Z
minhquanym marked the issue as duplicate of #704
#1 - c4-judge
2023-09-13T08:57:50Z
dmvt marked the issue as satisfactory
#2 - c4-judge
2023-09-20T20:12:28Z
dmvt changed the severity to 2 (Med Risk)
🌟 Selected for report: Ack
Also found by: 0xG0P1, KIntern_NA, cergyk, rvierdiiev
101.7807 USDC - $101.78
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L336-L376 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/singularity/Singularity.sol#L351-L373 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/singularity/SGLLeverage.sol#L147-L187
buyCollateral in Singularity and BigBang can be abused to steal users collateral
In the function Singularity.buyCollateral
, we can see:
function buyCollateral( address from, uint256 borrowAmount, uint256 supplyAmount, uint256 minAmountOut, ISwapper swapper, bytes calldata dexData ) external notPaused solvent(from) returns (uint256 amountOut) { require(penrose.swappers(swapper), "SGL: Invalid swapper"); // Let this fail first to save gas: uint256 supplyShare = yieldBox.toShare(assetId, supplyAmount, true); if (supplyShare > 0) { yieldBox.transfer(from, address(swapper), assetId, supplyShare); } uint256 borrowShare; (, borrowShare) = _borrow(from, address(swapper), borrowAmount); ISwapper.SwapData memory swapData = swapper.buildSwapData( assetId, collateralId, 0, supplyShare + borrowShare, true, true ); uint256 collateralShare; (amountOut, collateralShare) = swapper.swap( swapData, minAmountOut, from, dexData ); require(amountOut >= minAmountOut, "SGL: not enough"); _allowedBorrow(from, collateralShare); _addCollateral(from, from, false, 0, collateralShare); }
That _allowedBorrow(from, collateralShare)
is only checked after the swap. Which means that anyone can call this function with minAmountOut == 0
, extract equivalent to supplyShare + borrowShare
by sandwiching the swap (or using a malicious path), and since returned collateralShare == 0
, _allowedBorrow checks out even if from did not approve msg.sender
for any amount
This means msg.sender stole an arbitrary amount from from
, with the constraint however, that from
remains solvent after the call (supplyShare + borrowShare
value must be adjusted accordingly).
Manual review
Make the check on allowance first, by computing the conversion of supplyShare + borrowShare
exchange rate into collateral (with acceptable slippage).
Access Control
#0 - c4-pre-sort
2023-08-05T12:28:27Z
minhquanym marked the issue as duplicate of #147
#1 - c4-judge
2023-09-13T11:50:07Z
dmvt changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-09-13T11:52:54Z
dmvt marked the issue as satisfactory
🌟 Selected for report: zzzitron
Also found by: 0xG0P1, 0xRobocop, RedOneN, SaeedAlipoor01988, bin2chen, cergyk, rvierdiiev, unsafesol
37.0991 USDC - $37.10
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/singularity/SGLLiquidation.sol#L133 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L819
Total collateral is not updated during liquidation in BigBang.sol
and Singularity.sol
. This can mean that the minting of shares is broken, and a user minting after a liquidation has taken place can receive less shares than due.
We can see in the liquidation functions of BigBang and Singularity that although user collateral shares are reduced: https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/singularity/SGLLiquidation.sol#L133
totalCollateralShare
is not reduced accordingly.
This means that skim based adding collateral will be DOSed because totalCollateralShare
will always cause an underflow here:
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L700
Note that this is correctly done in the particular case of the order book flow in Singularity: https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/singularity/SGLLiquidation.sol#L157
Here all variables are correctly updated
Manual review
reduce totalCollateralShare
when updating individual collateral shares
Under/Overflow
#0 - c4-pre-sort
2023-08-05T01:18:19Z
minhquanym marked the issue as duplicate of #1040
#1 - c4-judge
2023-09-12T17:08:40Z
dmvt marked the issue as satisfactory
76.3356 USDC - $76.34
Balancer::rebalance will fail because msg.value is not set in _sendNative
We can see that _sendNative
in Balancer.sol
:
function _sendNative( address payable _oft, uint256 _amount, uint16 _dstChainId, uint256 _slippage ) private { if (address(this).balance < _amount) revert ExceedsBalance(); routerETH.swapETH( _dstChainId, _oft, //refund abi.encodePacked(connectedOFTs[_oft][_dstChainId].dstOft), _amount, _computeMinAmount(_amount, _slippage) ); }
Is supposed to swap native tokens (ETH) using routerETH. This call will always fail since msg.value is not set for the call
Manual review
Consider using instead:
function _sendNative( address payable _oft, uint256 _amount, uint16 _dstChainId, uint256 _slippage ) private { if (address(this).balance < _amount) revert ExceedsBalance(); routerETH.swapETH{value: msg.value}( _dstChainId, _oft, //refund abi.encodePacked(connectedOFTs[_oft][_dstChainId].dstOft), _amount, _computeMinAmount(_amount, _slippage) ); }
call/delegatecall
#0 - c4-pre-sort
2023-08-05T16:42:50Z
minhquanym marked the issue as duplicate of #179
#1 - c4-pre-sort
2023-08-07T10:07:42Z
minhquanym marked the issue as duplicate of #813
#2 - c4-judge
2023-09-29T18:31:54Z
dmvt marked the issue as satisfactory
453.755 USDC - $453.76
oTAP::participate call will always revert if msg.sender is approved but not owner
We can see in the implementation of oTAP::participate, the following lines are used to ensure caller has the correct access control wrt to the _tOLPTokenID
:
require( tOLP.isApprovedOrOwner(msg.sender, _tOLPTokenID), "tOB: Not approved or owner" ); // Transfer tOLP position to this contract tOLP.transferFrom(msg.sender, address(this), _tOLPTokenID);
However this will always fail if msg.sender is not the owner of the token, since tOLP.transferFrom(msg.sender, address(this), _tOLPTokenID);
will revert in that case.
This can break interoperability with operators supposed to be able to execute actions on behalf of a user such as MagnetarV2
or tOFT
.
Manual review
Transfer from the owner:
address owner = tOLP.ownerOf(_tOLPTokenID); tOLP.transferFrom(owner, address(this), _tOLPTokenID);
Access Control
#0 - c4-pre-sort
2023-08-06T11:39:02Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-16T16:11:02Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-judge
2023-09-29T23:21:25Z
dmvt marked the issue as selected for report
453.755 USDC - $453.76
In TapiocaOptionBroker an epoch can be skipped leading for unclaimed tap to distribute to be lost
The function newEpoch
is called to start a new epoch:
function newEpoch() external { require( block.timestamp >= lastEpochUpdate + EPOCH_DURATION, "tOB: too soon" ); uint256[] memory singularities = tOLP.getSingularities(); require(singularities.length > 0, "tOB: No active singularities"); // Update epoch info lastEpochUpdate = block.timestamp; epoch++; // Extract TAP uint256 epochTAP = tapOFT.emitForWeek(); _emitToGauges(epochTAP); // Get epoch TAP valuation (, epochTAPValuation) = tapOracle.get(tapOracleData); emit NewEpoch(epoch, epochTAP, epochTAPValuation); }
As we can see that lastEpochUpdate
is set to be block.timestamp
which can be slightly bigger than lastEpochUpdate + EPOCH_DURATION
,
which means that the transition from one epoch to another lags slightly behind a strict weekly schedule.
Inversely, in tapOFT.emitForWeek()
:
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 uint256 unclaimed = emissionForWeek[week - 1] - mintedInWeek[week - 1]; uint256 emission = uint256(_computeEmission()); emission += unclaimed; emissionForWeek[week] = emission; emit Emitted(week, emission); return emission; }
_timestampToWeek
function is used, which enforces a strict weekly schedule for epochs:
function _timestampToWeek( uint256 timestamp ) internal view returns (uint256) { return ((timestamp - emissionsStartTime) / WEEK) + 1; // Starts at week 1 }
Since the variable lastEpochUpdate is updated with an interval slightly bigger than 1 WEEK, the drift between _timestampToWeek and lastEpochUpdate with regards to WEEK grows over time.
At some point, when this remainder grows close enough to 1 WEEK (let's say 1 WEEK - 5 seconds
for the sake of the example):
An epoch is skipped in the process. Since the logic relies on transferring values from one epoch to another, this means that some rewards become unclaimable:
// Compute unclaimed emission from last week and add it to the current week emission uint256 unclaimed = emissionForWeek[week - 1] - mintedInWeek[week - 1]; uint256 emission = uint256(_computeEmission()); emission += unclaimed;
Manual review
Use the same fixed weekly schedule to determine if newEpoch can be called:
Change the condition to:
require( _timestampToWeek(block.timestamp) > lastEpochUpdate, "tOB: too soon" );
And assignement to:
lastEpochUpdate = _timestampToWeek(block.timestamp);
Timing
#0 - c4-pre-sort
2023-08-06T10:46:15Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-16T15:28:44Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-judge
2023-09-21T12:02:12Z
dmvt changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-09-21T12:04:49Z
dmvt marked the issue as selected for report
#4 - dmvt
2023-09-21T12:09:36Z
This is definitely an issue, but high is overstating things. Assuming the 10 second delay described in the report, it will take roughly 1163 years (604800/10/52) for a week to be skipped. I do think there are likely to be other issues that show up as the collective lag grows, so downgrading to medium.
🌟 Selected for report: cergyk
1008.3444 USDC - $1,008.34
https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionBroker.sol#L339 https://github.com/Tapioca-DAO/tapioca-periph-audit/blob/023751a4e987cf7c203ab25d3abba58f7344f213/contracts/Magnetar/modules/MagnetarMarketModule.sol#L542-L549
In MagnetarV2, exitPositionAndRemoveCollateral cannot be used to exitPosition without unlocking tOlp
in MagnetarMarketModule.sol, in the function _exitPositionAndRemoveCollateral
, we can see that in the case where `` is true:
ITapiocaOptionsBroker(removeAndRepayData.exitData.target) .exitPosition(removeAndRepayData.exitData.oTAPTokenID); if (!removeAndRepayData.unlockData.unlock) { IERC721(oTapAddress).safeTransferFrom( address(this), user, removeAndRepayData.exitData.oTAPTokenID, "0x" ); }
if removeAndRepayData.unlockData.unlock == false
, oTAPTokenID is transferred back to the user. This will always revert since the TapiocaOptionsBroker always burns the token when exiting position:
address otapOwner = oTAP.ownerOf(_oTAPTokenID); delete participants[oTAPPosition.tOLP]; oTAP.burn(_oTAPTokenID);
Manual review
The tOlp token should instead be sent back to the user
Error
#0 - c4-pre-sort
2023-08-06T05:22:51Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-20T14:38:25Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-judge
2023-09-21T13:36:57Z
dmvt marked the issue as selected for report
453.755 USDC - $453.76
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L394 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L417-L419
In both Singularity and BigBang, the remainder of removed collateral is never returned to the user in sellCollateral after repayment of the loan
A user can call sellCollateral with a bigger share
than the amount she borrowed.
The whole collateral share is first removed:
_removeCollateral(from, address(swapper), share);
The swap determines the shareOut
value, and then this condition is executed:
if (shareOwed <= shareOut) { _repay(from, from, partOwed); }
Which means that only the part borrowed by the user is repaid, and the rest of asset obtained after swap only stays in BigBang
.
This means that the user actually lost the remainder of what was swapped, and the call does not revert unless the user is not solvent
Manual review
Add the surplus of asset to the balance of the user selling collateral
Other
#0 - c4-pre-sort
2023-08-06T03:56:36Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-30T15:15:29Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-judge
2023-09-18T16:38:10Z
dmvt marked the issue as selected for report
#3 - c4-judge
2023-09-18T16:38:31Z
dmvt changed the severity to 2 (Med Risk)
#4 - dmvt
2023-09-18T16:40:13Z
Downgrading to medium because the amounts leaked / lost in the two reports are almost always very small.
#5 - JeffCX
2023-10-02T20:33:38Z
Thanks for judging the contest!
I agree with the judge's comments
the amounts leaked / lost in the two reports are almost always very small.
but I think whether the unhandled amount is small depends on slippage + the leak of value can always happen
it is possible that in one transaction, a tiny amount of Surplus of collateral is not refunded, but in 10000 transaction, all surplus does not refund to user and the amount add up
so the leak value is relatively large as more user use the application
so I politely think the severity is high
I respect judge's final decision and will have no further dispute!
#6 - dmvt
2023-10-05T10:37:12Z
I disagree. The leak would almost certainly be noticed and the contract replaced before the values got very high. Medium stands.
349.0423 USDC - $349.04
https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionLiquidityProvision.sol#L347-L355 https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionBroker.sol#L303-L306 https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionBroker.sol#L358-L359 https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/options/TapiocaOptionBroker.sol#L216-L219
Repeated oTap generation for one tOLP is possible when block.timestamp == lock.lockTime + lock.lockDuration. This means a malicious user can take a disproportionate share of TAP tokens and in turn manipulate governance of the protocol
TapiocaOptionBroker
handles the locking of tOLP tokens in order to mint a oTap (option to buy TAP tokens).
Inversely, it is possible to unlock the tOLP token when expired in order to unlock underlying singularity assets.
However, we can see that the condition for tOLP to be considered active is:
function _isPositionActive(uint256 tokenId) internal view returns (bool) { LockPosition memory lockPosition = lockPositions[tokenId]; return (lockPosition.lockTime + lockPosition.lockDuration) >= block.timestamp; }
And also the condition for the tOLP to be considered unlocked in TapiocaOptionBroker
is:
function exitPosition(uint256 _oTAPTokenID) external { require(oTAP.exists(_oTAPTokenID), "tOB: oTAP position does not exist"); // Load data (, TapOption memory oTAPPosition) = oTAP.attributes(_oTAPTokenID); (, LockPosition memory lock) = tOLP.getLock(oTAPPosition.tOLP); require( >>> block.timestamp >= lock.lockTime + lock.lockDuration, "tOB: Lock not expired" ); //... Some code }
These conditions are not exclusive, and it means that when block.timestamp == lock.lockTime + lock.lockDuration
,
So a malicious user Alice can execute following steps to repeatedly exercise options when block.timestamp == lock.lockTime + lock.lockDuration
:
TapiocaOptionBroker::participate
to use the tOLP position to mint a oTapTapiocaOptionBroker::exerciseOption
to exercise the oTap optionTapiocaOptionBroker::exitPosition
to burn the oTap tokenon step3 oTLP position is transferred back to Alice, so Alice can start again at step 1, until the gauge limit is reached for that epoch.
Manual review
Use exclusive conditions, e.g. block.timestamp < lock.lockTime + lock.lockDuration
for verifying a position is active and block.timestamp >= lock.lockTime + lock.lockDuration
to verify the position is unlocked
Timing
#0 - c4-pre-sort
2023-08-06T10:43:52Z
minhquanym marked the issue as duplicate of #189
#1 - c4-judge
2023-09-18T13:59:02Z
dmvt changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-09-18T13:59:22Z
dmvt marked the issue as satisfactory
141.3621 USDC - $141.36
twTap voting power is subject to manipulation when locking tokens via twTap::participate
Indeed magnitude calculations do not include the amount of tokens locked and allow for exceedingly high _duration
values (close to type(uint56).max
)
A user can lock minimal amount tokens and a very high _duration
, and have the same influence on the variables pool.cumulative
, pool.averageMagnitude
as a user locking more tokens,
The minimum amount is only constrained to be:
bool hasVotingPower = _amount >= computeMinWeight(pool.totalDeposited, MIN_WEIGHT_FACTOR);
e.g greater than 0.01% of totalDeposited
Let's consider the following scenario:
totalDeposited
is 10000
pool.averageMagnitude
is 1 WEEK = 604800
pool.cumulative
is also 1 WEEK
number of participants: 10
1/ Alice deposits 20 of TAP (just above the threshold 10000*0.001)
with a _duration == type(uint56).max-block.timestamp-1
such as the condition at lines 312-313:
uint256 expiry = block.timestamp + _duration; require(expiry < type(uint56).max, "twTAP: too long");
is verified.
At current time of writing such a value for _duration
would be around 72057592347712460
.
so new state is:
totalDeposited
: 10020
magnitude: 72057592347712460
pool.averageMagnitude
is 6550690213978224
pool.cumulative
becomes 6550690214583024
number of participants: 11
2/ Alice deposits again 20 of TAP this time with a _duration == 1 WEEK
(minimal)
new state is:
totalDeposited
: 10040
magnitude: 0
pool.averageMagnitude
is 6004799362813372
pool.cumulative
becomes 545890851769652
number of participants: 12
3/ Alice does exactly the same
new state is:
totalDeposited
: 10060
magnitude: 0
pool.averageMagnitude
is 5542891719520036
pool.cumulative
becomes 0
number of participants: 13
As a side note this proves the TODO remark incorrect:
// TODO: Strongly suspect this is never less. Prove it. if (pool.cumulative > pool.averageMagnitude) {
Now alice can lock the real amount of token she wants to mint because multiplier is maximal if pool.cumulative == 0
It is trivial to see that Alice can also make the value very large and grief other users of the protocol.
Manual review
Weigh in the amount locked in the magnitude calculation, otherwise it is too easily manipulatable
Math
#0 - c4-pre-sort
2023-08-05T17:31:15Z
minhquanym marked the issue as duplicate of #187
#1 - c4-judge
2023-09-21T12:14:24Z
dmvt changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-09-21T12:14:35Z
dmvt marked the issue as satisfactory
🌟 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/aave/AaveStrategy.sol#L192-L194 https://github.com/Tapioca-DAO/tapioca-periph-audit/blob/023751a4e987cf7c203ab25d3abba58f7344f213/contracts/Swapper/UniswapV2Swapper.sol#L58-L76
AaveStrategy computes slippage protection on spot price, can lead to unlimited slippage
In AaveStrategy, the minAmountOut
value computed for slippage protection is computed on spot price if swapper is UniswapV2Swapper
for example:
AaveStrategy::compound():
uint256 calcAmount = swapper.getOutputAmount(swapData, ""); uint256 minAmount = calcAmount - (calcAmount * 50) / 10_000; //0.5% swapper.swap(swapData, minAmount, address(this), "");
If swapper is UniV2Swapper
:
function getOutputAmount( SwapData calldata swapData, bytes calldata ) external view override returns (uint256 amountOut) { (address tokenIn, address tokenOut) = _getTokens( swapData.tokensData, yieldBox ); address[] memory path = _createPath(tokenIn, tokenOut); (uint256 amountIn, ) = _getAmounts( swapData.amountData, swapData.tokensData.tokenInId, swapData.tokensData.tokenOutId, yieldBox ); uint256[] memory amounts = swapRouter.getAmountsOut(amountIn, path); amountOut = amounts[1]; }
getOutputAmount returns spot price, which is very easily manipulatable. If a malicious user has manipulated the pool before calling compound()
, as is the case during a sandwich, the slippage protection computed here is useless.
Manual review
Ensure that a reliable price source is used to compute slippage protection in that case, such as a chainlink feed
MEV
#0 - c4-pre-sort
2023-08-06T04:06:40Z
minhquanym marked the issue as primary issue
#1 - minhquanym
2023-08-06T08:40:59Z
Grouping issues "Using current pool state for slippage protection in Strategy"
#2 - c4-sponsor
2023-08-16T14:39:32Z
0xRektora marked the issue as sponsor confirmed
#3 - c4-judge
2023-09-13T14:59:47Z
dmvt changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-09-22T22:20:13Z
dmvt marked the issue as satisfactory
#5 - c4-judge
2023-10-02T12:13:18Z
dmvt marked issue #163 as primary and marked this issue as a duplicate of 163