Back to Blog

Глубокий анализ: инцидент с Truebit

Code Auditing
January 14, 2026
8 min read

8 января 2026 года протокол Truebit в сети Ethereum подвергся атаке, в результате которой было потеряно около 26 миллионов долларов [1]. Первопричиной стало целочисленное переполнение (integer overflow) в логике определения цены покупки токенов TRU. Поскольку контракт был скомпилирован с использованием Solidity v0.6.10, где проверки на переполнение не выполняются по умолчанию, большое промежуточное значение при расчете стоимости покупки «зациклилось» (wrapped around) до гораздо меньшего числа. В результате злоумышленник смог приобрести огромное количество TRU за небольшое количество ETH или вовсе бесплатно, а затем немедленно продать полученные TRU обратно контракту за ETH по выгодному курсу, истощив резервы протокола.

0x0 Справочная информация

Truebit предоставляет вычислительные услуги для Ethereum посредством внесетевых вычислений и интерактивной верификации [2]. В рамках протокола токены TRU выступают в качестве основного экономического инструмента для координации стимулов, включая стейкинг и выплаты за выполнение задач.

Протокол предоставляет две публичные функции для покупки и погашения TRU:

  • buyTRU() выполняет покупку TRU. Необходимая стоимость в ETH рассчитывается внутренней функцией ценообразования, которая также используется getPurchasePrice(), поэтому getPurchasePrice() отражает точную логику ценообразования в сети, применяемую во время выполнения покупки.

  • sellTRU() выполняет продажу (погашение) TRU. Ожидаемую выплату в ETH можно узнать через getRetirePrice().

Ключевым аспектом дизайна является асимметрия ценообразования:

  • Для покупок используется выпуклая кривая связывания (маржинальная цена растет по мере увеличения предложения).
  • Для продаж используется правило линейного погашения (пропорционально резервам).

Поскольку исходный код контракта реализации не является публичным, следующий анализ основан на декомпилированном байт-коде.

Логика покупки

Функция buyTRU() (и функция getPurchasePrice()) делегирует ценообразование приватной функции _getPurchasePrice(), которая вычисляет объем ETH, необходимый для покупки amount токенов TRU.

function buyTRU(uint256 amount) public payable {
    require(msg.data.length - 4 >= 32);
    v0 = _getPurchasePrice(amount); // получить цену покупки
    require(msg.value == v0, Error('Платеж в ETH не соответствует заказу TRU'));
    v1 = 0x18ef(100 - _setParameters, msg.value);
    v2 = _SafeDiv(100, v1);
    v3 = _SafeAdd(v2, _reserve);
    _reserve = v3;
    require(bool(stor_97_0_19.code.size));
    v4 = stor_97_0_19.mint(msg.sender, amount).gas(msg.gas);
    require(bool(v4), 0, RETURNDATASIZE()); // проверка статуса вызова, распространение ошибки при сбое
    return msg.value;
}

function getPurchasePrice(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    v0 = _getPurchasePrice(amount); // получить цену покупки
    return v0;
}

function _getPurchasePrice(uint256 amount) private { 
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.totalSupply().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE());
    require(RETURNDATASIZE() >= 32);
    v2 = 0x18ef(v1, v1)
    v3 = 0x18ef(_setParameters, v2);
    v4 = 0x18ef(v1, v1);
    v5 = 0x18ef(100, v4);
    v6 = _SafeSub(v3, v5);// знаменатель = 100 * totalSupply**2 - _setParameters * totalSupply**2 
    v7 = 0x18ef(amount, _reserve);
    v8 = 0x18ef(v1, v7);
    v9 = 0x18ef(200, v8);// числитель_2 = 200 * totalSupply * amount * _reserve
    v10 = 0x18ef(amount, _reserve);
    v11 = 0x18ef(amount, v10);
    v12 = 0x18ef(100, v11);// числитель_1 = 100 * amount**2 * _reserve
    v13 = _SafeDiv(v6, v12 + v9); // ценаПокупки = (числитель_1 + числитель_2) / знаменатель
    return v13;
}

Исходя из декомпилированной логики, цена покупки может быть выражена функцией в стиле кривой связывания:

Где:

  • amount: количество TRU к покупке
  • reserve (_reserve): резервы эфира контракта
  • totalSupply: общее предложение TRU
  • θ (_setParameters): коэффициент, зафиксированный на уровне 75

Эта кривая призвана сделать крупные покупки значительно дороже (выпуклый рост стоимости), что препятствует спекуляциям и снижает риск манипуляции на стороне покупки.

Логика продажи

Функция sellTRU() (и функция getRetirePrice()) использует приватную функцию _getRetirePrice() для расчета выплаты в ETH при погашении TRU.

function sellTRU(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.allowance(msg.sender, address(this)).gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE());
    require(RETURNDATASIZE() >= 32);
    require(v1 >= amount, Error('Недостаточный allowance для TRU'));
    v2 = _getRetirePrice(amount); // получить цену погашения
    v3 = _SafeSub(v2, _reserve);
    _reserve = v3;
    require(bool(stor_97_0_19.code.size));
    v4, /* uint256 */ v5 = stor_97_0_19.transferFrom(msg.sender, address(this), amount).gas(msg.gas);
    require(bool(v4), 0, RETURNDATASIZE());
    require(RETURNDATASIZE() >= 32);
    require(bool(stor_97_0_19.code.size));
    v6 = stor_97_0_19.burn(amount).gas(msg.gas);
    require(bool(v6), 0, RETURNDATASIZE());
    v7 = msg.sender.call().value(v2).gas(!v2 * 2300);
    require(bool(v7), 0, RETURNDATASIZE());
    return v2;
}

