Back to Blog

Разбор эксплойта zkLend: детали и разъяснение недопониманий относительно флеш-лоун атаки на $10 млн

February 20, 2025
7 min read

12 февраля 2025 года протокол кредитования zkLend [1] в сети StarkNet подвергся атаке, в результате которой было похищено около 10 млн долларов США путем изощренной манипуляции механизмом аккумулятора. Злоумышленник использовал флэш-кредиты (flash loans) и уязвимости, связанные с округлением, для искусственного завышения стоимости обеспечения, что позволило заимствовать другие активы из протокола для получения прибыли.

Тем не менее, в настоящее время ощущается нехватка детального и точного технического анализа инцидента с точки зрения безопасности. Несмотря на существующие разборы от других исследователей, предоставившие ценную информацию, сохраняются некоторые недопонимания — особенно в анализе процесса атаки. Официальный отчет о последствиях (post-mortem) [2], опубликованный zkLend позднее, содержит упрощенное описание, но ему не хватает глубокого технического анализа. В этом блоге мы стремимся предоставить всестороннее исследование, чтобы внести ясность в произошедшее.

Основные выводы (TL;DR)

  • Первопричина этого инцидента кроется в сочетании следующих трех проблем:

    • Инициализация пустого рынка позволяет осуществлять произвольные депозиты активов.
    • Специфический механизм доната (пожертвования) в функции флэш-кредита zkLend позволяет манипулировать аккумулятором — глобальной переменной, выступающей множителем для динамической корректировки баланса обеспечения пользователей.
    • Потеря точности из-за усечения. В отличие от классической потери точности при делении, знаменатель начинался с 1, но был раздут до очень большого значения, что привело к недооценке при сжигании токенов доли (share token).
  • Злоумышленник не получал прибыль от wstETH, внесенных другими пользователями. Вместо этого он использовал уязвимости для манипуляции балансом обеспечения, применяя небольшое количество wstETH в качестве начального капитала для увеличения баланса обеспечения до более чем 7 000 wstETH, что позволило заимствовать другие активы с рынка.

В следующих разделах мы сначала представим ключевую справочную информацию о zkLend, а затем проведем углубленный анализ проблем и соответствующей атаки.

0x1 Общие сведения: понимание основного протокола zkLend

zkLend — это кредитный проект на базе StarkNet, который поддерживает распространенные механизмы кредитных протоколов, такие как кредиты под залог и флэш-кредиты. Давайте углубимся в детали реализации этих двух протоколов.

0x1.1 Кредиты под залог

Кредитование под залог относится к процессу, при котором пользователи вносят определенные активы в протокол в качестве обеспечения для получения заемных средств в других активах. Стоимость обеспечения используется для определения лимита заимствований. Важно отметить, что кредитные протоколы обычно не хранят стоимость активов обеспечения напрямую; вместо этого они рассчитывают ее по формуле:

collateral_balance = lending_accumulator * raw_balance

В частности, lending_accumulator — это масштабирующий коэффициент, который динамически корректирует стоимость обеспечения каждого пользователя, в то время как raw_balance представляет собой фактическую долю (share), которую пользователь удерживает на рынке. raw_balance вычисляется из collateral_balance с использованием lending_accumulator.

В чем цель такого дизайна? Он позволяет протоколу эффективно управлять стоимостью обеспечения, стимулируя пользователей вносить активы. Распределяя часть доходов протокола между поставщиками обеспечения, lending_accumulator увеличивается, тем самым пропорционально и одновременно повышая стоимость обеспечения всех пользователей.

0x1.2 Флэш-кредиты в zkLend

Флэш-кредит — это тип необеспеченного кредита, при котором пользователи могут занимать активы у протокола на очень короткий период, как правило, в рамках одной транзакции. Если заемщик не возвращает кредит или не выполняет указанные условия, вся транзакция откатывается (reverted), и кредит не считается исполненным.

В реализации флэш-кредитов zkLend существует уникальный механизм доната. В частности, когда пользователи возвращают активы, они не только возвращают требуемую минимальную сумму, но также могут внести дополнительные средства в качестве пожертвования. Протокол отслеживает эти пожертвованные средства и соответствующим образом обновляет lending_accumulator. Этот процесс реализован в функции thesettle_extra_reserve_balance(). Формула обновления lending_accumulator выглядит следующим образом:

