Denial of Service Attacks in DeFi - Synthetix vulnerability - Balancer vulnerability - Flash loans - double-entry point tokens

Denial-of-Service Attacks In DeFi: The Balancer-Synthetix Case

How can a DeFi project’s entire liquidity become inaccessible in an instant? In this article, we explore a type of Denial-of-Service attack vector. Namely, Denial-of-service by affecting internal token balances. This particular vulnerability arises when a Balancer multi-token flash loan is taken out for tokens with double entry points.

First, we will go over the following prerequisite concepts:

If you already understand these, you can skip to Exploring the Vulnerability

What are smart contract proxies?

Proxy architecture can be described as a smart contract design pattern that separates the state from the business logic. A proxy smart contract stores all the state, so if there is a need to change or update the logic or implementation contract, it may be changed without having to migrate the state from the previous deployment (which can be prohibitively costly). In addition, users can continue accessing the protocol using the same proxy contract address as it simply forwards all calls to the new logic contract.

The proxy pattern is primarily made possible through the delegatecall opcode, which allows a smart contract to call another smart contract, but in its own context. The term “Context” simply means the address or location of a smart contract inside the EVM. Specifically, it refers to a contract’s storage and values such as msg.sender (the caller’s address), msg.value (the ETH value sent by the caller) and address(this).

What is the difference between call and delegatecall ?

The difference between a call and delegatecall made to a contract is that in case of the former, the context used is that of the called contract, while in the latter, it is that of the calling contract.

Most proxy contracts employ delegatecall to make calls to the logic contract; incoming calls to the proxy are forwarded with a delegatecall to the implementation contract, which can be replaced with a new/modified one. The proxy remains the storage and user-facing contract.

Denial of Service Attacks in DeFi - Synthetix vulnerability - Balancer vulnerability - Flash loans - double-entry point tokens
Fig. 1 Proxy contract acts as storage, making delegate calls to the Logic contract. This is also illustrated in the code snippet below:

contract Proxy {
    ... {
    (bool success, bytes memory data) = Logic.delegatecall(
      abi.encodeWithSignature("transfer(address,uint256)", _recipient, _amount));
    }
    ...
}

contract Logic {
  function transfer(address _recipient, uint256 _amount) external returns(bool) {
    ...
  }  
  ...
}

Another approach is that the proxy makes a call to the logic contract instead of a delegatecall.

Denial of Service Attacks in DeFi - Synthetix vulnerability - Balancer vulnerability - Flash loans - double-entry point tokens
Fig. 2 Logic contract can be called directly as well. Storage contract holds the state. This is also illustrated in the code snippet below:

contract Proxy {
  function transfer(address _recipient, uint256 _amount) external returns (bool) {
    ...
    bool succ = logic.transfer(_recipient, _amount);
    ...
    }
}

contract Logic {
  
function transfer(address _recipient, uint256 _amount) external returns (bool) {
    ...
  }
}

Thus, the state may be stored in a state contract, whereas the business logic may be housed in a separate and replaceable implementation or logic contract. This sort of proxy structure is used by the Synthetix Network Token (SNX).

What are Double Entry Point Tokens?

Typically, the proxy contract itself holds the state and uses the implementation contract as a logic layer. However, as explained above with call-based proxies, this is not always the case. With some contracts, the proxy acts as a “relayer” contract, the implementation contract serves as the logic layer, while a third state contract acts as the storage layer.

Contracts that employ a call-based proxy structure may allow users to call them either through the proxy contract or directly at the implementation contract address.

In both cases, the same state is modified since both the proxy and implementation share the state. Such contracts are called Double Entry Point contracts, as they can be called via two different addresses and are said to have two entry points. ERC20 Token contracts that employ this structure are called Double Entry Point Tokens.

What are Flash Loans?

Generally in DeFi, users may borrow against a provided collateral, which is usually much higher than the amount being borrowed; the loans are thus over-collateralized. However, given the atomicity of transactions on blockchain networks such as Ethereum, a new lending paradigm has emerged: Flash Loans.

In a Flash Loan, a user may borrow a given amount of crypto tokens, without having to deposit any as collateral. However, the condition is that the borrowed amount (plus fees, usually) must be repaid within the same transaction; otherwise, the transaction will revert. Some protocols, such as Balancer, even allow users to take out Flash Loans of multiple tokens.

What are Flash Loans used for?

A primary use case for Flash Loans is to take advantage of arbitrage opportunities. As an example, if token A is cheaper at exchange P than at exchange Q, we can take out a Flash Loan for ETH, buy a lot of token A from exchange P with that ETH, sell it at exchange Q for ETH, repay our loan and keep the excess ETH. Other use cases include liquidations and collateral swapping.

Exploring the Vulnerability

Overview

