Vyper and the EVM

What is Vyper?

Vyper is a contract-oriented, domain-specific programming language targeting the Ethereum Virtual Machine (EVM). It focuses on simplicity, security, and auditability. Designed as an alternative to Solidity, Vyper has gained traction among developers who prioritize security over complexity. However, vulnerabilities like the reentrancy bug in older Vyper versions remind us that even the most security-focused tools can be vulnerable if not properly tested.

The Ethereum Virtual Machine (EVM)

The EVM is a single-threaded, non-concurrent environment. When an Ethereum smart contract calls another contract, the control flow is passed entirely to the called contract. This means that the original contract's execution is "paused" until the invoked contract finishes executing. Reentrancy vulnerabilities occur when the called contract maliciously calls back into the original contract before it finishes updating its state.


Understanding Reentrancy Attacks

What is a Reentrancy Attack?

A reentrancy attack occurs when a contract temporarily passes control to an external contract, and that external contract calls back into the original contract before the original execution is complete. This can lead to unintended consequences, such as draining funds or performing unauthorized actions. Reentrancy attacks are possible because smart contracts in Ethereum do not automatically lock execution once an external call is made.

Exploit Flow

  1. External Call: The original contract makes an external call to a malicious contract (e.g., sending tokens or Ether).

  2. Recursive Call: The malicious contract calls back into the original contract before the first function finishes execution.

  3. Outdated State: Since the original contract has not yet updated its state (e.g., reduced the balance), the attacker can perform actions multiple times, exploiting the outdated state.

This pattern can be used to drain contracts of funds or perform other malicious actions.


Reentrancy in Vyper: The Vulnerable Versions

In Vyper, reentrancy protection is typically provided by the @nonreentrant decorator, which prevents functions from being called recursively within the same transaction. However, in Vyper versions 0.2.15, 0.2.16, and 0.3.0, a bug in the compiler led to a malfunctioning implementation of this decorator, allowing reentrancy attacks to bypass the protection entirely.

Vulnerable Vyper Versions (0.2.15, 0.2.16, 0.3.0)

In these versions, the compiler introduced improper storage allocation for the @nonreentrant decorator, causing reentrancy guards to malfunction. Specifically:

  • The storage slots allocated to track reentrancy keys were not managed correctly.

  • This led to overlapping storage slots, where reentrancy keys could be overwritten by other variables in the contract.

  • As a result, contracts compiled with these versions were vulnerable to reentrancy attacks, even if they used the @nonreentrant decorator.

This vulnerability went unnoticed for several months, affecting a number of high-profile projects, including Curve.Fi, which lost millions of dollars due to the exploit.

Example of a Vulnerable Contract

Here’s an example of a vulnerable contract using Vyper 0.2.15:

# Vulnerable Contract
balances: public(map(address, uint256))

@external
@nonreentrant("withdraw")
def withdraw(amount: uint256):
    assert self.balances[msg.sender] >= amount, "Insufficient balance"
    # Send Ether to the user
    send(msg.sender, amount)
    # Update the state after the send
    self.balances[msg.sender] -= amount

In this example, the @nonreentrant("withdraw") decorator is supposed to prevent the function from being called recursively. However, due to the bug in Vyper 0.2.15, the reentrancy guard fails, and a malicious contract could exploit this vulnerability by reentering the withdraw function before the state is updated, allowing it to drain the contract.


How the Vulnerability was Fixed

The vulnerability was fixed in Vyper 0.3.1. The primary change was in how the compiler handled the allocation of storage slots for reentrancy keys. Instead of allocating a new slot for each instance of the @nonreentrant decorator, the compiler now ensures that reentrancy keys are allocated properly without overlapping with other storage variables.

Fix in Vyper 0.3.1:

for node in vyper_module.get_children(vy_ast.FunctionDef):
    type_ = node._metadata["type"]
    if type_.nonreentrant is not None:
        variable_name = f"nonreentrant.{type_.nonreentrant}"

        # Ensure each key is allocated only once
        if variable_name in ret:
            continue

        type_.set_reentrancy_key_position(StorageSlot(storage_slot))
        ret[variable_name] = {
            "type": "nonreentrant lock",
            "location": "storage",
            "slot": storage_slot,
        }
        storage_slot += 1

With this fix, the compiler properly allocates storage slots for each reentrancy key, ensuring that reentrancy locks function as intended.


Mitigating Reentrancy in Vyper

There are two primary ways to mitigate reentrancy vulnerabilities in Vyper:

  1. Use the Latest Version of Vyper: Always ensure you are using the latest stable version of Vyper, as vulnerabilities like the one in versions 0.2.15, 0.2.16, and 0.3.0 are patched in newer versions.

  2. Follow the Checks-Effects-Interactions (CEI) Pattern: The CEI pattern helps ensure that state changes are made before any external calls, reducing the risk of reentrancy attacks.

Example of the CEI Pattern in Vyper

@external
@nonreentrant("withdraw")
def safe_withdraw(amount: uint256):
    # Checks: Ensure the user has enough balance
    assert self.balances[msg.sender] >= amount, "Insufficient balance"
    
    # Effects: Update the user's balance
    self.balances[msg.sender] -= amount
    
    # Interactions: Transfer the Ether
    send(msg.sender, amount)

In this example, the state is updated before the external call (send(msg.sender, amount)), ensuring that any reentrancy attack would encounter an updated state and thus fail.


Conclusion

Reentrancy vulnerabilities are a critical security issue in smart contracts, and the Vyper compiler bug in versions 0.2.15, 0.2.16, and 0.3.0 demonstrated the importance of properly implemented reentrancy guards. Although Vyper provides the @nonreentrant decorator as a language-level protection, compiler bugs can undermine these safeguards, making it essential to stay up-to-date with the latest versions of the language.

By following best practices such as the CEI pattern and using the latest Vyper compiler, developers can protect their contracts from reentrancy attacks. Furthermore, regular auditing and security reviews should be a standard practice for any project handling significant amounts of funds.

Finally, it’s essential for the developer community to keep a close eye on compiler updates and vulnerabilities to avoid repeating these costly mistakes.

Last updated