On July 30, 2023, a series of exploits targeted multiple Curve pools, resulting in losses amounting to millions of dollars. It is a typical reentrancy attack with a non-typical root cause, stemming from a compiler bug that leads to the absence of reentrancy protection. Specifically, there was a mistake in which the reentrant locks for different functions within a smart contract were assigned different storage slots. As a result, smart contracts compiled using Vyper versions 0.2.15, 0.2.16, and 0.3.0 were vulnerable.
Background
Since Curve utilizes Vyper instead of Solidity for its smart contract development, a brief introduction to the Vyper language is provided to aid in understanding the associated vulnerability.
Vyper is a Python-based programming language created by Vitalik Buterin, the co-founder of Ethereum. As introduced in its documentation, Vyper is a contract-oriented, domain-specific, pythonic programming language that targets the Ethereum Virtual Machine (EVM). Its goals include simplicity, 'pythonicity', security, and auditability.
Vyper has become the second-most widely used programming language for Ethereum and EVM-compatible chains, following the well-known Solidity. Curve is one of the largest adopters of the Vyper language, with most of their contracts being written in it. Many projects related to or forked from Curve also use Vyper to ensure better code reuse and interoperability with Curve systems.
Below is a code segment from a Curve pool (the pETH/ETH pool that was attacked in this incident). Although the syntax is very similar to Python, there are notable differences between Vyper and Python:
@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:
"""
@notice Withdraw coins from the pool
@dev Withdrawal amounts are based on current deposit ratios
@param _burn_amount Quantity of LP tokens to burn in the withdrawal
@param _min_amounts Minimum amounts of underlying coins to receive
@param _receiver Address that receives the withdrawn coins
@return List of amounts of coins that were withdrawn
"""
total_supply: uint256 = self.totalSupply
amounts: uint256[N_COINS] = empty(uint256[N_COINS])
for i in range(N_COINS):
old_balance: uint256 = self.balances[i]
value: uint256 = old_balance * _burn_amount / total_supply
assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
self.balances[i] = old_balance - value
amounts[i] = value
if i == 0:
raw_call(_receiver, b"", value=value)
All parameters and return values of a function within the contract must be properly type-annotated. The language does not support classes or inheritance; the keyword 'self' is used solely to refer to the contract itself and to access state variables. Built-in decorators (e.g., @external and @nonreentrant) are utilized to denote the properties of a function, and there is no support for custom decorators.
Vulnerability Analysis
The vulnerability arises from a compiler bug that results in a lack of reentrancy protection.
Reentrancy attacks are one of the most common types of attacks within the blockchain ecosystem. Specifically, they occur when the execution of contract logic initiates external calls, some of which may recursively call back into the original contract. This can dangerously expose the contract's intermediate state during function execution to other contracts, potentially leading to vulnerabilities. To counter this, a reentrancy guard, or lock, is employed to ensure that the contract cannot be reentered during the execution of a single transaction.
In the code segment mentioned earlier, the @nonreentrant('lock') annotation indicates that the remove_liquidity function should be secured with the reentrancy lock named lock. For additional clarity, one might compare this to the OpenZeppelin ReentrancyGuard contract and its nonReentrant modifier. The principal difference in Vyper is that reentrancy locks are not provided by an external library but are instead built-in features of the language itself. This seemed satisfactory until a deeper investigation into the implementation of reentrancy locks was undertaken. It was found that the code segment introduced in Pull Request #2391 (merged on July 23, 2021) utilized the set_storage_slots function to assign storage slots based on the Vyper source code's AST (Abstract Syntax Tree).
def set_storage_slots(vyper_module: vy_ast.Module) -> None:
"""
Parse module-level Vyper AST to calculate the layout of storage variables.
"""
# Allocate storage slots from 0
# note storage is word-addressable, not byte-addressable
storage_slot = 0
for node in vyper_module.get_children(vy_ast.FunctionDef):
type_ = node._metadata["type"]
if type_.nonreentrant is not None:
type_.set_reentrancy_key_position(StorageSlot(storage_slot))
# TODO use one byte - or bit - per reentrancy key
# requires either an extra SLOAD or caching the value of the
# location in memory at entrance
storage_slot += 1
However, a critical error lies in the fact that for each reentrancy lock, the storage slot is incremented by 1. Consequently, different functions end up being assigned unique storage slots for their reentrancy locks. To evaluate this implementation, we compile the contract below:
# Compile command: vyper -f bytecode,bytecode_runtime,ir test_contract.vy
from vyper.interfaces import ERC20
test_addr: address
@external
def __init__(_addr: address):
self.test_addr = _addr
@external
@nonreentrant('lock')
def test_funcA():
pass
@external
@nonreentrant('lock')
def test_funcB():
pass
Using Vyper Compiler version 0.2.16+commit.59e1bdd, the Vyper IR (Intermediate Representation) generated during code compilation is partially listed as follows:
[if,
[eq, _func_sig, 2354224227 <test_funcA()>],
[seq,
[assert, [iszero, [sload, 0]]],
[sstore, 0, 1],
pass,
# Line 13
pass,
# Line 12
[sstore, 0, 0],
stop]],
# Line 17
[if,
[eq, _func_sig, 741100118 <test_funcB()>],
[seq,
[assert, [iszero, [sload, 1]]],
[sstore, 1, 1],
Let's focus on the IR in Lines 4-5 and Lines 16-17, where the generated code verifies the reentrancy lock and stores the lock state in storage. However, it was observed that different functions used different slots for the non-reentrant lock: test_funcA uses slot 0, while test_funcB uses slot 1. This indicates that the reentrancy lock is ineffective, as the contract can be re-entered through different functions.
Attack Analysis
Here we provide some context regarding Curve. A Curve pool enables users to supply and withdraw liquidity via the add_liquidity and remove_liquidity functions. When adding liquidity, the amount to be added is determined by a ratio of the total supply, specifically, the proportion of the added liquidity to the existing liquidity. On the other hand, remove_liquidity calculates the number of tokens to be withdrawn based on the ratio of the LP (Liquidity Provider) tokens submitted to the current total supply, after which the LP tokens are burned.
Furthermore, Curve supports pools that handle native tokens, and it utilizes low-level calls (the raw_call function in Vyper) to return the native token to the user. In the code segment below, the remove_liquidity function first computes and transfers the tokens to be removed based on the LP token quantity and total supply, and then the total supply is subsequently decreased.
Under normal circumstances, this would be secure, as the reentrancy lock should prevent exposure of the intermediate state during raw calls. However, when the reentrancy lock is ineffective—a flaw that was eventually exploited—an attack becomes feasible. The ineffective reentrancy lock means that the intermediate state (where tokens to be withdrawn have been transferred out, but the total supply has not yet been reduced) becomes vulnerable during a low-level call, allowing for potential reentry into the contract.
@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:
"""
@notice Withdraw coins from the pool
@dev Withdrawal amounts are based on current deposit ratios
@param _burn_amount Quantity of LP tokens to burn in the withdrawal
@param _min_amounts Minimum amounts of underlying coins to receive
@param _receiver Address that receives the withdrawn coins
@return List of amounts of coins that were withdrawn
"""
total_supply: uint256 = self.totalSupply
amounts: uint256[N_COINS] = empty(uint256[N_COINS])
for i in range(N_COINS):
old_balance: uint256 = self.balances[i]
value: uint256 = old_balance * _burn_amount / total_supply
assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
self.balances[i] = old_balance - value
amounts[i] = value
if i == 0:
raw_call(_receiver, b"", value=value)
else:
response: Bytes[32] = raw_call(
self.coins[1],
concat(
method_id("transfer(address,uint256)"),
convert(_receiver, bytes32),
convert(value, bytes32),
),
max_outsize=32,
)
if len(response) > 0:
assert convert(response, bool)
total_supply -= _burn_amount
self.balanceOf[msg.sender] -= _burn_amount
self.totalSupply = total_supply
log Transfer(msg.sender, ZERO_ADDRESS, _burn_amount)
log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply)
return amounts
This reentrancy can be exploited because the total supply has not been reduced as indicated in Line 27 above. If we re-enter the contract at this point and call add_liquidity, the provision of liquidity would be based on an incorrect total supply (higher than it should be), leading to an excessive number of LP tokens being minted and resulting in losses for the pool. Most attacks in this incident have exploited this vulnerability. The following discussion examines one of the largest attack transactions, 0xa84aa065ce, against the Curve pETH-ETH pool.
The attack trace is very clear.
- The attacker borrowed a flash loan from Balancer and then provided 40,000 ETH as liquidity to the Curve pETH/ETH pool, receiving 32,431.41 pETH-ETH LP tokens.
- The attacker then removed 3,740 pETH and 34,316 ETH from the pool by burning 32,431.41 pETH/ETH pool LP tokens.
- During the liquidity removal, the pool contract was reentered. Within the fallback function, the attacker provided another 40,000 ETH as liquidity to the Curve pETH/ETH pool, minting an additional 82,182 LP tokens. During this process, the total supply figure used was that before the liquidity removal, which was incorrect, resulting in a greater number of LP tokens being minted than should have been possible.
- Subsequently, the attacker withdrew 1,184.73 pETH and 47,506.53 ETH by burning 10,272.84 Curve LP tokens. In summary, the attacker profited by minting extra LP tokens and draining the pool using these additional LP tokens.
Summary
This vulnerability originated from the compiler, not the source code. This marks the first occasion where a compiler bug has resulted in significant financial loss within the blockchain ecosystem.
Indeed, smart contracts compiled using Vyper versions 0.2.15, 0.2.16, and 0.3.0 are vulnerable, which can lead to the failure of the reentrancy guard. https://t.co/GM7Ze5to39 pic.twitter.com/K6Lo29Pfn2
— BlockSec (@BlockSecTeam) July 30, 2023
Given that compilers form a critical infrastructure component, their security is paramount for the integrity and functionality of blockchain technology. Compiler-related issues might not be immediately obvious, yet they can have extensive and serious consequences. Securing compilers necessitates rigorous assessments, which should include comprehensive audits and robust bug bounty programs to uncover and resolve vulnerabilities. The inherent subtlety of compiler bugs renders their detection and mitigation complex. This complexity accentuates the importance of sophisticated attack detection and prevention mechanisms, such as those provided by BlockSec's BlockSec Phalcon, which deliver essential automated defenses to protect DeFi protocols effectively.
Read other articles in this series:
- Lead-In: Top Ten "Awesome" Security Incidents in 2023
- #1: Harvesting MEV Bots by Exploiting Vulnerabilities in Flashbots Relay
- #2: Euler Finance Incident: The Largest Hack of 2023
- #3: KyberSwap Incident: Masterful Exploitation of Rounding Errors with Exceedingly Subtle Calculations
- #5: Platypus Finance: Surviving Three Attacks with a Stroke of Luck
- #6: Hundred Finance Incident: Catalyzing the Wave of Precision-Related Exploits in Vulnerable Forked Protocols
- #7: ParaSpace Incident: A Race Against Time to Thwart the Industry's Most Critical Attack Yet
- #8: SushiSwap Incident: A Clumsy Rescue Attempt Leads to a Series of Copycat Attacks
- #9: MEV Bot 0xd61492: From Predator to Prey in an Ingenious Exploit
- #10: ThirdWeb Incident: Incompatibility Between Trusted Modules Exposes Vulnerability