El 30 de julio de 2023, una serie de exploits atacaron múltiples pools de Curve, resultando en pérdidas que ascendieron a millones de dólares. Es un ataque de reentrada típico con una causa raíz no típica, que surge de un bug en el compilador que lleva a la ausencia de protección contra reentrada. Específicamente, hubo un error en el que los bloqueos de reentrada para diferentes funciones dentro de un contrato inteligente fueron asignados a diferentes slots de almacenamiento. Como resultado, los contratos inteligentes compilados con las versiones 0.2.15, 0.2.16 y 0.3.0 de Vyper eran vulnerables.
Contexto
Dado que Curve utiliza Vyper en lugar de Solidity para el desarrollo de sus contratos inteligentes, se proporciona una breve introducción al lenguaje Vyper para ayudar a comprender la vulnerabilidad asociada.
Vyper es un lenguaje de programación basado en Python creado por Vitalik Buterin, el cofundador de Ethereum. Como se introduce en su documentación, Vyper es un lenguaje de programación orientado a contratos, de dominio específico y de estilo pythónico que tiene como objetivo la Máquina Virtual de Ethereum (EVM). Sus objetivos incluyen simplicidad, 'pythonicidad', seguridad y auditabilidad.
Vyper se ha convertido en el segundo lenguaje de programación más utilizado para Ethereum y las cadenas compatibles con EVM, después del conocido Solidity. Curve es uno de los mayores adoptantes del lenguaje Vyper, con la mayoría de sus contratos escritos en él. Muchos proyectos relacionados con Curve o bifurcados de él también utilizan Vyper para garantizar una mejor reutilización del código e interoperabilidad con los sistemas de Curve.
A continuación se muestra un segmento de código de un pool de Curve (el pool pETH/ETH que fue atacado en este incidente). Aunque la sintaxis es muy similar a Python, existen diferencias notables entre Vyper y 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)
Todos los parámetros y valores de retorno de una función dentro del contrato deben estar correctamente anotados con tipos. El lenguaje no admite clases ni herencia; la palabra clave 'self' se utiliza únicamente para referirse al propio contrato y para acceder a las variables de estado. Los decoradores incorporados (por ejemplo, @external y @nonreentrant) se utilizan para denotar las propiedades de una función, y no hay soporte para decoradores personalizados.
Análisis de la Vulnerabilidad
La vulnerabilidad surge de un bug en el compilador que resulta en una falta de protección contra reentrada.
Los ataques de reentrada son uno de los tipos de ataques más comunes dentro del ecosistema blockchain. Específicamente, ocurren cuando la ejecución de la lógica del contrato inicia llamadas externas, algunas de las cuales pueden llamar recursivamente de vuelta al contrato original. Esto puede exponer peligrosamente el estado intermedio del contrato durante la ejecución de la función a otros contratos, lo que potencialmente puede llevar a vulnerabilidades. Para contrarrestar esto, se emplea un guardia de reentrada, o bloqueo, para garantizar que el contrato no pueda ser reentrado durante la ejecución de una sola transacción.
En el segmento de código mencionado anteriormente, la anotación @nonreentrant('lock') indica que la función remove_liquidity debe estar protegida con el bloqueo de reentrada llamado lock. Para mayor claridad, se puede comparar esto con el contrato OpenZeppelin ReentrancyGuard y su modificador nonReentrant. La diferencia principal en Vyper es que los bloqueos de reentrada no son proporcionados por una biblioteca externa, sino que son características incorporadas del propio lenguaje. Esto parecía satisfactorio hasta que se realizó una investigación más profunda sobre la implementación de los bloqueos de reentrada. Se descubrió que el segmento de código introducido en el Pull Request #2391 (fusionado el 23 de julio de 2021) utilizaba la función set_storage_slots para asignar slots de almacenamiento basándose en el AST (Árbol de Sintaxis Abstracta) del código fuente de Vyper.
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
Sin embargo, el error crítico radica en el hecho de que para cada bloqueo de reentrada, el slot de almacenamiento se incrementa en 1. En consecuencia, diferentes funciones terminan siendo asignadas a slots de almacenamiento únicos para sus bloqueos de reentrada. Para evaluar esta implementación, compilamos el siguiente contrato:
# 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
Usando la versión del compilador Vyper 0.2.16+commit.59e1bdd, la IR (Representación Intermedia) de Vyper generada durante la compilación del código se lista parcialmente a continuación:
[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],
Centrémonos en la IR de las Líneas 4-5 y las Líneas 16-17, donde el código generado verifica el bloqueo de reentrada y almacena el estado del bloqueo en el almacenamiento. Sin embargo, se observó que diferentes funciones utilizaban diferentes slots para el bloqueo de no reentrada: test_funcA usa el slot 0, mientras que test_funcB usa el slot 1. Esto indica que el bloqueo de reentrada es ineficaz, ya que el contrato puede ser reentrado a través de diferentes funciones.
Análisis del Ataque
Aquí proporcionamos algo de contexto sobre Curve. Un pool de Curve permite a los usuarios suministrar y retirar liquidez a través de las funciones add_liquidity y remove_liquidity. Al agregar liquidez, la cantidad a agregar se determina por una proporción del suministro total, específicamente, la proporción de la liquidez agregada respecto a la liquidez existente. Por otro lado, remove_liquidity calcula el número de tokens a retirar en función de la proporción de los tokens LP (Proveedor de Liquidez) enviados respecto al suministro total actual, después de lo cual los tokens LP son quemados.
Además, Curve admite pools que manejan tokens nativos, y utiliza llamadas de bajo nivel (la función raw_call en Vyper) para devolver el token nativo al usuario. En el segmento de código a continuación, la función remove_liquidity primero calcula y transfiere los tokens a eliminar en función de la cantidad de tokens LP y el suministro total, y luego el suministro total se reduce posteriormente.
En circunstancias normales, esto sería seguro, ya que el bloqueo de reentrada debería prevenir la exposición del estado intermedio durante las llamadas raw. Sin embargo, cuando el bloqueo de reentrada es ineficaz —una falla que eventualmente fue explotada— un ataque se vuelve factible. El bloqueo de reentrada ineficaz significa que el estado intermedio (donde los tokens a retirar han sido transferidos, pero el suministro total aún no ha sido reducido) se vuelve vulnerable durante una llamada de bajo nivel, permitiendo una posible reentrada al contrato.
@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
Esta reentrada puede ser explotada porque el suministro total no ha sido reducido como se indica en la Línea 27 anterior. Si reingresamos al contrato en este punto y llamamos a add_liquidity, la provisión de liquidez se basaría en un suministro total incorrecto (más alto de lo que debería ser), lo que llevaría a que se acuñen un número excesivo de tokens LP y resultando en pérdidas para el pool. La mayoría de los ataques en este incidente han explotado esta vulnerabilidad. La siguiente discusión examina una de las transacciones de ataque más grandes, 0xa84aa065ce, contra el pool pETH-ETH de Curve.

