Photo by gustavo centurion on Unsplash
Abstract: The failure of Fomo3D and its followers is highly related to bugs in their smart contract design. Here we offer a detailed analysis of two problems in Fomo3D-like games from the view of security, along with several practical solutions for your reference. We welcome anyone interested to join our community for further discussion.
Fomo3D has already entered the third round. According to data on 2:00 GMT, Oct 9th, the pool only drew 103.4482 ethers. The total amount is less than 800 ethers plus 680 ethers from the last round, which is a great recession compared to prior rounds.
SECBIT Labs once published an article analyzing the situation of Fomo3D-like games for your reference [1].
Pic 1: Fomo3D player participation and funds statistics
The picture above shows the ratio of Fomo3D player participation and funds. The red line represents the number of participants, and the blue line represents the sum of funds entering the game contract. The peak is on the left side, on July 20th and 21st to be exact. Numerous media reported Fomo3D during this time period. Many people followed others and joined the game and the number of participants along with funds reached the highest peak, which is more than 18,000 persons and 40,000 ethers. After the peak, Fomo3D gradated sharply, ended the first round on Aug 22nd and entered the second round, while the heat could never be restored again.
However, hackers did not just walk away from this one.
Pic 2: Attacking statistics on Fomo3D
The picture above is a summary of attacks on Fomo3D. Some hackers attacked the game by the airdrop bug and gained a lot [2] during the peak of the first round and the start of the second round. Also, near the end of the first and second round, hackers are using transaction blocking attacks to get a chance winning the final prize [3].
Fomo3D is not along — other copycats are also targets of hackers.
Fomo3D-like games
Fomo3D is designed to participate by purchasing keys with ether and the last buyer wins final prize, Also, participants have chances winning airdrop reward from time to time. These two rewards are incentives for participants to make the game more interesting by randomness and competition, drawing more participants and ethers to extend the game length.
Yet things did not go as expected. Hackers could apply special techniques to get the airdrop reward with high possibilities continuously due to bugs in contract code and the final prize would be stolen by hacking. Average participants could hardly win prizes, and hope that enter early could benefit them from followers. However, two essential mechanisms have failed to work and the game could not draw more funds continuously. A vicious cycle comes to existence.
So how did hackers make use of these two bugs? Is there a way out for developing teams?
Take a look at airdrop reward first.
1% of all ethers entering the game would be directed to the minor pool. The possibility of winning airdrop starts from 0 and increases by 0.1% every time an order worth no less than 0.1 ether is placed. Also, the airdrop amount is related to the order, e.g. buying 0.1–1 ether might win 25% of ethers in the minor pool. The more you pay, the greater the reward ratio is. The game UI would show the current winning possibility and the amount of the minor pool.
Fomo3D airdrop implementation has two bugs:
The airdrop depends on a random number generated inside the smart contract controlled by airdrop()
in the contract code.
/** * @dev generates a random number between 0-99 and checks to see if thats * resulted in an airdrop win * @return do we have a winner? */function airdrop() private view returns(bool){ uint256 seed = uint256(keccak256(abi.encodePacked( (block.timestamp).add (block.difficulty).add ((uint256(keccak256(abi.encodePacked(block.coinbase)))) / (now)).add (block.gaslimit).add ((uint256(keccak256(abi.encodePacked(msg.sender)))) / (now)).add (block.number) ))); if((seed - ((seed / 1000) * 1000)) < airDropTracker_) return(true); else return(false);}
The random number seed
in airdrop()
is computed by block info and transaction caller address, which is easy to predict [4].
Fomo3D developing team also applied isHuman()
to prevent contracts from joining Fomo3D game to attack automatically and predict the random number with contracts.
/** * @dev prevents contracts from interacting with fomo3d */modifier isHuman() { address _addr = msg.sender; uint256 _codeLength; assembly {_codeLength := extcodesize(_addr)} require(_codeLength == 0, "sorry humans only"); _;}
Here we could see another common mistake. extcodesize
operator is for getting the code size at the target address. Addresses of deployed contracts is associated with specific code, thus extcodesize
is greater than 0. Many people use this approach to determine if the target address is a contract and Fomo3D relies on it to prevent contracts calling some functions, but this is an unreliable method - calling functions in constructors could bypass the restriction. When constructing contracts, addresses are not linked to any code and extcodesize
is 0 [5].
By combining these two bugs, hackers gained access to build attacking contracts and predict random numbers to increase their winning chances greatly [2].
So what should we do to solve the Fomo3D airdrop bug?
Only by using two bugs stated before could hackers attack successfully. Thus, we only need to implement one of the two patches:
Start with random number prediction first.
Random numbers in smart contracts are easy to predict in that the random seed is accessible by anyone. Attackers could construct a malicious contract, executing random number generation formulas in the exact same environment to get the random number for further steps.
Almost every variable of a smart contract is public and the generation formulas require consistency between results of each node. Thus, it is hardly possible to find a simple way generating unpredictable random numbers.
Still, there are other practical but complex solutions. Developers could commit and reveal, or postpone several blocks. Also, we could introduce exterior oracles, like Oraclize and BTCRelay [6].
Combined with Fomo3D mechanism, SECBIT Labs here introduce a method using hash values of current/future blocks [7].
Ethereum smart contracts could call block.blockhash()
to get the hash value of specific blocks. The accepted parameter is one of the block heights of the most recent 256 blocks except the current one. It returns 0 if passing other values.
The common unreliable random number computation would read the hash of the prior block block.blockhash(block.number-1)
as the random seed. Calling block.blockhash(block.number)
within the contract would return 0. We cannot get the current block hash in the contract, as the value has not been computed before miners packing and executing the transaction. Therefore, it is safe to say that the current block hash is in future and unpredictable.
We could save the address and current block height N to an array when players purchasing keys for the first time and get a sole id (shown in _purchase()
below).
function _purchase(address user) internal { Purchase memory p = Purchase({ user: user, commit: uint64(block.number), randomness: 0 });
uint id = purchases.push(p) - 1;
emit KeysPurchased(id, user, packCount);}
Players could use their ids in the following 255 blocks and hash values of blocks with height N are accessible for random number generation to determine if one player wins the prize (shown in _airdrop()
below).
function _airdrop(uint id) internal returns(bool) { Purchase storage p = purchases[id];
require(p.randomness == 0); require(block.number - 256 < p.commit); require(uint64(block.number) != p.commit); require(p.user == msg.sender);
bytes32 bhash = blockhash(p.commit); uint seed = uint(keccak256(abi.encodePacked(bhash, p.user, id))); p.randomness = seed;
if((seed - ((seed / 1000) * 1000)) < airDropTracker_) return(true); else return(false);}
The block hash generated when a player joins the game is no longer available after 255 blocks. Therefore, we must notify players to check the prize within a time range and get the prize in time. Of course we could give the player another chance if he or she misses it. More technical details are available in our technical community.
This method is also applied in the famous blockchain card game Gods Unchained to control rare cards bought by players. Certainly, we could use a given number of block hash values (e.g. 5) after the current height as the random seed according to the same principle [8].
Another question is how to determine if the caller is a contract address.
We could solve this in a simple and efficient way.
modifier isHuman() { require(tx.origin == msg.sender, "sorry humans only"); _;}
Ethereum development best security practices suggests not to use tx.origin
as many developers is not aware of differences between tx.orign
and msg.sender
. tx.orign
stands for the caller of a transaction, while msg.sender
stands for the caller of each contract call.
A -> B -> C
For example, a user A calls contract B and B calls contract C. msg.sender
in contract C is B, while tx.origin
is A. msg.sender
could be a contract, whereas tx.origin
would never be a contract. Therefore the approach above could restrict calling contracts by contracts efficiently.
It is time to check the final prize.
Fomo3D-like games has a countdown — the last one buying keys before the end of each round wins nearly half of ethers in the pool, hence many players would purchase keys near the end, hoping to be the lucky one packed by miners at the last second.
Average people employ similar strategies at the round end: check the countdown, raise gas price, buy keys and close eyes to pray for winning; nevertheless, this strategy is highly unlikely to bring you the prize.
According to analysis of SECBIT Labs, the winner of first two Fomo3D rounds applied the same attacking technique — make attacking transactions near the end.
The winner (hacker) called getCurrentRoundInfo()
in the deployed attacking contract and check time round ends and current player in leads address. When the remaining time reaches a threshold and the last buyer is the hacker itself, then the contract would call assert()
to fail the transaction and consume all gases; otherwise, it would do nothing and consume few gases.
The winner (hacker) used exactly this method and triggered massive mutable mysterious transactions: when the hacker is extremely likely to become the winner, draw mining pools to pack the hacker’s transactions with high gas price and occupy the following blocks, resulting in that other transactions buying keys could not get packed regularly, accelerate the ending of the game and raise the winning chance.
Near the round end, average players could just buy keys with high gas prices manually or through scripts. The hacking method is way more brilliant than these normal strategies.
Actually, this problem is not only threatening Fomo3D-like games. All games requiring players to compete within a time range would be threatened as well. There would always be hackers using the method described before in games, as long as the game prize is high and reward is much more than the cost.
To get rid of this problem, SECBIT Labs suggests that game developers should not relate the game winning to the countdown, minimizing the chance of rewarding by attacking and the desire for attacking.
For example, we could modify the rule as the last buyer has a relatively low probability to win the prize, like 5%. When the time ends and the prize is not revealed due to probability, the contract would add up the countdown time. Therefore, hackers could not certainly win the prize by blocking, while the transaction block attacking would cost too much gas for the hacker to keep attacking.
function buyCore(...) private{ ... // check to see if end round needs to be ran if (_now > round_[_rID].end && round_[_rID].ended == false) { // check to see whether or not this round should end if shouldRndEnd(lastCommitId) ( // end the round (distributes pot) & start new round round_[_rID].ended = true; _eventData_ = endRound(_eventData_); ... ) else { ... updateTimer(_keys, _rID); ... } } ...}
Here is a code sample. shouldRndEnd()
controls the probability at the end to determine the game ending time, which is relied on the unpredictable random number referring to the prior code controlling airdrop.
Another cause of Fomo3D attacking is the info querying interface of the game contract, which is callable by any addresses. Hackers now could query the game constantly and choose different strategies to minimize the cost and maximize the winning chance.
modifier isHuman() { require(tx.origin == msg.sender, "sorry humans only"); _;}
function getCurrentRoundInfo() isHuman() public view returns(...){ ...}
Accordingly, another patch of Fomo3D is to apply the safe isHuman()
stated above to getCurrentRoundInfo()
, preventing automatic attacks by contracts.
Fomo3D and its copycats are more likely to become a gold mine for hackers rather than attract more average players, due to the problems in security and fairness. The number of players would decrease round after round, and the recession goes on.
SECBIT Labs advises that late-comers should learn from it, avoid copying code and attracting people only by PR. Make some changes and the smart contract security would rise sharply for the future decentralized game development.
SECBIT was founded by a group of cryptocurrency-enthusiasts. We are doing research on smart contract security, smart contract formal verification, crypto-protocols, compilation, contract analysis, game theory and crypto-economics.