The vulnerability was first spotted by the ChainSecurity team in the course of investigating the Curve read-only reentrancy back in May, 2022. The issue arises when a Balancer Flash Loan is taken out for the Synthetix Network (SNX) token, which at the time was a double entry point token. This results in a situation where all SNX (or any other double entry point token) could end up being transferred from the Vault to the fee collector contract, thereby denying users access to these funds for trading or Flash Loaning purposes.

It is worth noting here that the issue arises from the Proxy and the Implementation both having functions with the same signatures; most importantly, transfer() and transferFrom(). Thus, an attacker is able to supply the two addresses for SNX to the flashLoan function which calls .transfer() (via safeERC20.safeTransfer()) on the supplied token contracts. A mitigation for this could be to have different function names in Proxy and the Implementation:

contract Proxy {
  ...
  function transfer(address _recipient, uint256 _amount) external returns (bool) {
    ...
    bool succ = logic.transferLogic(_recipient, _amount);
    ...
  }
}


contract Logic {
  
function transferLogic(address _recipient, uint256 _amount) external returns (bool) {
    ...
  }
}

Having said that, a straightforward fix would also be to have a modifier onlyProxy on the implementation’s function. In fact, this is what was done by the Synthetix team to patch the vulnerability.

Explanation

Balancer allows users to take out flash loans of multiple tokens through the flashLoan function of its Vault contract. Given below is the signature of the Vault.flashLoan function:

    function flashLoan(
        IFlashLoanRecipient recipient,
        IERC20[] memory tokens,
        uint256[] memory amounts,
      bytes memory userData) {
      ...
    }

An attacker could provide the two addresses for the SNX token and take out a Flash Loan for a very high first borrow amount and a zero second borrow amount. This would lead Balancer to perceive the entirety of the repaid SNX amount (the first amount) as protocol fees, thereby resulting in denial-of-service to users who want to trade or borrow SNX.

Attack Simulation

1.  Attacker makes the following call to the Balancer Vault:

contract AttackContract {
  
  function attack() public {
    BalancerVault.flashLoan
    (address(this), [SNXTokenAddress1, SNXTokenAddress2], [SNX.balanceOf(BalancerVault), 0])
  }
  
} 

Here, the two tokens being flash borrowed are one and the same, and the corresponding amounts being borrowed are the max balance of SNX held by the Vault contract and 0, respectively. The recipient address is that of the AttackContract.

  1. Flash Loan of the first token (SNX) is issued in the first loop. The amount transferred to the attacker is all SNX tokens in the contract, given that the borrow amount specified is SNX.balanceOf(Vault):

contract BalancerVault {
  
    function flashLoan(
        IFlashLoanRecipient recipient,
        IERC20[] memory tokens,
        uint256[] memory amounts,
        bytes memory userData
    ) external override nonReentrant whenNotPaused {
        ...
        for (uint256 i = 0; i < tokens.length; ++i) {
            // Ensure order of tokens
            _require(
                token > previousToken,
                token == IERC20(0) ? Errors.ZERO_TOKEN : Errors.UNSORTED_TOKENS
            );
          ...
            // Record the preLoanBalance of each `tokens`
            preLoanBalances[i] = token.balanceOf(address(this));
          ...
            // Ensure that the flashLoan amount being requested is covered by the available reserves                  for the `tokens`
            _require(
                preLoanBalances[i] >= amount,
                Errors.INSUFFICIENT_FLASH_LOAN_BALANCE
            );
            // Transfer the amount for each token to the `recipient`
            token.safeTransfer(address(recipient), amount);
        }
      recipient.receiveFlashLoan(tokens, amounts, feeAmounts, userData);
      ...
    }
} 
  1. In the first iteration, the preLoanBalance of SNX is recorded. Then, the loan amount is transferred to the recipient. Let’s say there is a total of 100 SNX in the Vault. The preLoanBalance recorded would thus be 100 SNX. At this point, the Vault has 0 SNX since all have been transferred to the recipient.

  2. In the second iteration, the preLoanBalance is recorded for the second token, this is again SNX but with a different address. Since all SNX had been lent out in the previous loop, the preLoanBalance would thus be set as 0 SNX. This results in the Vault balance check passing since there is 0 SNX and 0 is being borrowed. A Flash Loan of 0 SNX is then transferred to the user.

  3. The Attacker repays both loan amounts in AttackContract.receiveFlashLoan():

contract AttackContract {
  
  function attack() public {
    ...
  }
  
  function receiveFlashLoan
  (IERC20[] memory tokens, uint256[] memory amounts, uint256[] memory amounts, bytes memory userData) {
    // Simply repay the received token amounts
     for (uint256 i = 0; i < tokens.length; ++i) {
       // Loop1: All SNX is Repaid
       // Loop2: 0 SNX is repaid
       IERC20(token).safeTransfer(address(BalancerVault), amount);
     }
  }
  
} 
  1. The execution continues inside Vault.flashLoan(). Now, in the next loop, it is ensured that the Vault’s balance for the two tokens lent (both SNX) has not decreased post-loan. To this end, the existing Vault balance (postLoanBalance) is compared with the preLoanbalance for both tokens:

