# Incident Postmortem: Apr-04-2023 ## Overview This article analyzes the [incident](https://arbiscan.io/tx/0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d) affecting Sentiment that took place at 1750 UTC on 4th April 2023. This postmortem is a result of discussions with [Zach](https://twitter.com/zachobront), [Alex](https://twitter.com/GalloDaSballo), [WatchPug](https://twitter.com/WatchPug_), [Sherlock](https://twitter.com/sherlockdefi) and builds upon their analyses. We thank them for their support throughout the process. ## Root Cause Analysis The root cause is a view-only reentrancy bug exposed in Balancer pools when removing liquidity with one of the return tokens being ETH. Since the entry point is a non-mutating `view` call it could not be protected by a reentrancy guard allowing the caller to take control of execution and run arbitrary code. During liquidity withdrawal operations ("exit pool") Balancer first burns the LP Tokens (BPTs) and transfers funds to the user before updating its balances. Since this is not CEI-conformant it results in an intermediate state where the `totalSupply()` of the BPTs is reduced and some of the tokens could have been transferred, but internal asset balances are yet to be updated. Typically the intermediate state described above is harmless because there's no way to exploit it. Unfortunately, this is not the case with when one of the return tokens is ETH. Balancer uses a low level `call()` to transfer ETH to the caller which allows them to reenter and take control of execution in the intermediate state with inaccurate internal balances as described above. Sentiment oracles use the `WeightedBalancerLPOracle.getPrice()` to compute the onchain price of a BPT using its `totalSupply()`. The underlying pool token balances are fetched using Balancer's `Vault.getPoolTokens()`. The price of a BPT is calculated by getting all underlying token balances and multiplying them by their market prices, followed by dividing this by the total supply of LP tokens. The exact implementation of `getPrice()` is as follows: ```solidity! function getPrice(address token) external view returns (uint) { ( address[] memory poolTokens, uint256[] memory balances, ) = vault.getPoolTokens(IPool(token).getPoolId()); uint256[] memory weights = IPool(token).getNormalizedWeights(); uint length = weights.length; uint temp = 1e18; uint invariant = 1e18; for(uint i; i < length; i++) { temp = temp.mulDown( (oracleFacade.getPrice(poolTokens[i]).divDown(weights[i])) .powDown(weights[i]) ); invariant = invariant.mulDown( (balances[i] * 10 ** (18 - IERC20(poolTokens[i]).decimals())) .powDown(weights[i]) ); } return invariant .mulDown(temp) .divDown(IPool(token).totalSupply()); } ``` The `view` function `BalancerVault.getPoolTokens()` called within Sentiment's `WeightedBalancerLPOracle.getPrice()` can be used to reenter the Balancer Vault during a malicious `exitPool` transaction and abuse the inaccurate intermediate state described above. In the middle of such a malicious `exitPool` operation, `totalSupply()` returns a reduced value since the BPTs have already been burned but `BalancerVault.getPoolTokens()` still includes the tokens to be withdrawn since their balances are yet to be updated. This creates a pernicious situation where the final divisor `IPool(token).totalSupply()` for `WeightedBalancerLPOracle.getPrice()` has reduced greatly while the other values are yet to be updated. This causes the function to return a highly inflated price value of the BPT token. ## Methodology This particular exploit heavily interacts with the *'Balancer 33 WETH 33 WBTC 33 USDC'* pool ("the pool") and for clarity, we keep track of three values related to the pool state throughout this section: 1. `balances` - Balance of underlying tokens in the pool as returned by `BalancerVault.getPoolTokens()` 2. `totalSupply` - Total supply for LP tokens of the pool as returned by `totalSupply()` 3. `price` - The ETH denominated price of the LP Token for the pool as returned by `WeightedBalancerLPOracle.getPrice()` Additionally, the exploit is carried out by a [contract](https://arbiscan.io/address/0x9f626F5941FAfe0A5b839907d77fbBD5d0deA9D0) ("the attacker") deployed using an [EOA](https://arbiscan.io/address/0xdd0cdb4c3b887bc533957bc32463977e432e49c3) ("the EOA"). Below is the state of the pool before the exploit takes place: ``` balances:[ "40.9350" // WBTC "616.3996" // WETH "1,155,172.1668" // USDC ] totalSupply: 8412.43882 price: 0.220118561 // in ETH ``` * To fund the exploit, the attacker borrows 606 WBTC, 10,050.1 WETH and 18,000,000 USDC using an Aave v3 flash loan at the start of the transaction * The attacker then creates a new Sentiment [account](https://arbiscan.io/address/0xdf346f8d160424c79cb8e8b49b13dd0ca61c3b8c) and deposits 50 WETH of borrowed assets into the account. * Next, the attacker LPs the previously deposited 50 WETH into the pool and receives 221.214516 LP tokens (BPTs) in return. Note that the attacker will use these BPTs as collateral in the future. The pool state after the attacker adds 50 WETH of liquidity to the pool is as follows: ``` balances:[ "40.9350" // WBTC "666.3996" // WETH "1,155,172.1668" // USDC ] totalSupply: 8633.65333 price: 0.220127737 // in ETH ``` * The attacker now adds liquidity to the pool using the remainder of the flash loan funds i.e. 606 WBTC, 10,000 WETH, and 18,000,000 USDC. Note that these funds are not deposited to the Sentiment account and are directly deposited by the attacker to the pool. Since these tokens are added in proportion it does not materially change the price of the pool token. The attacker receives 130600.98 BPTs as a result of this operation. The pool state once this `joinPool` operation is completed is as follows: ``` balances:[ "646.9350" // WBTC "10,666.3996" // WETH "19,155,172.1668" // USDC ] totalSupply: 139234.634 ``` * The attacker now initiates an `exitPool` operation to remove the same liquidity added above with one important change --- they request that the WETH be returned in the form of native ETH. This ETH is transferred to the attacker through a low level `call()` which allows them to take control of the execution. When the attacker gains control of the execution, all the 130600.98 LP tokens from their huge position worth 606 WBTC, 10,000 WETH and 18,000,000 USDC have already been burned. Despite this, the underlying token balances for the pool are yet to be updated. This results in the inaccurate pool state as follows: ``` balances:[ "646.9350" // WBTC "10,666.3996" // WETH "19,155,172.1668" // USDC ] totalSupply: 8633.65333 price: 3.55007307 // in ETH ``` As observable from the change in `price` over the process, the attacker is able to overestimate the price of the LP token by more than 16x. This implies that the 50 ETH (~$92.5k) worth of liquidity supplied by the attacker in their account is now worth more than 785 ETH (~$1.45m) according to the `WeightedBalancerLPOracle`. * The attacker borrows 461,000 USDC, 361,000 USDT, 81 WETH and 125,000 FRAX from the Sentiment lending pools into their Sentiment account. Due to the inflated price of the BPT this is considered to be within the protocol risk thresholds. * The attacker swaps 120,000 FRAX for 119,949.112 USDC to help pay back the original flash loan. * The attacker then withdraws 580,000 USDC, 360,000 USDT and 80 WETH outside the sentiment account. Once again, due to the highly inflated price of the LP tokens in the account this is considered to be within the protocol risk thresholds. Finally, the attacker performs some housekeeping operations to pay back the flash loan and transfers remaining "profits" to their EOA. ## Resolution To resolve this issue, modified versions of all Balancer LP Oracle contracts including`WeightedBalancerLPOracle` were deployed. The modified [contract](https://arbiscan.io/address/0xC0Fc3193Bf2176D1DA6D2F24C14996766f46eB67) implements a state-mutating `getPrice()` function in place of the previous `view` function. The modified functions implements recommendations from the [Balancer docs](https://docs.balancer.fi/concepts/advanced/valuing-bpt.html#on-chain-price-evaluation) to guard against reentrancy attacks. ```solidity! function checkReentrancy() internal { vault.manageUserBalance(new IVault.UserBalanceOp[](0)); } function getPrice(address token) external returns (uint) { checkReentrancy(); ( address[] memory poolTokens, uint256[] memory balances, ) = vault.getPoolTokens(IPool(token).getPoolId()); //... } ``` `checkReentrancy()` makes a no-op call to the Balancer Vault using the `manageUserBalance` function. Calling this function with no argument has absolutely no effect on the state but has the benefit of ensuring that the reentrancy guard has not been engaged. In the case of this exploit, this `checkReentrancy()` call would revert the `getPrice()` call and block this attack. ## Key Timestamps * Issue Occurrence: 1750 UTC Apr-04-2023 [(Txn)](https://arbiscan.io/tx/0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d) * Issue Identified: 1800 UTC Apr-04-2023 * Issue Mitigated: 1926 UTC Apr-04-2023 [(Txn)](https://arbiscan.io/tx/0xbeedfeb88f2d83eb9e26f586bf6001c29627202cd539ca93e99f1bd11d61ac25) * Issue Resolved: 0435 UTC Apr-05-2023 [(Txn)](https://arbiscan.io/tx/0xfa324fb23cdac4b94f3dfb0071bc3075cde29917178753a720ef7905a21cc0e7)