new_accumulator = (reserve_balance + totaldebt - amount_to_treasury) / ztoken_supply

  • reserve_balance: Общее количество базового токена (например, wstETH), хранящегося в контракте, которое включает сумму токенов, пожертвованных пользователями.
  • totaldebt: Общая сумма задолженности всех заемщиков.
  • amount_to_treasury: Доход протокола.
  • ztoken_supply: Общее предложение токенов доли (например, zwstETH). Когда пользователи вносят wstETH, контракт токенов zkLend выпускает эквивалентное количество zwstETH.

Разобравшись с принципами работы протокола zkLend, мы теперь формально объясним, как злоумышленник манипулировал своими активами обеспечения через переменные lending_accumulator и raw_balance.

0x2 Анализ атаки

Злоумышленник использовал следующие механизмы и уязвимости в контракте zkLend для манипуляции стоимостью обеспечения:

  • Манипуляция lending_accumulator
    • Пустой рынок: Перед атакой рынок wstETH в zkLend был пустым, что создало идеальные условия для манипуляции. Более того, контракт рынка zkLend позволяет кому угодно вносить любое количество активов на пустой рынок. Злоумышленник внес небольшое количество активов, чтобы значительно раздуть значение lending_accumulator.
    • Механизм доната: Функция flash_loan() контракта Market содержит уникальный механизм доната. В частности, когда пользователь погашает флэш-кредит, контракт Market вычисляет излишек возвращенных средств и увеличивает глобальную переменную lending_accumulator, тем самым завышая стоимость обеспечения для всех пользователей в контракте.
  • Манипуляция raw_balance
    • Поведение при округлении: Операция деления в процессе сжигания (burn) токенов доли использует усечение, что приводит к недооценке изменения raw_balance пользователя во время вывода средств.

Манипулируя обеими переменными, злоумышленнику удалось увеличить баланс обеспечения до более чем 7 000 wstETH и занять другие активы с рынка для получения прибыли.

0x2.1 Манипуляция переменной lending_accumulator

0x2.1.1 Инициализация пустого рынка

Изучая запись транзакции контракта Market перед атакой, можно заметить, что злоумышленник первоначально вносит 1 wei wstETH в контракт рынка wstETH. Анализ внутренних вызовов этой транзакции показывает, что контракт рынка wstETH содержал 0 wstETH, а общий объем предложения zwstETH также был равен 0.

Таким образом, мы можем подтвердить, что до этого момента на рынке wstETH в zkLend не было ни депозитов, ни займов. И reserve_balance, и ztoken_supply имели свои начальные значения 0, а исходное значение lending_accumulator составляло 1. Этот сценарий пустого рынка создал условия для последующей атаки, позволив злоумышленнику значительно увеличить lending_accumulator с помощью минимального количества wstETH.

0x2.1.2 Манипуляция lending_accumulator через флэш-кредит

Далее, в этой транзакции, злоумышленник вызывает функцию flash_loan(), занимая 1 wei wstETH и возвращая 1000 wei wstETH. Излишек в 999 wei рассматривается как донат и записывается в reserve_balance контракта.

Согласно формуле расчета lending_accumulator, эта транзакция приводит к увеличению lending_accumulator с 1 до 851.0.

0x2.1.3 Повторное выполнение flash_loan()

Злоумышленник выполняет в общей сложности 10 вызовов flash_loan(), каждый раз занимая всего 1 wei wstETH, но возвращая большую сумму. В результате lending_accumulator вырастает до астрономического значения 4 069 297 906 051 644 020 (4.069 × 10^18), что совпадает с десятичной точностью wstETH.

0x2.2 Манипуляция переменной raw_balance

После манипуляции lending_accumulator до значения примерно 4.069 × 10^18, злоумышленник вызвал функцию deposit() контракта Market, внеся 4.069297906051644020 wstETH. Исходя из последнего значения lending_accumulator, raw_balance атакующего контракта стал равен 2.

0x2.2.1 Первая транзакция манипуляции raw_balance

В этой транзакции злоумышленник вызвал функцию callflashloandraaan() атакующего контракта. Хотя этот контракт не является открытым исходным кодом, на основе цепочки внутренних вызовов можно предположить, что логика этой функции включает цикл, выполняющий следующие действия:

  • Deposit: Злоумышленник вносит определенное количество wstETH в контракт рынка.
  • Withdraw: Злоумышленник выводит определенное количество wstETH.

Анализ записей о переводе токенов

Можно заметить, что сумма wstETH, которую вносит злоумышленник, всегда является целым числом, кратным lending_accumulator (например, в 2 раза больше значения 8.13859).

