#34851 [SC-Low] Adversary can freeze users' fund in stBTC using donation attack on MezoAllocator
Submitted on Aug 29th 2024 at 13:27:05 UTC by @nnez for Audit Comp | Acre
Report ID: #34851
Report Type: Smart Contract
Report severity: Low
Target: https://sepolia.etherscan.io/address/0x7e184179b1F95A9ca398E6a16127f06b81Cb37a3
Impacts:
Temporary freezing of funds
Description
Description
tBTC inside stBTC contract is allocated to Mezo portal via dispatcher contract from time to time by appointed maintainer calling `allocate()` function. See: https://github.com/thesis/acre/blob/main/solidity/contracts/MezoAllocator.sol#L190-L219 ```solidity function allocate() external onlyMaintainer { if (depositBalance > 0) { // Free all Acre's tBTC from MezoPortal before creating a new deposit. // slither-disable-next-line reentrancy-no-eth mezoPortal.withdraw(address(tbtc), depositId); }
} ``` Basically, it does the following:
The funds are all transferred from stBTC and deposit to Mezo portal.
The total deposit balance is written in a storage variable, `depositBalance`
The corresponding depositId is queried from Mezo portal and written in `depositId` as it needs this reference id for withdrawal process.
This has the implication that after each allocation, all tBTC will be deposited into Mezo portal, therefore, the balance of tBTC on stBTC contract should amount to zero.
In the opposite side, when users want to redeem their stBTC shares for tBTC, if the amount of tBTC waiting for allocation is insufficient for the redemption, stBTC contract calls `withdraw` function on dispatcher in order to withdraw tBTC from Mezo portal to meet with redemption requirement.
See: https://github.com/thesis/acre/blob/main/solidity/contracts/MezoAllocator.sol#L190-L219 and https://github.com/thesis/acre/blob/main/solidity/contracts/stBTC.sol#L432C5-L446C6 ```solidity File: MezoAllocator.sol function withdraw(uint256 amount) external { if (msg.sender != address(stbtc)) revert CallerNotStbtc();
}
File: stBTC.sol function withdraw( uint256 assets, address receiver, address owner ) public override returns (uint256) { uint256 currentAssetsBalance = IERC20(asset()).balanceOf(address(this)); // If there is not enough assets in stBTC to cover user withdrawals and // withdrawal fees then pull the assets from the dispatcher. uint256 assetsWithFees = assets + _feeOnRaw(assets, exitFeeBasisPoints); if (assetsWithFees > currentAssetsBalance) { dispatcher.withdraw(assetsWithFees - currentAssetsBalance); }
} ```
This has the implication that if the deposited amount in Mezo portal is insufficient, the transaction reverts.
totalAssets of stBTC comes from three sources:
totalDebt
balanceOf tBTC in stBTC contract
amount of tBTC sent to dispatcher
depositBalance in Mezo portal
balanceOf tBTC in dispatcher contract
All of these properties can affect the price per share (PPS) of stBTC as PPS is calculated from `totalAssets / totalSupply`.
Crucially, balanceOf tBTC in dispatcher contract is not used in any fund flows. This creates a situation where attackers can manipulate the share price by donating tBTC directly to dispatcher contract. Attacker could attempt to increase his shares value to eat up all the depositBalance in Mezo portal then force a withdrawal from Mezo portal, therefore, effectively transfer all other users' balance to balanceOf tBTC in dispatcher contract which is inaccessible.
The following attack scenario should illustrate the issue better.
Attack scenario
Supposed that stBTC is in the state after allocation is called and current `totalAssets` is `1_000` and `totalSupply` is `1_000` (implies no yield gain yet). Therefore, as we explore the funds flow earlier, all tBTC balance should already has been deposited to Mezo portal.
That means, `tBTC.balanceOf(stBTC)` and `tBTC.balanceOf(dispatcher)` should return zero.
Attacker deposits `1_000 * 99 = 99_000`, and get `99_000` shares back. (Attacker now owns 99% of totalSupply)
``` totalAssets = 100_000 totalShares = 100_000 tBTC.balanceOf(sTBTC) = 99_000 (attacker's asset) tBTC.balanceOf(dispatcher) = 0 depositBalance (in Mezo) = 1_000 (from earlier allocation) ```
Attacker transfers tBTC directly to dispatcher contract to inflate the share price so that attacker's shares worth `100_000` in tokens.
Target share price is `100_000/99_000 = 1.0101010101` The requrie total asset is `1.0101010101 * 100_000 (total share) = 101010.10101` Thus, attacker has to transfer `101010.10101 - 100_000 = 1010.10101 -> 1011 (round up)`
``` totalAssets = 101_011 totalShares = 100_000 tBTC.balanceOf(stBTC) = 99_000 tBTC.balanceOf(dispatcher) = 1_011 depositBalance (in Mezo) = 1_000 ```
Attacker's shares are now worth `(101_011/100_000)*99_000 = 100000.89 -> 100_000 (round down)`
Attacker redeems all his shares (his shares number is the same, so it passes non-fungible share validation).
Because the total amount of tBTC in stBTC contract is insufficient for this redemption, the stBTC will call dispatcher contract to withdraw tBTC token from Mezo portal, in this case, it falls short by a `1_000`.
Therefore, all depositBalance will be withdrawn from Mezo portal. Attacker gets `100_000` tBTC back and loses `1_011` tBTC residing in dispatcher.
Attacker's total loss = `100_000 - (99_000+1_011) = -11`
As a result of all this, it creates a problem for other users because now the state of the pool would look like this: ``` totalAssets = 101_011 - 100_000 = 1_011 totalShares = 100_000 - 99_000 = 1_000 tBTC.balanceOf(stBTC) = 99_000 - 99_000 = 0 tBTC.balanceOf(dispatcher) = 1_011 depositBalance (in Mezo) = 0 ```
The corresponding assets of other users are effectively transferred to `dispatcher` contract. However, the redemption or withdrawal flow doesn't utilize that balance to return assets to users.
Therefore, when users try to redeem his/her shares, it will always revert.
Although dispatcher contract also implements emergency function, `releaseDeposit` See: https://github.com/thesis/acre/blob/main/solidity/contracts/MezoAllocator.sol#L248-L257 ```solidity function releaseDeposit() external onlyOwner { uint96 amount = mezoPortal .getDeposit(address(this), address(tbtc), depositId) .balance;
} ``` The call to this function would also always revert because the Mezo portal reverts on withdrawing zero amount. See: MezoPortal implementation ```solidity function withdraw(address token, uint256 depositId) external { ...snipped...
} ```
Impact
Users fund will get stuck. Whether it's permanent or temporary is depending on the upgradability of the contract.
Rationale for Severity
Cost analysis
To determine the severity level of this bug, one has to calculate to cost for a successful attack as attacker doesn't gain profit from this exploitation. According to Immunefi's guideline, an attack that costs $1 to deal $10 or less in damage is Griefing. (See: https://immunefisupport.zendesk.com/hc/en-us/articles/17455102268305-When-Is-An-Impactful-Attack-Downgraded-To-Griefing)
The cost for an attack in the example attack scenario is about 1.1% of TVL (11/1000). However, in real world scenario, Acre has deposit and withdrawal fee which would also incur to attack cost.
For instance, in our attack scenario, attacker would lose another `100_000*0.0025 = 250` and it makes the total loss of `11+250 = 261`, 26.1% of TVL
I still could not figure out the math of how to optimize the attack cost but so far from fuzzing I found that for a 0.25% fee, if attacker choose to mint and acquire only 95% of totalSupply, the cost could be lower to 10% of the TVL (Shown in PoC).
All in all, I decided to submit this as a `High` severity because
Although the funds are stuck, it could still be recovered with upgradability so the impact might be only temporary funds freezing.
The cost for the attack could be lower in the future given that the logical forward move would be to lower the fee to attract more users.
Recommended Mitigations
The root cause of this vulnerability is the inclusion of `tBTC.balanceOf(dispatcher)` in `totalAssets`. Since there is no use of that balance in any fund flows, it should be safe to remove this balance of `totalAssets` calculation.
Proof of Concept
Proof-of-Concept
The following test demonstrates the aforementioned attack scenario.
BOB, a bystander, deposits 1_000e18 tBTC into stBTC vault.
Attacker mints a share to acquire 95% of the totalSupply
Attacker donates the amount required to perform the attack
Attacker redeems all his shares, the total loss compared to initial TVL is shown in percentage.
BOB tries to redeem but fail. Owner tries to call emergency function but fail
Steps
Create a new forge project, `forge init --no-commit --no-git --vscode`
Create a new test file in `test` directory
Paste the below code in the test file
Run `forge t -vv` and observe that BOB (bystander) could not reeem his shares and owner can't call emergency function. ``` // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol"; import "forge-std/interfaces/IERC20.sol"; import "forge-std/interfaces/IERC4626.sol";
interface IStBTC{ function mintDebt(uint256 shares, address receiver) external returns(uint); function totalSupply() external view returns(uint); function totalAssets() external view returns(uint); function deposit( uint256 assets, address receiver ) external returns(uint); function updateExitFeeBasisPoints( uint256 newExitFeeBasisPoints ) external; function owner() external view returns(address); }
interface IAllocater{ function allocate() external; function releaseDeposit() external; function depositBalance() external view returns(uint); }
contract AcreBoostTest is Test {
} ```
Expected Result: ``` Ran 1 test for test/Counter.t.sol:AcreBoostTest [PASS] testFuzzPoC() (gas: 901851) Logs: @> Maintainer calls allocate()
@> tBTC in stBTC: 0 @> tBTC in disptacher: 0 @> dispatcher depositBalance: 1002104435351260000000
@> Attacker mints to acquire 95% of totalSupply
@> tBTC in stBTC: 19083940916090104046548 @> tBTC in disptacher: 0 @> dispatcher depositBalance: 1002104435351260000000
@> Attacker owns: 95% of totalSupply @> Attacker inflates his shares so that it eat up all depositBalance in Mezo portal @> Required donation: 1054846774053957894737 @> Attacker's shares now worth: 20086045351441364046548
@> tBTC in stBTC: 19083940916090104046548 @> tBTC in disptacher: 1054846774053957894737 @> dispatcher depositBalance: 1002104435351260000000
@> Assert that when redeeming, attacker owed assets would eat up all tBTC in stBTC contract and depositBalance in Mezo portal @> Attacker's initial capital: 20138787690144061941285 @> Attacker's final balance: 20035955462784403038950 @> Total loss: 102832227359658902335 @> Cost per TVL: 0.102616278036544073
@> tBTC in stBTC: 0 @> tBTC in disptacher: 1054846774053957894737 @> dispatcher depositBalance: 0
@> BOB tries to redeem his shares @> Expect revert... @> BOB failed to redeem his shares @> Owner tries to call emergency function @> Expect revert DepositNotFound @> Owner failed to call emergency function from Mezo portal withdrawal ```