Intro to Smart Contract Security Audits | Randomness

SlowMist
6 min readJul 19, 2022

--

In the last article, we learned about the characteristics of the ‘delegatecall’ function and how it’s properly used. In this article, we’ll help you understand another function of smart contracts that’s commonly used — Randomness.

Background

Randomness is often seen used in the development of smart contracts. For example, we can see them being used in the properties of lotteries and popular NFT digital collections. As of now, two methods are commonly used to acquire Randomness: the use of block variables to generate Randomness and oracles. Let’s take a look at the different characteristics of these two methods:

  • Generating Randomness using block variables

Let’s first understand the common components that make up a block variable:

block.basefee(uint): the base fee for the current block
block.chainid(uint): current chain ID
block.coinbase(): current block miner address, address payable
block.difficulty(uint): current block difficulty
block.gaslimit(uint): current block gas limit
block.number(uint): current block number
block.timestamp(uint): current block timestamp (in seconds) since Unix epoch
blockhash(uint blockNumber) returns (bytes32): the hash of the given block, representing the most recent 256 blocks

Out of all those, the most frequently used are block.difficulty, blockhash, block.number, and block.timestamp. Randomness generated by block data limits the possibility of regular users to predict that random number, however that doesn’t typically apply to malicious miners. A miner can decide whether or not a block gets broadcasted. It’s important to note that miners do not have to broadcast when they have mined a block. Blocks can also be discarded by miners, a process called selective packing. Miners will keep trying to generate Randomness until they acquire the desired result, with which they will then broadcast a block. The premise for miners to continue doing this is based upon the level of incentive. For example, a large reward pool for a block mined will incentivise miners to continue committing resources to discover new blocks. That being said, obtaining Randomness using block variables is more suitable for some Randomness that don’t belong to a core application.

  • Generating Randomness through the use of an oracle

Blockchain oracles are mechanisms that link blockchains to external systems, allowing smart contracts to execute that depend on real-world inputs and outputs. An oracle is specifically built to generate random number seeds. In addition to the use of third-party services, DApp developers also have the ability to build off-chain services that provide Randomness. In this scenario, off-chain data is obtained on-chain through the use of on-chain oracles.

Using this method will undoubtedly pose some security concerns. For instance, your dependence on a third-party to provide you a random number seed can be combated by another corrupt third-party that cheats or accepts bribes. Even if you constructed your own random number generator, it may not be accessible due to faults or other factors. In another scenario, project members might even manipulate Randomness causing significant operational issues of DApps and user downtime. That being said, using off-chain services to obtain genuine Randomness is dependent upon a stable and trustworthy third-party service. If that’s the case, using this method creates more unpredictability compared to the method of using blockchain variables to generate Randomness, which makes it stronger.

Understandably, there may be some doubts and concerns when it comes to random number generation methodology. Clearly, these two methods have their own inherent risks. So we have to ask ourselves, are there any safe and reliable ways to obtain Randomness? The answer to that question is, yes. There are multiple decentralized oracle services that have demonstrated reliability through their track record. Take ChainLink for example, they’re a relatively stable and secure decentralized oracle service that provides Randomness. ChainLink VRF provides an off-chain solution for random number seed acquisition. ChainLink will provide random number seeds as long as a user pays using LINK coins, but I won’t go into great detail about the advantages and use cases here.

We’ll now utilize smart contract code to illustrate the potential damages weak Randomness might cause.

Example of a Vulnerability

pragma solidity ^0.8.13;contract GuessTheRandomNumber { constructor() payable {}
function guess(uint _guess) public { uint answer = uint( keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)) );
if (_guess == answer) { (bool sent, ) = msg.sender.call{value: 1 ether}(""); require(sent, "Failed to send Ether"); } }}

Analysis of the Vulnerability

First, let’s address two functions found within the code, abi.encodePacked and keccak256:

  • abi.encodePacked encodes the parameters. Solidity provides two encoding methods, encode and encodePacked. The former fills each parameter with 32 bytes, and the latter doesn’t fill up, but directly connects the parameters to be encoded.
  • The keccak256 hash algorithm can compress any length of input into a 64-bit hexadecimal number, with the probability of hash collision being close to 0.