Однако сумма выведенных wstETH в 1.5 раза превышает значение lending_accumulator (например, 6.10394).

Путем вычислений мы можем определить, что сумма выведенных wstETH превышает сумму внесенных. Почему это происходит?

Поведение округления

Изучив реализацию методов deposit() и withdraw(), мы видим, что они включают выпуск (minting) и сжигание (burning) zwstETH соответственно. Вот как это работает:

Функция `mint()` в контракте Market

Функция `burn()` в контракте Market

Процессы mint() и burn() оба включают логику масштабирования вниз. Логика масштабирования включает деление целых чисел с округлением вниз (до ближайшего целого), что играет ключевую роль в эксплойте.

Когда злоумышленник сжигает определенное количество zwstETH, применяется логика масштабирования вниз. Из-за чрезвычайно высокого значения lending_accumulator (около 4 069 297 906 051 644 020), это деление приводит к тому, что raw_balance злоумышленника уменьшается только на 1 единицу, несмотря на сжигание более 6 zwstETH.

Изменения raw_balance злоумышленника сведены в таблицу:

Мы видим, что в этой транзакции злоумышленник неоднократно выполняет логику Депозит - Вывод, используя потерю точности во время выполнения функции withdraw(), что приводит к недооценке разницы raw_balance. В конечном итоге, raw_balance пользователя увеличился с 2 до 3, принеся дополнительную единицу.

0x2.2.2 Последующий процесс атаки

Последующие транзакции атаки следовали тому же образцу: злоумышленник повторял циклы транзакций Депозит - Вывод для получения wstETH.

Полученный wstETH снова вносится на рынок, еще больше увеличивая raw_balance, из-за чего стоимость обеспечения злоумышленника продолжает расти.

Пример пояснения

Используем следующую транзакцию в качестве иллюстрации.

  • Всего было сделано 30 депозитов, каждый раз по 4.069 wstETH.
  • Всего было сделано 30 выводов средств, каждый раз по 6.104 wstETH.
  • После этого цикла злоумышленник успешно извлек 61.39 wstETH, согласно расчетам.

Стоит отметить, что между этими транзакциями атаки вызывалось несколько методов increase(). Эти методы использовались для перевода определенного количества wstETH со счета злоумышленника на атакующий контракт, который затем предоставлял средства для последующих депозитов в контракт Market.

Эти действия повышают значение raw_balance, позволяя злоумышленнику продолжать увеличивать стоимость обеспечения. В итоге raw_balance злоумышленника достиг 1724, со стоимостью 7015.4 wstETH, чего было достаточно, чтобы занять другие активы с рынка.

0x3 Анализ прибыли

0x3.1 Заимствование других видов фондов

После манипуляции стоимостью обеспечения злоумышленник занял другие виды фондов с рынка и продолжил выполнение следующих транзакций (выдержка):

0x3.2 Перевод заемных средств в Layer 1

Изучая транзакции моста (bridge) контракта злоумышленника, можно заметить, что злоумышленник перевел часть заемных средств в Layer 1.

0x4 Заключение

Подводя итог, эта атака на протокол zkLend подчеркивает несколько важных аспектов для разработки и обеспечения безопасности децентрализованных кредитных протоколов:

  • Инициализация рынка и условия депозита активов: Пустой рынок в начале позволил злоумышленнику внести небольшое количество wstETH и манипулировать lending_accumulator, получив рычаг для эксплойта. Обеспечение достаточной базы ликвидности или ограничение пожертвований активов на ранних этапах работы рынка могло бы помочь предотвратить подобные атаки.
  • Важность надлежащих механизмов аккумулятора: Злоумышленник использовал механизм доната в функции flash_loan() для манипуляции lending_accumulator, раздувая стоимость обеспечения для всех пользователей. Протоколы с аккумуляторными механизмами должны быть защищены от легкой манипуляции масштабирующими коэффициентами.
  • Поведение при округлении и потеря точности: Проблема округления при сжигании токенов zwstETH привела к потере точности и недооценке raw_balance, что позволило злоумышленнику манипулировать балансом. Протоколы должны использовать более высокую точность или проверки валидации для предотвращения подобных эксплойтов.

В очередной раз этот инцидент подчеркивает важность своевременных уведомлений об инициализации и операционном статусе, а также проактивного предотвращения угроз для смягчения потенциальных потерь.

Ссылки

[1] https://zklend.com/

[2] Отчет zkLend об инциденте безопасности: https://drive.google.com/file/d/10i1dh_J89tPPw7KRcmFIVM6iNrJZAyfi/view