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: 15/132
Findings: 12
Award: $4,564.37
🌟 Selected for report: 2
🚀 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
The modifier allowedBorrow
only checks that share
is less than allowance
, but both amount
and share
can be specified when calling addCollateral
. As a result, attacker can call function addCollateral
with a zero share
and a nonzero amount
to bypass the check.
function addCollateral( address from, address to, bool skim, uint256 amount, uint256 share ) public notPaused allowedBorrow(from, share) { _addCollateral(from, to, skim, amount, share); } function _allowedBorrow(address from, uint share) internal { if (from != msg.sender) { if (allowanceBorrow[from][msg.sender] < share) { revert NotApproved(from, msg.sender); } allowanceBorrow[from][msg.sender] -= share; } } 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, to, collateralId, share, oldTotalCollateralShare, skim ); emit LogAddCollateral(skim ? address(yieldBox) : from, to, share); }
When a innocent user tries to add collateral, they need to first approve or permit Singularity to spend YieldBox asset. If attacker detects such transactions, he can immediately call addCollateral
with {from: user, to: attacker, skim: false, amount: _amount, share: 0}
to steal the user's collateral by adding collateral for himself.
The attack also applies to any other users who have approved Singularity to spend YieldBox collateral.
Manual
function addCollateral( address from, address to, bool skim, uint256 amount, uint256 share ) public notPaused { if (share == 0) { share = yieldBox.toShare(collateralId, amount, false); } _allowedBorrow(from, share) _addCollateral(from, to, skim, 0, share); }
Invalid Validation
#0 - c4-pre-sort
2023-08-05T02:53:07Z
minhquanym marked the issue as duplicate of #55
#1 - c4-judge
2023-09-12T17:31:43Z
dmvt marked the issue as satisfactory
🌟 Selected for report: peakbolt
Also found by: Kaysoft, carrotsmuggler, cergyk, windhustler, xuwinnie
254.4518 USDC - $254.45
When a cross chain tx fails, bridged asset should be directly transfered to receiver. However, an extra revert is made after the transfer. As a result, the user will not be able to get the asset back by any means.
(bool success, bytes memory reason) = module.delegatecall( abi.encodeWithSelector( this.leverageDownInternal.selector, amount, swapData, externalData, lzData, leverageFor ) ); 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 }
A cross chain request can fail due to various reasons, such as high slippage, wrong parameters. When the request involves debiting and crediting USDO(TOFT), if the module execution fails, we need to directly transfer the balance to user. However, in current implementation, an extra revert is made after the transfer, so even if user retries, the tx will still always revert, which means the asset is permanently locked.
Manual
if (!success) { if (balanceAfter - balanceBefore >= amount) { IERC20(address(this)).safeTransfer(leverageFor, amount); } else { revert(_getRevertMsg(reason)); //forward revert because it's handled by the main executor } }
Context
#0 - c4-pre-sort
2023-08-07T04:57:03Z
minhquanym marked the issue as duplicate of #1410
#1 - c4-judge
2023-09-18T16:31:30Z
dmvt marked the issue as satisfactory
🌟 Selected for report: carrotsmuggler
Also found by: xuwinnie
581.7372 USDC - $581.74
When calling _unwrap
in function leverageDownInternal
, msg.sender
is actually LayerZero Endpoint as context remains the same during delegate call. As a result, the _unwrap
will fail.
else if (packetType == PT_LEVERAGE_MARKET_DOWN) { _executeOnDestination( Module.Leverage, abi.encodeWithSelector( BaseTOFTLeverageModule.leverageDown.selector, leverageModule, _srcChainId, _srcAddress, _nonce, _payload ), _srcChainId, _srcAddress, _nonce, _payload ); } function _executeModule( Module _module, bytes memory _data, bool _forwardRevert ) private returns (bool success, bytes memory returnData) { success = true; address module = _extractModule(_module); (success, returnData) = module.delegatecall(_data); if (!success && !_forwardRevert) { revert(_getRevertMsg(returnData)); } } function leverageDown( address module, uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload ) public { ( , , uint256 amount, IUSDOBase.ILeverageSwapData memory swapData, IUSDOBase.ILeverageExternalContractsData memory externalData, IUSDOBase.ILeverageLZData memory lzData, address leverageFor ) = abi.decode( _payload, ( uint16, bytes32, uint256, IUSDOBase.ILeverageSwapData, IUSDOBase.ILeverageExternalContractsData, IUSDOBase.ILeverageLZData, address ) ); uint256 balanceBefore = balanceOf(address(this)); bool credited = creditedPackets[_srcChainId][_srcAddress][_nonce]; if (!credited) { _creditTo(_srcChainId, address(this), amount); creditedPackets[_srcChainId][_srcAddress][_nonce] = true; } uint256 balanceAfter = balanceOf(address(this)); (bool success, bytes memory reason) = module.delegatecall( abi.encodeWithSelector( this.leverageDownInternal.selector, amount, swapData, externalData, lzData, leverageFor ) ); 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 } emit ReceiveFromChain(_srcChainId, leverageFor, amount); } function leverageDownInternal( uint256 amount, IUSDOBase.ILeverageSwapData memory swapData, IUSDOBase.ILeverageExternalContractsData memory externalData, IUSDOBase.ILeverageLZData memory lzData, address leverageFor ) public payable { _unwrap(address(this), amount); // ...... }
As above, leverageDownInternal
is delegate called by leverageDown
and leverageDown
is delegate called by _executeModule
, so msg.sender
in _unwrap
will be LayerZero Endpoint, who calls lzReceive
. As a result, the _unwrap
will revert since the Endpoint has no token to burn.
function _unwrap(address _toAddress, uint256 _amount) private { _burn(msg.sender, _amount); if (erc20 == address(0)) { _safeTransferETH(_toAddress, _amount); } else { IERC20(erc20).safeTransfer(_toAddress, _amount); } }
Manual
Call unwrap
externally
address(this).unwrap(address(this), amount);
call/delegatecall
#0 - c4-pre-sort
2023-08-07T05:11:00Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-18T17:04:52Z
0xRektora marked the issue as disagree with severity
#2 - c4-sponsor
2023-08-18T17:05:48Z
0xRektora marked the issue as sponsor confirmed
#3 - c4-sponsor
2023-09-19T21:17:41Z
0xRektora marked the issue as agree with severity
#4 - c4-judge
2023-09-26T16:25:28Z
dmvt marked issue #1034 as primary and marked this issue as a duplicate of 1034
#5 - c4-judge
2023-09-26T16:25:41Z
dmvt marked the issue as partial-50
🌟 Selected for report: carrotsmuggler
Also found by: 0x73696d616f, peakbolt, xuwinnie
471.2071 USDC - $471.21
Cross chain request to retrieveFromStrategy
can be frontrunned. Attacker can take advantage of the fact that input data amount
and share
can both be specified, and use two seperate transactions to steal user's asset.
function retrieveFromStrategy( address _from, uint256 amount, uint256 share, uint256 assetId, uint16 lzDstChainId, address zroPaymentAddress, bytes memory airdropAdapterParam ) external payable { require(amount > 0, "TOFT_0"); bytes32 toAddress = LzLib.addressToBytes32(msg.sender); bytes memory lzPayload = abi.encode( PT_YB_RETRIEVE_STRAT, LzLib.addressToBytes32(_from), toAddress, amount, share, assetId, zroPaymentAddress ); _lzSend( lzDstChainId, lzPayload, payable(msg.sender), zroPaymentAddress, airdropAdapterParam, msg.value ); emit SendToChain(lzDstChainId, msg.sender, toAddress, amount); } function strategyWithdraw( uint16 _srcChainId, bytes memory _payload ) public { ( , bytes32 from, , uint256 _amount, uint256 _share, uint256 _assetId, address _zroPaymentAddress ) = abi.decode( _payload, (uint16, bytes32, bytes32, uint256, uint256, uint256, address) ); address _from = LzLib.bytes32ToAddress(from); _retrieveFromYieldBox(_assetId, _amount, _share, _from, address(this)); _debitFrom( address(this), lzEndpoint.getChainId(), LzLib.addressToBytes32(address(this)), _amount ); bytes memory lzSendBackPayload = _encodeSendPayload( from, _ld2sd(_amount) ); _lzSend( _srcChainId, lzSendBackPayload, payable(this), _zroPaymentAddress, "", address(this).balance ); }
When a user initiates a cross chain request to retrieveFromStrategy
, they need to sign a EIP712 permit to approve YieldBox for TOFT contract. Attacker can take the signature and frontrun the tx with two seperate transactions.
retrieveFromStrategy
with {from: user, amount: 1 wei, share: user's balance, approvals:user's signature}
. All of the user's share will be withdrawn but _debitFrom
only burns 1 wei of TOFT. The extra TOFT will stay in the contract.retrieveFromStrategy
with {from: attacker, amount: TOFT's balance, share: 1 wei}
. Only 1 wei of share needs to be withdrawn from attacker's yieldbox balance and attacker can receive all the detained (in step 1) TOFT.function _withdrawFungible( Asset storage asset, uint256 assetId, address from, address to, uint256 amount, uint256 share ) internal returns (uint256 amountOut, uint256 shareOut) { // Effects uint256 totalAmount = _tokenBalanceOf(asset); if (share == 0) { // value of the share paid could be lower than the amount paid due to rounding, in that case, add a share (Always round up) share = amount._toShares(totalSupply[assetId], totalAmount, true); } else { // amount may be lower than the value of share due to rounding, that's ok amount = share._toAmount(totalSupply[assetId], totalAmount, false); }
Manual
Only allow specifying amount
.
function retrieveFromStrategy( address _from, uint256 amount, -- uint256 share, uint256 assetId, uint16 lzDstChainId, address zroPaymentAddress, bytes memory airdropAdapterParam )
Context
#0 - c4-pre-sort
2023-08-09T07:25:23Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-09-01T16:51:04Z
0xRektora (sponsor) confirmed
#2 - 0xRektora
2023-09-01T16:51:38Z
#3 - c4-judge
2023-09-29T19:21:09Z
dmvt marked the issue as duplicate of #1173
#4 - c4-judge
2023-09-29T20:51:13Z
dmvt marked the issue as satisfactory
🌟 Selected for report: xuwinnie
1008.3444 USDC - $1,008.34
multiHopSell
and multiHopBuy
can be frontrunned with high slippage tolerance. User may experince loss from a sandwich attack.
When a user initiates a cross chain request to multiHopSell
, they need to sign a EIP712 permit to approve borrow share for TOFT contract. Attacker can take the signature and frontrun the tx with same data except a lower swapData.amountOutMin
.
Action flow is multiHopSell(local) -> leverageDown(local) -> lend(remote)
function leverageDownInternal( uint256 amount, IUSDOBase.ILeverageSwapData memory swapData, IUSDOBase.ILeverageExternalContractsData memory externalData, IUSDOBase.ILeverageLZData memory lzData, address leverageFor ) public payable { _unwrap(address(this), amount); //swap to USDO IERC20(erc20).approve(externalData.swapper, amount); ISwapper.SwapData memory _swapperData = ISwapper(externalData.swapper) .buildSwapData(erc20, swapData.tokenOut, amount, 0, false, false); (uint256 amountOut, ) = ISwapper(externalData.swapper).swap( _swapperData, swapData.amountOutMin, address(this), swapData.data );
As a result, user may experince loss from a sandwich attack. The attack applies to multiHopBuy
in a similar way.
Manual
Set a maximum slippage.
Uniswap
#0 - c4-pre-sort
2023-08-09T08:06:26Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-28T15:43:31Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-judge
2023-09-19T11:56:08Z
dmvt marked the issue as selected for report
#3 - c4-judge
2023-10-02T11:40:44Z
dmvt changed the severity to 2 (Med Risk)
#4 - dmvt
2023-10-02T11:41:55Z
This attack requires external conditions. Downgrading to medium.
🌟 Selected for report: xuwinnie
1008.3444 USDC - $1,008.34
User could be forced to withdraw more amount than desired when calling retrieveFromStrategy
, because they can not specify the amount of yieldbox balance to permit.
function retrieveFromStrategy( address _from, uint256 amount, uint256 share, uint256 assetId, uint16 lzDstChainId, address zroPaymentAddress, bytes memory airdropAdapterParam ) external payable { require(amount > 0, "TOFT_0"); bytes32 toAddress = LzLib.addressToBytes32(msg.sender); bytes memory lzPayload = abi.encode( PT_YB_RETRIEVE_STRAT, LzLib.addressToBytes32(_from), toAddress, amount, share, assetId, zroPaymentAddress ); _lzSend( lzDstChainId, lzPayload, payable(msg.sender), zroPaymentAddress, airdropAdapterParam, msg.value ); emit SendToChain(lzDstChainId, msg.sender, toAddress, amount); }
When a user initiates a cross chain request to retrieveFromStrategy
, they need to sign a EIP712 permit to approve YieldBox for TOFT contract. Attacker can take the signature and frontrun the tx with {from: user, amount: larger than user wishes, approvals:user's signature}
to force the user to withdraw more amount than desired.
If user forgets to revoke their approvals, the attack could also happen. Attacker can withdraw the user's yieldbox balance without being noticed.
Note: I believe it is a mistake that ICommonData.IApproval[] calldata approvals
is missing at retrieveFromStrategy
input param. Even though this is intended, the issue still exists as user still needs to approve YieldBox for TOFT contract in other way.
Manual
Add more refined allowance control for YieldBox. (just like erc20)
Context
#0 - c4-pre-sort
2023-08-09T07:28:13Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-25T18:03:30Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-judge
2023-09-30T14:39:30Z
dmvt marked the issue as selected for report
349.0423 USDC - $349.04
In function strategyWithdraw
, _debitFrom
will fail because msg.sender
(Endpoint) has no allowance. We should directly _burn
TOFT here.
function strategyWithdraw( uint16 _srcChainId, bytes memory _payload ) public { ( , bytes32 from, , uint256 _amount, uint256 _share, uint256 _assetId, address _zroPaymentAddress ) = abi.decode( _payload, (uint16, bytes32, bytes32, uint256, uint256, uint256, address) ); address _from = LzLib.bytes32ToAddress(from); _retrieveFromYieldBox(_assetId, _amount, _share, _from, address(this)); _debitFrom( address(this), lzEndpoint.getChainId(), LzLib.addressToBytes32(address(this)), _amount ); function _debitFrom(address _from, uint16, bytes32, uint _amount) internal virtual override returns (uint) { address spender = _msgSender(); if (_from != spender) _spendAllowance(_from, spender, _amount); _burn(_from, _amount); return _amount; }
strategyWithdraw
is delegate called by function _nonblockingLzReceive
, so msg.sender
is LayerZero Endpoint. However, the Endpoint has not been approved, so _debitFrom(address(this),...)
will revert.
Manual
Replace
_debitFrom( address(this), lzEndpoint.getChainId(), LzLib.addressToBytes32(address(this)), _amount );
with
_burn(address(this), _amount);
call/delegatecall
#0 - c4-pre-sort
2023-08-07T08:16:15Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-20T15:34:28Z
0xRektora marked the issue as sponsor disputed
#2 - c4-sponsor
2023-08-20T15:35:55Z
0xRektora marked the issue as sponsor confirmed
#3 - c4-judge
2023-09-27T12:17:32Z
dmvt marked the issue as satisfactory
#4 - c4-judge
2023-09-27T12:17:35Z
dmvt marked issue #1209 as primary and marked this issue as a duplicate of 1209
#5 - c4-judge
2023-09-27T12:17:42Z
dmvt changed the severity to 2 (Med Risk)
349.0423 USDC - $349.04
When comparing total borrow with borrow cap, totalBorrow.base
is mistakenly used instead of totalBorrow.elastic
, this will make actual borrow cap higher than expected.
function _borrow( address from, address to, uint256 amount ) internal returns (uint256 part, uint256 share) { uint256 feeAmount = (amount * borrowOpeningFee) / FEE_PRECISION; // A flat % fee is charged for any borrow (totalBorrow, part) = totalBorrow.add(amount + feeAmount, true); require( totalBorrowCap == 0 || totalBorrow.base <= totalBorrowCap, "SGL: borrow cap reached" );
The borrow cap is meant to set a limit for the total debt. When interest accures, totalBorrow.elastic
increases while totalBorrow.base
remains the same. If we need to limit the actual debt amount, we should use totalBorrow.elastic
.
Manual
require( totalBorrowCap == 0 || totalBorrow.elastic <= totalBorrowCap, "SGL: borrow cap reached" );
Invalid Validation
#0 - c4-pre-sort
2023-08-07T04:13:58Z
minhquanym marked the issue as duplicate of #700
#1 - c4-judge
2023-09-26T15:05:03Z
dmvt changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-09-26T15:05:11Z
dmvt marked the issue as satisfactory
141.3621 USDC - $141.36
Malicious user can manipulate twAML by locking tOLP/TAP for a excessively long period of time. Current mechanism fails to re-adjust the cumulative
to a reasonable range. As a result, user will only have two choices, lock for 7 days to get 5% discount or lock permanently to get 50% discount.
// Participate in twAMl voting bool hasVotingPower = lock.amount >= computeMinWeight(pool.totalDeposited, MIN_WEIGHT_FACTOR); if (hasVotingPower) { pool.totalParticipants++; // Save participation pool.averageMagnitude = (pool.averageMagnitude + magnitude) / pool.totalParticipants; // compute new average magnitude // Compute and save new cumulative divergenceForce = lock.lockDuration > pool.cumulative; if (divergenceForce) { pool.cumulative += pool.averageMagnitude; } else { if (pool.cumulative > pool.averageMagnitude) { pool.cumulative -= pool.averageMagnitude; } else { pool.cumulative = 0; } }
Suppose currently cumulative
is 20 months and totalParticipants
is 1000. Alice is a crazy supporter of Tapioca. She decides to lock her SGL position for 1000000 years to earn maximum rewards. Her magnitude
would be 1000000 years, and averageMagnitude
would be 1000 years. Then, cumulative
would become 1000 years.
Afterwards, normal users will find that they only have two choices. The first choice is to lock 7 days and get a minimum discount (now cumulative
is 1000 years, so locking 7 days and locking 7 years receive almost the same discount); the second choice is to lock 2000 years or more (nearly permanently) to get a maximum discount. Angry users wait to see if cumulative
will go down after some time.
Bob tries to drive cumulative
down again. The best way is to lock for 999 years, magnitude
would be 414 years, and cumulative
would be driven down for 0.414 years. So Bob will at least need to repeat this process (lock nearly permanently) for 2000 times. The cost is high and no normal user is incentivized to do so (locking for 999 years but still unable to get max discount). Most user will choose to lock for 7 days but the averageMagnitude
(amount that cumulative
will be driven down) is ignorable comparing to 1000 years. As a result, twAML can no longer function properly.
Note: There is no loss for attacker as they will keep receiving oTAP with maximum discount.
Manual
Set an upper limit for lock duration.
Timing
#0 - c4-pre-sort
2023-08-08T21:01:31Z
minhquanym marked the issue as duplicate of #884
#1 - c4-judge
2023-09-27T20:03:14Z
dmvt changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-09-27T20:03:29Z
dmvt marked the issue as duplicate of #187
#3 - c4-judge
2023-09-27T20:03:53Z
dmvt marked the issue as satisfactory