Theoretical-Practical: Balancer and Read only Reentrancy Part 1

KamiWar
Coinmonks
5 min readMar 3, 2023

--

Due to historical precedence and the damage inflicted by standard reentrancy attacks (eg. 2016 DAO Hack) we can assume that most battle tested protocols employ a reentrancy guard/mutex modifier and are vigilantly targeted by auditors during an audit. However, it seems that DeFi is still coming into grips with the risks presented by read only reentrancy.

Read-only reentrancy occurs where a view function is called and reentered into during the execution of another function that modifies the state of that contract. This could potentially lead to stale data since what is read in memory during function invocation and what is recorded in storage has yet to be finalized and may be out of sync. While transactions in DeFi are atomic from an off chain perspective, multi-legged transactions in DeFi may exist in an “in-between state” and it is this in-between-state where view functions may be subject to reentrancy. As a result, functions or contracts that rely on the returned value can be exploited which may lead to undesirable/malicious behaviour (overpayment of protocol fees, rate manipulation, incorrect pricing).

Enough theoretical. We will now take a practical dive into Balancer’s recent spate with view only reentrancy which forced LPers to remove their liquidity from a few Balancer pools. The affected functions have been provided below but do note that extraneous code not related to the vulnerability has been omitted for the sake of readability. Please kindly reference this Github link to see the code in its entirety.

The entry point of Balancer’s reentrancy vulnerability lies in the _joinOrExit function. For context, the _joinOrExit function is called whenever the joinPool or exitPool is called by an external protocol user (note, in the case of read only reentrancy it would be a join by a malicious actor).