El rastro del ataque es muy claro.
- El atacante tomó prestado un préstamo flash de Balancer y luego proporcionó 40,000 ETH como liquidez al pool pETH/ETH de Curve, recibiendo 32,431.41 tokens LP pETH-ETH.
- El atacante luego retiró 3,740 pETH y 34,316 ETH del pool quemando 32,431.41 tokens LP del pool pETH/ETH.
- Durante la eliminación de liquidez, el contrato del pool fue reentrado. Dentro de la función fallback, el atacante proporcionó otros 40,000 ETH como liquidez al pool pETH/ETH de Curve, acuñando 82,182 tokens LP adicionales. Durante este proceso, la cifra del suministro total utilizada fue la anterior a la eliminación de liquidez, lo cual era incorrecta, resultando en que se acuñaran más tokens LP de los que deberían haber sido posibles.
- Posteriormente, el atacante retiró 1,184.73 pETH y 47,506.53 ETH quemando 10,272.84 tokens LP de Curve. En resumen, el atacante obtuvo ganancias acuñando tokens LP adicionales y vaciando el pool utilizando estos tokens LP adicionales.
Resumen
Esta vulnerabilidad se originó en el compilador, no en el código fuente. Esto marca la primera ocasión en que un bug de compilador ha resultado en pérdidas financieras significativas dentro del ecosistema blockchain.
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
Dado que los compiladores forman un componente crítico de la infraestructura, su seguridad es primordial para la integridad y funcionalidad de la tecnología blockchain. Los problemas relacionados con los compiladores pueden no ser inmediatamente obvios, pero pueden tener consecuencias extensas y graves. Asegurar los compiladores requiere evaluaciones rigurosas, que deben incluir auditorías exhaustivas y sólidos programas de recompensas por bugs para descubrir y resolver vulnerabilidades. La sutileza inherente de los bugs de compilador hace que su detección y mitigación sean complejas. Esta complejidad acentúa la importancia de mecanismos sofisticados de detección y prevención de ataques, como los proporcionados por BlockSec Phalcon de BlockSec, que brindan defensas automatizadas esenciales para proteger eficazmente los protocolos DeFi.
Lee otros artículos de esta serie:
- Introducción: Los Diez Principales Incidentes de Seguridad "Increíbles" de 2023
- #1: Cosechando Bots MEV Explotando Vulnerabilidades en Flashbots Relay
- #2: Incidente de Euler Finance: El Mayor Hackeo de 2023
- #3: Incidente de KyberSwap: Explotación Magistral de Errores de Redondeo con Cálculos Extraordinariamente Sutiles
- #5: Platypus Finance: Sobreviviendo Tres Ataques con un Golpe de Suerte
- #6: Incidente de Hundred Finance: Catalizando la Ola de Exploits Relacionados con la Precisión en Protocolos Bifurcados Vulnerables
- #7: Incidente de ParaSpace: Una Carrera Contra el Tiempo para Frustrar el Ataque Más Crítico de la Industria hasta la Fecha
- #8: Incidente de SushiSwap: Un Torpe Intento de Rescate Lleva a una Serie de Ataques de Imitación
- #9: Bot MEV 0xd61492: De Depredador a Presa en un Exploit Ingenioso
- #10: Incidente de ThirdWeb: La Incompatibilidad Entre Módulos de Confianza Expone una Vulnerabilidad