Next, we’ll look at the coding of the contract itself. This contract is a number guessing game to win ether. We can see that the contract deployer uses the block hash and block time of the previous block as the random number seed to generate Randomness. So we need to simulate his random number generation method to be rewarded. Now let’s bring in the attack contract.

Attack Contract

pragma solidity ^0.8.13;contract Attack { receive() external payable {}
function attack(GuessTheRandomNumber guessTheRandomNumber) public { uint answer = uint( keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)) );
guessTheRandomNumber.guess(answer); }
function getBalance() public view returns (uint) { return address(this).balance; }}

First, we’ll analyze the process of the attack:

  1. Alice deploys the GuessTheRandomNumber contract with one Ether
  2. Eve deploys the Attack contract to call the attack() function and passes in the address of the GuessTheRandomNumber contract
  3. Eve wins one Ether

Since the start of this article series, this attack may be considered the simplest contract creations created. So, what’s going on:

First, Attack.attack() simulates the random number generation method found in the GuessTheRandomNumber contract. After generating the random number, ‘guessTheRandomNumber.guess()is called and the generated random number is passed in, due to the fact that the random number was generated from the Attack.attack() to the call ‘guessTheRandomNumber’. guess() is executed in the same block, where the two parameters, block.number and block.timestamp are unchanged. So we have Attack.attack() and guessTheRandomNumber.guess() that produces the same results of the Randomness generated by each function, allowing the attacker to successfully pass the ‘if(_guess == answer)’ judgment and receive the reward.

Suggestions for Repair

As a developer:

If the random number belongs to a non-core enterprise, you can use the hash of the future block to generate the random number. That is, to separate the guessing and the reward for asynchronous processing. You can look at an optimized version I wrote for this loophole contract (because the loophole contract generates a 256-bit random integer using the block time and block hash, which undoubtedly increases the amount of guessing). (The difficulty of this situation makes it very inconvenient for players, so I will not make any changes in order to fit the parameters of the original vulnerability contract.)

// SPDX-License-Identifier: MITpragma solidity ^0.8.13;contract GuessTheRandomNumber { constructor() payable {} uint256 public deadline = block.timestamp + 72 hours; mapping ( address => uint256 ) public Answer;
modifier isTime(){ require(block.timestamp > deadline , "Not the time!"); }
event Guess(address,uint256); event Claim(address);
function guess(uint256 _guess) public { require(block.timestamp <= deadline , "Too late!"); Answer.[msg.sender] = _guess; emit Guess(msg.sender,_guess); }
function claim() public isTime{ uint256 key = uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))); uint256 answer = Answer.[msg.sender]; require(key == answer , "Sorry,may be next time."); (bool sent, ) = msg.sender.call{value: 1 ether}(""); require(sent, "Failed to send Ether"); emit Claim(msg.sender); }}

As you can see, I’ve added the deadline parameter to asynchronously process guesses and claims. Within 72 hours after deploying the contract, guess() can be called to guess the random number. After 72 hours, guess() will be closed and claim() will be enabled. Players can use claim() to verify whether they’ve guessed correctly. This repair is certainly not a perfect solution.

As stated earlier, if a miner enters the game, he will be able to tell when he packs it if his guess was accurate. (I believe that no miner is willing to pay such a large price to get one ether). Therefore, the best solution is to connect to a well-known oracle to obtain Randomness.

As an auditor:

If you encounter Randomness in the auditing process, focus should be directed towards the source of the random number seed. Nearly all block variables are susceptible to mining work’s potential for maliciousness. When encountering the use of random number seeds provided by third parties, the project team should be reminded to confirm whether the source is 100% reliable. Doing this avoids losses caused by malicious third-party services and can prevent hardware problems. If possible, the project team should also be advised to access a well-known oracle to obtain secure Randomness.

--

--

SlowMist
SlowMist

Written by SlowMist

SlowMist is a Blockchain security firm established in 2018, providing services such as security audits, security consultants, red teaming, and more.

No responses yet