function _joinOrExit(
PoolBalanceChangeKind kind,
bytes32 poolId,
address sender,
address payable recipient,
PoolBalanceChange memory change
) private nonReentrant withRegisteredPool(poolId) authenticateFor(sender) {

// The bulk of the work is done here: the corresponding Pool hook is called, its final balances are computed,
// assets are transferred, and fees are paid.
(
bytes32[] memory finalBalances,
uint256[] memory amountsInOrOut,
uint256[] memory paidProtocolSwapFeeAmounts
) = _callPoolBalanceChange(kind, poolId, sender, recipient, change, balances);

We see from above function that _joinOrExit will invoke the _callPoolBalanceChangefunction before calling _setMinimalSwapInfoPoolBalances which updates the token balances within a Balancer pool.

function _callPoolBalanceChange(
PoolBalanceChangeKind kind,
bytes32 poolId,
address sender,
address payable recipient,
PoolBalanceChange memory change,
bytes32[] memory balances
)
private
returns (
bytes32[] memory finalBalances,
uint256[] memory amountsInOrOut,
uint256[] memory dueProtocolFeeAmounts
)
{
(uint256[] memory totalBalances, uint256 lastChangeBlock) = balances.totalsAndLastChangeBlock();

IBasePool pool = IBasePool(_getPoolAddress(poolId));
(amountsInOrOut, dueProtocolFeeAmounts) = kind == PoolBalanceChangeKind.JOIN
? pool.onJoinPool(
poolId,
sender,
recipient,
totalBalances,
lastChangeBlock,
_getProtocolSwapFeePercentage(),
change.userData
)
: pool.onExitPool(
poolId,
sender,
recipient,
totalBalances,
lastChangeBlock,
_getProtocolSwapFeePercentage(),
change.userData
);

InputHelpers.ensureInputLengthMatch(balances.length, amountsInOrOut.length, dueProtocolFeeAmounts.length);

// The Vault ignores the `recipient` in joins and the `sender` in exits: it is up to the Pool to keep track of
// their participation.
finalBalances = kind == PoolBalanceChangeKind.JOIN
? _processJoinPoolTransfers(sender, change, balances, amountsInOrOut, dueProtocolFeeAmounts)
: _processExitPoolTransfers(recipient, change, balances, amountsInOrOut, dueProtocolFeeAmounts);
}

We can think of _callPoolBalanceChange as Balancer’s internal pool accountant whose aim is to update the pool whenever there is a new join by calculating the amounts in/amounts out, protocol fee amounts before returning the pool’s final balances by calling _processJoinPoolTransfers. Do also note that at this stage, Balancer pool tokens will already have been minted by virtue of _callPoolBalanceChange calling onJoinPool.

function _processJoinPoolTransfers(
address sender,
PoolBalanceChange memory change,
bytes32[] memory balances,
uint256[] memory amountsIn,
uint256[] memory dueProtocolFeeAmounts
) private returns (bytes32[] memory finalBalances) {
// We need to track how much of the received ETH was used and wrapped into WETH to return any excess.
uint256 wrappedEth = 0;

finalBalances = new bytes32[](balances.length);
for (uint256 i = 0; i < change.assets.length; ++i) {
uint256 amountIn = amountsIn[i];
_require(amountIn <= change.limits[i], Errors.JOIN_ABOVE_MAX);

// Receive assets from the sender - possibly from Internal Balance.
IAsset asset = change.assets[i];
_receiveAsset(asset, amountIn, sender, change.useInternalBalance);

if (_isETH(asset)) {
wrappedEth = wrappedEth.add(amountIn);
}

uint256 feeAmount = dueProtocolFeeAmounts[i];
_payFeeAmount(_translateToIERC20(asset), feeAmount);

// Compute the new Pool balances. Note that the fee amount might be larger than `amountIn`,
// resulting in an overall decrease of the Pool's balance for a token.
finalBalances[i] = (amountIn >= feeAmount) // This lets us skip checked arithmetic
? balances[i].increaseCash(amountIn - feeAmount)
: balances[i].decreaseCash(feeAmount - amountIn);
}

// Handle any used and remaining ETH.
_handleRemainingEth(wrappedEth);
}

_processJoinPoolTransfers transfers tokens from the sender and is the actual function that computes the new balances of the pool. More importantly, this is also the function that exposes the proverbial chink in Balancer’s armour. The last line of _processJoinPoolTransfers will always call the _handleRemainingEth function to send back any unused ETH to the protocol end user during a join.

function _handleRemainingEth(uint256 amountUsed) internal {
_require(msg.value >= amountUsed, Errors.INSUFFICIENT_ETH);

uint256 excess = msg.value - amountUsed;
if (excess > 0) {
msg.sender.sendValue(excess); //Low level call
}
}

//Note this function exists in a separate location and is placed here for
//sake of convenience
function sendValue(address payable recipient, uint256 amount) internal {
_require(address(this).balance >= amount, Errors.ADDRESS_INSUFFICIENT_BALANCE);

// solhint-disable-next-line avoid-low-level-calls, avoid-call-value
(bool success, ) = recipient.call{ value: amount }("");
_require(success, Errors.ADDRESS_CANNOT_SEND_VALUE);
}

Balancer calls the sendValue function which is derived from the Address library from OpenZeppelin(which uses the low level call API to handle the ETH transfer) with the excess variable as a parameter to refund any excess ETH back to the protocol end user.

A hacker can implement a fallback function within a malicious contract when Balancer refunds any unused ETH to call into the pool. It is important to note that at this point in time, the Balancer pool tokens will already have been minted but _setMinimalSwapInfoPoolBalances has yet to be invoked, that is the token balances will not have been updated in the Vault. Therefore, any calculation/function will be incorrect and subject to manipulation.

The salient issue with read only reentrancy, and what also makes this attack vector so interesting is how subtle and difficult it is to spot as opposed to other smart contract vulnerabilities such as timestamp manipulation, delegatecall to an untrusted callee or standard reentrancy attacks. Detecting view only reentrancy requires a holistic understanding of not just how a protocol functions as a whole, but also how the various components within a protocol interact and depend on each other. Protocols that have recently been affected (Notional Finance, Balancer) have all undergone multiple audits where this issue was not picked up by the auditors.

In Part II of this Article, we will look at an actual implementation and deep dive of a mock attack contract that aims to exploit this vulnerability.

[1]: Balancer Labs. (Last updated as of May 11, 2022).

New to trading? Try crypto trading bots or copy trading on best crypto exchanges

Join Coinmonks Telegram Channel and Youtube Channel get daily Crypto News

Also, Read

--

--