Borrowing::redeemYields debits ABOND from msg.sender but redeems to user using ABOND.State data from user
Summary
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.
Root Cause
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.
Internal pre-conditions
The following conditions are not a restriction, with just time and funds it can be done.
- An old position of
ABONDwithethBacked. - Another account owning some
ABOND(acquired in the protocol or purchased in an external market).
External pre-conditions
None
Attack Path
- The attacker deposits a big amount of ETH in the
Borrowingcontract. - The attacker withdraws that deposit receiving
ABOND(which is backed by ETH deposited in another protocol) - The attacker wait until the
cumulativeRateis significant. - The attacker purchases with another address some
ABOND - The attacker calls
Borrowing::redeemYieldswith theuserparameter set as the first address that deposited in step 1.
Impact
- The ETH deposited in the external protocol that backs
ABONDcan be completely drained. ABONDvalue will fall to zero as there won't be ETH to redeem it for.
PoC
None
Mitigation
Enforce msg.sender to be equal to user.