30 июля 2023 года серия эксплойтов была направлена на несколько пулов Curve, что привело к убыткам на миллионы долларов. Это типичная атака с использованием повторного входа (reentrancy attack), но с нетипичной первопричиной: она возникла из-за ошибки в компиляторе, которая привела к отсутствию защиты от повторного входа. В частности, произошла ошибка, при которой блокировки повторного входа для разных функций в рамках одного смарт-контракта получали разные слоты хранения. В результате смарт-контракты, скомпилированные с использованием версий Vyper 0.2.15, 0.2.16 и 0.3.0, оказались уязвимыми.
Предыстория
Поскольку Curve использует Vyper вместо Solidity для разработки своих смарт-контрактов, ниже приведено краткое введение в язык Vyper, чтобы помочь понять связанную с ним уязвимость.
Vyper — это язык программирования на основе Python, созданный Виталиком Бутериным, соучредителем Ethereum. Как указано в его документации, Vyper — это ориентированный на контракты, предметно-ориентированный, «питонический» язык программирования, предназначенный для Виртуальной машины Ethereum (EVM). Его цели включают простоту, «питоничность», безопасность и возможность аудита.
Vyper стал вторым по популярности языком программирования для Ethereum и EVM-совместимых блокчейнов после широко известного Solidity. Curve является одним из крупнейших пользователей языка Vyper, и большинство их контрактов написаны на нем. Многие проекты, связанные с Curve или созданные на базе её форков, также используют Vyper для обеспечения лучшего повторного использования кода и функциональной совместимости с системами Curve.
Ниже представлен фрагмент кода из пула Curve (пул pETH/ETH, который подвергся атаке в этом инциденте). Хотя синтаксис очень похож на Python, между Vyper и Python есть существенные различия:
@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:
"""
@notice Вывод монет из пула
@dev Суммы вывода основаны на текущих коэффициентах депозита
@param _burn_amount Количество LP-токенов для сжигания при выводе
@param _min_amounts Минимальное количество базовых монет для получения
@param _receiver Адрес, который получает выведенные монеты
@return Список количеств выведенных монет
"""
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], "В результате вывода получено меньше монет, чем ожидалось"
self.balances[i] = old_balance - value
amounts[i] = value
if i == 0:
raw_call(_receiver, b"", value=value)
Все параметры и возвращаемые значения функции внутри контракта должны иметь аннотации типов. Язык не поддерживает классы или наследование; ключевое слово 'self' используется исключительно для обращения к самому контракту и доступа к переменным состояния. Встроенные декораторы (например, @external и @nonreentrant) используются для обозначения свойств функции, а поддержка пользовательских декораторов отсутствует.
Анализ уязвимости
Уязвимость возникает из-за ошибки в компиляторе, которая приводит к отсутствию защиты от reentrancy (повторного входа).
Атаки с использованием повторного входа — один из самых распространенных типов атак в блокчейн-экосистеме. В частности, они происходят, когда выполнение логики контракта инициирует внешние вызовы, некоторые из которых могут рекурсивно вызывать исходный контракт. Это может опасно обнажить промежуточное состояние контракта во время выполнения функции перед другими контрактами, что потенциально ведет к уязвимостям. Чтобы противостоять этому, используется защитный механизм от повторного входа (reentrancy guard) или блокировка, гарантирующая, что контракт не может быть вызван повторно во время выполнения одной транзакции.
В упомянутом выше фрагменте кода аннотация @nonreentrant('lock') указывает на то, что функция remove_liquidity должна быть защищена блокировкой повторного входа с именем 'lock'. Для дополнительной ясности это можно сравнить с контрактом ReentrancyGuard от OpenZeppelin и его модификатором nonReentrant. Главное отличие в Vyper заключается в том, что блокировки повторного входа предоставляются не внешней библиотекой, а являются встроенными функциями самого языка. Это казалось удовлетворительным до тех пор, пока не было проведено более глубокое исследование реализации блокировок повторного входа. Выяснилось, что фрагмент кода, представленный в Pull Request #2391 (объединенном 23 июля 2021 года), использовал функцию set_storage_slots для назначения слотов хранения на основе AST (абстрактного синтаксического дерева) исходного кода Vyper.
def set_storage_slots(vyper_module: vy_ast.Module) -> None:
"""
Анализ AST Vyper на уровне модуля для вычисления макета переменных хранения.
"""
# Выделение слотов хранения начиная с 0
# примечание: хранилище адресуется по словам, а не побайтово
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 использовать один байт - или бит - для каждого ключа повторного входа
# требует либо дополнительного SLOAD, либо кэширования значения
# расположения в памяти при входе
storage_slot += 1
Однако критическая ошибка заключается в том, что для каждой блокировки повторного входа слот хранения увеличивается на 1. В результате разные функции получают уникальные слоты хранения для своих блокировок. Чтобы оценить эту реализацию, мы скомпилируем контракт ниже:
# Команда компиляции: 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
Используя компилятор Vyper версии 0.2.16+commit.59e1bdd, сгенерированный промежуточный код (IR) частично выглядит так:
[if,
[eq, _func_sig, 2354224227 <test_funcA()>],
[seq,
[assert, [iszero, [sload, 0]]],
[sstore, 0, 1],
pass,
# Строка 13
pass,
# Строка 12
[sstore, 0, 0],
stop]],
# Строка 17
[if,
[eq, _func_sig, 741100118 <test_funcB()>],
[seq,
[assert, [iszero, [sload, 1]]],
[sstore, 1, 1],
Давайте сосредоточимся на IR в строках 4-5 и 16-17, где сгенерированный код проверяет блокировку повторного входа и сохраняет состояние блокировки в хранилище. Однако было замечено, что разные функции использовали разные слоты для блокировки: test_funcA использует слот 0, а test_funcB — слот 1. Это указывает на то, что блокировка повторного входа неэффективна, так как контракт может быть повторно вызван через другие функции.
Анализ атаки
Здесь мы предоставим некоторый контекст относительно Curve. Пул Curve позволяет пользователям пополнять и выводить ликвидность с помощью функций add_liquidity и remove_liquidity. При добавлении ликвидности сумма определяется соотношением общего предложения, а именно долей добавленной ликвидности к существующей. С другой стороны, remove_liquidity вычисляет количество токенов, которые нужно вывести, исходя из отношения предоставленных LP-токенов к текущему общему предложению, после чего эти LP-токены сжигаются.
Более того, Curve поддерживает пулы для нативных токенов и использует низкоуровневые вызовы (функция raw_call в Vyper) для возврата нативного токена пользователю. В приведенном ниже фрагменте кода функция remove_liquidity сначала вычисляет и передает токены, подлежащие выводу, на основе количества LP-токенов и общего предложения, а затем общее предложение уменьшается.
При нормальных обстоятельствах это было бы безопасно, так как блокировка повторного входа должна предотвращать раскрытие промежуточного состояния во время низкоуровневых вызовов. Однако, когда блокировка неэффективна — а именно этот недостаток был в итоге использован — атака становится возможной. Неэффективная блокировка означает, что промежуточное состояние (где токены для вывода уже переданы, но общее предложение еще не уменьшено) становится уязвимым во время низкоуровневого вызова, что позволяет повторно войти в контракт.
@external
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[N_COINS]:
"""
@notice Вывод монет из пула
@dev Суммы вывода основаны на текущих коэффициентах депозита
@param _burn_amount Количество LP-токенов для сжигания
@param _min_amounts Минимальное количество базовых монет для получения
@param _receiver Адрес, который получает выведенные монеты
@return Список количеств выведенных монет
"""
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], "В результате вывода получено меньше монет, чем ожидалось"
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
Этим повторным входом можно воспользоваться, поскольку общее предложение не было уменьшено, как указано в строке 27 выше. Если мы войдем в контракт в этот момент и вызовем add_liquidity, предоставление ликвидности будет основано на неверном (завышенном) общем предложении, что приведет к созданию чрезмерного количества LP-токенов и, как следствие, убыткам для пула. Большинство атак в этом инциденте использовали именно эту уязвимость. Ниже обсуждается одна из крупнейших транзакций атаки 0xa84aa065ce против пула Curve pETH-ETH.

Сценарий атаки предельно ясен.
- Злоумышленник взял флэш-кредит (flash loan) на Balancer, а затем предоставил 40 000 ETH в качестве ликвидности в пул Curve pETH/ETH, получив 32 431,41 LP-токенов pETH-ETH.
- Затем злоумышленник вывел 3 740 pETH и 34 316 ETH из пула, сжегши 32 431,41 LP-токенов pETH/ETH.
- Во время вывода ликвидности был осуществлен повторный вход в контракт пула. Внутри функции обратного вызова (fallback) злоумышленник предоставил еще 40 000 ETH в качестве ликвидности в тот же пул, выпустив дополнительные 82 182 LP-токена. В процессе этого использовалось значение общего предложения до вывода ликвидности, что было неверно и привело к выпуску большего количества LP-токенов, чем должно было быть.
- Впоследствии злоумышленник вывел 1 184,73 pETH и 47 506,53 ETH, сжегши 10 272,84 LP-токена Curve. В итоге злоумышленник получил прибыль, выпустив дополнительные LP-токены и опустошив пул с их помощью.
Резюме
Эта уязвимость возникла на уровне компилятора, а не исходного кода. Это первый случай в истории, когда ошибка компилятора привела к значительным финансовым потерям в блокчейн-экосистеме.
Действительно, смарт-контракты, скомпилированные с использованием версий Vyper 0.2.15, 0.2.16 и 0.3.0, уязвимы, что может привести к отказу защиты от повторного входа. https://t.co/GM7Ze5to39 pic.twitter.com/K6Lo29Pfn2
— BlockSec (@BlockSecTeam) 30 июля 2023 г.
Поскольку компиляторы являются важнейшим компонентом инфраструктуры, их безопасность имеет первостепенное значение для целостности и функциональности технологии блокчейн. Проблемы, связанные с компилятором, могут быть не сразу очевидны, однако они могут иметь обширные и серьезные последствия. Обеспечение безопасности компиляторов требует строгих оценок, включая комплексные аудиты и надежные программы bug bounty для обнаружения и устранения уязвимостей. Врожденная неявность ошибок компилятора делает их обнаружение и смягчение сложными. Эта сложность подчеркивает важность передовых механизмов обнаружения и предотвращения атак, подобных тем, что предоставляет BlockSec Phalcon, которые обеспечивают необходимую автоматизированную защиту протоколов DeFi.
Читайте другие статьи из этой серии:
- Введение: Топ-10 «впечатляющих» инцидентов безопасности в 2023 году
- #1: Сбор урожая MEV-ботов путем использования уязвимостей в Flashbots Relay
- #2: Инцидент Euler Finance: Крупнейший взлом 2023 года
- #3: Инцидент KyberSwap: Мастерская эксплуатация ошибок округления с помощью чрезвычайно тонких вычислений
- #5: Platypus Finance: Выживание после трех атак по счастливой случайности
- #6: Инцидент Hundred Finance: Катализатор волны связанных с точностью эксплойтов в уязвимых форкнутых протоколах
- #7: Инцидент ParaSpace: Гонка со временем, чтобы предотвратить самую критическую атаку в отрасли
- #8: Инцидент SushiSwap: Неуклюжая попытка спасения приводит к серии подражательных атак
- #9: MEV-бот 0xd61492: От хищника к добыче в гениальном эксплойте
- #10: Инцидент ThirdWeb: Несовместимость между доверенными модулями раскрывает уязвимость