contract BalancerVault {
  
function flashLoan(
        ...
    ) external override nonReentrant whenNotPaused {
        ...
        for (uint256 i = 0; i < tokens.length; ++i) {
            ...
          // Record the postLoanBalance of this contract for each `token`
            uint256 postLoanBalance = token.balanceOf(address(this));
            // Ensure that postLoanBalance is more than or equal to the preLoanBalance for both tokens
            _require(
                postLoanBalance >= preLoanBalance,
                Errors.INVALID_POST_LOAN_BALANCE
            );
            // Since both addrs point to the same token, the preLoanBalance for the SNX token is 0 but the postLoanBalance is 100 since it has bene repaid.
          // 100-0 = 100 is the receivedFeeAmount
            uint256 receivedFeeAmount = postLoanBalance - preLoanBalance;
    ...
            // Transfer the fee amount for to the ProtocolFeesCollector contract
            _payFeeAmount(token, receivedFeeAmount);
            ...
        }
  ...
  
}
  function _payFeeAmount(IERC20 token, uint256 amount) internal {
    if (amount > 0) {
       token.safeTransfer(address(getProtocolFeesCollector()), amount);
    }
}

Since both addresses are of the SNX token, the postLoanBalance, which is checked via token.balanceOf(address(this)), is reported to be 100 SNX. However, the preLoanBalance remains 0.

  1. The difference is calculated between pre and post loan balances. All of the repaid amount is taken to be protocol fee 100 - 0 = 100 SNX and is transferred to the protocol fee contract.

Now, when a user comes to trade for SNX (or Flash Loans it), the transaction reverts since all SNX held by the Vault contract now lies in the fee collector contract. The user is thus denied service.

Lessons Learnt

There are several lessons we can learn from this vulnerability.

First, we should account for unusual function arguments in our code. In the case of Balancer’s flashLoan function, it assumes the tokens being borrowed to be different. However, as we now know, two different addresses can point to the same contract or token in case of double entry point tokens. It is worth noting that Balancer had disclaimed any use of unusual ERC20 tokens, e.g., those with double entry point. However, despite the disclaimer, such tokens did end up on the protocol and gathered high liquidity. As such, it is always worth the effort to account for edge cases in the code, notwithstanding any warnings or disclaimers in the documentation.

Second, instead of recording pre-loan balances and transferring tokens simultaneously, we could first record pre-loan balances, and then transfer the tokens. This way, even if a token has double entry points, the vulnerability is avoided. Reason being, the pre-loan balance recorded for the second token will not be 0 as it was recorded before any transfer took place. Hence, when the Flash Loan is ultimately repaid, it is compared with the accurate preLoanBalance and no amount is transferred to the fee collector contract as fee.

Third, we should always be wary of providing functionality that makes calls to user-provided addresses; in this case, it is user-provided token contracts on which methods like transfer and transferFrom are called.

Finally, it can be argued that having contracts with multiple entry points is not worth the trouble, to begin with. The DeFi ecosystem is increasingly resembling a lego structure, with each lego block expected to fit properly, to work in a standardised and predictable manner. This expectation is shattered with double-entry smart contracts.

Responding To Audit Findings

A major factor in dealing with vulnerabilities is the response of the project owners to such findings. When ChainSecurity reached out to Balancer with the news of this attack vector on May 13, 2022, they immediately responded and started to collaborate on understanding and resolving the issue. The Synthetix team was then made aware of the issue by the Balancer team. An improvement proposal was swiftly passed on June 10, 2022, green-lighting a patch that restricted  interactions with the SNX token to only via the proxy, and transferred back all the SNX from the fee collector contract back to the Vault contract.

It is thus paramount that project owners are easily reachable in case of such emergencies. Projects should have a dedicated channel for white hats to transmit information, which may then be triaged and passed to the relevant team. In addition, while white hats don’t need to be kept in the loop, they should be given confirmation that their report has been received and is being handled. Ideally, they should be given a timeline for the fix and asked for the best channel to contact them in case there are more questions.

Conclusion

In this article, we went over a specific denial-of-service attack vector. It affected the internal token balances in the Balancer protocol, leading to tokens being unavailable for users to trade. We looked at how the Balancer Flash Loan functionality can be used with double entry tokens like SNX to exploit this vulnerability. Learnings from this finding and the way forward were also considered. We also highlighted the importance of quick response by project owners, as exemplified by Balancer and Synthetix teams.

Further Reading

About ChainSecurity

At ChainSecurity, we have been securing smart contracts since 2017. Our clients comprise of blue-chip DeFi protocols, promising new Web3 projects, central banks, and large organizations.

Read our published audit reports.

Book a call to discuss auditing prospects.