function getRetirePrice(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    v0 = _getRetirePrice(amount); // получить цену погашения
    return v0;
}

function _getRetirePrice(uint256 amount) private { 
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.totalSupply().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE());
    require(RETURNDATASIZE() >= 32);
    v1 = v2.length;
    v3 = v2.data;
    v4 = 0x18ef(_reserve, amount);// числитель = _reserve * amount
    if (v1 > 0) {
        assert(v1);
        return v4 / v1;// ценаПогашения = числитель / totalSupply
    } else {
    // ...
}

Правило погашения является линейным:

Цена погашения пропорциональна доле общего предложения, которая погашается (т.е. amount / totalSupply), умноженной на reserve.

Эта осознанная асимметрия создает широкий спред: покупка является выпуклой (дорого при крупных объемах), в то время как продажа — линейной (погашается лишь пропорциональная доля резервов). В обычных условиях такой спред делает мгновенный арбитраж «покупка→продажа» непривлекательным.

0x1 Анализ уязвимости

Несмотря на задуманный дизайн, при котором крупные покупки являются дорогими, функция _getPurchasePrice() содержит целочисленное переполнение в своих арифметических операциях. Поскольку контракт был скомпилирован с использованием Solidity 0.6.10, арифметические операции над uint256 могут молчаливо переполняться и зацикливаться по модулю 2^256, если они не защищены явно (например, через SafeMath).

function _getPurchasePrice(uint256 amount) private { 
    // ... (код аналогичен приведенному выше)
    v13 = _SafeDiv(v6, v12 + v9); // ценаПокупки = (числитель_1 + числитель_2) / знаменатель
    return v13;
}

В _getPurchasePrice() достаточно большое значение amount вызывает переполнение во время сложения двух больших членов в числителе (v12 + v9 в декомпилированном фрагменте). Когда происходит это переполнение, числитель «сворачивается» до малого значения, что приводит к тому, что финальное деление возвращает искусственно заниженную цену покупки, потенциально вплоть до нуля.

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

  • купить большое количество TRU по заниженной (или нулевой) цене, а затем
  • погасить его за ETH через sellTRU() по гораздо более высокому эффективному курсу.

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

Злоумышленник выполнил несколько раундов арбитража в рамках одной транзакции [3], повторяя цепочку: getPurchasePrice() -> buyTRU() -> sellTRU().

Первый раунд: бесплатная покупка, затем продажа с прибылью

Предоставив тщательно подобранный объем покупки (240,442,509.453... TRU), злоумышленник вызвал переполнение в _getPurchasePrice(), снизив расчетную цену покупки до 0 ETH и позволив получить ~240 миллионов TRU бесплатно.

Приведенный ниже код на Python иллюстрирует, что числитель превышает 2^256, и после «зацикливания» вычисленная цена покупки становится крошечной дробной величиной, которая при приведении к целому числу становится нулевой.

>>> _reserve = 0x1ceec1aef842e54d9ee
>>> totalSupply = 161753242367424992669183203
>>> amount = 240442509453545333947284131
>>> numerator = int(100 * amount * _reserve * (amount + 2 * totalSupply))
>>> numerator > 2**256
True
>>> denominator = (100 - 75) * totalSupply**2
>>> purchasePrice = (numerator - 2**256) / denominator
>>> purchasePrice
0.00025775798757211426
>>> int(purchasePrice)
0

Затем злоумышленник немедленно вызвал sellTRU(), погасив TRU за 5,105 ETH из резервов протокола.

Последующие раунды: покупки по низкой цене, затем продажа с прибылью

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

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

В целом, злоумышленник вывел из резервов Truebit 8,535 ETH.

0x3 Итоги

Инцидент был вызван неконтролируемым целочисленным переполнением в логике ценообразования Truebit на стороне покупки. Хотя асимметричная модель ценообразования (покупка/продажа) была призвана противостоять спекуляциям, компиляция с использованием старой версии Solidity (до 0.8) без систематической защиты от переполнения подорвала этот дизайн и позволила истощить резервы.

Для любого производственного контракта, использующего версии Solidity ниже 0.8, разработчикам следует:

  • Применять безопасную арифметику (например, SafeMath или эквивалентные проверки) для каждой соответствующей операции, или,
  • предпочтительно, перейти на Solidity 0.8+, чтобы воспользоваться встроенными проверками на переполнение.

Ссылки

[1] https://x.com/Truebitprotocol/status/2009328032813850839

[2] https://docs.truebit.io/v1docs

[3] Транзакция атаки

О компании BlockSec

BlockSec — поставщик услуг в области безопасности блокчейнов и крипто-комплаенса полного цикла. Мы создаем продукты и услуги, которые помогают клиентам проводить аудит кода (включая смарт-контракты, блокчейны и кошельки), предотвращать атаки в режиме реального времени, анализировать инциденты, отслеживать незаконные средства и выполнять обязательства по ПОД/ФТ на протяжении всего жизненного цикла протоколов и платформ.

BlockSec опубликовала множество статей по безопасности блокчейнов на престижных конференциях, сообщила о нескольких атаках «нулевого дня» на DeFi-приложения, заблокировала многочисленные попытки взлома, сохранив более 20 миллионов долларов, и обеспечила безопасность криптовалют на миллиарды долларов.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit