Open c4-bot-7 opened 6 months ago
The attack described on this report is impossible to execute on any Strategy, the tx would reverte when the BalancerFlashLender contract tries to pull the WETH to repay the flashloan from the borrower
(the strategy)
function receiveFlashLoan(
...
) external {
...
//@audit => As per the report, the execution would transfer the flashloaned funds to the `borrower`, which is a Strategy
// Transfer the loan received to borrower
IERC20(asset).safeTransfer(borrower, amount);
//@audit => The execution would be forwarded to the borrower (The Strategy)
if (
IERC3156FlashBorrowerUpgradeable(borrower).onFlashLoan(
borrower,
tokens[0],
amounts[0],
feeAmounts[0],
originalCallData
) != CALLBACK_SUCCESS
) {
revert BorrowerCallbackFailed();
}
//@audit => At this point the tx will revert because the Strategy did not granted any allowance to this contract.
//@audit => The Strategy contracts grants the exact required allowance to this contract as part of the execution flow of the `deploy()`, `undeploy()` & `harvest()`, which all those functions are only callable by the owner (The Vault).
//@audit => So, unless the Strategy contract granted allowance to the BalancerFlashLender contract, the two below lines will cause the tx to be reverted.
// Verify that this contract is able to pay the debt
if (IERC20(asset).allowance(address(borrower), address(this)) < fee + amount) {
revert NoAllowanceToPayDebt();
}
// Return the loan + fee to the vault
IERC20(asset).safeTransferFrom(address(borrower), address(_balancerVault), amount + fee);
}
The only way calls to the BalancerFlashLender.receiveFlashLoan()
won't revert are when the borrower
(A Strategy) contract has granted allowance to the BalancerFlashLender contract to pull the required WETH to repay the flashloan, and the only place where a Strategy grants allowance to this contract is on the execution flow of any of the functions that initiates a flashloan (deploy()
, undeploy()
& harvest()
).
deploy()
=> wETH().approve(flashLenderA(), deltaDebt + fee)
undeploy()
=> wETH().approve(flashLenderA(), deltaDebt + fee)
harvest()
=> _adjustDebt()
=> wETH().approve(flashLenderA(), deltaDebt + fee)
For the above reasons, the described attack on the report doesn't cause any harm to the Strategies, thus it should be an invalid report.
0xleastwood marked the issue as primary issue
I think the strategy to pull this off would look a little differently. You would be flash-loaning a zero amount so then both the amount
and fee
parameters would be zero in StrategyLeverage.onFlashLoan()
. The target action would be FlashLoanAction.PAY_DEBT_WITHDRAW
where data.originalAmount
and data.receiver
are controlled by the attacker. Hence, they could skim off excess collateral, or alternatively repay on behalf of the borrower (because Aave allows this) and then take all the collateral.
When the BalancerFlashLender
contracts requests to transfer funds back to the flash loan contract and there is nothing to transfer, then it will not revert because it needs no approval to make a zero amount transfer. It doesn't appear either that balancer pools would reject zero amount flash loans.
This seems feasible even if the correct attack was not outlined by the wardens.
I'm considering giving partial credit for not outlining this correctly but will think on this.
0xleastwood marked the issue as selected for report
Hello Judge @0xleastwood
I see what you are saying, that's an interesting attack vector, unfortunately, because the Strategy contracts interact with the repay()
on Aave, this attack vector can't be executed.
If we trace the execution of the target action FlashLoanAction.PAY_DEBT_WITHDRAW
we have: StrategyLeverage.onFlashLoan() => StrategyLeverage._repayAndWithdraw() => StrategyAAVEv3._repay() => aaveV3.repay()
, and, checking the aave contracts, we have: Pool.repay() => BorrowLogic.executeRepay() => ValidationLogic.validateRepay()
, where we have a validation that prevents repayments of 0 amount.
The same is true for the target action FlashLoanAction.PAY_DEBT
, its execution flow is: StrategyLeverage.onFlashLoan() => StrategyLeverage._payDebt() => StrategyAAVEv3._repay() => aaveV3.repay()
function repay(
address asset,
uint256 amount,
uint256 interestRateMode,
address onBehalfOf
) public virtual override returns (uint256) {
return
BorrowLogic.executeRepay(
_reserves,
_reservesList,
_usersConfig[onBehalfOf],
DataTypes.ExecuteRepayParams({
asset: asset,
//@audit => Specified amount to be repaid
amount: amount,
interestRateMode: DataTypes.InterestRateMode(interestRateMode),
onBehalfOf: onBehalfOf,
useATokens: false
})
);
}
function executeRepay(
mapping(address => DataTypes.ReserveData) storage reservesData,
mapping(uint256 => address) storage reservesList,
DataTypes.UserConfigurationMap storage userConfig,
DataTypes.ExecuteRepayParams memory params
) external returns (uint256) {
...
ValidationLogic.validateRepay(
reserveCache,
//@audit => Specified amount to be repaid
params.amount,
params.interestRateMode,
params.onBehalfOf,
stableDebt,
variableDebt
);
...
}
function validateRepay(
DataTypes.ReserveCache memory reserveCache,
//@audit => Specified amount to be repaid
uint256 amountSent,
...
) internal view {
//@audit => Not possible to repay 0, tx is reverted
require(amountSent != 0, Errors.INVALID_AMOUNT);
...
}
Now, knowing that Aave doesn't allow repayments of 0 amounts, checking the contracts of the protocol we'll realize that the specified amount to be repaid on Aave is the amount of the flashloan, so, if the flashloan is for 0 tokens, the repayment on Aave will revert.
function onFlashLoan(
address initiator,
address token,
//@audit-info => `amount` is the amount that was borrowed on the flashloan!
uint256 amount,
uint256 fee,
bytes memory callData
) external returns (bytes32) {
...
if (data.action == FlashLoanAction.SUPPLY_BOORROW) {
...
// Use the Borrowed to pay ETH and deleverage
} else if (data.action == FlashLoanAction.PAY_DEBT_WITHDRAW) {
...
//@audit-info => `amount` is the amount that was borrowed on the flashloan!
_repayAndWithdraw(data.originalAmount, amount, fee, payable(data.receiver));
} else if (data.action == FlashLoanAction.PAY_DEBT) {
...
//@audit-info => `amount` is the amount that was borrowed on the flashloan!
_payDebt(amount, fee);
}
return _SUCCESS_MESSAGE;
}
function _repayAndWithdraw(
uint256 withdrawAmountInETh,
//@audit-info => flashloaned amount
uint256 repayAmount,
uint256 fee,
address payable receiver
) internal {
...
//@audit-info => Uses all the flashloaned amount to repay debt (WETH) in aave
_repay(wETHA(), repayAmount);
}
function _payDebt(uint256 debtAmount, uint256 fee) internal {
//@audit-info => Uses all the flashloaned amount to repay debt (WETH) in aave
_repay(wETHA(), debtAmount);
}
//@audit => If the flashloaned amount is 0, the execution on Aave will blow up the entire tx.
function _repay(address assetIn, uint256 amount) internal override virtual {
...
//@audit => Requests a withdraw for the amount of flashloaned tokens
if (aaveV3().repay(assetIn, amount, 2, address(this)) != amount) revert FailedToRepayDebt();
}
For the above reason, I still stand with my original comment about this report being invalid because the attack vector can't be executed on this protocol.
The key issue is that an attacker can pass arbitrary FlashLoanData into onFlashLoan.
An attacker can initiate Flash Loan with x Amount and call the _supplyBorrow or _repayAndWithdraw function with y amount of data.originalAmount
// data.originalAmount can be any value , amount is the amount of FlashLoan initiated by the attacker
_supplyBorrow(data.originalAmount, amount, fee);
@zhaojio Yes, data.originalAmount
can be crafted by an attacer that initiates a flashloan directly on Balancer, this is true, but the problem with this is that amount
is the amount of funds that were flashloaned from balancer, and:
amount
!= 0, the call reverts when the BalancerFlashLender
contract tries to pull amount + fees
from the Strategy contract because it has 0 allowance, this allowance is granted only when the call goes through the functions of the Strategy contracts that initiates a flashloan, see this comment for the full details amount
is 0, the call reverts on Aave, because Aave reverts withdrawals of 0 amount, see this comment for full details.Then, if any possible combination for the flashloaned amount ends up causing the tx to revert, the attack vector is not actually possible to be executed on these contracts, as such, the reported problem is invalid.
I checked it again. In most protocols, it is difficult to guarantee that all the tokens approved will be used up.
If the borrower(StrategyLeverage) has tokens that are not used up, the attacker can execute the attack. This is possible due to a division exception or an error in the oracle price
In short, allowing FlashLoads to be initiated from other contracts is wrong and dangerous and needs to be avoided.
@zhaojio
If the borrower(StrategyLeverage) has tokens that are not used up, the attacker can execute the attack. Where exactly is the division that would leave unspent allowance?
All the allowance that the Strategy grants to the BalancerFlashLender contract is consumed at the end of the flashloan, when the BalancerFlashLender pulls the amount + fee
to repay Balancer for the flashloan.
Allowance is granted here:
deploy()
=> wETH().approve(flashLenderA(), deltaDebt + fee)
undeploy()
=> wETH().approve(flashLenderA(), deltaDebt + fee)
harvest()
=> _adjustDebt()
=> wETH().approve(flashLenderA(), deltaDebt + fee)
And allowance is consumed here:
As we can see, the exact same allowance granted, is the exact same allowance consumed, no leftovers.
In short, allowing FlashLoads to be initiated from other contracts is wrong and dangerous and needs to be avoided.
Correct, there are different ways to prevent flashloans to be initiated from arbitrary accounts, in the case of this protocol, this is prevented by ensuring that the BalancerFlashLender contract has enough allowance to transfer from the Strategy, if it does not, it means that the flashloan was not initiated by the Strategy itself.
function flashFee(address, uint256 amount) external view override returns (uint256) {
uint256 perc = _balancerVault.getProtocolFeesCollector().getFlashLoanFeePercentage();
if (perc == 0 || amount == 0) {
return 0;
}
return (amount * perc) / _BALANCER_MAX_FEE_PERCENTAGE;
}
@stalinMacias As an Auditor, we should focus on finding as many bugs as possible rather than pointing out errors in other people's reports. It is the judge's job to find errors in the report. If there are errors in the report, but it makes sense to the project side, the judge can decide whether to pass the verification.
function flashFee(address, uint256 amount) external view override returns (uint256) { uint256 perc = _balancerVault.getProtocolFeesCollector().getFlashLoanFeePercentage(); if (perc == 0 || amount == 0) { return 0; } return (amount * perc) / _BALANCER_MAX_FEE_PERCENTAGE; }
This is a separate problem that has been reported on #11 . But this fact was never mentioned in this report, so, I doubt it would classify as a dupe of that report.
As an Auditor, we should focus on finding as many bugs as possible rather than pointing out errors in other people's reports.
As auditors, especially in contests, it is also our duty to help the judge by sharing all the information we can about the validity of a report, that's the purpose of the pjqa. More often than not, trying to fix something when there is not really a problem can end up introducing a real bug in the codebase
All in all, I've made my points and shared all the information I wanted to share with the judge. I'll let the judge take it from here.
More often than not, trying to fix something when there is not really a problem can end up introducing a real bug in the codebase
I doubt this claim, developers are not considering situations where flash loads can be originated from other contracts, and it is safer to prohibit flash loads from other contracts.
Reading through all these comments, i agree that _repayAndWithdraw()
would fail when a zero amount repayment is made, so the only action that is really possible is _supplyBorrow()
which would require some funds to already be in the contract. Unlikely for this to ever be the case because the contract doesn't normally hold funds that aren't being put to use in someway, but do correct me if this assumption is incorrect.
So i'm not sure how this issue can be exploited even if i do agree that it is an issue. For the time being, i will downgrade this to medium
severity because it is obvious this is not intended behavour even if it may not lead to funds being lost.
0xleastwood changed the severity to 2 (Med Risk)
Hey @0xleastwood , thanks for reading all of our comments. As you mentioned, the Strategy contracts are not expected to hold funds, all funds are deposited into Aave.
If funds are sitting on the Strategy, then, that is caused due to a different bug.
And, assuming there are any funds left on the Strategy, by executing this attack vector and calling the action _supplyBorrow()
, what will happen is that those leftover funds would be deposited to Aave, and the attacker would get nothing in return, all the opposite, the attacker would practically be gifting gas for putting to work those funds on behalf of the strategy.
While I do agree that this may not be intended behavior, I can't see any loss of funds, nor any disruption to the contract's functionalities, because of this, I'm more inclined to see this report as a QA due to the lack of impact.
Thanks for taking the time to walk through my comments on this report.
@zhaojio You may want to double-check report #11
The rounding difference can cause the tx to revert, not to leave unused allowance.
Balancer rounds up the fees to charge for the flashloan, while, the BalancerFlashFee contract rounds down. This rounding difference can potentially compute the allowance to grant between the Strategy and the BalancerFlashFee contract to be lower than what is required, thus, causing the tx to revert.
Thank you for all your comments, but this is clearly unintended behaviour and should be fixed even if it is not currently exploitable. I believe medium
severity is still justified.
Lines of code
https://github.com/code-423n4/2024-05-bakerfi/blob/59b1f70cbf170871f9604e73e7fe70b70981ab43/contracts/core/flashloan/BalancerFlashLender.sol#L109
Vulnerability details
Impact
receiveFlashLoan does not validate the originalCallData, The attacker can pass any parameters into the receiveFlashLoan function and execute any Strategy instruction :
_supplyBorrow
_repayAndWithdraw
_payDeb
.Proof of Concept
The
StrategyLeverage#receiveFlashLoan
function only validates whether the msg.sender is_balancerVault
, but does not validate theoriginalCallData
:_balancerVault.flashLoan can specify the recipient:
An attacker can initiate flashLoan from another contract and specify the recipient as
BalancerFlashLender
._balancerVault
will call thebalancerFlashlender#receiveFlashLoan
function, Since the caller of the receiveFlashLoan function is_balancerVault
, this can be verified againstmsg.sender
.The
StrategyLeverage#onFlashLoan
function parses the instructions to be executed fromoriginalCallData(FlashLoanData)
and executes them.So the attacker by calling the
_balancerVault
flashLoan function, designatedrecipient
forBalancerFlashLender
,borrower
forStrategyLeverage
, An attacker can invoke the_supplyBorrow
_repayAndWithdraw
_payDeb
function inStrategyLeverage
with anyFlashLoanData
parameter.Tools Used
vscode, manual
Recommended Mitigation Steps
BalancerFlashLender#flashLoan
function to record the parameters called via hash.receiveFlashLoan
function.Assessed type
Access Control